1. 从零理解 ReAct:为什么它是智能体开发的分水岭
ReAct 这个词,如果你最近在关注 LLM 应用开发或者智能体方向,大概率已经反复刷到过。它不是一个前端框架,虽然热搜里“react 2026 前端面试”和“ReAct”经常混在一起出现,但两者完全是两码事。ReAct 是 Reasoning + Acting 的缩写,是一种让大语言模型在推理和行动之间交替循环的提示工程范式,也是当前绝大多数智能体框架的底层思想来源。
我第一次接触 ReAct 是在做一个需要调用外部搜索接口来回答时效性问题的项目。当时最朴素的做法是把用户问题直接丢给模型,模型答不上来就胡编。后来加了检索增强,把搜索结果拼进提示词里再让模型回答,效果好了一些,但依然存在一个致命问题:模型不知道该搜什么、搜几次、什么时候该停止。ReAct 解决的正是这个“该想的时候想、该做的时候做”的调度问题。
它的核心逻辑用一句话概括就是:让模型在每一步先输出一段思考,再决定是否调用工具,拿到工具返回结果后继续思考,如此循环,直到模型认为可以给出最终答案。这个循环看起来简单,但它是目前所有主流智能体框架——无论是 LangChain 的 Agent、AutoGPT 的任务拆解,还是各类垂直领域智能体——的共同祖先。
这篇文章我会从 ReAct 的设计动机讲起,拆解它的提示词结构、循环机制、工具调用协议,然后手把手带你实现一个可运行的 ReAct 智能体,最后分享我在实际项目中踩过的坑和排查技巧。适合已经了解 LLM 基本调用方式、想进一步做智能体开发的读者,也适合刚入门提示工程、想搞清楚“智能体到底怎么运转”的朋友。
2. ReAct 的整体设计与核心思路拆解
2.1 为什么单纯的推理或单纯的行动都不够用
要理解 ReAct 的价值,得先看清楚它之前的两条路线各自的问题。
第一条路线是纯推理,也就是 Chain-of-Thought。你让模型一步步想,它确实能把复杂问题拆开,但它的知识完全来自训练数据。一旦问题涉及实时信息、私有数据或者需要精确计算,它就只能靠“记忆”去编,幻觉率非常高。我在早期项目里试过让模型直接回答“某公司最新财报的营收是多少”,它给出的数字看起来有零有整,实际上完全是虚构的。
第二条路线是纯行动,也就是让模型直接输出工具调用指令。这种方式的问题是模型没有“想清楚再动手”的环节,它可能在还没理解问题的情况下就胡乱调用工具,或者调用一次拿到结果后不知道怎么继续。比如用户问“帮我对比 A 和 B 两个城市今天的天气”,纯行动模式可能只查了 A 就急着回答。
ReAct 的思路是把两者交织起来。Thought 负责推理和规划,Action 负责获取外部信息,Observation 负责接收反馈,然后进入下一轮 Thought。这个设计的精妙之处在于,推理指导行动的方向,行动的结果又反过来修正推理,形成一个闭环。用生活化的类比来说,这就像一个人做菜:先想“我需要什么食材”(Thought),然后去冰箱拿(Action),看到冰箱里没有葱(Observation),再想“那我去楼下买”(Thought),再去买(Action),拿到葱(Observation),最后开始炒(最终答案)。
2.2 ReAct 与普通提示工程的本质区别
很多人会把 ReAct 和普通的提示词工程混为一谈,觉得无非就是写一段更长的提示词。这个理解只对了一半。ReAct 确实是通过提示词来实现的,但它和普通提示工程有一个本质区别:普通提示工程是“一次性”的,你给模型一段指令,它输出一个结果,结束。ReAct 是“循环式”的,它的提示词里定义了模型可以反复使用的格式和工具,模型会在多轮交互中不断产生新的输出,直到满足终止条件。
这个区别带来的工程影响非常大。普通提示工程你只需要关心单次输入输出的质量,ReAct 你需要关心的是循环的稳定性、工具调用的正确率、终止条件的可靠性、以及多轮交互中的上下文管理。这也是为什么智能体开发比单纯的提示词编写要复杂得多。
另一个区别是,ReAct 对模型的指令遵循能力要求更高。模型不仅要理解任务,还要严格按照 Thought/Action/Observation 的格式输出,不能跑偏。实测下来,指令遵循能力弱的模型在 ReAct 循环里很容易“忘记格式”,输出一段自然语言就停了,导致整个循环断裂。
2.3 ReAct 循环的完整状态机
把 ReAct 的循环拆开看,它其实是一个状态机。初始状态是用户输入的问题,然后进入 Thought 状态,模型输出一段推理。接着进入 Action 状态,模型决定调用哪个工具、传什么参数。然后系统执行工具,进入 Observation 状态,把结果拼回上下文。判断是否满足终止条件:如果模型输出了 Final Answer,循环结束;否则回到 Thought 状态,继续下一轮。
这个状态机里有两个关键设计点。第一个是上下文的累积方式:每一轮的 Thought、Action、Observation 都会追加到对话历史里,模型在下一轮能看到之前所有的推理轨迹。这让模型能够基于历史信息做决策,但也带来了上下文长度的问题。第二个是终止条件:ReAct 原始论文里是靠模型自己输出“Final Answer:”来终止,实际工程中通常还会加一个最大轮次限制,防止模型陷入死循环。
我在实际项目里会把最大轮次设成 5 到 8 轮。设太少,复杂问题还没解决就被截断;设太多,一旦模型卡住会浪费大量 token。这个值需要根据你的任务复杂度来调,没有万能数字。
3. 核心细节解析与实操要点
3.1 ReAct 提示词的结构拆解
ReAct 的提示词通常由几个部分组成:角色设定、工具描述、输出格式说明、示例(Few-shot)、以及用户问题。每一部分都有讲究。
角色设定要明确告诉模型它是一个“可以使用工具来解决问题的助手”,而不是一个纯聊天机器人。这个设定会影响模型的输出倾向。我试过不加角色设定,模型经常直接给答案而不调用工具。
工具描述是重中之重。每个工具需要说明名称、功能、参数格式。参数格式最好用结构化的方式写清楚,比如“输入应该是一个搜索关键词字符串”。工具描述写得越清晰,模型调用错误率越低。我见过很多 ReAct 实现失败,根源就是工具描述太模糊,模型不知道该传什么参数。
输出格式说明要严格定义 Thought、Action、Observation、Final Answer 的写法。通常用“Thought:”“Action:”“Action Input:”“Observation:”“Final Answer:”这样的前缀。格式定义得越死,模型越不容易跑偏。
Few-shot 示例是提升稳定性的关键。给一到两个完整的循环示例,让模型模仿。示例要覆盖“需要调用工具”和“直接回答”两种情况。实测下来,加了示例之后模型的格式错误率能降低一半以上。
3.2 工具调用的参数设计与边界处理
工具调用是 ReAct 里最容易出问题的环节。模型输出的 Action Input 是自然语言生成的,它可能格式不对、参数缺失、或者传了工具不支持的参数。所以工程上必须做参数校验和容错。
我的做法是在工具执行层加一层解析和校验。首先尝试用 JSON 解析 Action Input,如果失败就尝试用正则提取关键字段,再失败就返回一个友好的错误信息给模型,让它重新调用。这个错误信息会作为 Observation 拼回上下文,模型看到之后通常会修正自己的调用。
另一个要点是工具的幂等性。因为模型可能重复调用同一个工具,工具本身最好设计成幂等的,或者至少不会因为重复调用产生副作用。比如搜索工具重复调用没问题,但“发送邮件”这种工具就要加去重逻辑。
还有一个细节是 Observation 的长度控制。工具返回的结果可能很长,比如搜索返回了十条结果,每条几百字。如果全部拼进上下文,几轮下来 token 就爆了。我的做法是对 Observation 做截断或摘要,只保留最相关的部分。截断策略可以是按字符数截断,也可以是让模型先对结果做一次摘要再拼回去。
3.3 上下文管理与 token 预算控制
ReAct 循环的上下文会随着轮次增加而膨胀。每一轮都会追加 Thought、Action、Observation,如果工具返回结果很长,上下文增长会非常快。我做过一个统计,一个 5 轮的 ReAct 循环,如果每轮 Observation 平均 500 token,加上提示词本身,总 token 消耗轻松超过 5000。
控制 token 预算有几个手段。第一是限制工具返回结果的长度,在工具层就做截断。第二是定期对历史做摘要,把早期的推理轨迹压缩成一段简短总结。第三是设置最大轮次,硬性截断。第四是选择上下文窗口更大的模型,但这会增加成本。
我个人的经验是,对于大多数任务,把工具返回结果控制在 300 到 500 字以内,最大轮次设成 6,基本能覆盖 80% 的场景。如果任务特别复杂,再考虑加摘要机制。
3.4 终止条件的可靠性设计
终止条件是 ReAct 循环的出口,设计不好会导致两种问题:一是模型该停的时候不停,陷入无限循环;二是模型不该停的时候停了,答案不完整。
原始 ReAct 靠模型输出“Final Answer:”来终止。但实际中模型可能忘记输出这个前缀,或者输出了但格式不对。所以工程上需要双重保险:既检测模型输出里有没有“Final Answer:”,也设置最大轮次作为兜底。
另外,我还会加一个“空 Action”检测。如果模型输出了 Action 但 Action Input 为空,或者输出了无法解析的内容,就判定这一轮无效,让模型重试。连续无效超过两次就强制终止,返回当前已有的信息。
4. 实操过程与核心环节实现
4.1 环境准备与依赖安装
我们用一个最小化的实现来演示 ReAct 的完整流程。不依赖 LangChain 这类重型框架,纯手写,这样你能看清楚每一行代码在做什么。需要准备的东西很简单:一个能调用 LLM 的 API(这里用通用的 OpenAI 兼容接口举例)、一个搜索工具(用 SerpApi 举例,你也可以换成任何搜索接口)、以及 Python 环境。
先装依赖:
pip install openai requests如果你用 SerpApi,还需要去官网注册拿一个 API Key。不想注册的话,可以先用一个 mock 的搜索函数代替,返回固定结果,先把循环跑通再换真实工具。
环境变量里配置好 API Key:
export LLM_API_KEY="your_llm_api_key" export SERP_API_KEY="your_serp_api_key"注意:API Key 不要硬编码在代码里,也不要在公开仓库里提交。用环境变量或者配置文件管理,这是基本的安全习惯。
4.2 定义工具函数与工具描述
先定义搜索工具。SerpApi 的调用很简单,传一个查询词,返回搜索结果。我们只取前三条的标题和摘要,控制返回长度。
import os import requests def search(query: str) -> str: api_key = os.getenv("SERP_API_KEY") url = "https://serpapi.com/search" params = { "q": query, "api_key": api_key, "num": 3, "hl": "zh-cn" } try: resp = requests.get(url, params=params, timeout=10) data = resp.json() results = data.get("organic_results", []) if not results: return "没有找到相关结果。" lines = [] for i, r in enumerate(results[:3], 1): title = r.get("title", "") snippet = r.get("snippet", "") lines.append(f"{i}. {title}: {snippet}") return "\n".join(lines) except Exception as e: return f"搜索出错: {str(e)}"工具描述要写成模型能理解的格式。我通常用一个字典来管理工具,键是工具名,值是描述和函数引用。
TOOLS = { "search": { "description": "搜索工具,输入一个搜索关键词字符串,返回相关的网页摘要。当你需要获取实时信息或你不确定的知识时使用。", "func": search } }工具描述里我特意强调了“当你需要获取实时信息或你不确定的知识时使用”,这是给模型的行为指引。不加这句,模型可能在任何情况下都调用搜索,浪费轮次。
4.3 构建 ReAct 提示词模板
提示词模板是整个 ReAct 的核心。我把它拆成几个部分拼接,方便维护。
REACT_PROMPT = """你是一个可以使用工具来解决问题的智能助手。 你可以使用以下工具: {tool_descriptions} 请严格按照以下格式进行推理和行动: Thought: 你的思考过程,分析当前情况并决定下一步做什么。 Action: 要使用的工具名称,必须是[{tool_names}]中的一个。 Action Input: 传给工具的输入参数。 Observation: 工具返回的结果。 ...(Thought/Action/Action Input/Observation 可以重复多次) Thought: 我现在知道最终答案了。 Final Answer: 对用户问题的最终回答。 注意事项: 1. 每次只能输出一个 Thought 和一个 Action,等待 Observation 后再继续。 2. 如果不需要使用工具就能回答,直接输出 Thought 和 Final Answer。 3. Action Input 必须是一个字符串,不要输出 JSON 或其他格式。 4. 不要编造 Observation,Observation 由系统提供。 示例: 用户问题:今天北京的天气怎么样? Thought: 我需要查询北京今天的天气,这需要实时信息,应该使用搜索工具。 Action: search Action Input: 北京今天天气 Observation: 1. 北京天气预报: 今天晴,气温 15-25 度,微风。 Thought: 我已经获取到北京的天气信息,可以回答了。 Final Answer: 北京今天晴天,气温 15 到 25 度,微风,适合外出。 现在开始。 用户问题:{question} """这个模板里有几个细节值得说。第一,我明确写了“每次只能输出一个 Thought 和一个 Action”,这是防止模型一次性输出多轮内容,导致解析混乱。第二,我强调了“不要编造 Observation”,因为模型有时候会自己编一个 Observation 然后继续推理,这会让整个循环失控。第三,示例覆盖了完整的循环,让模型有样学样。
4.4 实现 ReAct 主循环
主循环的逻辑是:拼提示词、调模型、解析输出、执行工具、拼回上下文、判断终止。
import re from openai import OpenAI client = OpenAI(api_key=os.getenv("LLM_API_KEY"), base_url="https://api.openai.com/v1") def parse_action(text: str): action_match = re.search(r"Action:\s*(.+)", text) input_match = re.search(r"Action Input:\s*(.+)", text) if action_match and input_match: return action_match.group(1).strip(), input_match.group(1).strip() return None, None def react_agent(question: str, max_turns: int = 6) -> str: tool_descriptions = "\n".join( f"- {name}: {info['description']}" for name, info in TOOLS.items() ) tool_names = ", ".join(TOOLS.keys()) prompt = REACT_PROMPT.format( tool_descriptions=tool_descriptions, tool_names=tool_names, question=question ) history = prompt for turn in range(max_turns): response = client.chat.completions.create( model="gpt-4o-mini", messages=[{"role": "user", "content": history}], temperature=0 ) output = response.choices[0].message.content.strip() print(f"--- 第 {turn + 1} 轮 ---") print(output) if "Final Answer:" in output: return output.split("Final Answer:")[-1].strip() action, action_input = parse_action(output) if not action or not action_input: history += f"\n{output}\nObservation: 格式错误,请按照 Thought/Action/Action Input 的格式输出。\n" continue if action not in TOOLS: history += f"\n{output}\nObservation: 工具 {action} 不存在,可用工具为 [{tool_names}]。\n" continue observation = TOOLS[action]["func"](action_input) history += f"\n{output}\nObservation: {observation}\n" return "达到最大轮次限制,未能得出最终答案。"这段代码里,temperature=0是为了让输出更稳定,减少格式跑偏。max_turns=6是兜底。每一轮把模型的输出和 Observation 拼回 history,下一轮模型就能看到完整的推理轨迹。
4.5 跑一个完整案例看效果
用“对比北京和上海今天的天气”这个问题跑一下。第一轮模型输出 Thought 说要查北京天气,Action 是 search,Action Input 是“北京今天天气”。系统执行搜索,返回结果,拼回上下文。第二轮模型看到北京的结果,输出 Thought 说要查上海,Action 是 search,Action Input 是“上海今天天气”。第三轮拿到上海结果,模型输出 Final Answer 做对比。
整个过程模型自主决定了搜两次、搜什么词、什么时候停。这就是 ReAct 的威力:你不需要写死流程,模型自己规划。
实测下来,这个最小实现能覆盖大部分信息查询类任务。如果任务涉及多步计算或者需要调用多个不同工具,只需要在 TOOLS 里加工具,在提示词里更新工具描述即可。
5. 常见问题与排查技巧实录
5.1 模型不按格式输出怎么办
这是最常见的问题。模型可能输出一段自然语言,没有 Thought 和 Action 前缀;或者输出了 Thought 但忘了 Action;或者把 Action Input 写成了 JSON。
排查思路分三步。第一,检查提示词里的格式说明是否足够明确,示例是否覆盖了当前场景。第二,检查 temperature 是否设得太高,建议设成 0 或 0.1。第三,检查模型本身的指令遵循能力,有些小模型确实做不好格式遵循,换一个更强的模型试试。
如果格式问题依然频繁,可以在解析层做容错。比如用正则同时匹配“Action:”和“动作:”,或者匹配“Action Input:”和“输入:”。我还会在解析失败时,把错误信息作为 Observation 拼回去,让模型自己修正。通常模型看到“格式错误”的提示后,下一轮会改过来。
5.2 工具调用参数错误怎么处理
模型可能传了工具不认识的参数,或者参数格式不对。比如搜索工具期望一个字符串,模型传了一个 JSON 对象。
处理方式是在工具执行前做参数校验。如果参数不符合预期,返回一个描述性的错误信息给模型。错误信息要具体,比如“search 工具需要一个字符串参数,你传的是 JSON 对象,请重新调用”。模型看到具体错误后,修正的概率很高。
另外,工具描述里要明确参数类型。我通常会在描述里写“输入应该是一个搜索关键词字符串,不要包含引号或其他符号”。这种细节能显著降低参数错误率。
5.3 循环停不下来怎么办
模型可能反复调用同一个工具,或者一直在 Thought 阶段打转,不输出 Final Answer。这通常是因为模型没有从 Observation 里获取到有效信息,或者任务本身超出了它的能力范围。
兜底方案是设置最大轮次。超过轮次就强制终止,返回当前已有的信息。同时,可以在提示词里加一句“如果你已经获取到足够的信息,请立即输出 Final Answer,不要重复调用工具”。这句话能减少一部分无效循环。
如果模型反复调用同一个工具且参数相同,可以在工程层加一个去重检测。检测到重复调用时,返回一个提示“你已经调用过这个工具并得到了结果,请基于已有信息继续推理或给出最终答案”。
5.4 常见问题速查表
| 问题现象 | 可能原因 | 排查方向 | 解决手段 |
|---|---|---|---|
| 模型不输出 Thought/Action | 提示词格式说明不清 | 检查提示词和示例 | 强化格式说明,加 Few-shot 示例 |
| Action Input 解析失败 | 模型输出格式不规范 | 检查模型输出原文 | 加正则容错,错误信息回传模型 |
| 工具名不存在 | 模型幻觉出工具名 | 检查工具列表和描述 | 错误信息里列出可用工具 |
| 循环超过最大轮次 | 模型未获取有效信息 | 检查 Observation 质量 | 优化工具返回,加终止提示 |
| Observation 太长导致 token 爆 | 工具返回未截断 | 检查工具返回长度 | 在工具层截断或摘要 |
| 模型编造 Observation | 提示词未禁止 | 检查提示词约束 | 明确写“不要编造 Observation” |
5.5 我踩过的几个坑
第一个坑是工具描述写得太简略。早期我只写了“search: 搜索”,结果模型不知道该传什么参数,经常传一个完整的句子或者带引号的词。后来把描述写详细,包括参数格式和示例,错误率明显下降。
第二个坑是没控制 Observation 长度。有一次搜索工具返回了十条结果,每条几百字,拼进上下文后直接超了模型窗口,报错。后来改成只取前三条,每条截断到 200 字,问题解决。
第三个坑是终止条件只靠“Final Answer:”。有一次模型输出了“最终答案:”而不是“Final Answer:”,导致循环没终止,多跑了两轮。后来在解析层同时匹配中英文的终止标记,才彻底解决。
第四个坑是没设最大轮次。有一次模型陷入了一个“搜索-没找到-再搜索”的死循环,跑了十几轮,token 消耗爆炸。加了 max_turns 之后,最坏情况也可控了。
6. ReAct 的扩展方向与工程化建议
6.1 多工具协同与工具路由
当工具数量增多时,把所有工具描述都塞进提示词会导致提示词过长,模型选择工具的准确率也会下降。这时候可以考虑工具路由:先用一个轻量模型或者规则判断该用哪类工具,再只把相关工具的描述拼进提示词。
另一种做法是分层工具。把工具按领域分组,第一层让模型选领域,第二层在领域内选具体工具。这样每层的选择空间都变小,准确率更高。
6.2 与 RAG 的结合
ReAct 和 RAG 是天然互补的。RAG 负责从知识库里检索相关内容,ReAct 负责决定什么时候检索、检索什么、以及如何利用检索结果。可以把 RAG 的检索接口封装成一个工具,让 ReAct 在需要知识库信息时调用。
这种结合方式比传统的“先检索再生成”更灵活,因为模型可以多轮检索,逐步逼近答案。对于复杂问答场景,效果提升明显。
6.3 生产环境的稳定性保障
生产环境里,ReAct 智能体需要加监控和日志。每一轮的 Thought、Action、Observation 都要记录,方便出问题时回溯。还要监控 token 消耗、轮次分布、工具调用成功率这些指标。
另外,建议加一个降级策略。如果 ReAct 循环失败,可以降级到普通的 RAG 问答或者直接返回“暂时无法回答”。不要让用户看到一个报错页面。
超时控制也很重要。每一轮 LLM 调用和工具调用都要设超时,避免某个环节卡住导致整个请求挂起。我通常把单轮超时设成 30 秒,总超时设成 90 秒。
6.4 提示词的迭代与评测
ReAct 的提示词不是写一次就完事的,需要持续迭代。我的做法是建一个评测集,包含几十个典型问题,每次改完提示词就跑一遍,看成功率、平均轮次、token 消耗的变化。
评测集要覆盖不同类型的任务:单工具调用、多工具调用、不需要工具直接回答、工具调用失败后恢复、复杂多步推理。只有覆盖全面,才能发现提示词的短板。
迭代时一次只改一个变量,比如只改工具描述,或者只改示例,这样才能归因。同时改多个地方,出了问题不知道是哪个改动导致的。
6.5 关于模型选择的经验
ReAct 对模型的指令遵循能力要求较高。实测下来,同一个小模型在普通对话里表现不错,但在 ReAct 循环里格式错误率明显偏高。如果预算允许,建议用中等规模以上的模型跑 ReAct。
另外,不同模型对提示词的敏感度不同。有的模型对 Few-shot 示例依赖强,有的模型对格式说明依赖强。换模型时,提示词可能需要重新调优,不能直接照搬。
我在实际项目里的体会是,ReAct 的上限取决于模型能力,下限取决于工程兜底。模型再强,没有格式校验、没有最大轮次、没有错误恢复,循环照样会崩。反过来,模型一般但工程做得扎实,也能跑出可用的效果。所以别只盯着换模型,先把工程层的稳定性做起来。
最后分享一个小技巧:在提示词里加一句“如果你不确定,可以先搜索再回答”,能显著降低模型在不确定时直接胡编的概率。这句话看起来简单,但在实际使用中效果很好,尤其是面对时效性强的查询时。