← 返回资讯

ReAct 模式:Agent 推理与行动循环实现

2026-06-29

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 框架无需改动。