ReAct 模式:Agent 推理与行动循环实现
ReAct 是让 LLM 从”问答机器”变成”执行系统”的关键模式。它的核心思想极简:让模型在每一步先思考(Thought),再行动(Action),然后观察结果(Observation),循环直到任务完成。理解并正确实现这个循环,是 Agent 工程落地的第一步。
ReAct 循环原理
┌─────────────────────────────────────────┐
│ 输入任务:查询北京明天天气并生成出行建议 │
└────────────────────┬────────────────────┘
│
┌───────▼────────┐
│ Thought #1 │ "需要先查天气,使用 get_weather 工具"
└───────┬────────┘
│
┌───────▼────────┐
│ Action #1 │ 调用 get_weather(city="北京", date="tomorrow")
└───────┬────────┘
│
┌───────▼────────┐
│ Observation #1 │ "晴,25°C,东南风3级"
└───────┬────────┘
│
┌───────▼────────┐
│ Thought #2 │ "已有天气数据,可以直接生成建议"
└───────┬────────┘
│
┌───────▼────────┐
│ Final Answer │ "明天北京天气晴好,建议..."
└────────────────┘
每次循环把完整历史(所有 Thought/Action/Observation)拼回 prompt,模型”看着自己的轨迹”做决策,这是 ReAct 的核心机制。这句话背后藏着一个新手最容易踩的坑:历史不是可选项,是必须项。如果你在下一轮请求里只传最新一条 user 消息、把之前的 Thought/Action/Observation 丢掉,模型会失忆——它不知道自己已经查过天气,大概率会重新发起一次一模一样的工具调用,陷入原地打转。所以 ReAct 的每一轮,messages 数组只增不减,直到任务结束或者到达 max_steps。
代价也很直白:历史越长,每一轮请求携带的 token 越多。假设一次工具调用的 Observation 平均 300 token,跑 8 步的任务,到第 8 步时你实际发送给模型的 prompt 里已经背了前面 7 轮的全部内容,可能比第 1 步多出 2000~3000 token。这不是危言耸听,是这套架构的固有成本——你在用”重复发送历史”换”模型不失忆”,工程上要在两者之间找平衡(比如超过一定步数就把早期 Observation 摘要压缩,下文会讲)。
最小可运行实现
import json
from openai import OpenAI
client = OpenAI()
# 工具定义
tools = [
{
"type": "function",
"function": {
"name": "get_weather",
"description": "查询指定城市的天气预报。适合回答天气、温度、降雨相关问题。",
"parameters": {
"type": "object",
"properties": {
"city": {"type": "string", "description": "城市名称,如'北京'"},
"date": {"type": "string", "description": "日期,如'today'或'tomorrow'"}
},
"required": ["city"]
}
}
},
{
"type": "function",
"function": {
"name": "search_web",
"description": "搜索互联网获取实时信息、新闻、价格等。",
"parameters": {
"type": "object",
"properties": {
"query": {"type": "string", "description": "搜索关键词"}
},
"required": ["query"]
}
}
}
]
# 工具执行函数(实际项目中替换为真实实现)
def execute_tool(name: str, arguments: str) -> str:
args = json.loads(arguments)
if name == "get_weather":
# 模拟返回
return f"{args['city']} 天气:晴,25°C,东南风3级"
elif name == "search_web":
return f"搜索结果:关于 '{args['query']}' 的最新信息..."
return "工具不存在"
def run_react_agent(user_input: str, max_steps: int = 10) -> str:
messages = [
{
"role": "system",
"content": (
"你是一个智能助理。遇到需要实时数据、外部信息的问题时,使用工具获取后再回答。"
"工具执行失败时,分析原因并尝试换方案,而不是直接报错。"
)
},
{"role": "user", "content": user_input}
]
for step in range(max_steps):
response = client.chat.completions.create(
model="gpt-4o",
messages=messages,
tools=tools,
tool_choice="auto"
)
msg = response.choices[0].message
# 没有工具调用 → 任务完成,返回最终答案
if not msg.tool_calls:
return msg.content
# 有工具调用 → 执行所有工具,追加观察结果
messages.append(msg)
for tool_call in msg.tool_calls:
result = execute_tool(
tool_call.function.name,
tool_call.function.arguments
)
messages.append({
"role": "tool",
"tool_call_id": tool_call.id,
"content": result
})
return f"已达到最大步数({max_steps}步),任务未完成。请简化任务或增加步数上限。"
# 使用
answer = run_react_agent("查询北京明天天气,并给出外出穿衣建议")
print(answer)
拆开看这段代码里最容易被忽略、但线上一定会咬你一口的三个细节。
第一个细节在第 118 行 messages.append(msg)。这里追加的是模型返回的完整消息对象(包含 tool_calls 字段),不是你自己拼的字典。很多人图省事会写 messages.append({"role": "assistant", "content": msg.content}),这样看似省事,实际上把 tool_calls 信息丢了。下一轮请求时 API 会报错,提示大意是”tool 角色消息必须紧跟在一条带 tool_calls 的 assistant 消息之后”(不同 SDK 版本报错文案略有差异,但根因都是这个)。记住一条铁律:assistant 消息要原样回填,不要自己重新拼装。
第二个细节是 tool_call_id 的一一对应。第 119~128 行遍历 msg.tool_calls 时,每个 tool_call.id 都要在返回的 tool 消息里原样带回去。如果模型在一次响应里并行发起了两个工具调用,你只处理了其中一个就把消息发回去,API 会直接 400,因为它发现有个 tool_call_id “石沉大海”没有对应的 observation。这也是本文最后会讲的并行工具调用场景里最容易漏掉的一步。
第三个细节是 execute_tool 里没有任何异常处理。示例为了聚焦主流程做了简化,真实项目里工具执行大概率会失败——网络超时、下游 API 限流、参数不合法都可能发生。如果 execute_tool 直接抛异常,整个 Agent 循环会崩掉,而不是像 system prompt 里承诺的那样”分析原因并尝试换方案”。正确做法是让 execute_tool 内部 try/except 兜底,把错误信息也作为字符串塞进 content 返回给模型,比如返回 "工具执行失败:连接超时,请稍后重试或改用其他方式获取信息",模型看到这条 Observation 后会自己决定要不要重试、要不要换个工具,这才是 ReAct”观察后再决策”的完整闭环,而不是让程序在中间层就替模型做了”失败就终止”的决定。
System Prompt 设计要点
ReAct 的 system prompt 影响模型是否”主动用工具”:
| 要点 | 推荐写法 | 避免 |
|---|---|---|
| 何时用工具 | ”遇到需要实时数据时使用工具” | 太宽泛(“尽量用工具”)导致滥用 |
| 工具失败处理 | ”失败时分析原因,尝试换方案” | 不写 → 模型遇错直接停止 |
| 回答格式 | ”最终答案直接给用户,不需要重复思考过程” | 不写 → 模型把内部推理暴露给用户 |
| 步数意识 | ”控制在必要的最小步数内完成” | 模型可能做冗余工具调用 |
这张表里最容易被低估的是第一行。System prompt 写”需要时使用工具”和写”尽量多使用工具获取准确信息”,实际跑起来的行为差异很大——后者会让模型对着一句”你好”这种完全不需要外部数据的输入也去调一次 search_web,白白多花一轮延迟和 token。判断标准很简单:能不能提前想清楚”这类问题模型凭训练知识就能答对”和”这类问题必须查实时数据”的边界,然后把边界写进 system prompt,而不是笼统地鼓励或压制工具调用。
另外有个容易漏写、但实战中很关键的点:要不要允许模型追问用户。示例的 system prompt 没写这条,默认模型会尽量自己想办法完成任务。如果你的场景里用户输入经常信息不全(比如没给城市名),建议明确加一句”参数缺失时先反问用户,不要瞎猜”,否则模型可能会编一个城市名字硬着头皮调用工具,得到一个跟用户实际需求毫无关系的结果。
调试技巧
记录每一步的 Thought/Action/Observation,是排查 Agent 行为异常的关键:
for step_idx, step in enumerate(messages[2:], 1): # 跳过 system + user
if step.get("role") == "assistant" and step.get("tool_calls"):
for tc in step["tool_calls"]:
print(f"[Step {step_idx}] Action: {tc.function.name}({tc.function.arguments})")
elif step.get("role") == "tool":
print(f"[Step {step_idx}] Observation: {step['content'][:200]}")
推荐用 LangSmith 或 Arize Phoenix 可视化 Agent 轨迹,比打印日志更直观。打印日志能应付本地调试,但一旦 Agent 上了生产、跑几百上千次任务,靠肉眼翻日志排查”为什么这次多跑了 3 步”基本不现实,可视化平台能把每条轨迹按步骤拆开展示,还能按耗时、token 数排序,定位异常样本快得多。
如果你暂时不想接第三方平台,至少给每次调用加上三个可追踪字段:trace_id(本次任务的唯一标识)、step_idx(第几步)、elapsed_ms(本步耗时)。日后排查”某个任务为什么卡了 10 秒”时,这三个字段能让你直接定位是模型推理慢还是工具执行慢,而不用重新跑一遍复现。
常见报错与排查
跑 ReAct Agent 上线后,下面几类报错基本是必经之路,提前知道根因能少走弯路。
报错:Error code: 400 - tool_call_id ... not found in previous message。 根因几乎都是上面提到的”并行工具调用漏回填”——模型一次返回了两个 tool_calls,你的代码只处理并追加了其中一个的结果就把 messages 发回去了。修法:遍历 msg.tool_calls 时用 for 循环处理全部元素,一个都不能漏,哪怕某个工具你暂时没实现,也要返回一条占位的 tool 消息(内容写”该工具暂不可用”),维持 tool_call_id 一一对应。
报错:Error code: 429 - Rate limit reached。 说明请求频率或并发超过了账号限额,常见于 Agent 在循环里短时间内打了多次请求。别急着重试,先给请求加指数退避:第一次等 1 秒重试,失败再等 2 秒、4 秒,最多重试 3~4 次;同时检查是不是把 max_steps 设得太大、或者是在批量跑多个任务时没做并发限流。生产环境建议给同一账号的并发请求数设个上限(比如 5),用信号量控制,而不是任务来了就无脑发。
报错:模型反复调用同一个工具,max_steps 耗尽也没给出答案。 这通常不是接口报错,而是行为异常,最容易被忽略。根因往往是 Observation 里的信息模型”没看懂”——比如工具返回了一大段 JSON 但没有摘要,模型每次都觉得信息不够,于是再调一次。修法是让工具返回结果时做一层”人类可读摘要”,别把原始 JSON 直接丢给模型,比如把 {"temp": 25, "cond": "sunny"} 转成”晴,25°C”这种一句话结论。
现象:任务步数越跑越多,最后触发 context 长度超限报错。 这是历史累积问题的必然结果。缓解办法有两种:一是给 max_steps 设一个合理上限并配合任务拆分(见下文取舍表);二是做”历史压缩”——超过某个步数(比如第 6 步)之后,把再早一轮的 Observation 替换成一句话摘要,只保留最近 2~3 轮的完整内容,模型依然能获得足够上下文做决策,但 prompt 不会无限膨胀。
ReAct 与其他架构的取舍
不是所有任务都适合用纯 ReAct 硬跑到底,下面是几种常见架构的选择依据:
| 任务特征 | 推荐架构 | 原因 |
|---|---|---|
| 步骤少(≤5 步)、路径不确定 | 标准 ReAct | 边想边做,灵活性最重要 |
| 步骤多且路径固定(如”查天气→查路况→生成建议”) | Plan-and-Execute(先规划再逐步执行) | 提前规划一次比每步都重新思考更省 token,也更不容易跑偏 |
| 单步就能完成、无需多轮推理 | 直接 Function Calling,不用 ReAct 循环 | 循环本身有额外开销,简单任务硬套 ReAct 是杀鸡用牛刀 |
| 需要多个角色协作(如”调研+写作+校对”) | Multi-Agent 编排 | 单个 ReAct 循环里塞太多职责会让 system prompt 臃肿、模型决策变差 |
判断依据说白了就一句话:任务路径能不能提前确定。能确定就规划先行,省下每一步”要不要用工具、用哪个”的思考成本;不能确定就用 ReAct 让模型走一步看一步。
常见问题
ReAct 和 Function Calling 是什么关系? ReAct 是架构思路(思考-行动-观察循环),Function Calling 是 OpenAI 兼容接口实现工具调用的具体机制。实现 ReAct 通常借助 Function Calling,但 ReAct 也可以用纯文本解析(早期实现方式),Function Calling 的结构化输出更可靠。
max_steps 设多少合适?
简单任务(查询+生成)设 58 步;复杂多步任务设 1520 步。超过 20 步的任务建议拆分成子任务或引入 Plan-and-Execute 模式,单条 ReAct 链过长时模型容易”迷路”。
多个工具同时调用会怎样? OpenAI API 支持并行工具调用(parallel_tool_calls),模型可以在单次响应中请求多个工具并发执行,能显著减少总步数和延迟。处理时需对每个 tool_call_id 都返回对应的 tool 角色消息。
← 返回 应用模式总览:从 Prompt 到 Agent | 应用模式专题
相关阅读:Agent 工具调用设计 · Agent 记忆机制 · AI Agent 开发实战
多模型 ReAct Agent 统一接入?力达云聚合 API 统一接口,gpt-4o / Claude / DeepSeek 无缝切换,Agent 框架无需改动。