1. 别急着写代码,先搞清楚 ReAct 到底解决了什么
这几年调大模型,大家应该都遇到过同一个尴尬:让模型处理稍微复杂一点的任务,它要么一本正经地编答案,要么拿着工具乱打一通。后来我把 ReAct 这套“边想边做”的思路落地到真实项目里,总算是把这个问题理顺了。ReAct 全称是 Reason + Act,核心就一句话:在推理过程中交替生成思考、工具调用、观察结果,让模型不断从外部反馈里修正自己的判断。这篇文章不打算复述论文,而是从我自己的工程视角出发,聊聊这个东西解决什么问题、怎么落地、有哪些坑。
先说一个大前提:现在所谓的“智能体”应用,十有八九都离不开 ReAct 这套循环。你看到那些能查天气、订机票、写代码、跑数据分析的助手,背后基本都是一个类似的结构。搞懂它,不只是在学一个提示词技巧,而是在理解大模型产品化和工具化的一条主线。
1.1 从思维链到行动的断层
前两年大家特别喜欢用思维链,也就是让模型“一步一步思考”。这个方法确实有用,模型在数学推理、逻辑题上的表现一下子提升了不少。但用多了你会发现一个很别扭的地方:模型的推理完全发生在自己的脑子里,没有一个机制去检查“这一步想得对不对”。
举个例子,你让模型算一笔账:“某商品原价 320 元,打八折后再加上 15 元运费,最后多少钱”。思维链能老老实实写出 320 乘以 0.8 等于 256,然后加上 15 等于 271。看起来不错。但如果这个任务需要查实时汇率、需要调用公司内部 API、需要从某个数据库里取数,模型就麻烦了。因为它只能基于训练时见过的东西去“猜”,而不是去“问”。
我做过一个实验:让一个模型估算某个城市的实时交通耗时。模型在没有工具的情况下,会凭借训练数据里对这座城市的大致印象给一个数值,哪怕你明确告诉它“请查询实时路况”,它也会礼貌地道歉然后继续编一个合理但错误的结果。这不是模型笨,而是它的推理和行动之间缺了一座桥。
ReAct 的关键就是补上这座桥:它要求模型在产生想法之后,主动生成一个可执行的行动,比如调用工具,然后把工具返回的真实结果再融入到下一轮思考里。这样推理就不再是闭门造车,而是每一步都能被外部世界验证。
1.2 从工具调用到推理规划
还有人可能会说,那我不让模型想那么多,直接教它调用工具不就行了?市面上很多早期的“工具调用”就是这么做的:定义一堆函数,告诉模型什么情况下调哪个函数,模型输出一个结构化的调用指令,程序去执行,把结果返回。但实际跑起来,你会发现这个方案单独用也容易翻车。
因为它缺少“规划”。模型面对的是一个目标,而不是一个单一的指令。比如“帮我调研一下目前市场上主流的三款产品,整理成对比表”,模型可能会直接去搜索“产品对比”,拿到一堆广告页,然后煞有介事地整理。它不会先想“我到底需要哪些维度的信息”,也不会想“如果第一次搜索找不到优质结果,我是不是该换个关键词”。
我自己在项目里见过的一种典型错误是:模型面对一个需要五步才能完成的任务,第一步就调了一个错误的工具,或者在第二步执行完后发现结果不符合预期,却不知道停下来调整策略,而是继续沿着错误方向硬跑。本质上是模型没有把“当前状态”和对未来的“规划”结合起来。
所以 ReAct 聪明的地方在于:它不把推理和行动拆成两个独立阶段,而是把它们放在同一个循环里。模型每走一步都先思考“我现在知道什么、还需要什么、下一步用什么动作”,然后行动,拿到观察结果后再次思考。这种“状态更新的闭环”使得模型可以动态调整计划,而不是一条道走到黑。
1.3 核心思路:把“想”和“做”循环起来
用一句话概括 ReAct:Think, Act, Observe,然后重复。模型先根据当前问题产生一个 Thought(我想做什么),接着生成一个 Action(调用哪个工具)以及 Action Input(传给工具的参数),程序执行工具后返回 Observation(观察结果)。这个观察会作为下一轮上下文的一部分,模型再基于它生成新的 Thought,直到模型认为信息足够、输出最终答案。
这个循环很像我们人做事的习惯。你出门旅行,先想“我要去车站,但不知道地铁还是打车快”,然后掏出手机查实时路况,看到“地铁预计 40 分钟,打车预计 55 分钟”,于是决定坐地铁,再继续想“那我得提前查一下地铁末班车时间”。每一步的行动都在更新你对问题的认知,而不是一口气把计划从头编到尾。
在我实际工程落地时,这个循环通常由代码控制,而不是让模型自己控制一切。代码负责解析模型输出的字段、调用工具、拼接观察结果、判断是否退出。模型只负责“出一张嘴”:给出思考,给出下一步要调用的工具名和参数。这个分工很重要,后面我会详细展开。
2. 边想边做的核心机制拆解
ReAct 看起来简单,真正落地时有很多细节决定成败。尤其是三个核心字段的设计、循环的退出条件、以及提示词里“允许什么、不允许什么”的约束,每一项都直接影响效果。
2.1 三个关键字段:Thought、Action、Observation
在一个最基础的 ReAct 循环里,模型输出通常被拆成三个字段:
| 字段 | 含义 | 示例 |
|---|---|---|
| Thought | 模型对当前状态的分析,解释为什么要做下一步 | 用户想知道北京今天的天气,我需要先查询所在城市再获取天气数据 |
| Action | 要调用的工具名,必须是预定义列表里的一个 | weather_query |
| Action Input | 传给该工具的 JSON 格式参数 | {"city": "北京"} |
| Observation | 工具返回的结果,由程序填充,模型不能编造 | {"temperature": 32, "condition": "晴"} |
这里最容易踩坑的是 Thought 和 Action Input 的格式。Thought 写多长都行,但 Action 和 Action Input 必须严格遵循我定义的格式,否则程序没法解析。很多初学的人把 Action 写成自然语言,比如“我来查询一下北京的天气”,然后解析器直接崩溃。
我在设计提示词时,会明确告诉模型:Action 必须从工具列表中选择一个名称,Action Input 必须是合法的 JSON 对象。同时,我会在示例里给出两个完整的回合,让模型模仿这种输出模式。模型是 few-shot 学习者,示例比一百句规则都好使。
Observation 这个字段比较特殊,它不由模型生成,而是由程序执行工具后拼接到上下文中。如果工具返回的内容太长,我一般会做截断或者摘要,否则多轮循环之后上下文很快就被撑爆。后面第 5 部分我会专门讲这个问题。
2.2 ReAct 循环的工作原理
整个 ReAct 循环就像一个带反馈的 while 循环。用一个最简单的伪代码来描述:
1. 把用户问题放入消息队列 2. 调用大模型,得到回复 3. 解析回复中的 Thought / Action / Action Input 4. 如果回复包含 Final Answer,则结束循环并输出答案 5. 否则执行 Action 对应的工具,拿到 Observation 6. 把 Thought、Action、Action Input、Observation 追加到消息队列 7. 回到第 2 步这个循环里有两个关键控制点。第一个是退出条件:模型什么时候该停下来?我在提示词里约定,当模型认为已经收集到足够信息时,应该输出 Final Answer 字段,而不是继续调用工具。第二个是最大步数:即使模型一直不想结束,程序也要设置一个硬上限,比如最多 8 轮循环,超过就直接终止,避免死循环烧钱。
退出条件用提示词约束,但模型偶尔会不听话。所以我会在代码里做双重保险:一是解析时识别 Final Answer,二是记录循环次数,超过阈值就把当前收集到的信息整理一下,强制生成一个最终答案。这样即便模型出了幺蛾子,用户体验也不至于完全断开。
另外一个很容易被忽略的点是:每一轮循环都要把之前的历史完整传给模型。模型的决策依赖上下文里的“记忆”,如果只把当前 Observation 传回去、把之前的 Thought 丢掉了,模型就会失去连贯性,出现前后矛盾、重复查询等问题。实际项目中,消息列表通常是一个数组,每一轮追加四到六条消息,直到触发退出条件。
2.3 和普通提示词的本质区别
很多人以为 ReAct 只是换了一种提示词写法,其实它的本质区别在于交互模式。普通提示词是“一次问答”,模型输出答案就结束了。工具调用也好、思维链也好,都是单轮完成的。ReAct 则是一个“多轮交互协议”,模型与外部环境之间发生了多次信息交换。
我用过一段时间普通 prompting,感觉就像让一个助理坐在没有电话和电脑的房间里给建议。他只能凭记忆回答,错了也没办法核实。而 ReAct 相当于给这个助理配了一台电脑、一个浏览器和一套内部系统,让他可以查到什么再说什么,还能根据查到的结果调整自己接下来的动作。
从工程上看,两者的复杂度也不在一个等级。普通提示词只需要一次 API 调用;ReAct 至少需要两到三次,因为模型先输出计划,程序执行工具,再把结果喂回去,模型才能最终作答。如果模型规划得不好,可能来回 5、6 轮。这带来的成本、延迟和失败概率都显著增加,所以不是所有场景都需要上 ReAct。简单的事实问答、文本改写、翻译这些任务,普通提示词就够了。
我个人的判断标准是:如果任务需要访问外部信息、需要多步骤操作、或者需要根据中间结果动态调整方案,才值得用 ReAct。如果任务只是“基于已有知识做单次推理”,硬套 ReAct 反而是画蛇添足。
3. 动手实现一个最简 ReAct 循环
理论聊完,直接上实现。下面这套代码我前几天刚在项目里跑通,结构很简单,但足够展示 ReAct 的核心逻辑。你完全可以在本地环境复现,只需要一个大模型 API 的访问权限和一个小型工具库。
3.1 环境准备与工具接入
我先准备了三个最简单的工具:一个用于查询城市天气,一个用于做四则运算,一个用于获取当前时间。这三个工具不需要真实的外部服务,全部用本地函数模拟,重点在于演示循环机制。
# tools.py # 模拟天气查询工具 def get_weather(city: str): data = { "北京": "晴,28度", "上海": "多云,31度", "广州": "雷阵雨,27度" } return data.get(city, "暂未收录该城市数据") # 模拟计算器工具 def calculator(expression: str): try: return str(eval(expression)) except Exception as e: return f"计算错误: {e}" # 获取当前时间 def get_current_time(): from datetime import datetime return datetime.now().strftime("%Y-%m-%d %H:%M:%S")工具函数写好后,统一放到一个字典里,这样循环代码可以通过工具名直接调用:
available_tools = { "get_weather": get_weather, "calculator": calculator, "get_current_time": get_current_time }工具描述很重要。模型并不认识这些 Python 函数,它只会看你在提示词里写的说明。我一般会为每个工具写清楚“什么时候用、参数是什么、返回什么”,比如:
- get_weather: 当用户询问某城市天气时使用,参数为城市中文名,返回该城市天气情况。 - calculator: 当需要数学计算时使用,参数为合法的数学表达式,返回计算结果。 - get_current_time: 当需要知道当前日期或时间时使用,参数为空,返回当前时间。即使这些工具本身完全没有逻辑,只要描述写清楚,模型也能正确调用。反过来,如果描述含糊,模型就很容易在无关问题上去乱调。
3.2 核心提示词模板
接下来是核心提示词。我采用的是“角色定义 + 工具列表 + 示例 + 格式约束”的结构。示例部分非常重要,一定要让模型看到“从 Thought 到 Action 再到 Observation”的完整回合。
你是一个智能助手,可以通过调用工具来解决用户的问题。回答过程中必须遵循以下格式: 当需要调用工具时,输出: Thought: 你对此前结果的分析以及下一步计划。 Action: 工具名称,必须从工具列表中选择。 Action Input: 传给工具的 JSON 对象。 当已经获得足够信息时,输出: Final Answer: 对用户问题的最终回答。 工具列表: - get_weather: 参数 {"city": "城市名"},返回天气情况。 - calculator: 参数 {"expression": "数学表达式"},返回计算结果。 - get_current_time: 参数 {},返回当前时间。 示例: 用户问题:北京今天冷不冷? Thought: 需要先查询北京的天气,再根据温度判断冷不冷。 Action: get_weather Action Input: {"city": "北京"} Observation: 晴,28度 Thought: 北京温度 28 度,属于较暖和的天气。 Final Answer: 北京今天不冷,气温约 28 度,天气晴朗。 请严格按上述格式输出,不要输出与格式无关的内容。这套模板我调了一个多星期才达到满意的效果。最关键的一点是,不要给模型太多自由发挥的空间。“不要输出与格式无关的内容”这句话加上示例里的强约束,能大幅降低解析失败的概率。有时模型会在 Action 后面多写一句解释,导致解析错乱,这时我会把约束改成“Action 所在行只允许出现工具名,不要附带任何说明”。
3.3 解析模型输出并执行工具
提示词准备好之后,剩下的就是循环主逻辑。我写了一个parse_reply函数,用正则把模型回复里的 Thought、Action、Action Input、Final Answer 提取出来:
import re import json def parse_reply(text): final_match = re.search(r"Final Answer:\s*(.+)", text, re.DOTALL) if final_match: return {"type": "final", "answer": final_match.group(1).strip()} thought_match = re.search(r"Thought:\s*(.+)", text, re.DOTALL) action_match = re.search(r"Action:\s*(\w+)", text) input_match = re.search(r"Action Input:\s*(\{.*?\})", text, re.DOTALL) if action_match and input_match: try: action_input = json.loads(input_match.group(1)) except json.JSONDecodeError: action_input = {} return { "type": "action", "thought": thought_match.group(1).strip() if thought_match else "", "action": action_match.group(1).strip(), "action_input": action_input } return {"type": "error", "raw": text}这个解析函数看起来简单,但应对了大多数情况。正则里的\{.*?\}用了非贪婪匹配,能处理 Action Input 是单行 JSON 的情况。如果模型输出的 JSON 跨行,re.DOTALL也覆盖到了。解析失败时返回 error 类型,主循环会把原始回复片段反馈给模型,请它重新输出。
3.4 完整参考代码
把上面的函数拼起来,再封装一个调用大模型 API 的函数,完整的 ReAct 循环就出来了。为了不绑定任何具体厂商,我用一个抽象的call_llm函数代替实际调用,你替换成你自己的接口即可。
import json import re from datetime import datetime # 这里替换成你实际使用的大模型接口 def call_llm(messages, temperature=0.2, max_tokens=1024): # messages 是一个消息列表,例如 [{"role": "user", "content": "..."}] # 调用模型服务并返回文本 pass def get_weather(city: str): data = {"北京": "晴,28度", "上海": "多云,31度", "广州": "雷阵雨,27度"} return data.get(city, "暂未收录该城市数据") def calculator(expression: str): try: return str(eval(expression)) except Exception as e: return f"计算错误: {e}" def get_current_time(): return datetime.now().strftime("%Y-%m-%d %H:%M:%S") available_tools = { "get_weather": get_weather, "calculator": calculator, "get_current_time": get_current_time } system_prompt = """你是一个智能助手,可以通过调用工具来解决用户的问题。回答过程中必须遵循以下格式: 当需要调用工具时,输出: Thought: 你对此前结果的分析以及下一步计划。 Action: 工具名称,必须从工具列表中选择。 Action Input: 传给工具的 JSON 对象。 当已经获得足够信息时,输出: Final Answer: 对用户问题的最终回答。 工具列表: - get_weather: 参数 {"city": "城市名"},返回天气情况。 - calculator: 参数 {"expression": "数学表达式"},返回计算结果。 - get_current_time: 参数 {},返回当前时间。 示例: 用户问题:北京今天冷不冷? Thought: 需要先查询北京的天气,再根据温度判断冷不冷。 Action: get_weather Action Input: {"city": "北京"} Observation: 晴,28度 Thought: 北京温度 28 度,属于较暖和的天气。 Final Answer: 北京今天不冷,气温约 28 度,天气晴朗。 请严格按上述格式输出,不要输出与格式无关的内容。""" def parse_reply(text): final_match = re.search(r"Final Answer:\s*(.+)", text, re.DOTALL) if final_match: return {"type": "final", "answer": final_match.group(1).strip()} thought_match = re.search(r"Thought:\s*(.+)", text, re.DOTALL) action_match = re.search(r"Action:\s*(\w+)", text) input_match = re.search(r"Action Input:\s*(\{.*?\})", text, re.DOTALL) if action_match and input_match: try: action_input = json.loads(input_match.group(1)) except json.JSONDecodeError: action_input = {} return { "type": "action", "thought": thought_match.group(1).strip() if thought_match else "", "action": action_match.group(1).strip(), "action_input": action_input } return {"type": "error", "raw": text} def run_react(user_question, max_steps=8): messages = [ {"role": "system", "content": system_prompt}, {"role": "user", "content": user_question} ] for step in range(max_steps): response = call_llm(messages) reply = parse_reply(response) if reply["type"] == "final": print("最终答案:", reply["answer"]) return reply["answer"] if reply["type"] == "error": # 让模型重新输出 messages.append({"role": "assistant", "content": response}) messages.append({"role": "user", "content": "你的输出格式不正确,请严格按照要求重新输出。"}) continue tool_name = reply["action"] tool_input = reply["action_input"] if tool_name not in available_tools: observation = f"错误:不存在工具 {tool_name}" messages.append({"role": "assistant", "content": response}) messages.append({"role": "user", "content": f"Observation: {observation}"}) continue try: tool_result = available_tools[tool_name](**tool_input) observation = str(tool_result) except Exception as e: observation = f"工具执行失败: {e}" messages.append({"role": "assistant", "content": response}) messages.append({"role": "user", "content": f"Observation: {observation}"}) thought = "经过多轮尝试仍未收集到足够信息。基于当前已有信息给出一个最可能的答案:" messages.append({"role": "user", "content": thought}) final_response = call_llm(messages) print("达到最大步数,强制结束:", final_response) return final_response if __name__ == "__main__": run_react("上海现在天气怎么样?适合穿什么衣服?")这段代码已经是可运行的最小骨架。实际项目里,你只需要把call_llm替换成真实的模型调用,再把available_tools换成你自己的业务工具,就能用了。我建议你先用这个骨架跑几个简单任务,体验一下 ReAct 循环到底是怎么运作的,再往里面加复杂功能。
4. 参数调优与提示词设计经验
实现跑通之后,你很快会遇到质量问题。同样的代码,有时候模型规划得像模像样,有时候却反复横跳。这里面的差别很大程度在于参数设置和提示词细节。
4.1 温度、最大长度等关键参数
ReAct 循环对参数比普通对话更敏感。我实测下来,温度设置成 0.2 到 0.3 效果最好。温度太高,模型输出格式容易飘,Action 的名称可能被改写;温度太低,模型又可能太保守,不敢调用工具,总想直接给答案。
最大 Token 长度也要给够。因为每一轮的回复里要包含 Thought、Action、Action Input,有时候模型还会在 Action 前后加解释。如果最大 Token 太小,输出会被截断,解析器拿到一个残缺的 Action,循环就卡住了。我一般设置为 1024 到 2048 之间。某些模型如果支持“停止序列”,我会把Final Answer和Observation作为停止标记,这样模型生成到关键节点就停下来,省 token 也省时间。
还有一个很容易被忽视的参数是接口的超时设置和重试策略。ReAct 是多轮调用,一次任务可能触发 API 调用 3 到 8 次,任何一次超时都会导致整个任务失败。我在工程里会设置每个请求超时 30 秒,重试 2 次,并在第 3 次失败时把当前循环状态返回给用户,而不是无限等待。
4.2 动作空间怎么设计才不容易跑偏
所谓动作空间,就是工具列表。很多新手以为把工具写得越全越好,其实恰恰相反。工具列表里的每一项都会成为模型可选择的“行动选项”,列表越长,模型选错的概率越高。如果一个工具与当前任务完全无关,它会像一个干扰项,诱导模型在无关问题上调用。
我的经验是分层设计。对用户可见的入口,只保留当前上下文最可能用到的 3 到 5 个工具。更复杂的工具,可以做成“子智能体”或“二级菜单”,让模型先选择大类,再进入细分的工具列表。比如一个客服机器人,第一层只有“查询订单、查询物流、处理售后、转人工”四个工具,而不是把所有几十个内部接口一次性暴露给模型。
工具命名也要讲究。用动词开头的短名称,比如search_products、get_order_detail、calculate_shipping,比products、detail、calc这类模糊命名更容易被模型理解。工具描述里不要只写“参数是什么”,还要写“什么时候不应该用这个工具”。比如计算器工具的描述可以加上一句“当需要计算日期差值时不要使用本工具,应使用日期计算工具”。这种负面约束能显著减少错误调用。
4.3 提示词里那些隐含约束
提示词内容里有一句话容易被忽略:“请严格按上述格式输出”。实际上光说这一句不够,你要把“不允许做什么”也说得更具体。我常用的几条约束如下:
- 只允许从工具列表中选择 Action,不允许凭空发明工具名。
- 一次只能输出一个 Action,不要试图在一次回复里调用多个工具。
- 每次执行完工具后,必须使用 Observation 中的真实数据继续推理,不能忽略它。
- 当用户问题可以通过已有知识直接回答时,直接输出 Final Answer,不要强行调用工具。
- 当工具返回的结果与预期不符时,不要重复调用同一个工具超过两次,应该尝试换一个工具或换一个参数。
这些约束看起来像提示词废话,但实际影响很大。在没加约束之前,我的模型经常一次输出两个 Action,或者在一个工具调用失败后疯狂重试同一个工具,直到把上下文占满。加了约束之后,行为明显收敛。
不过也要提醒一句:模型对提示词的遵循程度不是百分之百,尤其在复杂语境下,设再多的规则也可能失效。所以工程层面必须要有兜底,比如我在第 3.4 节里那个“工具不存在就报错”的分支,就是对错误调用的程序级防御。提示词约束和代码校验要相互配合,不能只靠提示词。
5. 常见问题与排查技巧实录
ReAct 看起来是个很简单的循环,但真跑起来,问题五花八门。我把过去两个月踩过的坑整理成一份排查记录,你遇到类似问题时可以直接对着查。
5.1 模型陷入循环不停止
最典型的问题就是循环出不去。模型反复调用同一个工具,Observation 每次都一样,但它就像卡住了一样,不断重复“我再查一次”。原因通常是模型没有得到足够的信息来判断“已经可以收尾了”,或者它根本没理解 Final Answer 这个退出选项。
我采用的解决方法是双管齐下。提示词层面,明确写“当工具返回结果已经回答用户问题时,直接输出 Final Answer,不需要再次调用工具”。代码层面,设置最大步数上限,并记录历史中相同 Action 和 Action Input 的出现次数,如果同一个动作出现 3 次,就自动终止循环并强制生成最终答案。
还有一个我后来才发现的细节:某些模型在长上下文里会“忘记”自己已经调用过哪些工具。为了减少这种情况,我会在 Observation 拼接时加上简短的摘要,比如“Observation: 北京,晴,28度”,而不是给出一大段原始数据。这样模型能更快识别到“这个信息我已经拿到了”。
5.2 输出格式解析失败
这是新手最容易碰到的硬骨头。模型输出五花八门,有时候没有 Thought,有时候 Action 和 Action Input 的顺序反了,有时候 Action Input 不是合法 JSON 而是自然语言。我见过最夸张的一种是模型把 Final Answer 和 Action 混在一起,前面说答案,后面还要调工具。
这时候不要急着骂模型,先检查你的提示词示例是不是足够清晰。我自己的经验是,示例里必须包含一个“完整成功回合”和一个“直接答最终答案”的例子,让模型知道两条路分别长什么样。同时解析器要写得宽容一点:正则匹配时允许 Action Input 前后有空格,允许字段顺序不是绝对固定,甚至可以通过json.loads失败后尝试用文本提取参数。
如果解析实在失败,程序可以把它当作一次错误回复,把原始输出拼回去请模型重新生成。但重试次数不要过多,最多 2 次,否则对话质量和成本都会失控。我在代码里就是用{"type": "error"}分支来处理这类情况的。
5.3 工具结果太长把上下文撑爆
工具返回的数据往往又臭又长。比如查询订单列表,可能返回几百个条目;调用搜索接口,可能返回几十段网页摘要。如果这些内容全部塞进上下文,几轮之后 token 就超限了。
我的做法是封装一个“观察结果精简器”。工具返回后,先按规则做截断,比如只保留前 500 个字符,多余部分用省略号代替;如果数据是结构化的,就提取关键字段做成单行文本。也可以在提示词里告诉模型“工具返回结果可能被截断,请基于已有信息继续推理,不要抱怨信息不全”。
更彻底的办法是让每个工具返回之前先做一次摘要。比如把长文档搜索工具改成“返回最相关的 3 段摘要”,而不是返回全文。这需要在工具设计阶段就考虑清楚,不能指望大模型自己消化超长上下文。
5.4 错误工具调用
模型明明要查天气,却调用了计算器,参数还传了一个城市名。这种错误一般不是模型分不清,而是工具描述写得太含糊。比如计算器描述里如果写了“可以计算各种表达式”,模型就可能把“天气指数”这种自然语言也当成表达式传进去。
我在工具描述里会加入“适用场景”和“禁用场景”两个部分。例如计算器:“适用:四则运算、数学公式求值。禁用:不要将自然语言问题作为表达式,不要用于非数值计算问题。”加上负面约束之后,错误率明显下降。
代码层面也要做参数校验。工具函数接收参数后,先检查类型是否正确,参数是否在合法范围,不合法就返回一个明确的错误信息,比如“参数 error:city 不能为空”。这样模型下一轮看到 Observation 里的错误信息,就能自己纠正。
5.5 实测避坑清单
我把这段经历的教训浓缩成一张清单,每一条都是真金白银换来的:
| 坑 | 建议 |
|---|---|
| 模型反复调用同一个工具 | 设置同动作去重计数,超过 3 次强制退出 |
| Action Input 不是 JSON | 提示词里给一个 JSON 示例,解析器用正则非贪婪匹配 |
| 上下文被 Observation 撑爆 | 工具返回结果做截断或摘要,单条 Observation 不超过 500 字符 |
| 模型调用不存在的工具 | 代码层面校验工具名,错误信息反馈给模型修正 |
| 模型一次输出多个 Action | 提示词明确“一次只能输出一个工具调用” |
| 循环不结束 | 设置 max_steps 硬上限,同时引导模型用 Final Answer 收尾 |
| 工具参数为中文逗号或全角符号 | 在工具函数里做参数清洗后再执行 |
| 模型忽略历史 Observation | 每次拼接时用“Observation: 摘要”保持信息精简,减少干扰 |
这张表不是标准答案,但它覆盖了绝大多数 ReAct 落地初期的典型问题。你实际跑的时候肯定还会遇到新坑,但只要把握住“程序控制循环 + 模型只做决策”这个原则,大部分问题都有解。
6. 扩展方向与我的个人体会
ReAct 这个框架看着简单,想象力其实很大。把它用在不同场景里,能演化出很多有意思的形态。
6.1 从单轮循环到多智能体协作
我最早实现的 ReAct 只有一个模型在循环里自己思考、自己调工具。后来发现某些业务场景需要多个不同角色的模型一起配合。比如一个模型负责拆解用户需求,另一个模型负责调用内部系统,第三个模型负责审核最终答案。每个模型内部都是一个小的 ReAct 循环,但它们之间通过消息队列传递结果。
这种多智能体架构能带来几个好处。第一是职责隔离,每个模型只需要关注自己领域的工具,动作空间小,错误率低。第二是可维护性变高,某个环节出了问题只需要替换对应的子智能体。第三是安全控制更精确,审核模型可以在最终输出前拦截错误或敏感信息。
但代价也很明显:多个循环嵌套会导致延迟叠加,一次用户请求可能需要几十次模型调用,成本直线上升。我在实际项目里只会在高价值、低频率的任务上启用这种架构,比如自动生成复杂报告、跨系统数据核对,而不是在普通聊天里使用。
6.2 从文本工具到代码解释器
另一个我很看好的扩展方向是让 ReAct 循环里的工具变成“代码执行环境”。模型不光可以调用预定义的函数,还可以在循环中生成一段代码,由一个安全的沙箱执行,并把执行结果返回。这样模型就能处理一些无法预先定义工具的开放性问题。
比如用户丢来一个 CSV 文件,让做数据清洗和可视化。模型先观察文件结构,生成一段 pandas 代码,沙箱执行后返回结果,模型根据结果调整代码,再执行下一版。这本质上也是 ReAct 循环,Action 变成了“执行代码”,Observation 变成了“代码输出”。
这个方向的坑也比较多。沙箱必须严格隔离,不能让它访问宿主文件系统;代码执行时间要有限制;循环次数要控制,防止模型写一段死循环代码把资源耗尽。我目前只在离线任务里使用这种模式,线上服务还是倾向于用预定义工具,至少方便做权限控制。
6.3 个人实操体会
最后聊聊我自己的感受。最初接触 ReAct 时,我犯过一个很典型的错误,就是把它当成“万能提示词”,以为套上这个框架模型就什么都能干了。跑了几十轮测试后才发现,ReAct 真正解决的是“模型如何获取外部信息并动态调整行动”这个工程问题,而不是“模型如何变聪明”这个模型能力问题。
如果你手里的大模型本身理解能力不行,ReAct 只会让错误更有节奏地发生。所以我的建议是:先确保底层模型在不借助工具时能做好单步推理,再上 ReAct;先在小范围工具集上跑通闭环,再逐步扩展动作空间;先做好退出条件和错误兜底,再追求复杂的多智能体协作。
每加一个新的工具,我都会专门写一条测试用例去验证模型能不能正确调用、能不能从 Observation 里提取有效信息、能不能在出错后自行纠正。这样做听起来笨,但长期下来积累的测试集比任何代码评审都管用。我现在维护智能体项目,最重要的资产不是那套循环框架,而是这份针对每个工具的行为测试清单。
如果你正准备上手 ReAct,我建议你也从一个小例子开始:选两个工具、设计一个简单的提示词、写一个不超过两百行的循环脚本,然后把用户问题从简单到复杂一个个试。多跑几轮之后,你自然能体会到“边想边做”这四个字在工程里到底意味着什么。