公司动态

从零实现50行Python代码的AI Agent:深入理解ReAct范式与工具调用

📅 2026/8/12 12:02:25
从零实现50行Python代码的AI Agent:深入理解ReAct范式与工具调用
1. 从“大而全”到“小而美”为什么我们需要一个50行的Agent最近和几个做AI应用的朋友聊天发现一个挺有意思的现象大家一提到要搞个AI Agent第一反应就是去翻LangChain、AutoGen或者CrewAI的文档。这些框架确实强大功能齐全社区活跃文档也写得不错。但问题也随之而来——为了一个简单的、验证性的想法我们往往需要先花半天时间理解框架的抽象概念再花半天时间配置环境、处理依赖冲突最后写出来的代码里属于自己核心逻辑的部分可能还不到20%。更头疼的是当你想调整一下Agent的思考流程或者想看看某个中间状态到底发生了什么时你发现自己被困在了框架预设的“黑盒”里调试起来异常痛苦。这让我想起了早期学编程时老师总说“不要一上来就用框架先理解底层原理”。这句话放在Agent开发上同样适用。一个50行代码的Agent其价值不在于它功能有多强大而在于它足够“透明”和“可控”。它剥离了所有非必要的抽象层让你能清晰地看到从用户输入到LLM思考再到工具调用和最终输出的完整链路。这对于理解Agent的核心工作模式——尤其是经典的ReActReasoning Acting范式——至关重要。通过亲手实现一个微型Agent你能彻底搞明白几个关键问题LLM的提示词Prompt是如何引导其进行链式思考的工具Tools的接口应该如何设计才能让LLM理解和调用Agent的内部状态State该如何管理和传递这些认知是直接使用成熟框架时很难深刻体会到的。当你掌握了这些“元知识”再回头去用那些大框架你会更加得心应手知道它们每个组件在解决什么问题甚至能在框架不满足需求时自己动手进行定制或优化。所以这篇内容的目标很明确我们不依赖任何第三方Agent框架仅用Python标准库和OpenAI API或其他你喜欢的LLM API用大约50行代码构建一个具备基础ReAct推理能力的微型Agent。这个过程更像是一次“外科手术式”的解剖让我们看清Agent的“五脏六腑”。2. 核心蓝图ReAct范式的极简实现在开始写代码之前我们必须先统一思想明确我们要构建的Agent究竟遵循什么样的工作流程。这里我们选择实现ReAct范式这是目前最经典、也最易于理解的Agent架构之一。ReAct的核心思想是让LLM在“思考”和“行动”之间循环。具体来说它包含以下几个步骤观察ObservationAgent接收来自用户的问题Question和来自外部环境或工具的反馈Observation。思考ReasoningLLM基于当前的观察进行内部推理分析现状并计划下一步该做什么。这一步的输出是“思考轨迹”Thought。行动Acting根据上一步的思考LLM决定是调用某个工具Action来获取新信息还是已经得出最终答案可以结束任务Final Answer。循环如果决定调用工具则执行工具将工具返回的结果作为新的“观察”送入下一轮循环。这个循环会一直持续直到LLM认为它已经掌握了足够的信息可以给出最终答案为止。整个过程LLM的“思考”和“行动”都被记录并暴露出来形成了可解释的推理链。那么在一个极简实现中我们需要哪些核心组件呢一个LLM客户端用于发送提示词和接收回复。我们将使用openai库但设计上会保持接口通用性。一套工具Tools赋予Agent行动能力。我们将实现两个最基础的工具一个网络搜索模拟和一个计算器。一个提示词Prompt模板这是Agent的“大脑”和“操作规程”它必须清晰地定义ReAct的格式、可用工具以及输出规范。一个主循环Loop负责管理对话状态拼接提示词调用LLM解析输出并执行工具调用。我们的代码结构将围绕这四个部分展开。为了让逻辑更清晰我们会先定义工具和提示词模板再实现主循环。整个代码将保持在一个Python文件中无需任何额外的目录结构。3. 工具定义赋予Agent“手”和“脚”工具是Agent与外部世界交互的桥梁。在框架中工具通常被抽象成带有复杂描述和验证逻辑的类。在我们的极简版本里我们只关注最本质的东西一个工具就是一个可以被调用的函数以及一段能让LLM理解它用途的描述。我们先来实现两个工具工具一模拟搜索search在真实场景中这可能需要调用Serper API、Google Search API等。为了简化且避免网络依赖我们实现一个“模拟”搜索函数它根据查询关键词返回一段预设的文本。这足以演示工具调用的完整流程。import json def search(query: str) - str: 模拟网络搜索工具。 根据查询词返回一段预设的文本信息。 # 一个简单的模拟数据库 knowledge_base { 上海天气: 上海今天晴转多云气温15-22摄氏度东南风3-4级。, Python创始人: Python语言的创始人是吉多·范罗苏姆Guido van Rossum。, OpenAI: OpenAI是一家人工智能研究公司推出了GPT系列模型。, 11: 这是一个基本的数学运算问题。 } # 简单匹配实际应用应使用更复杂的匹配或真实API for key in knowledge_base: if key in query: return knowledge_base[key] return f未找到与 {query} 直接相关的信息。工具二计算器calculator这个工具直接使用Python的eval函数来执行数学表达式。请注意在生产环境中直接使用eval是极度危险的因为它会执行任意代码。这里仅用于演示真实场景中必须使用安全的数学表达式解析库如ast.literal_eval配合自定义解析器。def calculator(expression: str) - str: 计算数学表达式。 警告此实现使用eval仅用于演示存在安全风险。 try: # 极度危险仅用于演示。 result eval(expression) return str(result) except Exception as e: return f计算错误{e}有了工具函数我们还需要一份“工具说明书”让LLM知道它有哪些工具可用以及每个工具怎么用。我们用字典来定义# 工具定义表 TOOLS { search: { function: search, description: 当你需要获取实时或事实性信息如天气、新闻、概念解释时使用此工具。输入应为搜索查询字符串。 }, calculator: { function: calculator, description: 当需要进行数学计算时使用此工具。输入应为合法的数学表达式字符串例如 3 * (4 5)。 } } # 生成给LLM看的工具描述文本 def get_tools_description(): descriptions [] for name, info in TOOLS.items(): descriptions.append(f{name}: {info[description]}) return \n.join(descriptions)这样get_tools_description()函数就能生成一段清晰的文本告诉LLM“你现在有两个工具一个叫search用来查资料一个叫calculator用来算数。”4. 大脑的指令精心设计提示词模板提示词是Agent的灵魂它直接决定了LLM的行为模式。一个糟糕的提示词会让聪明的模型表现得像个傻瓜。我们的提示词需要完成以下几件事设定角色告诉LLM它现在是一个Agent。交代任务明确它的目标是回答问题。说明规则严格规定它必须按照“Thought: ... Action: ... Observation: ...”的格式进行输出。提供工具列出所有可用工具及其用法。定义终止条件告诉它什么情况下应该输出“Final Answer:”。下面是我们精心设计的提示词模板。注意我们使用了三重引号来定义多行字符串并使用花括号{}作为占位符用于在运行时插入变量如工具描述、对话历史等。# 系统提示词模板 SYSTEM_PROMPT_TEMPLATE 你是一个智能助手必须严格按照以下格式进行回应以完成任务。 你可以使用以下工具 {tools_descriptions} 你必须遵循的格式 Thought: 这里是你对当前情况的分析和下一步计划。 Action: 你要调用的工具名称必须是以下之一[{tool_names}]。如果你认为已经可以回答问题则 Action 为 “Final Answer”。 Action Input: 调用工具时需要的输入内容必须是一个字符串。如果 Action 是 “Final Answer”则此项为空。 Observation: 工具返回的结果。如果 Action 是 “Final Answer”则此项为空。 ...这个 Thought/Action/Action Input/Observation 循环可以重复多次 最终当你拥有足够信息时你必须输出 Thought: 我已经得到所有需要的信息。 Action: Final Answer Action Input: Observation: Final Answer: 这里是你对用户问题的最终答案。 现在开始。所有对话历史如下 {history} 当前问题{question} 这个模板有几个关键设计点工具列表动态化{tools_descriptions}和{tool_names}会在程序运行时被替换这样我们增减工具时只需修改TOOLS字典提示词会自动更新。严格的格式约束明确要求LLM以“Thought:”、“Action:”等为前缀输出。这便于我们后续用程序进行解析Parsing。LLM特别是GPT-4对这种结构化格式的遵循能力很强。包含对话历史{history}占位符将包含之前所有轮次的Thought/Action/Observation记录这为Agent提供了完整的上下文使其能进行多轮复杂推理。清晰的终止信号明确给出了输出最终答案的格式范例减少了LLM的困惑。提示在实际使用中你可能会发现LLM偶尔不按格式输出。除了优化提示词更健壮的做法是在代码中加入输出格式的校验和修复逻辑例如使用正则表达式进行匹配或在提示词中提供更详细的示例Few-Shot Prompting。为了保持代码简洁本文暂不展开。5. 主循环实现串联一切的引擎现在我们有了工具有了“大脑指令”提示词最后需要实现一个驱动整个流程的引擎——主循环。这个循环将负责维护对话历史。根据历史和当前问题组装完整的提示词。调用LLM获取回复。解析LLM的回复判断是调用工具还是给出最终答案。如果调用工具则执行工具函数并将结果作为“Observation”加入历史进入下一轮循环。如果给出最终答案则结束循环返回答案。以下是主循环的核心代码。我们假设你已经设置了OpenAI的API密钥环境变量OPENAI_API_KEY。import os import re from openai import OpenAI # 初始化OpenAI客户端 client OpenAI(api_keyos.environ.get(OPENAI_API_KEY)) def run_agent(question: str, max_turns: int 5) - str: 运行Agent主循环。 :param question: 用户问题 :param max_turns: 最大循环轮次防止无限循环 :return: 最终答案字符串 history [] # 用于存储每一轮的 Thought, Action, Action Input, Observation tools_descriptions get_tools_description() tool_names , .join(TOOLS.keys()) for turn in range(max_turns): # 1. 构建当前轮次的完整提示词 history_text \n.join(history) if history else 无 prompt SYSTEM_PROMPT_TEMPLATE.format( tools_descriptionstools_descriptions, tool_namestool_names, historyhistory_text, questionquestion ) # 2. 调用LLM try: response client.chat.completions.create( modelgpt-3.5-turbo, # 也可使用 gpt-4 messages[{role: user, content: prompt}], temperature0.1, # 低温度使输出更稳定、更遵循格式 max_tokens500 ) llm_output response.choices[0].message.content.strip() except Exception as e: return f调用LLM API时出错{e} # 3. 解析LLM的输出这是关键且容易出错的一步 # 使用正则表达式匹配关键字段 thought_match re.search(rThought:\s*(.*?)(?\nAction:|$), llm_output, re.DOTALL) action_match re.search(rAction:\s*(.*?)(?\nAction Input:|$), llm_output, re.DOTALL) action_input_match re.search(rAction Input:\s*(.*?)(?\nObservation:|$), llm_output, re.DOTALL) final_answer_match re.search(rFinal Answer:\s*(.*), llm_output, re.DOTALL) thought thought_match.group(1).strip() if thought_match else action action_match.group(1).strip() if action_match else action_input action_input_match.group(1).strip() if action_input_match else # 4. 将本轮LLM的“输出”记录到历史中 current_step fThought: {thought}\nAction: {action}\nAction Input: {action_input} history.append(current_step) # 5. 判断并执行动作 if action Final Answer: # 找到最终答案结束循环 final_answer final_answer_match.group(1).strip() if final_answer_match else 未找到明确最终答案。 # 将最终答案也加入历史便于查看完整流程 history.append(fObservation: \nFinal Answer: {final_answer}) print(\n 完整推理链 ) print(\n.join(history)) return final_answer elif action in TOOLS: # 执行工具调用 try: tool_function TOOLS[action][function] observation tool_function(action_input) except Exception as e: observation f调用工具 {action} 时出错{e} # 将观察结果记录到历史作为下一轮LLM的输入 history.append(fObservation: {observation}) print(fTurn {turn1}: {action}({action_input}) - {observation[:50]}...) # 打印进度 else: # LLM输出的Action不在工具列表中可能是格式错误或未知工具 error_msg fLLM返回了未知或格式错误的Action: {action}. 本轮输出{llm_output[:200]} history.append(fObservation: {error_msg}) # 可以选择直接退出或继续这里我们选择返回错误 return fAgent执行过程中出现错误{error_msg} # 如果循环结束仍未得到最终答案 return f经过 {max_turns} 轮推理仍未得出最终答案。当前历史\n \n.join(history) # 示例运行Agent if __name__ __main__: user_question 上海今天的天气怎么样如果气温是22摄氏度相当于多少华氏度 answer run_agent(user_question) print(f\n用户问题{user_question}) print(fAgent最终答案{answer})代码逐段解析与避坑指南历史history的管理我们用一个简单的字符串列表来存储每一轮的完整输出。每一轮结束后我们将Thought、Action和Action Input拼接成一个字符串加入历史。当工具返回结果后再将Observation加入历史。这样在构建下一轮的提示词时history_text就包含了之前所有的推理步骤为LLM提供了完整的上下文。这是实现多步推理的关键。提示词组装在每一轮循环开始时我们都用当前的history、question以及工具描述来填充提示词模板。这意味着LLM每次看到的都是完整的对话进程。LLM调用参数这里使用了temperature0.1。较低的temperature值会使LLM的输出更加确定性和一致性这对于需要严格遵循格式的Agent任务非常重要可以减少输出格式的随机性错误。max_tokens限制了单次回复的长度防止输出过长。输出解析Parsing这是整个循环中最脆弱但也最关键的一环。我们使用了正则表达式re.search来从LLM的文本回复中提取Thought、Action等字段。为什么用正则表达式因为它简单、直接且在我们的严格格式要求下足够有效。更健壮的方案是要求LLM输出JSON格式然后直接解析JSON或者使用LangChain等框架提供的OutputParser组件。正则表达式的风险如果LLM的输出格式稍有偏差比如多了一个空格换行符不一致正则表达式就可能匹配失败。我们的模式rThought:\s*(.*?)(?\nAction:|$)使用了非贪婪匹配(.*?)和前瞻断言(?\nAction:|$)旨在匹配从“Thought:”开始到下一个“Action:”或字符串结尾为止的内容这在一定程度上提高了容错性。必须添加错误处理代码中对每个字段的匹配结果都进行了判断if match如果匹配失败字段会被设为空字符串并在后续逻辑中可能导致错误。在生产环境中这里需要更完善的错误处理和重试机制。工具调用与状态更新如果解析出的action是Final Answer则提取最终答案并结束循环。如果action是一个已知工具名则从TOOLS字典中取得对应的函数并执行将结果存入observation然后将其加入历史。如果action既不是Final Answer也不是已知工具则说明LLM输出不符合预期程序会记录错误并退出。循环终止条件我们设置了max_turns默认为5来防止Agent陷入无限循环。对于一些复杂问题可能需要更多轮次但这个安全阀是必要的。运行上面的示例代码你会看到类似以下的输出具体内容因LLM的随机性可能略有不同Turn 1: search(上海天气) - 上海今天晴转多云气温15-22摄氏度东南风3-4级。... Turn 2: calculator(22 * 9/5 32) - 71.6... 完整推理链 Thought: 用户问了两个问题上海的天气以及22摄氏度换算成华氏度。我需要先获取天气信息然后进行温度换算。 Action: search Action Input: 上海天气 Observation: 上海今天晴转多云气温15-22摄氏度东南风3-4级。 Thought: 我已经得到了上海的天气信息。现在需要将22摄氏度转换为华氏度。转换公式是 F C * 9/5 32。 Action: calculator Action Input: 22 * 9/5 32 Observation: 71.6 Thought: 我已经得到所有需要的信息。 Action: Final Answer Action Input: Observation: Final Answer: 上海今天晴转多云气温15-22摄氏度东南风3-4级。其中22摄氏度约等于71.6华氏度。 用户问题上海今天的天气怎么样如果气温是22摄氏度相当于多少华氏度 Agent最终答案上海今天晴转多云气温15-22摄氏度东南风3-4级。其中22摄氏度约等于71.6华氏度。可以看到Agent成功地进行了两步推理先搜索天气再计算温度换算最后整合信息给出了最终答案。整个思考过程清晰可见。6. 从“玩具”到“工具”优化与扩展思路我们的50行核心代码已经展示了一个可工作的Agent雏形。但正如你所见它还很脆弱像一个精致的“玩具”。要把它变成一个可靠的“工具”我们需要在以下几个关键方面进行强化1. 健壮性提升让Agent更稳定输出解析加固正则表达式是脆弱的。更优解是使用结构化输出Structured Outputs。例如在调用LLM时要求其以指定的JSON格式返回。OpenAI的Chat Completions API支持通过response_format参数指定JSON Schema这能极大提高输出的一致性。如果使用的模型不支持此功能可以在提示词中更严格地要求输出JSON并使用json.loads()进行解析同时做好异常捕获。错误处理与重试网络请求可能失败LLM可能返回无法解析的内容工具函数可能抛出异常。主循环中每个与外部交互的步骤API调用、工具执行都应该有try...except包裹。对于可恢复的错误如格式错误可以设计重试逻辑例如将错误信息作为Observation反馈给LLM让它自我修正。超时与循环控制除了最大轮次还应设置总耗时超时。对于某些卡住的场景比如LLM反复调用同一个工具得不到新信息可以设计更智能的终止逻辑例如检测到历史中出现重复的Thought-Action模式就提前退出。2. 能力扩展让Agent更强大工具扩展TOOLS字典的设计使得添加新工具非常方便。只需定义好函数和描述将其加入字典即可。你可以集成真正的搜索引擎API、数据库查询、代码执行环境、企业内部系统接口等。记忆与状态管理我们当前的history是简单的会话记忆。对于更复杂的任务你可能需要长期记忆将重要的历史信息向量化后存入数据库如ChromaDB供后续会话检索。状态总结在对话轮次较多时完整的history可能会超出LLM的上下文长度限制。此时需要引入“总结”步骤定期将冗长的历史压缩成精炼的摘要再喂给LLM。多Agent协作单个Agent能力有限。你可以创建多个具有不同专长如“研究员”、“写手”、“校对员”的Agent实例让它们通过共享一个工作空间或互相传递消息来协同完成任务。这需要设计更复杂的协调机制如一个“主控”Agent来分配任务。3. 效率与成本优化提示词优化我们的系统提示词还可以精炼。使用少样本示例Few-Shot在提示词中提供几个完美的输入输出对能更有效地引导LLM遵循格式。对于复杂工具可以提供更具体的调用示例。上下文管理LLM的上下文窗口是宝贵的资源。除了上述的状态总结还可以有选择地将历史中的Observation尤其是冗长的工具返回结果进行摘要后再存入历史只保留关键信息。模型选择对于简单的工具调用任务gpt-3.5-turbo通常足够且成本更低。对于需要复杂规划或推理的任务再考虑使用gpt-4。可以根据任务难度动态选择模型。4. 监控与可观测性日志记录将每一轮的prompt、llm_output、action、observation都详细记录下来这对于调试和优化Agent行为至关重要。链路追踪Tracing在分布式或复杂流程中可以使用像OpenTelemetry这样的标准来追踪一个请求在多个Agent或工具间的完整流动路径便于定位性能瓶颈或错误源头。实现这些优化后你的微型Agent将逐渐具备生产级应用的雏形。这个从零搭建的过程会让你对市面上那些成熟框架的每个设计决策都有更深刻的理解。7. 与主流框架的对比我们获得了什么放弃了什么最后让我们把目光拉回起点对比一下我们这个50行的“手搓”Agent和直接使用LangChain这样的框架到底有什么区别。我们获得的东西极致的透明度和控制力每一行代码你都知道在干什么。出现bug时你可以迅速定位到是提示词问题、解析问题还是工具函数问题。你可以随心所欲地修改Agent的决策逻辑、状态管理方式。深刻的概念理解你亲手实现了ReAct循环、工具调用、历史管理这些核心概念。现在你去读LangChain的AgentExecutor源码会发现它无非是用更优雅、更健壮的方式实现了类似我们主循环的逻辑周围包裹了更多功能组件。无依赖的轻量级部署你的代码库可能只需要openai这一个第三方库如果你用其他LLM服务甚至可能只需要requests。这意味著更小的镜像、更快的启动速度、更少的依赖冲突。定制的灵活性如果你的业务逻辑非常特殊框架的抽象反而会成为束缚。而你的微型Agent可以轻松地融入现有的代码架构或者实现一些框架不支持的古怪工作流。我们放弃的东西开箱即用的丰富功能LangChain提供了数十种现成的工具搜索引擎、维基百科、Python REPL等、多种Agent类型ReAct, Plan-and-Execute, OpenAI Functions等、以及链Chains、记忆Memory、索引Indexes等一系列高级组件。我们需要自己从头实现每一个需要的功能。工业级的健壮性框架经过了大量生产环境的测试处理了各种边界情况如LLM输出格式错误、工具调用超时、上下文窗口管理等。我们自己实现的简单版本需要投入大量工作才能达到同等稳定性。活跃的社区和生态使用主流框架意味着可以轻松找到大量的教程、示例代码、现成的集成方案和遇到问题时的社区解答。自己造轮子则要独自面对所有挑战。开发效率对于大多数标准场景使用框架能让你在几分钟内搭建一个可用的Agent原型。自己实现则需要更多时间。结论与选择建议这个50行的Agent不是一个用来替代LangChain的生产方案而是一个绝佳的教学工具和原型验证工具。如果你是学习者强烈建议按照本文的思路亲手实现一遍。这是理解Agent内核最快、最深刻的方式。如果你在验证一个非常新颖的AI工作流想法框架的复杂性可能会干扰你的核心创意。先用极简代码实现核心循环验证想法是否跑得通之后再考虑用框架重构以获得更好的工程支持。如果你的需求极其简单且固定比如就是一个内部用的、调用一两个固定工具的小助手那么这个微型Agent可能就足够了引入一个大框架反而是过度设计。对于大多数正式项目当你理解了原理之后还是应该选择像LangChain、LlamaIndex这样的成熟框架。它们能帮你节省大量时间让你更专注于业务逻辑本身而不是重复造轮子。编程的世界里最好的工具不是最强大的那个而是最适合你当下场景的那个。这次从零构建Agent的经历正是为了帮你获得这种“选择”的能力。下次当你启动一个新AI项目时你可以自信地判断这次我是该直接pip install langchain还是先花半小时写一个属于自己的、50行的核心循环