做了几年 Agent 开发,我见过太多把"会调用工具"当成"智能体"的项目。demo 演示的时候,模型准确调起了天气 API、计算器、数据库查询,观众席一片惊叹,但代码走查完我就知道,这只是个套了层自然语言外壳的函数路由脚本。真正让我改变看法的,是那篇 ReAct 论文:让模型在每一步行动之前先推理,再把推理结果和行动结果一起喂回模型,循环往复直到任务收敛。这套思路看起来很简单,但它才是"智能体"和"自动化脚本"之间的真正分水岭。这篇文章我想把 ReAct 的原理、实现方式和踩坑经验完整拆开讲清楚,适合正在做 Agent 开发、或者准备从工具调用转向真正智能体设计的朋友参考。
1. 先想明白一个问题:工具调用和智能体差在哪
1.1 工具调用本质上是"路由",不是"思考"
先把结论放在前面:会调用工具只是智能体的必要条件,不是充分条件。Function Calling 这个能力刚出来的时候,业内确实兴奋了一阵,但冷静下来看,它做的事情就是把用户的自然语言输入映射到一个函数签名上。模型的任务是解析意图、抽取参数、选择候选函数,本质上跟你写一个if (input.includes("天气")) { getWeather(input.city); }没有本质区别,只是把规则引擎换成了概率模型。
我用一个实际的例子说明。假设用户说"帮我查一下北京明天的气温",工具调用做的事情是:模型输出getWeather(city="北京", date="明天"),系统执行函数,把结果返回给模型,模型组织语言告诉用户"北京明天 25 度"。这个流程快、准、稳定,但它有一个致命缺陷:它不会根据执行结果调整下一步动作。
打个比方。如果你让一个实习生去查资料,你告诉他"去档案室找 A 文件",他找到了就回来,找不到就告诉你没有。这是工具调用。但你如果告诉他"我需要 A 文件里的数据来写一份报告,如果没有 A 文件,你看看 B 文件有没有相关数据,再不行就问一下档案室的人",他会在执行过程中不断调整策略,这是 Agent。后者的核心不是"调用工具",而是"根据观察结果重新决策"。
1.2 为什么"能调工具"会被误认为"很智能"
这个现象我在很多项目里都见过。一个 Agent demo 看起来很强:能查天气、能算数学题、能查数据库、能编排任务,一问一答非常流畅。但把系统拆开看,所有工具调用路径都是预设好的,模型只是在多个 API 之间做路由。这就像电话自动应答系统,菜单层级再多,也只是个交互式脚本。
真正露馅的场景往往是这样:工具返回的结果出了问题,或者需要根据中间结果做二次决策。比如用户要"统计上季度各区域的销售数据,找出下滑最严重的区域,并且猜测可能的原因"。工具调用模式会规规矩矩地把 SQL 跑完,把数据列出来;但如果 SQL 查出来某个区域数据为空,脚本就不知道怎么处理了。ReAct 模式会怎么做?模型看到某个区域没有数据,会想"数据为空可能是表结构里区域名称不同,换个名称查一下"或者"用同期的其他维度数据做替代分析",然后把新的行动反馈到循环里。
换句话说,工具调用负责"把活干完",ReAct 负责"知道下一步该干什么"。前者是执行力,后者才是决策力。这个区别,就是标题里说的"自动化脚本"和"真正的智能体"之间的那条线。我并不否认工具调用的价值,它仍然是 Agent 感知和操作世界的"手"和"脚",但如果没有 ReAct 这类推理机制做"大脑",手脚再灵活也只是个提线木偶。
2. ReAct 的核心机制:推理与行动的交替循环
2.1 论文里的核心思想,一句话可以讲清楚
ReAct 的论文标题是Reasoning and Acting in Language Models,注意这里的 ReAct 是 Reason + Act,不是前端框架 React,这俩名字经常被搞混,网上搜"React 面试题"出来的全是前端内容,如果你找的是 Agent 方向的东西,记得搜 ReAct 全称。
论文的核心贡献,是把两条本来分开的技术路线合到了一起。一条是 Chain-of-Thought (CoT) 提示词,让模型在回答前先输出思考过程,但 CoT 不跟外部世界交互,纯靠模型内部知识推演,知识截止之后的事情它一概不知;另一条是"行动"路线,让模型调用外部工具获取信息,但早期这种模式只做单轮 tool call,缺少推理链条,模型不知道为什么调这个工具、调完结果怎么用。
ReAct 的思路特别朴素:让模型在一个循环里交替输出三种内容——Thought(当前这一步的思考)、Action(决定调用哪个工具、传入什么参数)、Observation(工具返回的结果)。然后把 Observation 拼到上下文里,让模型继续输出下一步的 Thought 和 Action,直到模型认为问题已经解决,输出 Final Answer。循环的每一步都是基于前一步的真实结果,而不是模型自己脑补出来的假设。
2.2 一个完整的 ReAct 循环长什么样
我这里用一个实际跑过的场景来拆解。假设任务是"对比一下 Python 和 Go 在并发处理上的社区热度,并给出我的学习建议"。一个典型 ReAct 循环的对话轨迹大致是:
Thought 1: 我需要先获取两个语言的社区活跃度数据。GitHub 的 star 数和 Stack Overflow 的讨论量是比较直观的指标。 Action 1: search_web(query="Python vs Go GitHub stars 2025") Observation 1: 返回一组 GitHub 仓库 star 数据,Python 相关项目平均 30k,Go 相关项目平均 18k…… Thought 2: star 数只代表项目热度,我需要看语言本身的讨论趋势。再搜索一下 Stack Overflow 的年度开发者调查数据。 Action 2: search_web(query="Stack Overflow developer survey language popularity 2025") Observation 2: 返回调查中 Python 和 Go 在"最常用语言"和"最想学习的语言"两项中的排名数据。 Thought 3: 现在数据足够回答了。Python 在社区规模和生态资源上领先,Go 在云原生和并发场景的热度增长更快。基于这些信息,我的建议是…… Final Answer: ……注意这个流程里的关键点:Thought 2 的产生不是因为代码里写死了"查完 A 再查 B",而是模型看到了 Observation 1 的数据之后,判断出"这些数据不足以回答问题,还需要补充另一个维度的信息"。这就是 ReAct 和脚本的分水岭——决策是动态的,由上下文里的真实信息驱动。
我在实际项目里做 Agent 效果评估时,有一个简单的判断标准:如果工具返回的内容发生改变,后续的行动序列会不会跟着变。如果不会变,说明你的"Agent"只是按预设剧本走的脚本;如果会变,说明推理链路真正参与到了决策里。
3. 从 Function Calling 到 ReAct:动手落地一个真实 Agent
3.1 方案一:自己写一个最简 ReAct 循环
理论讲完了,直接上实操。很多人觉得 ReAct 很玄乎,其实核心代码可以很短,几百行以内就能跑通。我下面用一个最小实现来演示,没有用 LangChain 这类框架,是为了让每一步都看得清清楚楚。
先看完整代码,然后逐段解释:
import json import re from openai import OpenAI client = OpenAI(base_url="YOUR_API_BASE", api_key="YOUR_API_KEY") # 定义两个模拟工具 def get_weather(city: str) -> str: """模拟天气查询,实际可接天气API""" weather_data = { "北京": "晴,25°C,湿度40%", "上海": "多云,27°C,湿度65%", "广州": "小雨,29°C,湿度80%", } return weather_data.get(city, f"暂无{city}的天气数据") def calculate(expression: str) -> str: """安全计算器,只允许数字和四则运算""" if not re.fullmatch(r"[0-9+\-*/().\s]+", expression): return "错误:表达式包含非法字符" try: return str(eval(expression)) except Exception as e: return f"计算错误:{str(e)}" TOOLS = { "get_weather": get_weather, "calculate": calculate, } # ReAct 提示词模板 SYSTEM_PROMPT = """你是一个能推理并调用工具完成任务的智能体。 请严格按以下格式输出: Thought: 你对当前情况的思考和下一步计划 Action: 工具名称,必须从 [get_weather, calculate] 中选择 Action Input: 传给工具的 JSON 格式参数 Observation: 工具返回的结果(系统提供,不需要你生成) 你需要交替输出 Thought 和 Action,直到收集足够信息后,用以下格式结束: Thought: 我已经获得足够信息 Final Answer: 对用户的最终回答 注意:Observation 由系统返回,你不能自己编造。一次只输出一个 Action。""" def run_react(user_query: str, max_steps: int = 8): messages = [ {"role": "system", "content": SYSTEM_PROMPT}, {"role": "user", "content": user_query}, ] for step in range(max_steps): print(f"\n===== Step {step + 1} =====") resp = client.chat.completions.create( model="YOUR_MODEL_NAME", messages=messages, temperature=0.0, ) ai_text = resp.choices[0].message.content print(f"[模型输出]\n{ai_text}") # 检测是否结束 if "Final Answer:" in ai_text: print("\n" + ai_text.split("Final Answer:")[-1].strip()) return # 解析 Action 和 Action Input action_match = re.search(r"Action:\s*(\w+)", ai_text) input_match = re.search(r"Action Input:\s*(\{.*?\})", ai_text, re.DOTALL) if not action_match or not input_match: # 模型没按格式输出,给它一个纠错提示 messages.append({"role": "assistant", "content": ai_text}) messages.append({ "role": "user", "content": "你的输出格式不正确,必须包含 Action 和 Action Input。请重新输出。", }) continue action_name = action_match.group(1) if action_name not in TOOLS: messages.append({"role": "assistant", "content": ai_text}) messages.append({ "role": "user", "content": f"工具 {action_name} 不存在,可用工具:[get_weather, calculate]", }) continue try: action_args = json.loads(input_match.group(1)) except json.JSONDecodeError: messages.append({"role": "assistant", "content": ai_text}) messages.append({ "role": "user", "content": "Action Input 必须是合法的 JSON 格式,请重新输出。", }) continue # 执行工具 tool_result = TOOLS[action_name](**action_args) print(f"[工具执行] {action_name}({action_args}) => {tool_result}") # 把 Observation 拼回上下文 messages.append({"role": "assistant", "content": ai_text}) messages.append({ "role": "user", "content": f"Observation: {tool_result}", }) print("\n[达到最大步数,强制结束]")这段代码的关键点有几个。第一,循环次数必须设上限,我在生产环境里一般设 8 到 15 步,防止模型陷入死循环烧 token。第二,模型输出必须保留在 messages 里,不然上下文链条就断了,模型看不到自己之前说过什么。第三,工具结果以Observation的形式作为 user 消息拼回去,模拟论文里"执行工具 -> 观察结果 -> 继续推理"的循环。
3.2 几个必须注意的坑:输出格式、上下文管理和停止条件
上面这段代码能在大多数支持 OpenAI 接口的模型上跑通,但实际项目里光这样还不够。我逐个说坑。
第一个坑是模型不按格式输出。这个概率比你想象的高,尤其是用小参数模型的时候。它可能会直接输出一句话回答而不走工具,或者把 Action 和 Action Input 放在同一行。解决方案有两个方向:一是像上面代码里那样做格式校验和纠错重试;二是在 system prompt 里给一个 few-shot 示例。我自己试过,给一个示例比不加示例的错误率能降低一半以上。
第二个坑是最容易被新手忽略的——模型生成的Observation有时会被模型自己"脑补"。注意代码里 tool_result 是真实执行的返回值,而不是模型生成的文本。在真实项目里,这也意味着你绝对不能把模型的输出直接当工具结果回填,一定得在代码层做边界隔离。你可以把观察结果当成"环境反馈",只能来自真实的工具执行或外部 API 返回。模型一旦在 Observation 的位置开始编造结果,这个 Agent 就彻底失控了。我见过不止一次线上事故,原因就是有人图省事把工具结果拼接成了模型续写文本。
第三个坑是停止条件。上面的代码只在文本里检测Final Answer:四个字,但实际模型经常变体输出,比如Final Answer(中文):或者最终答案:。更稳的做法是设置两个条件,命中任何一个就终止:步数达到上限,或者模型输出的文本里包含明确的结束标记。我个人的经验是,在最终答案之前强迫模型先输出一个类似Thought: 已经可以回答用户的问题的思考句,然后才输出 Final Answer,这种两步结束法可以显著降低提前结束的概率。
3.3 方案二:用现成的 Agent 框架快速搭建
如果你不想从头写循环,LangChain、Dify、Coze 这些框架和平台都已经内置了 ReAct 策略。拿 Dify 举例,你在编排应用时把"Agent 策略"选成 Function Calling 或 ReAct,然后挂上各种工具,平台会自动帮你完成"循环调用模型 -> 解析 Action -> 执行工具 -> 回填 Observation"这一步。
用平台的好处除了省事,还有一个非常实在的点:它们会把上下文管理、token 截断、并发控制这些工程问题提前处理好。自研 ReAct 循环时,遇到长任务很容易把上下文撑爆,平台一般会做历史消息的压缩或截断,这在你自己的代码里是要从头实现的。
但平台也有平台的问题。最大的问题是灵活度受限,比如你希望工具执行时注入自定义逻辑,或者希望在每一步推理里插入一个人工审批节点,平台不一定支持。我的建议是:验证想法、做 demo、快速迭代用平台;上生产、做复杂的定制化 Agent,还是得自己掌握底层循环逻辑,技术团队至少要有完全自己实现一遍 ReAct 的能力。
4. ReAct 实战中反复踩到的坑和排查实录
4.1 Agent 陷入死循环,token 成本暴涨
这是我第一次接智能体项目时遇到的最头疼的问题。模型在某个问题上反复调用同一个工具,每次输入参数都差不多,结果也差不多,但循环就是停不下来。核心原因往往是:模型认为"我需要更多信息才能回答",但工具返回的信息已经足够,模型却看不懂。
排查路径有两步。第一步看日志里模型的 Thought 内容,判断它是"信息不足需要继续查"还是"不会总结"。前者可能是工具返回结果格式太乱、信息量低,模型读不出关键内容;后者则需要换更强的模型,或者把工具返回的结果先做一层格式化预处理。
第二步是硬性手段——最大步数限制和预算控制。我线上线下所有的 ReAct 实现里都会加这两个东西:最大步数(比如 10 步)和最大 token 消耗(比如 2 万 token),任一个达到上限就直接终止并输出当前结果。代码里的max_steps=8就是干这个的。那些花大几百块跑一个任务的惨案,基本都是少了这两个保险丝。
4.2 工具返回的 JSON 太大,把上下文塞爆了
有一次我给 Agent 接了数据库查询工具,一张表有几十个字段,查询结果一次性全部返回,几千行数据直接塞进上下文。后果是模型响应变慢、输出质量下降,甚至开始胡言乱语。
解决方案是"工具结果手术"。在把工具结果回填到 Observation 之前,先做三件事:字段过滤只保留回答当前问题需要的列;行数截断只保留前 20 条记录;如果数据量仍然大,用一个小模型先把数据做摘要,把摘要结果作为 Observation 回填。这个"工具结果摘要层"是生产级 Agent 的标配,但很多教程不会提到。
4.3 模型开始"脑补"Observation,撒谎比说实话多
之前说过 Observation 必须来自真实工具返回,但在某些情况下模型还是会编造。比较危险的一种情况是:模型作为 ReAct 循环的执行者,它输出的 Action 是一个不存在的工具名,代码里TOOLS字典查不到,报错后如果直接把报错信息丢给模型让它"重试",有时候模型会开始假装自己调用成功了、编造一个结果出来。
我现在的做法是,一旦工具调用失败或者工具不存在,返回一个标准化的错误 Observation,比如Error: tool not found. Available tools: [...],同时要求模型必须看到Error开头的 Observation 时重新规划 Action,不能直接给出最终答案。这个策略在多个模型上都验证过,能有效减少幻觉式的工具结果。
4.4 工具调用嵌套的参数格式问题
这个坑很隐蔽,多见于一个工具的输出作为另一个工具输入的时候。比如第一个工具返回的结果是一个嵌套 JSON,模型要把{"data": {"list": [{"name": "北京", "value": 25}]}}里的列表项取出来作为第二个工具的输入,这时模型在生成Action Input的时候很容易产生转义错误、字段名猜测错误。我在生产环境里遇到这类问题,处理思路是尽量在代码层做转换,不要依赖模型自己理解结构——比如用jsonpath先把嵌套结构拍平,再传给模型让它选择。工具与工具之间的数据流转,能用代码处理的就不要让模型"翻译"。
4.5 ReAct 的安全边界:不要把所有工具都交给模型
ReAct 给了模型越来越大的自主权,意味着风险边界也在扩大。我吃过的一个大亏是:模型在连续推理过程中自动调用了一个带删除操作的数据库工具,差点造成数据丢失。事后总结,工具注册表要做权限分级,"只读型工具"模型可以自由调用,"任意参数型工具"比如执行 SQL、发送邮件、删除文件,必须在调用前加人工审批节点。另外工具描述里也要写明副作用,比如"删除之后不可恢复,仅在用户明确确认后执行",模型对这种提示的遵循程度比想象中好。
5. 完成了 ReAct 之后,还有个更进阶的问题等着你
5.1 ReAct 的短板:高延迟、高 token 消耗、单线程决策
ReAct 不是银弹,它有几个非常明显的短板。第一是延迟高,因为循环里每一步都要做一次完整的大模型推理,一个复杂任务跑十几步 Loop,响应时间可能从秒级进到分钟级。第二是 token 消耗高,每一步都要把所有历史 Observation 重新送入模型,随着循环加深,成本是二次方增长的——这是 ReAct 被诟病最多的工程问题。第三是单线程决策,模型每一步只能做一个动作,但对于需要并行并发调用的场景,比如"同时对比三个方案的成本、风险和收益",ReAct 会非常笨拙。
5.2 从 ReAct 到 Plan-and-Execute:让 Agent 先规划再行动
针对 ReAct 单步推断的低效问题,业界主流的进化方向是 Plan-and-Execute 模式,思路是把"规划"和"执行"拆成两个模型层次。先用一个高层次的 Planner 模型把复杂任务拆解成步骤清单,再用 Executor 模型逐条执行这些步骤。它跟 ReAct 最大的区别是:ReAct 走一步看一步,Plan-and-Execute 先画好地图再走路。如果任务中途发现语境变化,可以触发 re-plan 重新生成步骤清单。
我在任务结构清晰、步骤可预测的场景下会更倾向 Plan-and-Execute,比如周报生成、多步骤数据处理、批处理任务。而 ReAct 更适合探索式的、需要根据实时反馈动态调整的任务,比如研究分析、排障诊断、信息收集。这两个模式不是互斥的,很多生产级 Agent 是混合使用的:先用 Plan-and-Execute 拆出大的步骤,每个步骤内部再用 ReAct 做细粒度的工具调用。
5.3 再往下:反思机制和多智能体协作
ReAct 之后还有两条值得关注的路。一条是 Reflexion 这类带反思机制的方案,模型在完成任务后会对自己的执行过程做复盘,总结哪里做得好、哪里做得差,把复盘结论存下来影响后续轮次,本质上给 Agent 加了"自我纠错"的能力。另一条是多智能体系统,多个 Agent 各司其职、分角色协作,有些负责推理、有些负责执行、有些负责审查,安排它们互相辩驳,可以有效降低单 Agent 的幻觉率。
但这些进阶方案复杂度提升了一个数量级,不是所有项目都需要。我给团队的选型建议很简单:你的任务需要多步决策和动态调整就上 ReAct,步骤固定、执行明确的用普通工具调用或者 Plan-and-Execute 反而更高效,不要为了用新技术而用。最优架构永远是"够用就好"。
最后分享一个个人心得。我判断一个 Agent 项目是不是"挂羊头卖狗肉",就会去看它有没有推理-行动-观察这个闭环。如果没有,不管它调了多少个工具、接了多少个 API,本质上还是一个自动化脚本,只是披了个智能的外壳。真正从脚本走向 Agent,看起来是加了一个循环,实际上是把"决策权"从代码移交给了模型——这是一个整个团队都要重新适应的思维转变。如果你的项目也卡在"会调工具但显得很笨"的阶段,不妨拿 ReAct 的思路重写一遍主流程,我猜你会有完全不一样的体验。