ReAct模式实战:Agent智能体循环控制与工程落地
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, temperature0.2) content response[choices][0][message][content] final_match re.search(rFinal Answer\s*:\s*(.), content, re.S) if final_match: return final_match.group(1).strip() action_match re.search(rAction\s*:\s*(.), content) input_match re.search(rAction 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: fObservation: {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的循环逻辑吃透再根据实际任务特征去叠加和改造。这个基础打得越牢后面换模式、接新工具的时候就越从容。