ReAct智能体架构实战:让大模型学会调用工具的完整指南 如果你只是想让大模型帮你写一段文案那它自带的知识就够用了但如果你希望它去查今天的天气、算一笔复杂账、自动把网站内容整理成表格光靠模型“背诵”知识根本做不到。这就是ReAct智能体架构要解决的问题让大模型在思考之后实际去调用外部工具再把工具结果带回来继续推理形成一个闭环。我在实际项目中落地智能体时ReAct是第一套真正让业务跑起来、也让团队愿意继续投入的架构。这篇文章会围绕ReAct的核心原理、数据流、最小可运行代码、工具封装、常见坑点以及工程化评估展开。内容偏向“收藏级教程”既适合刚接触智能体的新手对照复现也适合已经写过简单Prompt、想更进一步把模型接入真实业务的开发者。我会尽量把每一步的“为什么”也讲清楚因为这套架构的坑大部分不在代码逻辑而在你对模型行为的理解。1. 为什么说只靠大模型“思考”是不够的从CoT到知行合一1.1 CoT能推理但不能改变世界先说一个基础概念CoTChain-of-Thought也就是思维链。它的核心做法是让模型在给出答案之前先把推理过程写出来。比如你问“一个西瓜30元3个人平摊再打八折每人付多少”模型如果直接回答可能算出个20但如果强制它写“原价30三人平摊每人10元打八折后8元”错误概率就会低很多。CoT本质上在利用模型的语言生成能力去“模拟计算过程”它确实能提升复杂推理任务的准确率。但CoT有个根本性局限它只能利用模型参数里已有的知识。模型训练时看过的世界是“过去的世界”它不知道此刻的实时气温、最新股价、某个接口是否可用。你让模型“推理”一个它根本没见过的信息它只能根据概率去编一个最像样的答案这就成了幻觉。我举个自己踩过的例子最早我做一个客服问答机器人用户问“我们小区明天几点停水”模型一本正经地给出了一个时间表。后来查证那个时间表完全是编的。问题不在于模型不聪明而在于它没有工具去查停水公告。CoT只解决了“怎么想”没有解决“怎么去查、怎么去做”。1.2 ReAct与Function Calling的关系一套思想两种形态ReAct这个名字来自Reason i ng和Act i ng的组合。它的想法很直白不要只让模型坐在那里“想”而是让它交替进行“思考-行动-观察”。思考决定下一步做什么行动去调用外部工具观察把工具返回的真实结果带回上下文再继续思考。很多读者会问这和大模型平台提供的Function Calling函数调用有什么区别其实两者是同一套思想的不同工程形态。Function Calling是模型服务商把“工具定义”结构化地传给模型模型直接返回一个JSON格式的函数名和参数由你的代码来执行函数。ReAct则更强调把Thought思考轨迹也保留下来让整个决策过程可解释。你完全可以用ReAct思路手写一个函数调用循环也可以在Function Calling之上再加一层“Thought”。从落地角度我更喜欢手写ReAct尤其是在调试阶段。框架封装好的Agent往往把“模型输出解析”和“工具调度”藏在黑盒里一旦出错很难定位。手写版虽然要多写几百行代码但它让你对整个流程了如指掌模型什么时候在瞎猜、工具结果有没有被正确回传、上下文中积累了哪些垃圾信息全都看得清清楚楚。2. ReAct的完整数据流Thought、Action与Observation的闭环2.1 一个完整回合的拆解Thought、Action、Observation为了说清ReAct的数据流我以一个最经典的“查天气”场景为例。用户问“北京今天需要带伞吗”一个标准的ReAct交互过程是这样的System: 你是一个智能助手可以调用工具获取实时数据。请按格式输出Thought、Action和Final Answer。 User: 北京今天需要带伞吗 Assistant: Thought: 用户需要实时的天气信息我并不知道今天的天气应该调用天气查询工具。Action: get_weather\nAction Input: {city: 北京} User工具结果: Observation: {city: 北京, weather: 小雨, temperature: 18} Assistant: Thought: 北京今天下雨应该建议用户带伞。Final Answer: 北京今天有小雨气温18度建议带上雨伞。这里面有三个关键信息。Thought是模型的内部推理它不能直接返回给用户看但你可以把它记录到日志里Action是模型决定调用的工具名称和参数Observation是工具返回给模型的真实结果。模型看到Observation后再继续生成下一轮Thought或直接给出Final Answer。整个循环看起来简单但它是“模型与世界交互”的最小闭环。没有这个闭环模型就是一个自说自话的聊天窗口有了这个闭环模型才能回答“今天是否下雨”这类需要实时数据的问题。2.2 为什么Observation必须显式放回对话上下文我在教同事写ReAct时经常被问到一个问题工具结果已经拿到了为什么还要把它拼进messages里再发给模型直接用一个变量存起来不行吗关键原因在于大模型本身是无状态的。它每次生成回答时只能看到当前传给它的上下文。如果你用Python变量存了Observation但下一轮请求的messages里没有包含它模型就完全不知道刚才工具返回了什么它会重新开始“猜测”。这就像你让一个人去查完资料却把资料藏起来不给他看却问他“你查到了什么”他只能继续瞎编。所以必须把Observation作为一条新消息通常是roleuser或roletool追加到对话历史里再连同之前的System、User、Assistant消息一起发给模型。只有模型“亲眼看到”工具返回的真实数据它才会基于事实继续推理而不是凭记忆编造。这也解释了为什么ReAct对上下文的消耗比较大。每一步决策工具结果都会留在上下文里所以后面要专门讨论截断和记忆优化。但这是保证正确性的必要代价。3. 动手搭建一个最小可运行的ReAct智能体关键代码3.1 环境准备与模型接入方式先声明一下下面这段代码追求的是“原理清晰”不追求做复杂工程。我用纯Python实现只依赖requests库所有模型调用都通过一个抽象函数call_llm()完成。你可以在不修改主循环的前提下把call_llm()替换成任意大模型服务的SDK调用。import json import re import requests # 这是一个模型调用的抽象层实际使用时可替换为具体服务的SDK def call_llm(messages, temperature0.0): messages: 对话历史格式为[{role: system, content: ...}, ...] temperature: 越低越稳定 返回模型生成的文本字符串 # 示例一个兼容多数API的HTTP调用框架 # 这里务必用环境变量保存密钥不要写死 api_key os.environ.get(LLM_API_KEY) resp requests.post( urlhttp://localhost:11434/v1/chat/completions, # 示例地址 headers{Authorization: fBearer {api_key}}, json{ model: qwen2.5:latest, messages: messages, temperature: temperature, max_tokens: 800, }, timeout60, ) resp.raise_for_status() return resp.json()[choices][0][message][content]上面的代码用了本地服务的示例地址实际情况中你可以换成任何你熟悉的服务端点。有一点请务必记住所有模型API请求都要放在后台服务里不要把密钥写进前端代码。我见过很多初学者把API key直接写在Notebook里最后不小心提交到仓库几分钟内就被盗刷。3.2 工具注册表从get_weather到calculatorReAct里的工具本质上就是一个“名字描述执行函数”的注册表。我习惯用一个Tool类来封装class Tool: def __init__(self, name, description, func): self.name name self.description description # 这个描述会被写入System Prompt非常重要 self.func func def run(self, **arguments): return self.func(**arguments) def get_weather(city: str): 查询指定城市当前天气返回结构化JSON字符串 # 这里我写一个模拟返回实际使用时替换为真实天气API调用 mock_data { 北京: {city: 北京, weather: 小雨, temperature: 18}, 上海: {city: 上海, weather: 多云, temperature: 23}, 广州: {city: 广州, weather: 晴, temperature: 27}, } return json.dumps(mock_data.get(city, {error: 暂时没有该城市数据}), ensure_asciiFalse) def calculator(expression: str): 执行一个简单四则运算表达式返回数值结果 # 注意这里不直接使用eval后面会讲原因 import ast try: tree ast.parse(expression, modeeval) # 基于安全考虑这里只允许数值、加减乘除、括号、幂运算等节点类型 allowed (ast.Expression, ast.BinOp, ast.Num, ast.Add, ast.Sub, ast.Mult, ast.Div, ast.Pow, ast.Constant) for node in ast.walk(tree): if not isinstance(node, allowed): raise ValueError(f不支持的语法节点: {type(node)}) result eval(compile(tree, filename, modeeval), {__builtins__: {}}, {}) return str(result) except Exception as e: return f计算错误: {e} # 注册工具列表 TOOLS [ Tool( nameget_weather, description查询指定城市的实时天气。入参city: 城市名称字符串。返回天气、温度等JSON。, funcget_weather, ), Tool( namecalculator, description计算数学表达式。入参expression: 合法的数学表达式字符串例如(35)*2。返回计算结果。, funccalculator, ), ]工具描述往往被初学者忽略但它才是决定智能体“智商”的关键。模型不会真的“看”你的Python函数代码它只能看到你写在description里的文字。这个描述必须写清楚三件事什么场景用、入参是什么、返回什么。如果描述写得太模糊模型就会在错误的场景调用工具或者生成错误的参数。3.3 主循环实现解析模型输出并执行工具接下来是核心主循环。每一轮我们把包含工具列表的描述放到System里让模型输出固定的Action格式然后用正则解析执行工具把Observation追加回messages再次调用模型直到模型输出Final Answer或达到最大步数。def build_system_prompt(tools): tool_docs \n.join( f- {tool.name}: {tool.description} for tool in tools ) return f你是一个严谨的智能助手可以调用以下工具来获取信息或进行计算 {tool_docs} 你每次只能输出以下两种格式之一 1. Action格式当需要调用工具时 Thought: [简要说明你的思路] Action: [工具名称] Action Input: [JSON格式的参数] 2. 最终回答格式当已经获得足够信息时 Thought: [简要说明你的思路] Final Answer: [给用户的最终答案] 请务必遵守 - 不要编造工具返回结果只能根据Observation中的信息作答。 - 如果某个工具调用失败请使用另一个工具或直接说明无法完成。 - 不要重复执行已经执行过的工具。 def run_agent(user_query, toolsNone, max_steps6): tools tools or TOOLS messages [ {role: system, content: build_system_prompt(tools)}, {role: user, content: user_query}, ] for step in range(max_steps): print(f\n Step {step 1} ) response call_llm(messages, temperature0.0) print(f[模型输出]\n{response}) # 1. 尝试解析为工具调用 action_match re.search(rAction:\s*(\w)\s*\nAction Input:\s*(\{.*?\}), response, re.DOTALL) if action_match: action_name action_match.group(1) try: action_input json.loads(action_match.group(2)) except json.JSONDecodeError as e: final_text 工具参数解析失败请重新生成正确的Action Input JSON格式。 messages.append({role: assistant, content: response}) messages.append({role: user, content: fObservation: {final_text}}) continue tool next((t for t in tools if t.name action_name), None) if tool is None: observation_text f错误没有找到名为 {action_name} 的工具。请从已有工具列表中选择。 else: try: observation_text tool.run(**action_input) except TypeError as e: observation_text f参数错误: {e}请检查Action Input字段是否符合工具入参。 except Exception as e: observation_text f工具执行异常: {e} print(f[执行工具] {action_name} {action_input}\n[Observation] {observation_text}) messages.append({role: assistant, content: response}) messages.append({role: user, content: fObservation: {observation_text}}) continue # 2. 如果没有Action检查是否有Final Answer final_match re.search(rFinal Answer:\s*(.*), response, re.DOTALL) if final_match: return final_match.group(1).strip() # 3. 两者都没有说明模型输出格式跑偏了 messages.append({role: assistant, content: response}) messages.append({role: user, content: 格式错误你没有输出Action或Final Answer。请按照System Prompt中的格式重新输出。}) return 达到最大步数任务未完成请尝试简化问题或补充更多信息。 if __name__ __main__: result run_agent(北京今天需要带伞吗) print(f\n[最终结果]\n{result})这段代码已经可以跑通一个最简单的ReAct智能体。你输入“北京今天需要带伞吗”模型会先输出Thought和Action主循环解析到get_weather执行工具得到Observation再把Observation拼回messages模型看到真实天气后会生成Final Answer。3.4 为什么用正则解析而不是直接要求模型输出JSON有读者可能会问为什么不直接让模型输出一个完整的JSON那样解析不是更稳定吗实际踩过坑的我告诉你模型在复杂对话中很容易输出“JSON废话”的混合体比如在JSON外面包一个Markdown代码块或者在JSON后面加一句“希望这个回答对你有帮助”。你用json.loads()直接解析大概率会崩溃。正则方案看似笨但它在格式设计上给了模型更多容错空间。我们只要求模型输出两段关键行Action:和Action Input:即便模型在前后多写了点别的正则依然能提取到我们需要的信息。你还可以进一步用两次解析先找JSON片段再用json.loads()解析失败再要求模型重新生成。还有一个稳定输出的小技巧把生成温度设为0。ReAct循环本身是多步决策每一步的格式错误都会逐级放大所以这里不要追求“创造性”稳定压倒一切。4. 工具层的设计细节从搜索引擎到代码解释器的封装思路4.1 给工具写的描述会决定智能体的智商我在3.2节已经提到工具描述是模型判断“何时调用”的唯一依据。这里再展开说说怎么写好描述。我总结了一个模板工具名尽量短用下划线分割能看明白即可。 描述包含三部分——适用场景、入参字段、返回内容。加一句“不要在其他情况使用”。举个例子。假设你有一个web_search工具如果你只写“搜索引擎”模型可能在任何模糊问题上都去搜索白白浪费步骤。如果你写成“当用户询问实时信息、新闻、最新事件、人物/机构动态或你无法从知识库确认的事实时使用该工具进行网页搜索。入参query: 搜索关键词。返回网页标题、摘要和链接列表以JSON格式返回。若搜索无结果返回空列表。”模型使用工具的准确率会立刻提升。你可以试着把同一个Agent跑两组一组使用模糊描述一组使用模板化描述观察工具调用准确率差距。我实测下来后者在20个测试问题上的工具调用正确率从70%左右提升到了90%以上效果非常显著。4.2 工具返回值的归一化与截断不同工具返回的数据类型千差万别搜索API返回列表、数据库返回元组、计算器返回数字、文件读取返回字符串。如果你不统一直接塞进Observation模型可能会被各种嵌套结构绕晕。我的做法是所有工具统一返回一个“可读性强的字符串”最好是一行或几行JSON。同时工具返回内容会占用上下文长度。很多新手第一次做Agent把搜索返回的20个网页全文都拼进Observation结果下一轮请求直接超过模型上下文限制报错就算没报错长文本也会淹没关键信息模型注意力会被无关内容稀释。我常用的策略是把搜索结果截断成“标题摘要链接”最多保留前5条每一条内容控制在200字以内如果工具原本返回的是超长文本先提取关键实体、关键数字、第一段开头再做拼接在Observation的最后加一句“以上结果已被截断如需完整内容请使用另一次工具调用”。截断本身会丢失信息但总比整个对话崩掉强。你可以根据任务复杂度逐步调整截断长度。4.3 从工具到“危险操作”计算器与代码执行的安全边界ReAct智能体一旦接入代码执行、数据库操作、文件系统等工具就从“建议助手”变成了“行动者”安全边界必须优先考虑。最简单的例子是计算器。很多教程直接让大家用eval()。但在生产环境里模型生成的表达式可能包含恶意内容。即使模型是无意的它也可能生成__import__(os).system(rm -rf /)这种表达式。我前面的代码里用ast限制了解析节点类型这就是一个低成本的防护。如果你需要支持更复杂的解析可以在Python生态里找一个安全的表达式求值库而不是自己裸用eval()。代码解释器类工具更要谨慎。别让Agent在你的服务器上直接跑exec()。正确做法是使用隔离环境本地用子进程超时CPU/内存限制云端用容器或函数计算。如果你只是学习至少也要把代码执行的工具加上“人工确认”开关模型生成代码后打印出来等用户按键确认再执行。安全不是附加功能是Agent能上线的前提。5. 实战中一定会遇到的六个坑及对应解法5.1 模型死循环无限调用同一个工具第一个让我头疼的坑是死循环。AGEnt在执行某个不常见的任务时会反复调用同一个工具比如不停地查天气、不停地搜同一个关键词就是不输出Final Answer。这通常是因为模型在Observation中没有找到它期望的答案又不知道该换策略于是陷入循环。解法至少有两层。第一层是硬限制max_steps必须设置我在代码里默认用6步实际场景5步左右就足够。第二层是“重复检测”你可以在工具执行历史中检查如果同一个工具同一组参数已经出现过就强制停止这次调用并把提示返回给模型“你正在重复执行同一工具请根据已知Observation直接生成Final Answer。”5.2 模型不好好输出格式解析失败的补救模型输出格式跑偏是家常便饭。常见错误有把Action Input写成普通文本而不是JSON在Action前后加了多行分析文字甚至直接输出一段完整回答而没有Final Answer:标记。我在主循环里已经加了一个兜底逻辑如果解析不到Action也解析不到Final Answer就把错误提示塞进Observation让模型自己重新生成。你还可以再加一个“重试计数器”连续两次格式错误就返回失败防止模型一直胡说。注意这个兜底提示也要写清楚正确的格式示例而不是只说“你错了”否则模型可能仍然不知道怎么改。5.3 Observation太长上下文爆炸与注意力稀释前面讲了截断这里从一个真实案例说说为什么必须处理。我曾把一份5000字的PDF摘要交给一个文本处理工具工具原样返回了5000字文本结果下一轮模型既没有正确提取数字还开始“复述”原文片段。原因就是上下文过长导致关键信息被淹没。解法是分级处理先对Observation做摘要把最重要的数字和结论放在最前面如果任务需要原文就只保留对应段落。你可以让模型自己生成Observation的摘要也可以人工写一个提取规则。我的经验是Observation永远不要超过模型单次max_tokens的一半否则会挤压模型生成最终答案的配额。5.4 工具本身报错把错误信息也交给模型工具调用失败时很多初学者的第一反应是直接跳出循环把异常抛给用户。但ReAct的优雅之处在于工具的错误本身也是一种Observation。例如模型调用天气API时因为网络超时报错。如果你把错误信息“TimeoutError: 请求超时”作为Observation返回模型可能会说“工具暂时不可用请稍后再试”也可以提出“调备用天气接口”。如果工具返回了一个空列表模型也能理解“搜索到了0条结果可能关键词不对”进而换关键词重试。所以我强烈建议工具执行函数不要吞异常把异常信息转成字符串返回只有彻底无法恢复的错误才终止循环。5.5 多步工具依赖模型忘记前序结果复杂任务往往需要多步协作先搜索问题背景再用计算器算数然后根据两者结果回答。模型在执行第3步时容易忘记第1步搜索出来的关键背景。这个问题本质上是“短期记忆”不足。我在主循环里除了追加Observation还会在每轮回复前面自动插入一段摘要比如“此前已获得的信息背景XXX计算结果XXX”。你可以用一个小函数在每轮调用前生成这段摘要或者更简单地在System提示词里要求模型每轮Action前先用Thought把“已知信息”总结一遍。后者不用改代码只改提示词就能显著提升多步任务的完成率。5.6 权限过大不要给智能体一把万能钥匙最后一个坑涉及Agent的权限设计。很多Demo为了演示效果给了Agent极大的工具权限能写文件、能执行命令、能访问全部数据库。这在开发机器上或许无所谓但一旦部署到生产环境就是灾难级别的漏洞。我推荐的权限模型是“最小白名单人工审批”。Agent默认只有只读工具比如搜索、查询天气。所有写操作、代码执行、数据删除都必须经过一个人工审批层。审批可以用一个简单的JSON回调Agent生成一个pending_action你推送给用户在聊天界面确认确认后主循环才真正执行。直观上会损失一点“全自动”的效果但在真实业务里稳定和安全远比“显得智能”重要。6. 让智能体真正可用上下文记忆、安全防控与效果评测6.1 短期记忆与长期记忆的取舍在核心的ReAct循环里短期记忆就是整个messages列表。每一步的Thought、Action、Observation都在消耗上下文长度到一定程度后最前面几轮会逐渐“溢出”模型就会忘记用户最初的完整诉求。解决长期记忆有很多方案把历史对话摘要后替换、引入向量数据库做检索增强、把关键实体单独缓存。但我的建议是先别急着上复杂架构。你先把短期记忆这件事做好也就是“每轮合理地截断Observation、并在每条Observation前加上上下文摘要”。很多业务其实不需要长期记忆用户不会在一个对话里聊50轮。只有在短期闭环稳定之后再根据实际数据决定要不要做向量存储否则你会在同一时间踩到多个坑根本定位不了问题。6.2 评测智能体从“看起来对”到“可复用”很多开发者做完一个Agent Demo随手跑几个例子觉得效果不错就宣布“完成”。但真实项目里你必须给它建立评测指标否则根本无法迭代。我常用的评测表长这样指标名称计算方式备注任务完成率测试集中最终给出正确答案的比例需要人工或规则判定工具调用正确率正确工具正确参数的比例独立于最终答案平均轮次完成任务所需Action数量轮次过多说明模型犹豫或死循环格式错误率模型输出不符合Action/Final格式的比例应尽量低于10%成本每任务消耗的token数与轮次强相关注意最终答案“看起来对”不算对。你需要为每个测试问题准备标准答案或者至少准备一个“验证函数”。比如查天气任务验证函数可以断言答案文本里包含“小雨”和“带伞”。有了这套评测集你每次改提示词、改截断策略后都能量化对比效果而不是靠感觉。6.3 手写循环和Agent框架怎么选写到这里你会发现自己手写ReAct并不复杂真正复杂的是工具层和评测层。市面上已经有一些成熟的Agent开发框架如果你只是做原型验证可以组一个试试但我不建议你在不熟悉原理的情况下直接拿框架去做生产项目。手写方案的优点是可控制、可调试、无额外学习成本。坏处是缺少现成的缓存机制、并发控制、日志链路。框架方案的优缺点刚好相反。我的判断标准很简单工具数量少于5个、决策逻辑以搜索和计算为主时手写足够一旦工具超过10个、需要多Agent协作、需要分布式追踪就迁移到框架。但迁移的前提是你已经用全套评测集验证了功能正确性否则框架只会把问题装进一个更难调试的盒子里。最后说点我自己的体会。这个架构看起来只有三个英文词但真正让它稳定工作80%的精力都花在格式解析和工具返回的整理上。我第一次写完ReAct主循环时觉得终于解放了结果第一次跑真实场景就连续遇到死循环和JSON解析失败差点把工具调用都删了重写。如果你也想从零搭一个我建议先只用“天气查询计算器”两个工具跑通闭环不要急着接复杂API。把循环、解析、重试这些基础设施打磨好后面每加一个新工具都只是往注册表里多增一条记录而已。