Agent智能体这几年被聊得很多,但真正落到工程实践上,很多人第一步就卡在“这个Agent到底怎么循环起来的”。我见过不少团队,模型接上了、工具也注册了一堆,结果跑起来要么是单次问答就结束,要么是疯狂调用工具停不下来,根本原因就是没把ReAct这套“思考-行动-观察”的运行模式吃透。这篇就用我实际做过的项目来拆一遍ReAct,从原理到代码再到调优,尽量把那些文档里不写的坑也一并说清楚。
1. 为什么单次调用搞不定复杂任务
先从一个最朴素的场景说起。假设你要让模型查询某个数据表格,然后根据结果生成一份汇总报告。如果采用传统的单次Prompt调用,模型拿到问题后只能凭训练时的记忆回答,实在不知道就编一个。反例我见过太多,比如把销售数据的结论直接写反,或者把两个完全不相干的指标硬凑到一起。原因很简单,模型并没有真正“看到”表格内容。
这时候自然想到:能不能让模型先调用查询工具,拿到真实数据之后,再基于这些数据继续回答?方向是对的,但光“能调用工具”还不够。难点在第二个环节——模型怎么知道接下来该调用哪个工具、参数填什么、结果怎么解读?如果模型只有一次思考机会,它依然会错。
ReAct模式解决的正是这个连续决策问题。它的核心思路是让模型在每一步都显式输出Thought(当前推理逻辑)、Action(要执行的动作),然后程序把Action的结果以Observation的形式回填给模型,模型再基于Observation进入下一轮思考。就这样循环,直到模型认为信息足够、输出最终答案。
这个设计巧妙在三点。第一,它把推理过程和工具调用合并在同一个上下文里,模型不需要在中间保存什么状态,所有历史都写在对话上下文中。第二,它天然支持“试错”,模型某一步调错了工具或者参数,可以从Observation中看到报错信息,下一步自动修正。第三,它让整个过程可追踪、可审计。每一步模型在“想什么”、准备“做什么”,人类一目了然,这在实际落地时非常重要。
我自己的体会是,ReAct本质上是把“人的工作方式”翻译给机器:遇到问题先分析现状,动用工具查证,看到结果再判断下一步。没有这个循环,模型就是个记忆力有限的问答机;有了这个循环,才勉强算个能执行任务的智能体雏形。
2. ReAct循环的四个关键阶段详解
2.1 Thought阶段:模型如何表达推理意图
在ReAct框架里,Thought不是给人看的装饰品,而是模型决策的依据。工程实现时通常要求模型按固定格式输出,比如:
Thought: 用户需要查询某产品本周的销售数据,我需要先调用查询接口获取原始数据。 Action: query_sales_data Action Input: {"start_date": "2024-06-01", "end_date": "2024-06-07"}这里的关键是让Thought保持简洁,但必须包含“为什么做下一步”的信息。我一开始犯过的错误是让模型把思考过程写得像小作文,结果Token消耗大,而且在后续循环中,冗长的Thought会干扰模型对重要信息的注意力。实践中更好的做法是限定Thought在一两句话内,重点交代“当前状态判断”和“下一步理由”。
还有一个小细节:如果模型已经拿到了全部需要的信息,Thought应该交代不再调用工具,而是准备生成最终答案。这个判断是整个循环能否正常退出的关键,不少死循环就是模型始终认为自己还要再查一次。
2.2 Action阶段:工具调用的参数设计
Action阶段是整个循环的“手”,但“手”好不好用,取决于工具定义。我强烈建议把工具描述写清楚,包括功能说明、参数类型、参数含义、返回结构。模型理解一个工具的成本,和一个人读一份说明书的成本类似,说明书越烂,用错概率越高。
以注册一个天气查询工具为例:
from typing import Optional, Type from pydantic import BaseModel, Field class WeatherInput(BaseModel): city: str = Field(description="城市名称,如:北京") date: str = Field(description="查询日期,格式为YYYY-MM-DD,缺省为今天") def get_weather(city: str, date: Optional[str] = None) -> str: # 实际代码省略 return f"{city}在{date}的天气:晴,气温23-32摄氏度"注意,这里模型并“看不到”函数内部的Python代码,它只能看到函数名、参数描述和一段功能说明。所以描述里最好附带参数格式示例,尤其当参数是JSON、日期、枚举值时。这个细节能在上线第一天替你挡掉大量“Action Input解析失败“的报错。
2.3 Observation阶段:结果回填与信息截断
Observation是模型下一步思考的养料。这一步看似简单,其实坑最多。比如某查询接口返回了一长串JSON,有几百个字段,如果原封不动塞回上下文,模型会陷入信息过载,反而抓不住重点。我在实际项目里处理方式是:先让程序对结果做精简,只保留核心字段,或做聚合摘要,再作为Observation返回。
举个例子,一个订单查询接口返回了全量20多个字段,真正影响决策的可能就订单号、状态、金额、时间。没必要把内部字段也丢给模型。精简后的Observation可能是:
Observation: 共查到3条订单,总数依次为:订单A(已支付,299元),订单B(退款中,59元),订单C(已完成,120元)。如果工具结果本身并不适合截断,另一个常用策略是限制最大长度,超出部分舍弃并注明“……已截断,可缩小查询范围”。这样既保护上下文,也引导模型调整查询策略。
2.4 Final Answer阶段:循环的出口
当模型判断信息充足,它需要输出Final Answer而不是继续Action。这个出口的设计直接决定循环能否正常结束。我给模型定义的输出格式里,Final Answer必须和Action互斥,一旦出现“Final Answer:”,循环立即终止。
有个小技巧:在System Prompt里明确告诉模型,如果信息不足或工具报错,优先考虑修正请求重新调用,而不是生成臆测的答案。只有经过至少一次Observation后,才能输出最终结论。这样做虽然会多一轮循环,但能显著降低幻觉输出。实测中,设置一个“至少经过一次工具调用”的硬约束,在一些依赖实时数据的场景下,回答质量提升非常明显。
3. 工程落地:从Prompt设计到循环控制
3.1 ReAct的Prompt模板怎么写才不翻车
ReAct能不能跑通,一半看Prompt。我早期用的是网上流传的通用模板,结果模型经常在“该不该调用工具”上纠结。后来我总结出一套相对稳定的结构:角色与目标、可用工具列表、工作流规则、输出格式约束、终止条件。
以下是我惯用的模板框架:
你是智能助手,负责完成用户任务。你可以使用以下工具: - query_sales_data: 查询销售额数据,输入JSON格式日期范围,输出汇总结果 - send_email: 发送邮件,输入收件人、标题、正文 工作流程: 1. 先分析用户需求,明确需要哪些信息; 2. 如果现有信息不足,输出Thought和Action调用工具; 3. 工具返回结果作为Observation,基于新信息继续推理; 4. 当信息充足时,输出Thought和Final Answer。 输出格式: 每轮必须严格按以下格式输出: Thought: <你的推理> Action: <工具名> Action Input: <JSON格式参数> 或 Thought: <你的推理> Final Answer: <最终回答> 约束: - 不得在未调用工具时虚构数据; - 工具调用失败时,根据错误信息修正后重试; - 当发现查询范围过大,应缩小范围并再次调用工具; - 最多执行5轮工具调用。这里特别要留意的是“终止条件”和“最大轮数”。只靠模型自觉退出并不可靠,程序侧必须有一把硬尺子,即循环次数上限。上限设置合理的话,能防死循环,还能掐住成本。我一般把上限设在4-8轮,具体看任务复杂度。
3.2 循环控制代码框架
ReAct的循环本质就是一个while循环。核心代码不复杂,难的是把解析、回填、容错做扎实。下面是一个精简版示例:
import json import re def run_react_loop(user_question: str, max_rounds: int = 6): messages = [ {"role": "system", "content": SYSTEM_PROMPT}, {"role": "user", "content": user_question} ] for step in range(max_rounds): response = llm_chat(messages, temperature=0.2) content = response["choices"][0]["message"]["content"] final_match = re.search(r"Final Answer\s*:\s*(.+)", content, re.S) if final_match: return final_match.group(1).strip() action_match = re.search(r"Action\s*:\s*(.+)", content) input_match = re.search(r"Action Input\s*:\s*(\{.*\})", content, re.S) if not action_match or not input_match: messages.append({"role": "user", "content": "输出格式错误,请严格按指定格式输出。"}) continue action_name = action_match.group(1).strip() action_args = json.loads(input_match.group(1).strip()) observation = execute_tool(action_name, action_args) messages.append({"role": "assistant", "content": content}) messages.append({"role": "user", "content": f"Observation: {observation}"}) raise RuntimeError("超过最大轮数,循环结束")这个框架有几个可以打磨的点。第一,模型输出的Action Input偶尔不是合法JSON,所以我用正则粗提取后,还会用json.loads做一次校验,失败就回传“Action Input不是合法JSON”的错误信息。第二,把模型的整段输出拼进messages时,直接用assistant角色,保证上下文连贯。第三,工具执行异常要捕获,并以异常信息作为Observation回填,千万不要让程序在循环内崩溃。
3.3 工具注册与执行的安全边界
工具执行是智能体接触外部世界的出入口,也是最容易出事的地方。我习惯在工具注册层做三层防护:参数白名单校验、请求频率限制、敏感操作二次人工确认。这些规则和ReAct本身无关,但缺少它们,再漂亮的循环也会变成事故现场。
参数白名单校验指的是,工具只接受预定义字段,其他一律拒绝。避免模型生成一些奇怪字段混进请求。频率限制是为了防止模型在循环里重复发起同一种请求,某些外部API计费很贵。敏感操作二次确认则针对发邮件、转账、删除数据这类不可逆动作,在公司内部我直接规定这类工具在Agent模式下需要额外审批标识,否则拒绝执行。
4. 参数调优与性能实测
4.1 温度值该设多少
ReAct模式下温度值的设定很讲究。温度太高,模型的Thought会发散,工具名和参数都可能乱编;温度太低,又可能导致模型过度保守,有工具也不敢用。我实测下来的经验是,ReAct循环内LLM调用温度设在0.1-0.3比较平衡。
有人担心温度太低会让最终答案过于死板。这其实可以拆开处理:循环过程中用低温保证工具调用准确,最终Final Answer生成阶段可以单独提高温度。不过为了工程简单,我通常统一设在0.2,实测用户对回答质量的满意度差异并不大。
4.2 上下文长度对决策质量的影响
ReAct是“滚动对话”,每轮循环后,messages会越来越长。这个累积效应对模型的影响比很多人以为的大。我在一个模拟项目X里测试过:任务本身需要5轮工具调用,每一轮Observation大约500字符,加上Thought和Action,上下文的有效长度会翻倍。
模型在长上下文中更容易“忘掉”最开始的用户问题,尤其当中间夹杂着工具报错时。我在Prompt里会再次重复用户核心目标,或者要求模型每轮Thought先简述用户意图。另一个做法是每轮循环前,把早期不重要的Observation压缩成摘要,只保留最新几轮完整内容。
4.3 工具数量与检索机制
很多初期的ReAct Demo只注册两三个工具,效果看着不错。一旦工具数量增加到十几个,模型选择工具的准确率就会明显下降。我见过一个项目注册了24个工具,模型几乎每两轮就会选错一次,要么调了无关工具,要么输入参数张冠李戴。
解决办法是给工具加上分组和描述关键词,让模型更容易聚焦。更进一步,可以做一个工具预筛层,根据用户问题先用关键词或向量召回TopK个候选工具,再交给ReAct循环选择。这相当于给模型配了个“工具搜索”的前置步骤,大幅减轻选择负担。
实测下来,预筛层在工具数超过8个时性价比最高,可以显著降低模型乱选工具的概率。当然,这也增加了系统的复杂度。工具少时不必上这个机制,以免过度设计。
5. ReAct模式最常见的几个坑
5.1 工具报错信息的处理策略
工具执行失败时,有些开发者选择无视错误,直接把“调用失败”四个字作为Observation。这样模型往往无从下手,然后反复尝试同一个错误调用,直到轮数耗尽。
更好的做法是把异常信息和原因透传给模型。比如查询接口返回超时,Observation应该是“查询超时:上游服务响应超过10秒,请检查日期范围是否为近1天内数据,或稍后重试”,而不是简单的“Error”。模型只有知道“为什么失败”,才能提出新的Action。实际操作中,我会在工具封装层统一捕获Exception,并把异常类型、关键错误信息、建议操作拼进Observation。
5.2 模型反复调用同一工具的“假循环”
有一种特别隐蔽的Bug:模型一直在调新的工具,没有重复,但事件没有推进,而是每次都在查询相似但不同的数据,永远不收敛。这种“假循环”表现上和死循环不同,因为它每轮都“有动作”,但目的其实是模型在做无意义的试探。
我在系统里加了两个机制来应对:一是记录工具调用轨迹,如果某工具被调用超过3次且历史Observation没有新信息,就主动截断并提示模型转向其他途径;二是要求模型在Thought中明确说出“这个Action是为了验证哪个假设”,一旦发现假设不清,就把控制权交回模型,让它重新分析。这套机制上线后,假循环的比例少了一半还多。
5.3 解析模型输出时的容错
模型输出的格式偶尔会不按套路出牌,比如Action被放在Thought之前,或者Final Answer和Action同时出现。我在解析层做过一套容错顺序:先看是否有Final Answer,再看是否有Action,最后才检查Action Input。只要某个关键部分缺失,就不继续执行,而是反馈“格式不符合要求,请重新输出”。
这套策略比“让模型自由发挥再事后补救”要稳得多。毕竟循环的好处之一就是可以即时纠错,程序端要做的就是每次严格校验,发现异常就推回给模型修正。
5.4 成本与延迟的权衡
ReAct循环天然会带来多次LLM调用,成本绝不是单次Prompt可以比的。一个5轮任务,意味着至少5次模型推理。如果模型每次输出300 Token,总Token消耗会明显上涨。延迟方面,每轮调用加解析、工具执行,大概会增加两三秒,整体耗时可能在十几秒以上。
控制成本有几个土办法。第一,把循环上限调低,能3轮解决的任务绝不放宽到8轮。第二,阶段性压缩上下文,工具结果不用全部保留,中间推理可以摘要化。第三,对可缓存的工具调用结果做缓存,比如查询昨天的天气,一天内结果不变,完全可以缓存复用。这些优化对在线体验影响很大,值得逐项做。
6. 超越ReAct的其他路径与组合形态
ReAct不是唯一的Agent运行模式,但它是理解其他模式的基础。我后来也试过Plan-and-Execute(先规划再执行)、Self-Ask(自问自答)以及Tree-of-Thought(思维树,树状搜索多个推理路径)等变体,它们本质上都在用不同的方式组织思考与行动的关系。
Plan-and-Execute适合任务步骤相对确定的场景,比如“先提取文件列表,再按格式转换,最后归档”。它先让模型规划一个任务清单,再逐个执行,中间如果出现意外,则可以回溯重新规划。这个模式的好处是过程可控,适合长链路任务,但规划阶段一旦出错,后面容易连锁崩。
Tree-of-Thought的思路则更激进,它让模型同时探索多个候选推理分支,在分支间做评估和回退。这种模式对复杂推理题效果很好,但Token成本也高,不是所有业务场景都能承受。
在实操里,我常把ReAct作为默认模式,再在它外层包一层任务分解器。用户问题先被拆分出子任务,每个子任务内部跑ReAct循环,这样既有分解的清晰度,又有循环的动态调整能力。这种组合在真实业务里比单用任何一种模式都更稳。
ReAct的可组合性正是它最宝贵的地方。作为工程师,与其纠结选哪种模式,不如先把ReAct的循环逻辑吃透,再根据实际任务特征去叠加和改造。这个基础打得越牢,后面换模式、接新工具的时候就越从容。