如果你也在折腾Agent,一定见过这种场景:模型明明很聪明,查资料的时候却像愣头青一样横冲直撞,让它做个简单的任务判断,它偏要选最笨的那条路走到底。这个问题我啃了很久,直到把ReAct这套框架真正吃透,才明白Agent缺的不是智商,而是思考和行动之间的那个闭环。ReAct把推理(Reasoning)和行动(Acting)编成一条循环链,让每一步决策都有迹可循。这篇文章是我从零摸爬滚打出来的经验总结,不扯玄乎的概念,直接拆到Thought、Action、Observation这三个齿轮的内部,配合完整可跑的代码和排坑记录。适合刚接触Agent框架的同学,也适合那些已经搭过原型、但总觉得Agent“不够聪明”的开发者。
1. 先搞清楚:Agent为什么从“聪明”变成“莽夫”
1.1 只会动手不会动脑的“手脚派”
很多初版Agent实现长这样:把用户问题拼进提示词,让大模型直接输出一个工具调用的结果,然后把返回内容丢给用户。这种方案看起来高效,实际上有一个致命问题——模型生成的是“一次性决策”,它没有机会根据中间结果修正自己。
举个例子。你想让Agent帮你查“最近三个月不同渠道的广告投放ROI对比”,传统做法是让它一次性调用一个报表接口,拿回一堆数字直接总结。问题出在哪?如果接口返回的数据里有两个渠道的数据缺失,或者报表结构和你预期不一致,模型没机会发现这些异常。它只会把残缺数据当成完整数据一顿分析,最后输出一个煞有介事但根本不准的结论。
我管这叫“手脚派Agent”——充满了执行力,但缺乏判断力。就像让一个实习生去打杂,活是干了,但干到什么程度、干得对不对,全看运气。
1.2 只会空想不行动的“学院派”
后来又流行过一种思路:既然直接调工具有问题,那就让模型先推理。做法是在提示词里加上“请一步步思考”,于是模型噼里啪啦输出一大段推理过程,但没有调用任何外部工具。
这同样有硬伤。模型的知识是有截止日期的,它的推理再严密,也拿不到实时数据。你让它推理“今天出门要不要带伞”,它能煞有介事地分析出一堆气象学原理,但就是不帮你查天气API。等于请了个只会纸上谈兵的谋士,分析得头头是道,落到实地上毫无用处。
“学院派Agent”的问题不是不聪明,而是它把推理和行动割裂了。推理再长,也只是在模型已有的知识范围内打转,没有新的信息输入,思维链条再漂亮也等于原地空转。
1.3 “谋士”的正确姿势:边想边干
ReAct这套框架的核心思想,就是把上面两种分裂的能力缝合起来。它在模型的每一步输出里强制要求三个部分:
- Thought(思考):解释当前这一步为什么这么做,模型对全局的判断。
- Action(行动):决定调用哪个工具、传入什么参数。
- Observation(观察):工具执行之后返回的真实结果。
模型看到Observation之后,会基于这个新信息再次输出Thought,再决定下一步Action,如此循环,直到它能给出Final Answer(最终答案)。
这套机制的关键在于:模型在每个决策点都拥有重新评估的机会。第一次想错了没关系,Observe之后它会发现“哦原来这条路径走不通”,然后自动修正。这就是从“莽夫”到“谋士”的本质转变——不是模型变聪明了,而是它的每一步行动都被挂上了反馈回路,犯了错也来得及回头。
提示:ReAct里的Thought不是给用户看的“思考过程展示”,它是给模型自己看的决策依据。所以它必须真实、具体,而不是“好的,我将按步骤进行分析”这种废话。
2. ReAct的核心机制拆解:三个齿轮怎么咬合
2.1 提示词模板:整个框架的地基
接触ReAct之后我才明白一件事:ReAct不是一个需要安装的库,而是一套提示词工程加循环解析策略。它的灵魂就在提示词模板里。
一个标准的ReAct提示词模板长这样:
你是一个智能助手,你可以使用以下工具来帮助你回答问题: - search(query): 搜索互联网信息,适用于查询实时数据、新闻、百科等内容。 - calculator(expression): 计算数学表达式,例如 calculator("(3+5)*2")。 - get_time(): 获取当前日期和时间。 你的回答必须严格遵循以下格式: Thought: 描述你当前的想法和推理过程。 Action: 工具名称,必须是上述工具列表中的一项。 Action Input: 工具对应的输入参数,必须是合法的JSON字符串。 当你有足够的信息回答用户问题时,请输出: Thought: 我已经获得了足够的信息。 Final Answer: 给用户的最终回答。 注意:一次只能执行一个Action,必须等待Observation返回后再进行下一步。这里每一步都藏着细节。为什么要强制要求先写Thought再写Action?因为如果你允许模型直接输出Action,它很容易跳过思考直接蒙一个工具,又变回“莽夫”。先逼它想一步,再让它做一步,错误率会显著下降。
Action Input为什么要求用JSON字符串?这是为了在代码里稳定解析。如果你不加以约束,模型可能输出“查询北京的天气”这种乱七八糟的格式,解析阶段会让你头疼到怀疑人生。
工具描述里的那句“适用于查询实时数据”也很重要。大模型是通过工具描述来理解工具的用途的,描述写得含糊,它就不知道该在什么时候用这个工具。把边界条件、适用场景直接写进描述里,相当于给模型发了一份工具说明书。
2.2 循环是怎么转起来的
模板定好之后,运行机制其实是一个简单的while循环。我画一条逻辑线:
- 把用户问题拼上提示词模板,塞给大模型。
- 模型返回一段文本,形如“Thought: ... Action: ... Action Input: ...”。
- 解析这段文本,判断是否包含Final Answer。如果有,循环结束,把答案返回给用户。
- 如果没有Final Answer,提取Action和Action Input,在代码里找到对应的工具函数并执行。
- 把工具返回的结果拼成一个Observation字段,追加到对话历史里,再次发给模型。
- 重复第2步,直到模型给出Final Answer,或者达到最大迭代次数。
这看起来简单,但有几个关键点要注意。
第一,Observation文本用什么前缀开头?这个前缀要和模板里的格式严格一致。如果你的模板里写的是“Observation:”,那代码里拼历史时也要用“Observation:”开头。模型看到这个标记,就知道刚才的工具调用已经结束,接下来该推理了。
第二,循环的终止不仅靠模型自觉,还要靠代码兜底。模型有时候会陷入死循环,同一个Thought翻来覆去地输出。所以必须设置最大迭代次数。我在项目里一般设置5到8轮,超过就直接终止,返回当前的部分结果或报错。
第三,模型输出的文本可能有多余的空格、换行、甚至标点符号。解析的时候要做容错处理,最好是正则匹配,而不是严格的字符串查找。
2.3 这套循环凭什么比“莽夫式”稳定
从直观感受上,大家都觉得多轮调用大模型会更慢、更费钱。确实如此,ReAct的每一步思考+行动都要发起一次模型调用,一个复杂任务可能要跑四五轮,延迟肯定比一次性输出高。
但这个代价换来的是任务成功率的质的提升。核心原因是:ReAct把长任务的误差控制在了“单步决策”的范围内。一次复杂任务如果一口气完成,中间任何一步出错都会污染最终结果,而且这个错误是隐蔽的、难以察觉的。ReAct通过每步反馈把错误暴露出来,模型自己就会修正。
打个比方,传统Agent是一次性闭卷考试,交卷了才知道得分;ReAct是开卷考试,每做一道题就可以翻一次书、对照一次答案。同样是最笨的模型,后者考出来的分数绝对高得多。
我实测过一个差异很大的场景:让Agent查某个月的销售数据,按地域维度做排序,并给出前三个地区贡献占比。用命令式Agent,它经常直接把接口返回的原始数据全都列出来,或者排序逻辑出错。用ReAct循环,模型在Thought里自己会发现“这个排序不对,我要在Action里加上排序字段重新取数”,Observation返回后它再修正思路。这种纠错能力是命令式方案完全不具备的。
3. 从零搭一个ReAct Agent:完整实操记录
3.1 准备环境和提示词模板
下面这套代码是我实际在项目里用过的骨架,去掉业务相关部分之后做了精简。不依赖任何特定的模型SDK,抽象成fetch_llm函数,你只要把它换成你想用的模型接口就能跑起来。
先说工具集。为了完整演示,我准备三个工具:搜索、计算器、取当前时间。搜索用本地模拟数据做演示,实际项目中你可以对接自己的搜索服务。
import json import re import datetime def search(query: str) -> str: # 实际场景中替换为真实搜索服务 if "天气" in query and "北京" in query: return "北京今天晴,气温18~26摄氏度,空气质量良。" if "天气" in query and "上海" in query: return "上海今天多云,气温20~27摄氏度,空气质量优。" return "未找到相关结果,请尝试换个关键词。" def calculator(expression: str) -> str: try: # 注意:eval有安全风险,仅供演示,生产环境请解析表达式 return str(eval(expression)) except Exception as e: return f"计算错误: {e}" def get_time() -> str: return datetime.datetime.now().strftime("%Y-%m-%d %H:%M:%S")工具集内部实现其实无所谓,关键是每个函数都接收一个字符串参数,返回一个字符串。这样一来,Agent循环解析和执行就非常简单了——不管是什么工具,都走同一个调用和返回通道。
提示词模板直接采用第2节里的模板。我建议把工具列表动态生成,这样以后加工具不用改模板,只改tools字典就能同步更新到提示词里。
TOOLS = { "search": search, "calculator": calculator, "get_time": get_time, } TOOL_DESCRIPTION = """ - search(query): 搜索互联网信息,适合查询天气、新闻、百科等实时内容。 - calculator(expression): 计算数学表达式,输入如 calculator("(3+5)*2")。 - get_time(): 获取当前日期和时间。 """3.2 核心循环的Python实现
循环逻辑我用一个类封装,便于复用。
class ReActAgent: def __init__(self, llm_func, max_iterations=6): self.llm_func = llm_func self.max_iterations = max_iterations self.template = """ 你是一个智能助手,可以使用工具回答问题。可用工具: {TOOL_DESCRIPTION} 你的回答必须严格遵循以下格式: Thought: ... Action: 工具名称 Action Input: 工具入参(JSON字符串) 当有足够信息时,请输出: Thought: 我已经获得了足够的信息 Final Answer: 最终回答 """ def run(self, user_query: str): history = [{"role": "user", "content": self.template.format( TOOL_DESCRIPTION=TOOL_DESCRIPTION )}] history.append({"role": "user", "content": f"用户问题:{user_query}"}) for step in range(self.max_iterations): response = self.llm_func(history) print(f"--- Step {step + 1} ---") print(response) print() # 1. 检查是否给出最终答案 if "Final Answer:" in response: final = response.split("Final Answer:")[-1].strip() return final # 2. 解析 Action 和 Action Input action_match = re.search(r"Action:\s*(\w+)", response) input_match = re.search(r"Action Input:\s*(.+)", response) if not action_match or not input_match: # 模型输出格式异常时,给它一个观察结果让它修正 observation = "格式错误:未找到Action或Action Input,请严格按照指定格式输出。" else: action_name = action_match.group(1) action_input_raw = input_match.group(1).strip() if action_name not in TOOLS: observation = f"未知工具:{action_name},可用工具为:{list(TOOLS.keys())}" else: try: # 解析 JSON,去掉可能存在的引号包裹 action_input = json.loads(action_input_raw) observation = TOOLS[action_name](action_input) except json.JSONDecodeError: observation = "Action Input解析失败,请确保它是合法的JSON字符串。" except Exception as e: observation = f"工具执行出错:{e}" # 3. 把 Observation 拼接进历史,继续下一轮 history.append({"role": "assistant", "content": response}) history.append({"role": "user", "content": f"Observation: {observation}"}) return "达到最大迭代次数,未能生成最终答案。"这段代码有几个细节值得单独说。
解析Action Input时,我用了json.loads。如果模型输出的是calculator("(3+5)*2")这种带引号的字符串,json.loads会解析失败。所以我在工具函数实现里直接接收字符串,但模型输出时我还要求它传JSON字符串。有个取巧的办法:如果json.loads失败,尝试用ast.literal_eval或者干脆去掉最外层的引号再解析。项目里我通常写两套解析逻辑,第一套严格JSON,第二套宽松处理。
观察异常时的处理也很有讲究。当模型输出格式错了,不要直接终止程序,而是把“格式错误”作为Observation塞回去,让模型自己纠正。这个操作我一开始也没想到,后来发现这是提升稳定性的关键一招——模型看到Observation是报错信息,下一轮会老老实实按格式输出,比你在代码里强行纠偏要自然得多。
3.3 实测案例:Agent怎么从“想错”到“想对”
我用一个多步推理问题来测试这个循环,问题如下:
“今天是哪一天?北京和上海今天的温差是多少?让Agent给出日期和温差结论。”
第一轮模型输出:
Thought: 用户想知道日期和两地温差。我应该先获取当前日期,然后搜索两地的天气。 Action: get_time Action Input: {}Observation返回“2025-06-15 14:30:00”。模型接着输出:
Thought: 我已经知道了日期。现在需要获取北京和上海的天气。 Action: search Action Input: {"query": "北京今天天气"}Observation返回北京的天气后,模型再思考:
Thought: 拿到了北京的天气,18~26度。还需要上海的数据。 Action: search Action Input: {"query": "上海今天天气"}最后一步模型发现两地温差可以算了,于是输出:
Thought: 北京18~26度,上海20~27度,高低温差分别是8度和7度。平均温差为1度。 Final Answer: 今天是2025-06-15,北京今天晴18~26度,上海多云20~27度。两地平均气温相差1度。这个过程看起来平平无奇,但其实每一个Thought都在指导下一步的Action。模型没有一次性把三个工具全调一遍,而是按需逐个调用,拿到一个新信息就更新一次判断。这就像剥洋葱一样,一层一层接近答案。
实际跑的时候你会遇到模型输出风格不一致的情况,比如Action Input里带了多余文字、Thought里顺便把最终的答案也写出来了等。后面第4节我会专门写这些坑的排查方法。
4. 实战中踩过的坑:排查与优化实录
4.1 循环不收敛:同一个Thought反复出现
这是我最早遇到的问题,也是最让人崩溃的问题。模型每轮都输出差不多的Thought:“我需要搜索更多信息来回答这个问题”,但Action迟迟不执行,或者执行了同一个搜索十几次。
根因通常是提示词里没有强调“每轮必须推进”。模型把ReAct当成了普通的对话,以为只要表达思考过程就能交差。解决办法是在模板里加一句:“每一轮必须执行一个Action,不要重复已经执行过的Action。”另外把temperature调低也有奇效,我一般在0到0.3之间。
我在代码里还加了一个去重逻辑:如果连续三轮的Thought相似度超过阈值,直接终止循环。简易版可以用“最后一次Action的输入是否和上一轮完全相同”来判断。这个兜底逻辑救了我很多次。
4.2 上下文越塞越长,直接爆掉
Model的上下文窗口是有限的。ReAct每跑一轮,就要把这一轮的Thought、Action加上Observation全量拼回历史里。如果你让工具返回一份超长的报表,有两三轮迭代,上下文直接就爆了。
我的做法是给工具返回加一个“摘要层”。新闻搜索返回原始正文前,先截断到500字以内;报表工具返回前先做一次字段裁剪。在ReAct循环里,Observation对模型而言只是“下一步决策的依据”,它不需要看到全量细节,只需要看到能支撑下一步判断的关键信息。
如果任务确实需要完整大文本,我建议先让模型生成一个摘要存到外部存储里,然后把摘要塞回上下文,完整内容等最终输出时再从存储里取。这一步对长文档场景几乎是必须的。
4.3 工具调用抛异常,Agent开始胡编
工具出错在所难免,但你得想想模型会怎么应对。如果某个工具抛了异常,而你把异常信息原样塞进Observation里,模型的常见反应是:在下一轮Thought里道歉,然后尝试换个工具或者重新请求。但也有部分模型会直接开始编造一个看似合理的答案,把错误糊弄过去。
我踩过最深的坑是:某个数据接口报错,模型在下一轮输出“根据我的知识库,这个数据应该是……”——完全是在胡说八道。后来我在Observation里加了一行固定话术:“如果工具持续报错,请尝试更换策略或告知用户无法获取数据,不得编造。”这行话对抑制幻觉有显著效果。
另一个排查点是Action Input解析失败。模型经常会把输入写成带引号的形式,比如Action Input: "北京天气",而不是JSON。我的宽松解析逻辑是在json.loads失败之后,尝试直接去掉首尾的引号和花括号,再匹配开发者定义的参数占位。项目上线之后这套容错逻辑处理了大约三成的解析异常。
4.4 ReAct常见问题排查速查表
| 现象 | 可能原因 | 快速解法 |
|---|---|---|
| 模型每轮只输出Thought不输出Action | 提示词未强调必须执行Action | 模板中明确“每轮必须行动”;降低temperature |
| 同一个Action反复执行 | 模型没有意识到已经查过同个信息 | 加去重检测;提示“不要重复已有查询” |
| 输出格式经常不标准 | 提示词约束力不够或模型版本弱 | 严格模板+格式错误Observation回灌 |
| 上下文超限报错 | 每轮全量拼接历史+工具返回过长 | 截断Observation;历史只保留最近N轮 |
| 工具报错后模型开始编答案 | 异常信息没引导模型正确处理 | Observation后追加“不得编造”提示 |
| 超过最大迭代次数无结果 | 问题太复杂或模型思路发散 | 增大次数;或引导模型拆解子任务 |
5. 下一步怎么玩:ReAct的变体与工程化建议
5.1 ReAct和Plan-and-Execute怎么选
ReAct不是Agent工程里唯一的框架思路。另一个常见方案是Plan-and-Execute:先让模型一次性生成完整计划,再逐条执行,执行完一条就汇报结果,最终汇总。
这两套方案的取舍很直接。Plan-and-Execute适合任务步骤清晰、环境变化小的场景。比如“每天早上9点抓取数据、清洗、生成报表再发邮件”,这个流程是固定的,用计划先行模式执行效率高,而且省掉了大量不必要的模型推理调用。
ReAct则适合探索性任务,比如“帮我分析这个竞品的定价策略”,过程中需要不断搜索、对比、调整方向。这类任务你没法提前规划所有步骤,因为每一步都可能发现意想不到的信息。ReAct的优势在于,它的计划是隐式的、流动的,分布在一个个Thought里,随时可以被新Observation推翻。
我个人的项目经验是:如果任务步骤之间耦合度高,前一步结果会决定下一步方向,优先ReAct;如果任务流水线特征明显,Plan-and-Execute更稳、更快。两者也可以混用:外层用Plan定方向,内层对每个子任务用ReAct循环处理。这种组合我管它叫“定向巡航+局部避障”,实战里效果很不错。
5.2 多Agent协作场景下ReAct怎么放
多Agent系统里,ReAct经常被用作单个Worker的内部行为逻辑。顶层有调度型Agent负责拆解任务,下面每个Worker独立跑ReAct循环,完成自己的子任务后把结果汇总给调度层。
这种结构的好处是:每个Worker只需处理自己领域内的工具集,工具描述可以写得非常精确,模型的决策负担大幅下降。比如一个Worker只负责搜索和抓取,另一个Worker只负责数据分析,它们各自的ReAct循环都短而高效,不会出现一个Worker既要搜索又要写代码又要做报表的混乱局面。
但多Agent协作也带来了新问题:Worker之间传递中间结果时,信息格式往往不统一。我在设计时给每个Worker的输出强制加了结构化字段,比如summary、source、confidence。调度层只看这些字段做决策,不直接读大段文本,避免上下文里塞满无用的推理过程。
5.3 给“莽夫”变“谋士”的几条工程建议
最后把我折腾了大半年的经验浓缩成几条建议,照着做能少走弯路。
第一,工具数量宁少勿多。ReAct每轮只选择一个工具执行,工具列表太长以后,模型需要花更多时间做选择,而且选错的概率也会变大。我把单个Worker的工具数量控制在5个以内,必要时拆多个Worker。
第二,工具描述要写清边界。我在工具描述里不仅写了用途,还写了“什么情况下不要用这个工具”。比如搜索工具描述后加一句“不要用本工具查询用户私有数据”,模型就会在Thought阶段主动放弃错误的工具调用。这比在代码里做一堆拦截要省事得多。
第三,限制过度思考。ReAct虽然是谋士路线,但谋士想太多也不一定是好事。模型有时候会为了凑一个Thought格式,输出大段无关推理。我后来在模板里加了“Thought必须简洁,不超过两句话”的约束,实测对减少token消耗和提升收敛速度都有明显帮助。
第四,做好成本控制。ReAct的模型调用次数是动态的,一次复杂任务跑10轮也很常见。上线前一定要设置兜底的最大轮数和单轮最大token数。如果任务平均轮数超过预期,优先优化工具设计而不是换更贵的模型。
第五,Final Answer也要校验。模型可能给出格式正确的Final Answer,但内容根本无法回答用户问题,比如答非所问。我在代码里加了简单的校验逻辑:如果Final Answer长度小于5个字,或者包含“无法回答”之类的字样,会再追加一轮“请尝试用其他工具获取信息”的提示,让模型再走一轮而不是直接结束。
我自己在实际项目中最大的体会是:ReAct真正的难点不在跑通Demo,而在把每个环节的容错边界都补齐。一开始我总想着让模型一次答对,后来发现更好的策略是“让它犯错,但确保它有机会改错”。观察轮次越多,模型的决策越靠谱,但也越费钱,这个度需要针对你自己的任务去调。
如果你刚搭好一套ReAct Agent,建议先用10个典型问题做回归测试,记录每个问题消耗的轮数和失败点。不要急着上模型优化,先把工具描述、模板格式、观察截断这三件事打磨到位,收获往往比换更大的模型还要明显。这套框架的价值不在于它多新奇,而在于它给了模型一种“反悔”的机会——而这恰恰是传统Agent方案最缺的东西。