尧图建网站 尧图建网站 YAOTU WEB BUILD 免费咨询
ARTICLE DETAIL

资讯详情

深耕网站建设与建站编程的一线实战洞察。

从零手写Agent运行器:深入理解ReAct范式与LangChain核心原理

从零手写Agent运行器:深入理解ReAct范式与LangChain核心原理 1. 项目概述从“玩具”到“利器”的Agent实战最近在折腾LangChain发现很多教程都在讲概念一上手实操就懵。特别是Agent这块感觉像在用一个黑盒子出了问题都不知道从哪查起。于是我决定反向操作不直接用现成的AgentExecutor而是从零开始手写一个最精简、功能完整的Agent运行器我把它叫做mini-cursor。这个项目的目标不是造一个比LangChain更好的轮子而是通过“拆轮子”的过程彻底搞懂Agent内部的工作流、状态管理和错误处理机制。当你自己实现一遍agent.run(“查询今天的天气”)背后发生的一切再回头去看LangChain的源码那种豁然开朗的感觉才是真正的“完全实战”。这个mini-cursor麻雀虽小五脏俱全。它能理解你的自然语言指令自动规划、调用工具比如搜索、计算并根据结果决定下一步是继续执行还是给出最终答案。整个过程完全透明你可以看到每一步的思考Thought、行动Action、观察Observation。无论你是想深入理解Agent原理的开发者还是希望定制独特Agent工作流的项目负责人这个实战过程都能给你带来远超阅读文档的收获。接下来我就带你一步步拆解如何用几百行代码构建这个智能的“迷你光标”。2. 核心设计构建一个自驱动的智能循环2.1 理解Agent的核心运行范式Agent的本质是一个自主决策循环。它不像普通的函数调用输入输出一次完成。相反它处在一个持续感知、思考、行动、再感知的循环中。我们设计的mini-cursor必须完美封装这个循环。其核心状态机非常简单却极其重要接收输入用户提出一个问题或指令。思考与分析基于当前对话历史和问题决定下一步该做什么。是调用某个工具还是直接给出答案执行行动如果决定调用工具则以正确的参数执行该工具。观察结果获取工具执行后的输出。评估与循环判断这个结果是否足以回答问题。如果可以则跳出循环返回最终答案如果不行则带着新的观察结果回到第2步继续思考。这个循环的驱动力来自于一个大语言模型LLM。在每一步“思考”中我们都需要将当前的状态用户问题、对话历史、之前的工具执行结果组织成合适的提示词Prompt送给LLM让它来做出决策。因此我们的mini-cursor架构必须清晰地区分控制流循环逻辑、状态判断和推理层与LLM的交互。2.2 定义关键组件与接口为了让代码清晰且易于扩展我们需要先定义几个核心的类。这是模仿LangChain但极度简化的设计Tool工具Agent可以调用的外部能力单元。每个工具必须有明确的名称name、描述description和一个执行函数_run。描述至关重要因为LLM就是靠描述来决定在什么情况下使用这个工具。class Tool: def __init__(self, name, description, func): self.name name self.description description self.func func def run(self, input_str): return self.func(input_str)例如一个计算器工具其描述可能是“当需要对数学表达式进行计算时使用此工具。输入应为一个可计算的数学表达式字符串。”AgentState代理状态这是一个数据容器用于在循环中传递和保存所有必要信息。通常包括input: 用户原始问题。chat_history: 对话历史对于多轮对话很重要。intermediate_steps: 一个列表记录已经执行过的(Action, Observation)对。这是Agent“记忆”的核心。agent_scratchpad: 一个临时字符串用于将之前的步骤格式化成提示词的一部分供LLM在下一步思考时参考。OutputParser输出解析器LLM返回的是自然语言文本我们需要从中精确地解析出结构化信息它到底是想执行一个动作Action还是想直接回答FinalAnswer一个动作又包含工具名tool和输入参数tool_input。解析器的鲁棒性直接决定了Agent的稳定性。2.3 设计决策为什么选择ReAct范式在众多Agent范式中如Plan-and-Execute, ReAct, AutoGPT我为mini-cursor选择了ReActReasoning Acting。原因有三点都是实战中总结出来的透明可调试ReAct要求LLM显式地输出“Thought:”、“Action:”、“Observation:”等关键词。这就像给Agent的运行过程加上了调试日志每一步的推理和决策都清晰可见。当Agent出错时你可以精准定位是“思考”错了还是“行动”的参数错了或是“观察”的结果没理解对。实现简单直观其提示词模板和解析逻辑相对固定非常适合教学和作为理解更复杂Agent的基础。我们不需要引入额外的规划模块或复杂的任务分解逻辑。广泛的适用性ReAct范式被证明在多种需要多步工具调用的任务上都非常有效如复杂问答、数据分析等是一个很好的通用起点。注意选择ReAct也意味着我们将决策压力完全交给了LLM的单步推理能力。对于极其复杂、需要长链条规划的任务这可能不是最优解。但在mini-cursor的目标——理解和掌握基础Agent循环——上它是完美的选择。3. 核心细节解析与实操要点3.1 提示词工程驱动智能的“燃料”提示词是Agent的“大脑指令集”。一个糟糕的提示词会让最强大的LLM也表现得像个傻瓜。我们的提示词需要精心组装以下几个部分系统指令定义Agent的角色、能力和行为规范。例如“你是一个乐于助人的助手可以调用工具来回答问题。如果你不知道答案就说不知道不要编造信息。”工具描述这是最关键的部分。必须清晰、无歧义地列出所有可用工具的名称和描述。LLM完全依靠这些描述来匹配用户意图和工具功能。描述应该以“Useful for...”开头并说明输入格式。格式指令强制要求LLM以严格的格式输出。对于ReAct就是Thought: 我需要分析用户的问题并决定第一步做什么。 Action: 搜索工具 Action Input: “今天北京的天气”或者当答案明确时Thought: 我已经通过工具获得了足够信息。 Final Answer: 今天北京晴气温20-25度。历史与临时便签将agent_scratchpad即之前的步骤记录和当前的input插入到提示词中为LLM提供完整的上下文。实操心得在组装提示词时我习惯使用f-string或Jinja2模板将变量部分清晰地标记出来。一定要在开发初期多打印几次生成的完整提示词确保格式正确、信息完整没有多余的换行或空格导致LLM解析混乱。3.2 输出解析从自由文本到结构化动作LLM的输出是自由的但我们的程序需要结构化的数据。输出解析器是连接两者的桥梁也是最容易出错的环节之一。一个健壮的解析器需要处理以下情况正常情况输出严格符合“Thought:...\nAction:...\nAction Input:...”或“Thought:...\nFinal Answer:...”的格式。格式微调LLM可能在关键词后多加了个空格或者用了中文冒号或者“Action”写成了“Action”。我们需要用正则表达式进行灵活的匹配。提前结束LLM可能在没有调用任何工具的情况下直接输出了“Final Answer”。解析器需要能识别这种情况。解析失败当完全无法匹配任何模式时不能直接崩溃。最好的策略是将整个输出视为一个“思考”并设计一个默认的“回退动作”比如调用一个“请求澄清”的工具或者直接返回一个包含错误信息的最终答案提示用户重新表述。我实现的解析器核心是一个基于正则表达式的函数import re def parse_llm_output(text: str) - dict: thought_pattern rThought:\s*(.*?)(?\nAction:|\nFinal Answer:|\nThought:|\Z) action_pattern rAction:\s*(.*?)(?\nAction Input:|\Z) action_input_pattern rAction Input:\s*(.*?)(?\nThought:|\nFinal Answer:|\Z) final_answer_pattern rFinal Answer:\s*(.*?)(?\nThought:|\nAction:|\Z) # 尝试匹配各种模式... # 如果匹配到Action返回 {type: action, thought: ..., tool: ..., input: ...} # 如果匹配到Final Answer返回 {type: answer, thought: ..., answer: ...} # 如果都匹配不到返回 {type: error, raw: text}踩坑记录最初我用了过于严格的正则比如r“Thought:(.*)\nAction:(.*)\nAction Input:(.*)”一旦LLM的输出稍有偏差比如多一个空行整个解析就失败了。后来改用非贪婪匹配(.*?)和更灵活的边界条件(?...)容错性大大增强。3.3 工具的设计与注册工具是Agent能力的延伸。设计工具时有以下几个要点单一职责一个工具只做一件事。不要设计一个“万能搜索”工具而应该拆分成“网页搜索”、“学术搜索”、“本地文件搜索”等。描述精准工具描述是给LLM看的“说明书”。要写明用途、输入格式和输出示例。例如“当需要计算数学表达式时使用此工具。输入应该是一个像‘23*4’或‘sin(30度)’的字符串。输出是一个数字或计算结果。”错误处理工具的执行函数内部必须有完善的try-except。如果工具执行失败如网络超时、API错误应该返回一个清晰的错误信息例如“调用天气API失败请检查网络或稍后重试”而不是抛出异常导致整个Agent崩溃。这个错误信息会成为Observation让LLM知道行动失败了它可能需要尝试其他工具或告知用户。注册与管理我们需要一个ToolRegistry来集中管理所有工具方便Agent在运行时根据名称快速查找和调用。4. 实操过程一步步实现mini-cursor4.1 环境准备与基础架构搭建首先确保你的Python环境建议3.8以上并安装核心依赖。我们主要需要LLM的调用库这里以OpenAI API为例但你完全可以替换为任何兼容OpenAI接口的本地模型或其它云服务。pip install openai接下来创建项目结构。我们主要需要以下几个文件mini_cursor.py: 主逻辑文件包含Agent核心循环。tools.py: 定义所有工具。prompts.py: 定义提示词模板。parsers.py: 定义输出解析器。我们从定义最基础的MiniCursorAgent类开始# mini_cursor.py class MiniCursorAgent: def __init__(self, llm, tools, max_iterations10): self.llm llm # 一个可调用的LLM对象需要实现 generate(prompt) 方法 self.tools {tool.name: tool for tool in tools} # 工具字典 self.max_iterations max_iterations # 防止无限循环 def run(self, user_input, chat_historyNone): 运行Agent的主入口。 # 初始化状态 state { “input”: user_input, “chat_history”: chat_history or [], “intermediate_steps”: [], “agent_scratchpad”: “” } for i in range(self.max_iterations): # 1. 组装当前提示词 prompt self._construct_prompt(state) # 2. 调用LLM llm_output self.llm.generate(prompt) # 3. 解析LLM输出 parsed self._parse_output(llm_output) if parsed[“type”] “answer”: # 获得最终答案结束循环 return parsed[“answer”] elif parsed[“type”] “action”: # 执行工具调用 tool_name parsed[“tool”] tool_input parsed[“tool_input”] if tool_name not in self.tools: observation f“错误工具 ‘{tool_name}’ 不存在。” else: observation self.tools[tool_name].run(tool_input) # 更新状态记录这一步 state[“intermediate_steps”].append(((tool_name, tool_input), observation)) # 更新临时便签供下一轮思考使用 state[“agent_scratchpad”] f“\nThought: {parsed[‘thought’]}\nAction: {tool_name}\nAction Input: {tool_input}\nObservation: {observation}” else: # 解析出错处理错误 observation f“解析LLM输出时出错{parsed[‘raw’]}” state[“agent_scratchpad”] f“\nObservation: {observation}” # 可以选择直接返回错误或者让Agent继续尝试 if i 2: # 连续出错多次后放弃 return “抱歉在处理您的请求时遇到了困难请稍后再试或重新表述您的问题。” # 达到最大迭代次数 return f“经过{self.max_iterations}轮尝试仍未完成请求。最后的状态是{state[‘agent_scratchpad’][-500:]}” # 返回最后一部分日志供调试4.2 实现LLM调用层与提示词构造为了让代码更灵活我们抽象一个简单的LLM包装器。这里以OpenAI为例# llm_client.py import openai class OpenAIClient: def __init__(self, model“gpt-3.5-turbo”, api_keyNone): self.model model openai.api_key api_key or os.getenv(“OPENAI_API_KEY”) def generate(self, prompt): try: response openai.ChatCompletion.create( modelself.model, messages[{“role”: “user”, “content”: prompt}], temperature0, # 为了稳定性温度设为0 max_tokens500 ) return response.choices[0].message.content.strip() except Exception as e: return f“LLM调用失败{str(e)}”接下来是提示词模板这是Agent的“灵魂”。我们将它单独放在prompts.py中# prompts.py REACT_PROMPT_TEMPLATE “”” 你是一个智能助手可以使用以下工具 {tools} 使用格式如下 问题用户输入的问题 思考你需要思考当前情况决定使用哪个工具或直接回答 行动要调用的工具名称必须是[{tool_names}]中的一个 行动输入工具的输入 观察工具返回的结果 … (这个“思考/行动/行动输入/观察”的循环可以重复多次) 当你有了最终答案时必须使用以下格式 思考我已经获得了足够的信息。 最终答案你的最终回答 开始 之前的对话历史 {history} 当前问题{input} 这是你之前的操作和结果 {agent_scratchpad} 思考 “”” def construct_react_prompt(tools, state): tool_descriptions “\n”.join([f“- {t.name}: {t.description}” for t in tools]) tool_names “, “.join([t.name for t in tools]) history “\n”.join([f“Human: {h[0]}\nAssistant: {h[1]}” for h in state.get(“chat_history”, [])]) prompt REACT_PROMPT_TEMPLATE.format( toolstool_descriptions, tool_namestool_names, historyhistory, inputstate[“input”], agent_scratchpadstate.get(“agent_scratchpad”, “”) ) return prompt在MiniCursorAgent的_construct_prompt方法中调用这个函数即可。4.3 实现工具与解析器我们先实现两个简单的工具一个计算器一个模拟的搜索工具。# tools.py import math import re class CalculatorTool: name “计算器” description “用于计算数学表达式。输入应为一个包含数字和运算符 -, *, /, **, sqrt等的字符串。例如‘(23)*4’ 或 ‘sqrt(16)’。” staticmethod def _run(expression: str) - str: try: # 安全警告这里使用eval有安全风险仅用于演示。生产环境应用安全的评估库如asteval。 # 替换一些常用数学函数和常量 expression expression.replace(“^”, “**”).replace(“sqrt”, “math.sqrt”).replace(“pi”, “math.pi”) # 限制可用的命名空间增加安全性 allowed_names {“math”: math} result eval(expression, {“__builtins__”: {}}, allowed_names) return str(result) except Exception as e: return f“计算错误{e}” class MockSearchTool: name “搜索” description “用于获取关于通用知识、事实或当前事件的信息。输入应为一个搜索查询字符串。” staticmethod def _run(query: str) - str: # 模拟一个简单的搜索返回 mock_knowledge_base { “今天的天气”: “根据模拟数据今天全国大部分地区晴间多云气温在15-25摄氏度之间。”, “Python的作者”: “Python编程语言由吉多·范罗苏姆Guido van Rossum创造。”, “地球周长”: “地球的赤道周长约为40075公里。” } for key, value in mock_knowledge_base.items(): if key in query: return value return “未找到相关信息。请尝试更具体或不同的查询词。” # 工具工厂函数 def get_tools(): return [ Tool(nameCalculatorTool.name, descriptionCalculatorTool.description, funcCalculatorTool._run), Tool(nameMockSearchTool.name, descriptionMockSearchTool.description, funcMockSearchTool._run), ]然后是解析器我们实现一个健壮的版本# parsers.py import re def parse_react_output(text: str) - dict: text text.strip() # 尝试匹配最终答案 final_answer_match re.search(r“Final Answer[:]\s*(.*?)(?\n|$)”, text, re.IGNORECASE | re.DOTALL) if final_answer_match: # 尝试提取最终答案前的思考 thought_before re.search(r“Thought[:]\s*(.*?)(?\nFinal Answer|\nAction:|$)”, text, re.IGNORECASE | re.DOTALL) thought thought_before.group(1).strip() if thought_before else “” return { “type”: “answer”, “thought”: thought, “answer”: final_answer_match.group(1).strip() } # 尝试匹配行动 action_match re.search(r“Action[:]\s*(.*?)(?\nAction Input:|$)”, text, re.IGNORECASE | re.DOTALL) action_input_match re.search(r“Action Input[:]\s*(.*?)(?\nThought:|\nFinal Answer:|$)”, text, re.IGNORECASE | re.DOTALL) thought_match re.search(r“Thought[:]\s*(.*?)(?\nAction:|\nFinal Answer:|$)”, text, re.IGNORECASE | re.DOTALL) if action_match and action_input_match: thought thought_match.group(1).strip() if thought_match else “” return { “type”: “action”, “thought”: thought, “tool”: action_match.group(1).strip(), “tool_input”: action_input_match.group(1).strip() } # 如果都不匹配可能是LLM输出了非标准格式或者直接给出了答案 # 这里做一个简单的启发式判断如果文本较短且没有明显的行动指令视为最终答案 if len(text) 150 and not re.search(r“Action|工具|调用”, text): return {“type”: “answer”, “thought”: “”, “answer”: text} # 否则视为解析错误 return {“type”: “error”, “raw”: text}4.4 组装与运行测试现在把所有部分组装起来进行端到端测试。# main.py from llm_client import OpenAIClient from tools import get_tools from mini_cursor import MiniCursorAgent def main(): # 1. 初始化LLM客户端请替换为你自己的API KEY llm_client OpenAIClient(model“gpt-3.5-turbo”, api_key“your-api-key-here”) # 2. 获取工具列表 tools get_tools() # 3. 创建Agent agent MiniCursorAgent(llmllm_client.generate, toolstools, max_iterations5) # 4. 运行测试 test_queries [ “计算一下 (15 7) * 3 等于多少”, “Python是谁发明的”, “先告诉我地球的周长再计算这个数字除以1000是多少。” ] for query in test_queries: print(f“\n 用户问题{query} “) answer agent.run(query) print(f“助手回答{answer}”) print(“ 结束 \n”) if __name__ “__main__”: main()运行这个脚本你应该能看到类似以下的输出清晰地展示了Agent的思考过程 用户问题先告诉我地球的周长再计算这个数字除以1000是多少。 助手回答地球的赤道周长约为40075公里。40075除以1000等于40.075。 结束 在后台Agent的agent_scratchpad会记录下完整的轨迹Thought: 用户需要两个信息地球周长和该数字除以1000的结果。我需要先获取地球周长。 Action: 搜索 Action Input: 地球周长 Observation: 地球的赤道周长约为40075公里。 Thought: 我已经获得了地球周长的数据现在需要计算40075除以1000。 Action: 计算器 Action Input: 40075 / 1000 Observation: 40.075 Thought: 我已经获得了足够的信息。 Final Answer: 地球的赤道周长约为40075公里。40075除以1000等于40.075。5. 常见问题与排查技巧实录在开发和测试mini-cursor的过程中我遇到了不少典型问题。这里把它们整理成排查清单希望能帮你快速定位问题。5.1 Agent陷入无限循环或重复调用同一工具症状Agent不停地调用同一个工具或者来回执行几个动作始终无法输出最终答案。排查步骤检查工具描述这是最常见的原因。工具描述是否清晰、无歧义LLM是否误解了工具的用途尝试修改描述使其更精确。检查观察结果工具返回的Observation是否清晰、完整如果工具返回了错误信息或模糊的结果LLM可能无法理解从而反复尝试。确保工具的错误信息对人类和LLM都友好。查看思考链打印出每一轮的agent_scratchpad。观察LLM的“Thought”部分看它的推理逻辑是否合理。有时LLM的思考会陷入死胡同。调整提示词在系统指令中增加约束例如“如果同一个工具连续调用三次仍未获得有效信息请尝试其他方法或承认无法解决。”设置迭代上限就像我们代码中的max_iterations这是一个必要的安全阀。5.2 LLM输出格式不符合预期解析失败症状解析器频繁返回error类型或者解析出的工具名、输入参数是错的。排查步骤打印原始输出第一时间将LLM返回的原始文本打印出来。99%的问题可以通过肉眼发现比如多了奇怪的换行、使用了中文标点、关键词拼写错误等。强化提示词格式指令在提示词中用更醒目的方式如括起来强调输出格式并给出多个清晰的正反面示例。优化解析器正则使用更宽松、容错性更强的正则表达式如使用re.IGNORECASE忽略大小写用.*?非贪婪匹配处理好可能存在的空格和换行变体。后处理LLM输出在解析前可以对LLM输出进行简单的清洗比如去除首尾空行将中文冒号统一替换为英文冒号等。5.3 工具调用结果不佳导致任务失败症状Agent调用了正确的工具但任务还是失败了因为工具返回的结果质量不高。排查与优化工具输入预处理LLM给出的tool_input可能包含多余的自然语言描述。例如对于计算器它可能输出“请计算一下sin(30)的值”。你的工具_run函数开头应该有一层预处理尝试从字符串中提取出纯粹的数学表达式“sin(30)”。工具结果后处理工具返回的结果可能过于冗长或包含无关信息。设计一个精简或格式化的步骤提取出核心信息作为Observation。例如一个网页搜索工具返回整个HTML你需要从中提取出文本摘要。引入验证工具对于一些关键操作可以设计一个“验证”工具。例如在调用“发送邮件”工具前先调用“确认邮件内容”工具让LLM二次确认减少错误。5.4 性能与成本优化上下文长度agent_scratchpad会随着循环轮次增长可能很快触及LLM的上下文长度限制。需要设计截断策略例如只保留最近3-4轮的交互或者将更早的历史总结成一段摘要。LLM调用延迟每一轮思考都是一次LLM API调用延迟累加可能很可观。对于简单任务可以尝试在提示词中鼓励LLM“一步到位”减少不必要的工具调用。Token消耗长上下文意味着高Token消耗。除了截断历史还可以考虑使用更便宜的模型进行部分轮次的思考例如用gpt-3.5-turbo代替gpt-4或者对工具描述进行压缩。一个高级技巧让Agent学会“反思”。你可以在agent_scratchpad的组装中不仅记录步骤还在每轮之后让LLM自己对当前进展做一个小结。这有时能帮助它在后续步骤中做出更连贯的决策。实现起来就是在更新scratchpad时不仅拼接原始记录还可以问LLM一句“基于以上步骤我们目前取得了什么进展下一步最应该关注什么”并将这个小结也放入上下文。这稍微增加了复杂度但对于复杂任务非常有效。通过这个从零构建mini-cursor的完整过程我们不仅实现了一个可运行的Agent更重要的是我们亲手触摸了Agent每一个组件的脉搏理解了它们如何协同工作。下次当你使用LangChain的initialize_agent时你看到的将不再是一个魔法函数而是一个由清晰的数据流和控制逻辑构成的、你可以预测和调试的系统。这才是“完全实战”带来的真正力量——从知其然到知其所以然最终到创造属于你自己的“然”。
返回列表