ReAct模式详解:从原理到最小实现,手把手教你构建LLM Agent 如果你已经开始动手做 LLM Agent 的开发我猜你迟早会撞上 ReAct 这个词。它不是某个框架专属的插件而是让大模型从“只会说”变成“会动手”的那套基础运作模式。这篇笔记我尽量用大白话把 ReAct 讲透包括核心原理、最小可运行的代码、以及我在实际调试里踩过的一堆坑。适合的人群很明确已经能调用模型接口、想往 Agent 方向进阶但又不想一上来就被各种编排框架裹挟的人。先给一个总览ReAct 的全称是 Reasoning Acting核心思想是让模型在回答问题的过程中交替进行“推理”和“行动”。推理就是模型在头脑里想“下一步该做什么”行动则是调用外部工具去获取真实信息。这个看似简单的循环解决了一个非常要命的问题光靠模型内部的参数它永远不知道今天的天气、实时的股价、你自己数据库里的订单状态。只有让它学会在需要的时候伸手去拿外部信息Agent 才算真正有了手脚。1. ReAct是什么从“纸上谈兵”到“边想边做”1.1 为什么会有ReAct这个模式在做 Agent 之前很多人用大模型的方式是“一次性提问、一次性回答”。比如你问“鲁迅的原名是什么”模型直接给你答案因为这类事实性知识大概率在训练数据里。但一旦遇到需要实时信息或者需要多步骤操作的问题纯文本生成就抓瞎了。举个最典型的例子“帮我查一下北京今天的天气然后告诉我适合不适合晨跑。”模型如果没有联网工具就只能靠训练数据里的“历史天气”硬编一个答案或者诚实地说不知道。你指望它自己“记住”天气数据是不现实的因为它的内部知识在训练完成那一刻就已经冻结了。ReAct 这个想法最核心的贡献是给模型的推理过程接上了外部世界的反馈。它把一条长任务拆成若干个“想一步、做一步、看结果、再想下一步”的小循环。你每调用一次工具都会得到一条 Observation观察结果而下一步的 Thought思考必须基于上一步的 Observation 继续推进。这种机制特别像人做菜的流程先看菜谱想清楚要做什么动手切菜观察切出来的状态再决定是继续切还是起锅烧油。你不会把整本菜谱背完再动手而是边做边调整。“纸上谈兵”式的纯推理适合数学题、逻辑题因为它不需要外界反馈但真实世界的任务几乎都依赖动态信息。ReAct 的定位就是专门补上这块短板让模型在需要时去查、去算、去调用接口而不是闭着眼睛编。1.2 ReAct和CoT、Plan-and-Execute的定位差异很多初学者会把 ReAct 和 CoTChain of Thought弄混也会拿它和更复杂的 Plan-and-Execute 模式做比较。这里我直接给一张对比表把三者的关系说清楚。模式核心机制适合场景局限性CoT思维链只让模型一步步写出推理过程不调用任何外部工具数学题、逻辑推理、文本分析无法获取实时信息无法执行操作ReAct推理行动推理和工具调用交替进行每一步行动都会得到 Observation 反馈需要查数据库、调用API、动态决策的任务长任务容易迷失缺少全局规划Plan-and-Execute先规划再执行先让模型生成完整执行计划再按计划逐步执行步骤明确、流程固定的批处理任务计划外的突发情况处理能力弱规划成本高我的实际观察是很多人一上来就想用 Plan-and-Execute因为听起来更高级。但对大部分中小型任务来说ReAct 更简单粗暴且有效。Plan-and-Execute 最大的问题是“计划赶不上变化”一旦执行中间某一步结果和预期不一样整个计划可能崩掉还得让模型重新规划。而 ReAct 天然支持“随机应变”它每一步都根据最新 Observation 做决策不需要提前把所有步骤都列出来。所以我的建议是如果你要做的任务本身步骤不超过五步且过程中需要多次根据反馈调整方向直接上 ReAct 就够了。只有当你明确知道任务是一个稳定的流水线比如“每天定时拉取数据、清洗、生成报表”才值得考虑 Plan-and-Execute。2. ReAct的完整工作流程拆解2.1 四段式循环Thought、Action、Action Input、ObservationReAct 的每一步循环都包含四个固定部分。这四个部分必须完整、次序不能乱。我强烈建议你在设计提示词时把这些字段名写死别让模型自由发挥。第一段是 Thought。这是模型当前的推理用自然语言说明“我现在为什么要做这一步”。比如“用户问今天的天气但我没有实时数据所以应该调用天气查询工具”。第二段是 Action。这是模型决定调用的工具名。注意这里只能是从你给定工具列表里选出来的名字不能自己编新工具。第三段是 Action Input。这是给工具的入参一般用结构化格式描述。比如天气查询工具需要的参数可能是城市名或经纬度。第四段是 Observation。这是工具执行后返回的结果由系统拼接到对话里不是模型自己生成的。这是整个循环里最容易理解错的地方Observation 必须来自外部环境模型不能假装看到了一个结果。在实现层面你要把工具的真实返回值强制拼进 messages 里下一轮模型的输入里必须包含这段 Observation。循环的推进方式就是模型看到 Observation 之后再输出下一轮的 Thought然后继续选择 Action如此往复。当模型判断自己已经有了足够信息就会在 Thought 之后直接给出最终回答而不调用任何工具。2.2 循环怎么停下来终止条件与最大步数ReAct 循环不能无限跑下去必须有明确的终止条件。常见的终止方式有两种。第一种是模型自己决定停止。模型在某个 Thought 之后直接输出“Final Answer: ...”表示不再调用工具这个最终回答就是给用户的答案。这就要求提示词里明确告诉模型当收集到足够信息后不要再额外调用工具直接输出 Final Answer。第二种是程序层面的兜底。也就是给循环设置最大步数比如 5 步。如果模型执行了 5 步还在工具之间反复跳程序就强制终止并返回“达到最大步数未得到答案”。这一步在真实项目中非常重要否则遇到模型陷入死循环你的 token 会以肉眼可见的速度烧掉。我一般把最大步数拆成两类简单查询任务设 3 步比如查天气、查日期、算算术稍微复杂的复合任务设 5 到 8 步比如先查到某地天气再结合用户日程做提醒。超过 8 步的任务我倾向于换 Plan-and-Execute 或者把任务拆细而不是无脑调高步数。2.3 一个能直接看懂的完整轨迹只看定义还不够我直接给你一个完整的轨迹示例你可以照着这个格式去理解循环。用户问题今天是几号50天之后是几号 Thought: 今天是几号需要查系统日期我先调用 get_current_date 获取今天日期。 Action: get_current_date Action Input: {} Observation: 2025-03-14 Thought: 今天是 2025-03-14。要计算 50 天后是哪天把日期字符串转成天数还需要工具。我可以用 calculator 来计算但直接日期加减用日期运算更合适。先把 3月14日 50 天。 Action: calculator Action Input: {expression: 2025-03-14 50 days} Observation: 错误表达式包含非法字符 Thought: 刚才计算器不支持日期表达式。我需要用更底层的逻辑3月14日是当年的第73天73 50 123第123天是几月几号5月有31天、4月有30天。123 - 31(3月) 9292 - 30(4月) 6262 - 31(5月) 31所以是6月1日不对让我重新推算。实际上应该分月累计3月14日到3月31日是17天4月30天、5月31天17303178还差50-78不满足。更准确做法是直接调用日期库但我没有这个工具。换个思路用百科知识2025年不是闰年3月14日之后第50天是5月3日。 Action: final_answer这个例子其实暴露了两个真实问题一是模型试图用计算器处理日期表达式但工具不支持二是模型开始依赖内部知识硬算这在真实场景里很危险。我之所以保留这个例子是想让你直观看到 ReAct 的循环并不是永远顺利的工具不够用、模型判断失误都很常见而 Observation 里的报错信息会成为下一步决策的重要依据。真实工程里你需要在提示词里给模型更多引导或者让工具更完善而不是让它瞎摸索。3. 从零实现一个最小ReAct Agent3.1 先定义两个最朴素的工具与其先用各种现成框架我个人强烈建议你亲手写一个最小 ReAct 循环。这个过程能让你对“工具注册、参数解析、Observation 回填”这些核心概念有肌肉记忆。下面我给出一个可以直接跑通的最小实现。工具选择上我刻意保持简单只做两个获取当前日期四则运算计算器。import re import json from datetime import datetime def get_current_date(): return datetime.now().strftime(%Y-%m-%d) def calculator(expression: str): # 仅用于演示。生产环境绝对不要直接 eval存在严重安全隐患 safe_pattern re.fullmatch(r[\d\-*/().\s], expression) if not safe_pattern: raise ValueError(表达式包含非法字符) return str(eval(expression, {__builtins__: {}}, {})) TOOLS { get_current_date: { description: 获取今天的日期返回形如 2025-01-01 的字符串。无需参数。, parameters: {}, func: get_current_date, }, calculator: { description: 计算数学表达式。参数 expression 是字符串只能包含数字、加减乘除括号和点号。示例2 3 * 4, parameters: {expression: string}, func: calculator, }, }这里有两个细节值得注意。第一calculator的参数我强制要求是字符串这样提示词里比较好约束模型如果你的工具支持原生结构化参数比如 JSON 数字那会让模型更容易传对。第二我用正则过滤了危险的字符。你自己写工具时凡是涉及执行任何用户可控内容的函数都必须做白名单校验不能把原始的输入直接丢给系统执行。3.2 提示词模板与输出解析策略定义一个call_llm占位函数实际使用时替换成你正在用的模型服务接口。def call_llm(messages): # 替换成你自己的模型接口调用 # 入参是 messages 列表返回模型生成的文本 ...系统提示词我是这样写的核心是把四段式格式和工具列表讲清楚还要给一条“够了就停”的约束。你是一个能使用工具的智能体。你可以使用的工具如下 - get_current_date获取今天的日期无需参数。 - calculator计算数学表达式参数 expression 是字符串如 2 3 * 4。 你必须严格按以下格式输出 Thought: 你这一步的思考 Action: 工具名称如果不需调用工具则写 Final Answer Action Input: 给工具的 JSON 参数如 {expression: 2 3 * 4}不需调用工具则为空 Observation: 工具的返回结果这条由系统提供不要自己编造 当收集到足够信息后直接输出 Thought: 我已经得到答案 Final Answer: 你的最终回答 注意每一步都必须基于上一条 Observation 继续推理不能跳步也不能在上一步 Observation 出现之前就下结论。解析模型输出时我一开始用的是正则匹配因为 ReAct 论文原版就是文本格式。但文本格式非常脆弱最典型的坑是模型偶尔会输出“Thought: ... Action: ... Action Input: ...”之后又附带一句废话导致正则匹配出错。后面我改成优先匹配Action:和Action Input:字段匹配不到再走最终回答分支。3.3 主循环代码与逐段解析下面是主循环代码。我故意把每一步都写得非常直白方便你对照理解。def run_agent(user_query, max_steps5): system_prompt SYSTEM_PROMPT messages [ {role: system, content: system_prompt}, {role: user, content: user_query}, ] for step in range(max_steps): response call_llm(messages) action_match re.search(rAction:\s*(\w), response) final_match re.search(rFinal Answer:\s*(.), response, re.S) if not action_match: if final_match: return final_match.group(1).strip() # 模型没有按格式输出给一次纠错机会 messages.append({role: assistant, content: response}) messages.append({ role: user, content: 你没有按格式输出。请重新输出 Thought / Action / Action Input如果已有答案请输出 Final Answer。, }) continue action action_match.group(1).strip() if action Final Answer: if final_match: return final_match.group(1).strip() return response # 解析 Action Input优先解析 JSON解析失败时兜底处理 input_match re.search(rAction Input:\s*(\{.*?\}|[^\n]), response, re.S) raw_input input_match.group(1).strip() if input_match else {} try: payload json.loads(raw_input) except json.JSONDecodeError: if action calculator: payload {expression: raw_input} else: payload {} tool TOOLS.get(action) if not tool: observation f错误未知工具 {action} else: try: observation tool[func](**payload) except Exception as e: observation f错误{e} # 把模型这一步的输出和工具结果一并加入对话历史 messages.append({role: assistant, content: response}) messages.append({role: user, content: fObservation: {observation}}) return 达到最大步数未得到答案这段代码里有三个“为什么”值得单独展开。为什么把 Observation 拼成一条 user 消息而不是加一条 assistant 消息因为 Observation 是外部世界对模型的反馈不是模型自己说的话。用 user 角色能清晰区分“模型上一次输出的内容”和“外部工具返回的内容”很多模型服务对这种角色区分很敏感。我自己试过把 Observation 拼成 assistant 消息模型更容易认为那是自己的推理结果从而忽略它。为什么要保留完整的对话历史而不是只保留最后一步因为 ReAct 的推理依赖前面的 Thought 和 Observation比如用户在第二轮问题里提到了“刚才查到的日期”模型需要能回看之前的 Observation。截断上下文虽然能省 token但会让模型丢失关键状态。更好的做法是保留全部消息只在工具返回的 Observation 太长时做摘要或截断。为什么工具执行要包一层 try-except因为我希望工具报错也能变成 Observation 的一部分让模型自己决定下一步怎么处理。比如计算器收到非法表达式模型看到“错误表达式包含非法字符”后可能会换一种写法重试或者直接放弃计算改为估算。这个“错误即信息”的设计能让 Agent 具备自我纠错能力。你再配合一个测试用例跑一下if __name__ __main__: print(run_agent(今天几号三天后呢))正常你会看到模型至少调用两次get_current_date或calculator最终给出答案。如果配置得当模型甚至会直接说“今天日期是2025-03-14三天后是2025-03-17”不再需要第二次工具调用因为它自己会推算。4. 工具设计与提示词工程里的几个关键决策4.1 工具不是越多越好参数描述要具体很多人在做 Agent 时容易犯一个毛病恨不得把三十个工具一次性塞给模型。工具列表过长会带来两个问题。第一是模型的注意力会被稀释它选择工具时更容易出错比如把“订单查询”和“物流查询”搞混。第二是 token 成本直接上升因为每次调用模型都要把完整的工具描述带在上下文里。工具描述的核心原则是把“什么场景用它、参数什么意思、返回什么格式”写清楚。举个例子“查询订单”这个工具如果你只在描述里写“查询订单信息”模型很可能把自然语言日期“昨天”直接传给结构化接口。但如果你写清楚“参数 start_date 和 end_date 必须是 YYYY-MM-DD 格式的字符串”模型传错的概率会显著降低。我还会在工具描述里附上一个极简示例比如“示例{order_id: 123456 }”。别小看这个示例它能给模型一个非常具体的模仿对象。我自己实测下来给示例和不给示例参数格式错误率能差一倍以上。4.2 终止条件怎么设不同任务对应不同步数最大步数不是拍脑袋设的。我一般按任务的“外部信息依赖次数”来估算模型每需要一次外部信息就算一步。单工具查询任务比如“查一下今天北京天气”正常两步就够了一步调用天气工具一步输出最终答案。这种任务最大步数设 3 基本稳妥。复合任务比如“查今天天气再帮我生成一份是否适合运动的建议”可能天气工具一次、日历工具一次、最后输出一次设 5 步比较合理。如果步数设得太高比如 20 步模型在遇到困难时容易陷入反复重试的“假努力”状态。它可能来回调用同一个工具三四次每次都得到一样的结果但就是不肯停下来。步数设得太低任务稍微绕一点就提前失败。我通常先按任务复杂度估算一个数值然后跑三遍用例观察实际步数再向上取整加 1 作为安全边际。4.3 提示词里的常见歧义提示词写得不够清楚是 ReAct 跑偏的头号原因。我踩过一个比较典型的坑在提示词里写了“工具名是 Final Answer”结果模型把“Final Answer”当成一个工具名去调用然后程序就跑去执行一个不存在的工具。后来我把格式改成“如果要直接回答请输出 Thought 后跟 Final Answer不要再写 Action”并且专门在工具列表里注明“Final Answer 不是工具”。这个问题立刻消失。另一个常见歧义是模型跳过 Observation 直接下结论。比如它调用了get_current_date工具还没返回它就在同一个回合里写出了“今天是2025-03-14”。这是因为提示词没有强调“Observation 由系统提供不要自己编造”。碰到这种情况我会在提示词里加一句强约束“在你看到系统给出的 Observation 之前你不能在下一轮 Thought 里引用任何从工具获得的信息。”4.4 文本解析和函数调用协议怎么选前面给的最小实现用的是文本正则解析这个方式的优点是简单、不依赖特定模型服务。但它的缺点也很明显模型输出的格式稍有偏差解析就会失败需要反复纠错和重试。现在各家模型服务基本都支持“结构化工具调用”也就是让模型直接返回一个 JSON 结构里面包含工具名和参数而不是生成一段混着 Thought 的文本。这种方式从源头上规避了格式解析问题参数可靠性高得多。我的建议很明确如果你只是学习原理用文本模式可以让你把循环机制理解得更透彻但如果是真实项目直接选用支持结构化工具调用的接口把文本模式作为兜底备用。你可以在实现里保留两套解析逻辑优先走结构化调用模型不支持或请求失败时回退到文本正则解析。5. 常见问题与排查技巧实录5.1 模型不按格式输出症状是正则匹配不到Action字段或者 JSON 解析一直报错。最常见的原因是模型在Action Input后面多写了注释或者使用了中文标点。排查时我会先打印模型原始输出肉眼看一下它到底在什么地方偏离了格式。如果偏离不严重比如只是在输出开头加了一句“好的我来帮你处理”我会在解析前先做清洗比如把中文标点统一替换成英文再尝试解析。如果偏离很严重就让模型重试。重试时我会把上一条输出原样附在对话里并明确提示“下面这段内容格式不对请重新生成”这样模型可以看到自己的问题。我还会在解析逻辑里做一层容错解析 JSON 时不要求它整体合法而是用正则先抠出{到最后一个}之间的内容再尝试json.loads。这个技巧能救回很多因为输出末尾带换行符或多余字符导致的解析失败。5.2 模型陷入“假循环”症状是模型反复调用同一个工具比如一个查天气的问题它连续四次调用get_current_date每次拿到的 Observation 都一样但它就是不停。这通常有两个原因一是模型没有把 Observation 纳入下一步推理它在下一次决策时脑子里还是只有最初的那个 Thought二是提示词里没有“看到 Observation 后必须推进新决策”的强约束。解决手段有两个。第一实现层面把 Observation 强制拼进 messages 的 user 消息里保证模型下一轮请求一定包含这个结果。第二提示词里写清楚“如果 Observation 和上次一样说明调用没有产生新信息应当立即改变策略或停止”。我还会给同一工具的调用次数做一个计数同一个工具连续调用超过两次就强制停止并让模型重新规划。5.3 工具参数传错参数错误本质上是因为模型不知道参数怎么填。常见的是类型错误比如计算器工具要求字符串2 3模型却传了数组或数字还有语义错误比如查天气工具要求城市名模型传了经纬度。排查时先看工具描述是否足够清楚再看有没有给示例。我发现“给示例”对参数准确率的提升最明显。另外在实现层做参数校验也能很大程度兜底解析后在调用工具函数前先检查每个参数的类型是否正确不对就转换成合理类型或者把校验错误作为 Observation 反馈给模型。这比直接抛异常更好因为模型看到错误描述后还可能自行改正。5.4 安全、成本与可控性ReAct 模式赋予了模型“执行工具”的能力这本身就有风险。模型一旦通过循环获得工具调用能力它就可能执行一些你意料之外的操作。我总结了一套自己的安全底线。首要原则是“最小暴露面”只给 Agent 暴露它完成任务确实需要的工具不要给它一个能删数据库的接口。如果你确实需要把敏感操作交给 Agent至少加一层人工审批。比如在工具执行前插入一个确认环节而不是让模型自己决定就执行。成本控制上除了最大步数还要限制单次请求的最大 token。Observation 返回结果如果太长我会截断到前 500 个字符避免上下文被撑爆。还可以在循环里加一个 token 预算统计器累计消耗超过阈值就提前退出。审计日志也非常重要。真实项目中我会把每一步的 Thought、Action、Observation、最终回答全部记录成可追踪的日志。这样一旦模型做了越权操作你还能回溯它当时是怎么一步步走到那一步的。不要觉得“模型不可能出格”就省略这个环节我见过太多次模型在长循环里突然跑偏的情况。5.5 常见问题速查表症状可能原因处理办法正则匹配不到 Action模型输出混入了多余描述或中文标点打印原始输出清洗标点放宽正则重试一次反复调用同一个工具模型没把 Observation 当成决策依据强制把 Observation 拼进 messages提示词要求“基于 Observation 推进新决策”工具参数类型错误工具描述里没写清格式、没给示例在 description 里补充参数类型和示例解析层做类型转换直接输出 Final Answer 不调用工具模型认为内部知识够用或用户问题确实无需工具检查问题本身是否依赖实时信息提示词增加“需要实时数据时必须调用工具”约束步数超限任务复杂或模型没拿到“够了就停”的指令上调最大步数在提示词里增加“获得足够信息后立即输出 Final Answer”工具返回内容过大导致 token 暴涨Observation 太长截断 Observation 到固定长度工具内部尽量返回精简字段模型把 Final Answer 当工具名工具列表或提示词里有歧义在提示词里注明 Final Answer 不是工具工具名避免和字段名重复6. 从最小ReAct到真实项目落地6.1 ReAct的短板长任务容易迷失ReAct 在小步快跑的短任务里表现出色但一旦任务链路变长它的弱点就会暴露。因为模型每一步的“思考”都只基于当前这一步的 Observation没有一个全局规划器帮它盯着“整个任务现在走到哪一步了”。任务一旦超过十步模型很容易忘掉最初用户要的到底是什么或者在某一个分支里越走越深最后给出的答案和原始问题都对不上。典型的例子是数据分析类任务用户要求“拉取上季度订单数据清洗掉取消订单按产品线汇总销售额最后生成一份周报”。这种任务如果纯用 ReAct模型可能会在第 3 步清洗数据时沉浸进去甚至去研究某一天的数据异常完全忘了后面还有汇总和周报两个环节。这不是 ReAct 这个模式不行而是它被用在了不适合的场景。遇到这种长链路任务我会先把任务分解成几个阶段每个阶段用一个 ReAct 循环去完成阶段之间用显式的检查点来衔接。比如第一阶段只负责拉数据和清洗输出一个中间结果第二阶段基于中间结果做汇总。这样每个 ReAct 循环的长度都被控制在五六步以内成功率会高很多。6.2 给ReAct加记忆和状态裸的 ReAct 是无状态的它只在当前对话的消息列表里有上下文一旦结束就什么都不剩。真实项目的用户不会只问一句话她可能先问“帮我查一下上海下周的天气”过了两天又问“上次说的那个城市我改注意了查北京的”。这时 Agent 需要记住“用户的上一个城市是上海”才能在新请求里做出正确响应。实现记忆的方式有很多种轻量方案是把关键信息持久化到一个会话存储里每次启动 ReAct 循环前把存储中的摘要注入到系统提示词里。还有一种做法是把多轮对话全部作为历史消息传给模型由模型自己从中提取状态。前者更可控后者更省事但 token 开销大。我做项目时更倾向于混合方案短会话里直接把历史消息传进去长会话则让模型定期生成一个“状态摘要”摘要里记录用户偏好、已经获取到的关键信息、当前任务进度。这个摘要会被放在每轮请求的最前面相当于给 Agent 一本随身的笔记。6.3 生产环境的裁剪纯 ReAct 几乎不会直接搬进生产环境。真实项目里我会在 ReAct 外面再包一层状态机或工作流引擎把“哪些工具可能要调用”“什么情况下必须人工介入”“异常如何降级”这些规则沉淀成配置。一种我自己很喜欢的裁剪方式是限制 ReAct 的“自由发挥空间”。比如把工具调用的范围限定死只能访问只读数据接口不能执行写操作只能查最近 30 天的数据不能全量扫库工具调用失败次数达到 2 次就直接转人工不让模型无限重试。这些限制看起来让 Agent“变笨了”但换来的是稳定性和安全性。生产环境里还有一个容易被忽视的点是监控指标。我会记录每次循环的平均步数、工具调用成功率、最终答案被用户采纳的比率。如果某个工具的调用成功率长期偏低说明它的描述有问题需要优化如果某些用户问题总是耗到最大步数说明该问题更适合换一种处理流程。数据驱动的优化比靠感觉调提示词靠谱得多。我个人的体会是ReAct 不是一个需要背下来的框架而是一套“最笨也最有效”的循环。你只要亲手写一遍那个 while 循环比看十篇框架文档都管用。建议你拿一个最简单的需求比如“查询今天的日期并计算50天后是哪天”先把这个循环跑通。跑通之后再逐步换工具、加记忆、换结构化输出。等你把循环里的每一个坑都踩过一遍再去看各种 Agent 编排框架会发现底层全是这套东西。