1. ReAct 到底在解决什么问题:先想清楚“想”和“做”的关系
先说个扎心的事实:让大模型直接答题,它写得头头是道,但你要让它“帮我查一下明天三场航班的准点率,然后订一个最不容易延误的”,绝大多数模型当场就卡住了——不是不知道航班信息在哪,而是它压根没有“去查一下”这个动作能力。这就是纯文本模型和真正的 Agent 之间那条看似很细、实则很宽的裂缝:模型只会从上下文里推断答案,不会主动去调用工具、观测结果、修正判断。
ReAct 模式(Reasoning + Acting,推理加行动)要解决的,正是这个“只会想不会做”的问题。它把模型的推理过程和执行过程拼成一条流水线:模型先输出一段思考(Thought),说明这一步要做什么、为什么;然后输出一个动作(Action),比如“调用天气查询工具”;工具返回结果后,模型把结果读进来,再输出下一段思考……循环往复,直到拿到最终答案。这就是标题里说的“想一步做一步”。
我第一次在项目里真正把 ReAct 跑起来的时候,第一反应不是“哇好智能”,而是“这不就是把人类的办事流程模板化了吗?”——你查资料、写结论、发现资料不够、再查、再修正,本质上就是这么个循环。但难点在于:怎么把这些步骤用提示词约束住,怎么让模型输出的动作能被程序可靠地解析执行,怎么让循环不失控。这篇文章会把设计思路、实操模板、避坑经验全部摊开来讲,适合刚接触 Agent 开发、想从零搭建一个可运行的 ReAct 模型的开发者,也适合已经写了一版 demo但总在试错的朋友对照排查。
2. 整体设计思路拆解:为什么是“推理–行动”交替,而不是一步到位
2.1 和两种极端方案的对比:CoT 只动嘴,Act-only 只动手
在 ReAct 出现之前,业内有两类主流做法。
一类是思维链(Chain-of-Thought,CoT)风格,让模型把推理步骤写出来再接最终答案。它的优点是把复杂问题拆小了,模型每一步的压力不大;缺点也明显——模型算是一路“想”到了答案,但没有验证手段。它说“明天第一班航班最稳”,它并不知道第一班航班是不是真的延误,因为全程没有接入查数据的动作。这就像一个人坐在屋里闭门写方案,写得再漂亮,和外面实际情况对不上也没用。
另一类是纯动作式(Act-only),直接让模型输出工具调用,拿到结果后继续调用。这种模式效率高,但缺少中间的判断层。模型可能拿到一个不够准确的数据就直接进入了下一步,或者工具报错了,它也不知道换个思路再来。就像一个人只会机械地拨打电话,但拨通以后该问什么、对方答的哪句话值得追问,完全没有章法。
ReAct 的设计恰恰是把这两者的优点缝到了一起:推理负责“决定下一步干什么,以及为什么”,动作负责“真的去干”,观测(Observation)负责“把结果拿回来验证”。每一步都留下文字痕迹,模型能自我反思,开发者也能排查问题。代价是每一步多花一些 token、多一两次请求,但对大多数实际场景来说,这个代价换来的准确率和可控性完全是值得的。
2.2 核心循环机制:Thought–Action–Observation 是怎么转起来的
ReAct 的单步循环可以抽象成三个字段:
- Thought(思考):当前这一步的判断。比如“用户需要知道明天三趟航班中哪一趟最可能准点,那我需要先拿到三趟航班的实时状态”。
- Action(动作):决定调用哪个工具、传什么参数。比如“search_flight_status(flight_no=CA1234, date=2025-01-15)”。
- Observation(观测):工具执行后返回的结果。这一步不是模型生成的,而是程序真实执行动作后把结果拼回给模型看的。
模型每轮只生成 Thought 和 Action,程序负责执行 Action 并构造 Observation,再继续让模型生成下一轮 Thought。这个“生成–执行–回填”的循环一直持续,直到模型认为信息够了,不再输出 Action,而是输出 Final Answer。
拿生活场景类比,这就是一个“先想一步,再走一步;用这一步看到的结果,决定下一步怎么走”的过程。它不是让模型一开始就规划好全程,而是每一步都基于最新观测做决策——这正是它应对不确定性的关键。
2.3 为什么不用一次规划全部步骤:动态修正才是 Agent 的护城河
有人可能会问:反正模型上下文能承载不少内容,能不能让它先一次性生成完整的执行计划(Plan),然后按计划执行?这其实是另一种思路——Planner-Executor 架构。但 ReAct 模式没有这样做的原因是:真实环境充满变数。比如查询接口返回异常、某个航班的数据缺失、用户中途补充了新条件(“价格别超过八百”),如果计划是固定生成的,遇到这些情况要么中断,要么只能按错的计划硬跑。
而“想一步做一步”的循环天然有韧性——每一步都是对上一步结果的回应。计划可能粗略,但执行过程中模型的方向感是靠真实观测来校正的。这就像你出门前不会把“到哪个路口往哪拐、路上要打几次方向盘”全部想好,而是开到路口看到路牌再决定往哪拐。开车靠的是路牌反馈,Agent 靠的是工具返回的 Observation。
3. 环境准备与模板搭建:把 ReAct 的“循环骨架”先立起来
3.1 整体技术选型:模型、工具和编排框架怎么配
在实际实现中,ReAct 并不需要特别重的框架支撑。核心就三部分:一个支持通用指令遵循的大模型(聊天补全接口即可)、一组可以被调用并返回文本结果的外部工具、一个负责循环调度和文本解析的编排层。
我的建议是,第一版不要一上来就上重量级 Agent 框架,先用最简单的方式把循环逻辑写透,跑通一条完整链路以后再考虑抽象和封装。具体来说:
- 模型端:选择通用对话模型就好,关键是上下文长度要够用(至少把几轮 Thought/Action/Observation 装下),并且输出格式可控。实测下来,只要能稳定输出指定格式的 JSON,都能跑 ReAct。
- 工具端:第一版挑 2-3 个纯函数式工具最好。比如“查询城市天气”“计算两个日期差”“从本地 CSV 读某列统计值”。工具返回结果统一转成纯文本字符串,方便拼接进对话。
- 编排层:用普通代码写一个 while 循环加解析函数就够,不需要引入复杂状态机。循环体做四件事:把对话历史拼成 prompt 发给模型、解析模型输出、执行动作、把 Observation 拼回对话。
这里有个容易被忽略的配置点:模型温度。ReAct 对温度非常敏感,温度太高模型容易在输出里加一些和格式无关的话,导致解析失败;温度太低又可能让思考模式僵化。我实际用的比较多的是 0 到 0.3 之间,在可控性和一点随机性之间取平衡。
3.2 Few-shot 示例怎么写:先给模型做一个“三件套”示范
ReAct 的提示词里,最核心的部分是一组“案例演示”(few-shot examples)。目的是让模型明白 Thought、Action、Observation 三者怎么配合,而不是靠一句“请你一步一步思考”空泛引导。
一个标准的 few-shot 段落通常长这样(这里用伪代码表达):
用户问题:明天北京和上海的最高气温分别是多少?哪个城市更适合户外跑步? Thought: 需要先获取两个城市明天的天气数据,分别查询北京和上海。 Action: get_city_weather(city=北京, date=明天) Observation: 北京明天多云,最高气温 26 摄氏度,微风。 Thought: 北京气温 26 度,条件不错。继续查上海。 Action: get_city_weather(city=上海, date=明天) Observation: 上海明天阵雨,最高气温 31 摄氏度,湿度较大。 Thought: 上海有雨且湿度大,不适合户外跑步;北京多云微风,条件更好。 Action: finish(北京)看到关键点了吗?每个 Thought 都是对上一条 Observation 的直接反应,每个 Action 都只做一件事,工具参数必须具体。这既是给模型做示范,也等于给模型立了一条行为准则:一次只走一步,每步都有依据。
3.3 动作空间约束:不是所有工具都能随便调
另一个关键设计是动作空间(Action Space)的明确约束。提示词里应当写清楚“你可以使用以下工具列表”,并对每个工具附上简要说明和参数要求。比如:
- get_city_weather(city, date):查询指定城市指定日期的天气情况,city 为城市中文名,date 为“明天”或具体日期。
- calculate_date_diff(date1, date2):计算两个日期的间隔天数。
- search_flight(flight_no, date):查询航班计划与实时状态。
约束动作空间有两个作用:一是防止模型脑补出根本不存在的工具,二是让后续的动作解析逻辑简单可控。如果你不给工具列表,模型偶尔会输出一些“像那么回事”但实际无法执行的函数名,程序只能干瞪眼。
注意:每轮 prompt 中都要把这套 few-shot 和工具列表完整带上。ReAct 依赖上下文中的示例来约束格式,删掉示例以后格式稳定性会立刻下降,不要省这个 token。
4. 实操演示:用“想一步做一步”搭一个天气对比 Agent
4.1 一个具体可跑的循环样例
为了不空谈理论,下面用一个简单但完整的例子,演示整个 ReAct 循环如何一步步运行。假设工具库里有两个函数:
def get_city_weather(city: str, date: str) -> str: # 实际项目里这里会调天气接口,这里用模拟数据代替 data = { ("北京", "明天"): "多云,最高 26 度,风力 2 级", ("上海", "明天"): "阵雨,最高 31 度,湿度 85%", ("广州", "明天"): "雷阵雨,最高 33 度,湿度 90%", } return data.get((city, date), "查无数据") def is_good_for_run(weather_info: str) -> str: if "雨" in weather_info or "湿度 8" in weather_info: return "不适合户外跑步" return "适合户外跑步"用户提问:“明天北京、上海、广州三个城市里,最适合户外跑步的是哪个?”
循环第一轮,模型读入用户问题和 few-shot 示例后,输出:
Thought: 我需要分别获取三个城市明天的天气信息,先从北京开始。 Action: get_city_weather(city=北京, date=明天)程序解析出 Action 里的函数名和参数,执行get_city_weather("北京", "明天"),得到文本“多云,最高 26 度,风力 2 级”,然后把它作为 Observation 拼回对话,再发给模型。第二轮,模型看到北京天气后,会继续查上海;第三轮查广州。
三轮之后,模型已经拿到三份天气观测,进入收尾阶段:
Thought: 北京多云 26 度,上海阵雨湿度高,广州雷阵雨。综合看北京最适合户外跑步。 Action: finish(最终答案:北京)这里我特意设计了一个finish动作代替常规 Final Answer 输出。原因是:统一把“结束”也作为动作处理,能让解析逻辑只面对一种模式——看见finish就停止循环,否则继续。比同时兼容“输出 Final Answer 文字段”和“输出 Action 字段”两种状态要省心得多。
4.2 解析与循环控制的四个关键细节
解析这步看起来简单,实际踩坑最多。我总结四个容易出问题的细节:
格式解析要宽容:模型偶尔会在 Action 前后多输出解释性文字,比如“我先查一下北京的天气,Action: get_city_weather(...)”。用正则宽松提取
Action:\s*(\w+\(.*?\))通常比严格 JSON 解析更稳。但注意参数内部不要包含右括号,否则正则会提前截断。循环要有上限:必须设置
max_iterations,我一般设置在 6 到 8 轮。原因很直白:模型可能一直在循环里绕不出来,比如反复查同一个工具、参数始终不对。没有上限的循环是生产环境里最可怕的隐患——不仅浪费 token,还可能在无人值守时把接口打爆。历史记录要完整拼接:模型不具备自己的记忆,每一轮请求都得把之前所有的 Thought/Action/Observation 按顺序拼接好再发回去。一个常见的低级错误是只发最近一轮,导致模型失去前文信息,行为立刻变得混乱。
工具报错也是 Observation:如果工具抛出异常,不要把异常抛出中断程序,而是把错误信息文本化后作为 Observation 返回给模型,让模型自己决定怎么应对。比如天气接口返回“超时”,模型可能会改一个时间范围重试,或者换一个数据源。这一步是 Agent “看起来聪明”的关键:它学过“收到错误可以不慌”。
4.3 完整运行记录长什么样
为了让你更直观地看到全貌,这是我实际跑通一个类似 Demo 时的对话记录简化版:
用户: 明天北京、上海、广州哪个最适合户外跑步? Thought: 需要三个城市的天气数据,先查北京。 Action: get_city_weather(北京, 明天) Observation: 多云,最高 26 度,风力 2 级 Thought: 北京天气不错,继续查上海。 Action: get_city_weather(上海, 明天) Observation: 阵雨,最高 31 度,湿度 85% Thought: 上海有雨湿度高,不适合跑步。继续查广州。 Action: get_city_weather(广州, 明天) Observation: 雷阵雨,最高 33 度,湿度 90% Thought: 广州雷阵雨更不适合。综合对比,北京天气最好。 Action: finish(最终答案:北京)整个过程清晰可见:模型没有在一开始就猜“哪个城市最好”,而是走一步、看一步,用三次观测支撑最终判断。这也让使用者(或调试者)能一眼看出模型每一步的依据是否合理——这种透明性是直接端到端生成答案做不到的。
5. 常见问题与排查技巧实录
5.1 模型陷入重复循环,反复调用同一个工具
这是 ReAct 模式里我遇到过最多的一个问题。现象是模型每轮都输出几乎一样的 Action,比如反复查同一个城市的天气,哪怕 Observation 没变化。
排查思路分三步:
- 先看是不是工具返回的 Observation 信息量太低。如果 Observation 永远只是“查询失败,请重试”,模型除了重试确实没有更好的选择。解决方式是改进工具返回,让报错信息更具体:“查询失败:城市名或日期格式错误,请传入标准城市中文名”。
- 再看提示词里 few-shot 是否展示了“不同选择”。如果你的示例全都是顺序执行同一个工具,模型学到的最优策略就是继续查下一个同样的工具。适当在示例中加入一次“上一步查询结果不理想,所以更换策略”的示范,能明显减少死循环。
- 最后检查是否缺少
finish引导。有些模型不知道什么时候该收手,会默认“只要还能调用工具就继续调用”。在 few-shot 里专门放一个“信息已足够,直接完成”的示例,比在系统提示里强调一百遍“别循环了”都有效。
5.2 模型输出格式不稳定,Action 解析经常报错
格式问题是新手最容易受挫的地方。明明 few-shot 里写的很清楚Action: func(arg),模型却输出成Action: 调用函数 func(arg)或者后面跟了一堆解释。
我实践的解法是“双层兜底”。第一层,提示词里明确说明:“Action 必须严格遵循工具列表中的函数名和参数格式,不要包含解释,不要使用任何自然语言描述。”第二层,解析代码里做容错处理:先用正则识别出函数名(参数)片段,找不到再尝试从文本里提取形如工具名 参数的片段。这里推荐一个技巧:在工具列表描述里把参数类型也写清楚,比如“date: string, 支持格式:今天/明天/YYYY-MM-DD”。参数越明确,模型就越不容易自由发挥。
如果上述方式仍然不稳,还有一个杀手锏:要求模型把整轮输出放在 JSON 里,例如{"thought": "...", "action": {"name": "...", "args": {...}}},但代价是偶尔会遇到括号嵌套或引号转义问题。对生产项目,我还是倾向于“提示词约束 + 宽松正则解析”的组合,整体稳定性更好。
5.3 模型幻觉 Observation:它根本没查工具,直接编了个结果
这个问题比格式问题更隐蔽。现象是模型没调用任何 Action,直接输出了 Final Answer,而且答案里带着貌似具体的数字或状态。深挖对话记录会发现,这些“数据”其实是模型根据常识猜测的,压根不是工具返回的。
产生原因通常有两个:一是 few-shot 示例让模型误以为“某些情况可以不调用工具直接回答”;二是模型对用户问题判断失误,觉得问题简单到不需要工具。
解决办法是强化规则:“所有回答都必须基于 Observation 中出现的信息,任何没有在 Observation 中出现的数据都不允许出现在最终答案里。”这句话要在系统提示词里反复写。同时,在 few-shot 中加入一个反例——模型一开始想直接猜答案,然后自己否决了这个思路,转而调用工具——这种“纠偏”示范能有效告诉模型:宁可多走一步查询,不要凭空编造。
提示:排查这类问题的时候,把对话里所有
Thought单独拉出来读一遍,比盯着 Action 更容易找到问题根源。因为大多数“编数据”的行为,会在 Thought 里留下蛛丝马迹,比如“根据我的经验……”“我认为大概是……”,看到这样的念头出现,就要警惕最终答案是否可验证。
5.4 工具参数太复杂,模型总传不对
当工具参数从一两个扩展到五六个甚至嵌套结构时,模型传参出错的概率会明显上升。比如一个book_flight(from, to, date, seat_class, passenger_name),模型经常把机场代码和城市名搞混,或者日期格式写错。
我的应对策略是“拆工具”。不要试图让模型一步调用一个巨大函数,而是把复杂动作拆成几个语义明确的小函数。比如先search_flights(from, to, date)拿到航班列表,再get_flight_detail(flight_id)拿到具体信息,最后book_flight(flight_id)下单。单步动作输入越简单,模型出错概率越低。这本质上就是“想一步做一步”的精髓:把大动作拆小,每一步都有验证机会。
如果实在不能拆,就给每个复杂参数准备一个“归一化函数”。比如不管模型传的是“北京”“BJ”还是“PEK”,归一化层统一转成标准代码。这套做法在生产环境里简直救命——与其要求模型永远不出错,不如把容错放到代码侧。
6. 从 Demo 到可用:ReAct 实战进化的几条经验
6.1 把工具返回变成“有观点”的文本,而不是原始数据
很多工程实现容易忽略一件事:Observation 不只是给程序看的,更是给模型“读”的。工具返回的原始数据往往不够友好。比如天气接口返回 JSON{"code": 200, "data": {"temp": 26, "cond": "cloudy"}},模型要自己翻译这串结构,容易出错。
我建议在工具函数内部就做一层“文本化包装”,把结构化数据转成自然语言描述。上面的 JSON 可以包装成“北京明天多云,最高气温 26 摄氏度”。模型生成下一步 Thought 时就不需要做额外解析,准确率自然提升。
这也引出一个设计原则:工具的输出要以“服务于模型下一步决策”为目标,而不是以“服务于程序接口规范”为目标。最开始我犯的错就是把原始 JSON 直接丢给模型,结果模型总在一些不重要的字段上打转。
6.2 使用记忆压缩:循环过长时不能无限堆上下文
ReAct 的循环轮数一旦超过十几步,对话历史会变得非常长,token 消耗上涨,模型也可能被前面的干扰信息带偏。这时候就需要做记忆压缩,把旧的 Thought 和 Observation 概括成一段摘要,再接新的交互。
我的做法是:设置一个窗口大小,比如最近 6 轮完整保留,更早的部分每 2 轮合并成一句摘要。比如“此前已确认北京天气晴好、上海有雨,广州数据待查”。这样既能保住关键信息链,又能避免上下文过长引发理解漂移。这个压缩动作本身也可以看作一个 Agent 任务——有的框架会专门用一个轻量模型做摘要,效果比简单截断更好。
6.3 不同模型对 ReAct 的适配差异
最后聊一个实战选型话题,不是广告,单纯是经验。不同模型对 ReAct 的指令遵循能力差异很大。有的模型写 few-shot 就能稳定跑循环,有的模型必须借助系统提示强调“你是一个工具调度器,只能输出规定字段”。
我的建议是:在选定模型前,用一个统一的测试集把两三轮循环跑通,观察格式稳定性、幻觉频率和循环收敛速度。选型时优先看重三点:一是输出格式稳定性(不是智商,而是格式纪律);二是长上下文下的记忆保持能力;三是对“信息不足时应继续查询”的理解力。这三个点直接决定了 ReAct 循环能不能跑得顺。
单轮问答的模型“聪明程度”和 Agent 场景下的表现并不完全正相关。有些模型单轮答题很惊艳,但多轮循环里会逐渐偏题;反而是些朴素但纪律强的模型,在 ReAct 场景下表现更可靠。
最后再分享一个体会:ReAct 模式最迷人的地方不是它让模型“变聪明”,而是它创造了一套可观测、可干预、可调试的“人机协作回路”。模型每想一步、每做一步,都有明确依据可以追溯。这套回路里,需要你仔细打磨的不是某个精密算法,而是那些看似不起眼的提示词细节、工具返回设计和解析策略。照着前面的方法把第一版跑起来,你会在调试中快速积累起自己的判断力——这个过程本身就是做 Agent 开发最值钱的经验。