1. 为什么单纯的思维链不够用:ReAct的出发动机
我第一次认真研究Agent时,心里一直有一个别扭的感觉:大模型明明很聪明,能写诗、能解数学题、能答常识问题,但一旦让它"干点实事",比如帮我查个实时股价、做个加减乘除之外的计算、把某个操作在本地环境真正执行一遍,它立刻就露馅了。原因很简单——模型的知识是静态的,它的世界里没有"执行"这个概念。它可以描述怎么做,但它做不了。这种落差,是所有想往Agent方向进阶的人都会撞上的第一堵墙。
当时我第一个想法是,那用思维链(Chain of Thought,CoT)不就行了吗?让模型一步步思考,把问题分解,比如"首先我要查天气,然后决定要不要带伞,最后写一封出行通知"。这看起来已经很接近了,但问题在于:CoT只能产生推理轨迹,它不会和外界交互。推理的终点还是推理,模型没有一个机制去调用真实工具、接受真实反馈、根据反馈修正下一步。换句话说,思维链解决的是"怎么想"的问题,ReAct解决的是"怎么想而后做、做了再想"的问题。
ReAct这个思路不是凭空冒出来的。它的核心洞察特别朴素:既然模型既有推理能力,又可以通过文本指令去调用工具,那么就让推理和行动交替执行好了。模型每走一步,先思考当前状态、决定需要什么信息,然后输出一个行动,系统执行这个行动并把结果(Observation)返回给模型,模型再基于这个新信息继续思考。整个过程就在这条"思考—行动—观察"的闭环里循环,直到模型认为问题已经解决。
我当时读到这个思路时,第一反应是"这有什么稀奇的?不就是把问答系统加个循环吗?"但真正动手去实现一个最小版本之后我才意识到,魔鬼全在细节里。你如何告诉模型"该输出Thought还是Action了"?你如何保证模型的输出能被程序稳定地解析?工具返回一大段乱七八糟的文本时,模型怎么从中提取关键信息?这些问题在论文里只是一句话,在实际代码里却要一行一行调。这篇文章我就把自己从零实现ReAct Agent的全过程摊开讲,包括那些让我抓狂的坑。
2. Thought/Action/Observation三要素:一次完整循环的内部拆解
2.1 三条核心语句,三个角色
ReAct的循环可以用三条带格式的文本记录来概括:Thought、Action、Observation。这里的Thought不是模型内心的意识流,而是它对外输出的一段推理轨迹,以"思考:"开头;Action是它决定做的具体动作,通常包括动作名称和参数;Observation是工具执行动作后返回的结果。程序每次看到Action,就去执行对应的工具,然后把结果组装成Observation填回对话历史里,模型再基于历史继续生成下一轮Thought。
这个过程很像一个实习生干活:先想清楚要什么信息(Thought),然后打电话给相关部门问(Action),部门把资料发回来(Observation),实习生继续判断下一步怎么办。没有Observation的Agent,就像打了电话但不听对方说话,只顾自己往下念稿子;没有Thought的Agent,就像无头苍蝇乱拨号码,连自己在做什么都不知道。只有三个环节全部打通,Agent才具备"根据现实反馈动态调整"的基本素质。
2.2 为什么"观察"这一步经常被忽略
很多教程讲ReAct时,重点都放在Thought和Action上,把Observation当成一个可有可无的回显。我自己实验后可以负责任地说:Observation的设计直接决定了Agent智商的上限。模型并不具备真实的感知,它只能通过文本反馈来理解"这个世界现在变成什么样了"。如果工具返回的结果含糊、字段缺失、格式混乱,模型就只能在错误的地图上继续推演,越走越偏。
举个例子:我让Agent查询某个产品的库存状态,工具返回的原始结果是一大段JSON,里面有仓库ID、SKU、更新时间、备注字段。如果没有对Observation做预处理,模型很容易被"备注"字段里的无关文本带偏,重点放到了一句不知道谁写的"今天发货有点慢"上,而忽略了真正需要的库存数值。后来我的做法是:在工具函数返回结果之前,就把它整理成"对推理最有用的摘要句式",比如"SKU-10023库存量为37件,状态正常"。这相当于替模型把原始噪音过滤掉,只留关键信息。Observation的质量,才是Agent稳定性的隐形瓶颈。
2.3 ReAct和"先规划再执行"有什么本质区别
还有一个我一开始没想清楚的问题:ReAct和常见的Plan-then-Execute(先规划、再执行)方案到底有什么区别?两者的差别很像"照着施工图干活"和"边施工边改图"。Plan-then-Execute会让模型第一轮就输出一个完整的分步计划,然后按照计划逐步执行工具,执行过程中一般不允许大改计划。这种方式在需求明确、环境稳定的场景下效率很高,但只要中途任何一个步骤的假设破灭了,整套计划就只能作废重来。
ReAct则相反,它走一步看一步。模型不需要在一开始就预判所有未来状态,它可以根据每次Observation的结果实时修正下一步行动。这种设计换来的是更强的容错性和对不同任务形态的适配力,代价是更多的交互轮次、更大的Token消耗和更长的响应时间。所以不存在哪个方案绝对更好,只存在哪个方案更匹配你手里的问题。ReAct更适合那种"问题边界模糊、工具返回结果不可预判"的场景,比如在多个信息源之间交叉核验的问答任务。
3. 手把手实现一个最小ReAct Agent:代码全解
3.1 整体设计:四段式Prompt的构思
动手写代码之前,我先把整个系统的边界画清楚。一个最小可用的ReAct Agent,至少需要四样东西:一份任务描述、一套工具清单、一个循环控制逻辑、一个格式化解析器。工具清单我用模拟函数实现,不让它依赖任何外部框架,这样你可以直接跑通,理解核心机制后再替换成真实API。
核心的提示词模板我设计成四段式:第一段固定说明"你是一个需要反复推理和行动来回答问题的人工智能";第二段给出可用工具列表,每个工具包括名称和描述;第三段用一个示例演示Thought、Action、Observation的循环格式;第四段开始正式的提问和历史记录。这个顺序不能乱,尤其是示例段落,它相当于给模型做一次"格式示范",能不能稳定输出规范格式,很大程度取决于示范样例够不够清晰。
3.2 工具模拟:一个搜索API和一个计算器
工具这块先写两个最通用的:一个用来"搜索信息",一个用来"执行计算"。搜索工具内部用一个字典模拟结果,计算工具就直接调用Python的表达式求值。注意,真实项目里绝不建议直接用eval做计算,会有严重的安全风险,这里只是演示循环机制才这么干的,你换成安全函数即可。
def search_info(query: str) -> str: # 模拟搜索引擎返回结果 data = { "某产品价格": "该产品官方建议零售价为799元", "某公司财报": "该公司第二季度营收同比增长12%", "天气": "某城市今日多云转晴,气温21到28摄氏度", } return data.get(query, "抱歉,没有找到该查询的相关结果。") def calculator(expression: str) -> str: try: result = eval(expression) # 演示用,实际项目禁止直接eval return f"计算结果: {result}" except Exception as e: return f"计算表达式有误,错误信息: {e}"注意到每个工具函数都返回字符串,这是有意为之。Observation的统一类型就是字符串,模型只认文本。无论你背后是数据库、HTTP API还是本地文件,交到模型手里的,一定得是一段它能够直接放进上下文里继续推理的干净文本。
3.3 核心循环代码:解析、执行、追加历史
循环控制是整个Agent的中枢神经。每轮循环做四件事:把历史记录和用户问题拼接成完整提示词发送给模型;拿到模型输出后,按正则规则提取其中的Thought和Action;如果Action存在,就去执行对应工具,把Observation追加到历史里;如果Action不再出现,说明模型已经给出了最终答案,循环结束。
import re history = [] MAX_STEPS = 5 def parse_model_output(text: str): thought_match = re.search(r"思考[::](.*?)(?=行动[::]|$)", text, re.DOTALL) action_match = re.search(r"行动[::](.*)", text, re.DOTALL) thought = thought_match.group(1).strip() if thought_match else "" action = action_match.group(1).strip() if action_match else "" return thought, action tools = {"搜索": search_info, "计算": calculator} def run_agent(question: str): history.append(f"问题: {question}") for step in range(MAX_STEPS): prompt = build_prompt(history) response = call_llm(prompt) # 这里封装你的模型调用 thought, action = parse_model_output(response) if not action: print("最终答案:", response) return # 解析动作名与参数,格式约定为 "工具名(参数)" action_pattern = r"^(\w+)\((.+)\)$" match = re.match(action_pattern, action) if not match: history.append(f"思考: {thought}\n行动: {action}\n观察: 行动格式非法,请重新输出") continue tool_name, tool_arg = match.group(1), match.group(2) if tool_name not in tools: history.append(f"思考: {thought}\n行动: {action}\n观察: 找不到工具{tool_name},请从可用工具中选择") continue result = tools[tool_name](tool_arg) history.append(f"思考: {thought}\n行动: {action}\n观察: {result}") print("已达最大轮次,停止迭代")这里的call_llm我故意留成接口,不绑定具体模型。你换成任何支持对话补全的模型接口都可以,只要把history列表按对话消息的格式组装进去就行。真正影响成败的往往不是调哪个模型,而是build_prompt里的历史整理方式。
3.4 我踩过的格式化解析坑
解析这一步看起来只有几行正则,却是我整个开发过程中翻车最多的部分。模型经常不按剧本出牌,比如它会把Thought和Action写在同一行,会在Action后面偷偷多写一句解释,甚至偶尔直接输出中文的"思考:"和"行动:"之间的冒号变化。为了适配这些情况,我反复调整了几轮正则,最终采用了一个更宽松的解析策略:不要求正则一次性完美匹配,而是先用一个宽松的模式提取候选文本,再按顺序拼接回历史记录。这在工程上叫"尽量宽容输入,严格控制输出模板"。
另外一个教训是:尽量在每次循环都把模型完整的原始输出追加进历史,而不要只追加解析出来的Thought和Action。因为原始输出里可能包含它对一个问题的自我纠正,比如"突然意识到上面的计算不对",这种自我纠错文本如果被过滤掉,模型就丧失了从错误中往回拉的能力。保留原文,模型才能看到自己上一轮到底说了什么。
4. 从Demo到可用:输出解析、工具描述与循环控制
4.1 工具描述写得好不好,决定了Agent是牛还是障
这是我最想强调的一点:在ReAct Agent里,工具函数本身的实现固然重要,但模型完全看不到你的代码,它唯一能接触到的,是你在Prompt里写的工具描述。描述写得含糊,再好的工具函数也发挥不出来。我早期给搜索工具写的描述只有一句话:"搜索工具,参数为查询字符串",结果模型经常把参数传成半句话、带标点、甚至把整个问题塞进去。
后来我总结出工具描述的五要素:工具名称、功能边界、参数格式、一个成功示例、一个失败返回语义。比如搜索工具的合格描述是:"搜索工具,适用于查找实时信息或静态知识库中没有的信息。参数为简短关键词,不要使用完整的自然语言问句。示例:搜索(某产品价格)。当无结果时,会返回'抱歉,没有找到相关结果',此时请换一个关键词重试,不要重复相同搜索。"这样写下来,模型对何时调用、如何传参、失败后干什么都有了明确预期,调用质量立刻好了不止一个档次。
4.2 轮次上限与终止条件:安全网必不可少
ReAct循环如果不加约束,模型完全可能陷入"搜索-没结果-再搜索-还是没结果"的无限循环。Token在烧,用户在等,系统却毫无进展。因此轮次上限不是可选项,而是必需的。以我的经验,通用问答场景5到8轮足够;带多步骤验证的复杂任务可以放宽到10轮,但不要太多,轮次越长,上下文越臃肿,模型的注意力越容易涣散,总成本也在同步膨胀。
更优雅的终止条件之一是"当模型连续两轮输出的Action完全相同时,强制终止"。这通常意味着它已经弹尽粮绝——所有可尝试的行动都用过了,再试也只是重复劳动。此时程序可以直接提示"无法通过当前工具完成该请求",这比让它无意识空转要体面得多。另一个我认为很有用的设计是给Observation设置长度上限:超过一定字符就截断,并追加一句"结果过长已截断,请聚焦关键字段"。这能防止某个工具一次性返回巨大文本,彻底压垮模型的上下文窗口。
4.3 历史消息的组织方式与Token成本
ReAct的长期记忆完全靠上下文堆叠,每一轮循环,历史记录都会增加几百到上千Token。10轮下来,光历史就有几千Token,这还没算系统提示和工具描述。很多人在Demo里跑得很欢,一上生产就被账单吓一跳。控制Token的手段我常用三条:尽量精简工具描述、把超长Observation摘要化、以及定期压缩前面轮次的历史,只保留"关键结论"而丢弃中间过程。后面这条操作起来比较讲究,压缩的时机和策略要反复试验,压缩太狠会丢掉推理线索,压缩太晚则浪费空间。我的建议是先跑通不加压缩的版本,再根据实际轮次分布去优化。
5. 实测最常踩的坑:死循环、误调工具与虚假推理
5.1 死循环的三种典型形态与应对
死循环不只有"反复搜同一个词"这一种。我实测下来,模型还有一种更隐蔽的死法:它每隔几轮换一个新工具,但每次换工具前不做任何基于Observation的推理变化,像是为了执行而执行。比如先搜产品价格、再算一遍零售价、然后回去再搜那个产品价格。这种循环从日志上看每一步都不一样,但实际上毫无进展。要抓出它,必须把每轮的"思考"文本也打出来看,才能发现模型其实一直在原地兜圈子。针对这类问题,我加了一条观察规则:如果连续三轮的Thought关键词相似度极高,就当死循环处理。
5.2 工具参数错乱:模型把它当成了人类口述
另一个高频问题是模型传参太"口语化"。它不会老老实实按格式传参,而是把参数写成"帮我查一下某产品价格吧"这种完整句子。我的搜索函数用字典匹配,整句输入几乎必然返回"没有找到"。解决思路有两条:一是把工具函数改造成支持关键词抽取,对参数先做一轮清洗,提取核心实体;二是强化Prompt里对参数格式的约束,甚至给出正反两个示例。我把第一种方案叫"工具侧容错",第二种叫"提示侧约束",两边的效果不太一样——工具侧容错能提升鲁棒性,但清洗逻辑写复杂了会增加维护负担;提示侧约束更干净,但对模型的服从度有要求。
5.3 虚假推理:模型可能在编造Observation后的反应
最让我警惕的是模型的"虚假推理"。模型在看到Observation前,就已经有了先入为主的答案,于是它在阅读理解Observation时,会强行把它往自己的结论上靠。比如Observation写"该产品当前无货,预计三周后补货",模型却在Thought里写"根据最新信息,该产品目前货源充足,可以正常购买"。这种错误不是你Prompt写得不够好,而是模型本身存在确认偏误,它太想给你一个答案了。
针对这种问题,我能给的有效手段有限但有用:第一,在系统提示里强调"观察结果代表事实,必须严格基于观察修改你的结论";第二,在Observation前加上醒目标记,比如一行分隔符,让模型更容易把"外部事实"和"自己脑补的推理"区隔开;第三,对高风险场景设置"矛盾检测",当模型结论与Observation明显冲突时,强制它重新阅读Observation。虽然这也不能百分之百拦截,但至少能减少一大半错误回答。
6. ReAct不是银弹:边界、成本与替代方案
6.1 成本和延迟是绕不开的现实
ReAct对Token的消耗远高于单次问答,这是它的原生短板。一个复杂问题跑下来,模型可能要调用五六个工具、阅读五六轮Observation,上下文累积上万Token,单次任务的成本可能是普通问答的十倍。如果你的应用面临高并发、低延迟的要求,就必须在每一层做优化:精简Prompt、限制轮次、给观察结果做摘要,甚至考虑把部分常见问题直接写成规则脚本,用不上Agent的地方坚决不让Agent上。
6.2 什么时候直接写规则比ReAct更香
我接手过一个内部信息提取需求,看起来很适合Agent,因为要处理不同格式的报告。但后来我发现,报告的字段虽然不同,格式规律却很强,用正则加关键字规则就能解决,处理速度是毫秒级,成本几乎为零。这件事给我很大触动:技术选型应该以任务复杂度为准,而不是以技术热度为准。如果你面对的是一组固定流程、边界清晰的操作,直接写代码判断比让模型"自由发挥"要可靠得多。ReAct的价值在于应对"非结构化、不可预测"的任务,而不是包打天下。
6.3 从ReAct往前走一步:规划器与记忆体
如果你已经熟练掌握了ReAct,下一步自然会有两个方向:一是给Agent增加"规划模块",让它在启动前先输出一个粗略计划,再在每轮循环中用Observation修正计划;二是给它加上"持久化记忆",把中间轮次的关键结论写入外部存储,避免上下文被撑爆。这两个方向各有各的坑,规划模块容易和ReAct的循环角色重叠,记忆体的写入策略则会直接影响模型的后续推理依据。我建议先把ReAct循环调扎实,再慢慢引入这些增强组件,不要一步到位。
6.4 关于上下文设计的一个补充感悟
最后分享一条我用血泪换来的经验:ReAct Agent的Prompt不要写得像说明书,要写得像"带一个聪明但容易飘的实习生"。你需要在约束和自由度之间找到平衡:太松它乱跑,太紧它丧失灵活性。我通常会给足示例,但不会过度列举"禁止"事项,而是用一个正例和反例暗示边界。模型对示例的模仿能力很强,你想要的格式和节奏,都可以通过精心构造示例来传递。这比在提示词里写一百句"不要做某某"有效得多。