《AI Agent 开发入门》5.1 ReAct:把「想—做—看」写进循环

本节给 minagent 装上第一种推理循环 ReAct:把 Thought、Action、Observation 三元组显式写进每轮。我们说明现代 tool-use API 下 Thought 就是 assistant 消息的文本部分,实现一个 ReActAgent,用 FakeModel 脚本化「想—做—看」两轮后收敛的完整轨迹并真跑打印,最后给出与 3.1 朴素循环的差异表。

5.1 ReAct:把「想—做—看」写进循环

第 3 章的 run() 已经能跑完任务,第 4 章的 ToolRegistry 给了它一双手。但那个循环是「哑」的:模型要么直接要工具,要么直接给答案,中间没有任何「为什么这么做」的痕迹。一旦它连着调错两个工具,你除了盯着 tool_call 的序列发呆,什么也做不了。这一节给循环补上「说话」的能力——让模型每做一步之前,先讲一句它为什么这么做。

本节把 minagent 推进到「会自我解释的循环」:新增 minagent/react.py 里的 ReActAgent,把 Thought / Action / Observation 三元组写进每轮,轨迹从「工具调用流水」升级成「带理由的决策链」。

5.1.1 ReAct 的三元组:想、做、看

ReAct 是 Reasoning + Acting 的缩写,核心主张只有一句话:推理和行动不该分成两个阶段,而应交替进行。它的每一次迭代由三个固定角色组成:

元素谁产生内容在协议里的落点
Thought模型「我为什么要做下一步」的推理文本assistant 消息的 content
Action模型「我决定调哪个工具、带什么参数」assistant 消息的 tool_calls
Observation环境工具执行的结果tool 消息的 content

把这三者按 Thought → Action → Observation → Thought → … 的顺序串起来,直到某轮模型认为「够了」,就得到一条完整的推理轨迹。它最大的价值不在效果,而在可解释:每一步都能回答「模型当时在想什么」,而不是只留下一串冷冰冰的工具调用。

ReAct 经常被拿来和「思维链」(Chain-of-Thought, CoT)对比,两者确实有亲缘关系,但有一个本质差别:

维度纯思维链 CoTReAct
推理是否落地否,全程只在脑子里想是,想完就调工具
能否获得新信息不能,只靠已有上下文能,Observation 带回真实结果
与环境交互无有,每轮可读写外部世界
纠错能力弱,想错了只能将错就错强,看到 Observation 可改主意
本质一次性的内部推理多轮的推理—行动循环

一句话概括:CoT 是「想」,ReAct 是「想了还要做、做了再看结果」。CoT 的推理全程不接触外部世界,一旦某个假设是错的,它会沿着错误假设一路推到底;ReAct 每做一步都从 Observation 拿回真实反馈,因此能在中途发现自己想错了。这就是为什么带工具的 Agent 几乎都采用 ReAct 形态,而不是单纯让模型「多想想」。

5.1.2 在现代 tool-use API 下,Thought 住在哪里

早期 ReAct 论文里,Thought / Action / Observation 都是纯文本,靠正则从模型输出里抠出来(Thought: ...\nAction: search[...])。那是没有原生工具调用时的权宜之计。今天不必再这样——现代 tool-use API 把「行动」做成了结构化字段,于是三元组各归其位:

  • Thought 不需要额外协议。它就是 assistant 消息的 content:模型在同一轮里既写一段推理,又发出结构化的 tool_calls。文本部分天然就是「想」,tool_calls 天然就是「做」。
  • Action 走结构化字段。模型不再用文本模拟调用,而是产出真正的 tool_calls,我们直接 registry.call(),不用解析。
  • Observation 走 tool 角色消息。执行结果以 tool_call_id 回灌,下一轮模型看到的就是完整的「想—做—看」。

这带来一个非常干净的实现:循环结构几乎不变,只是多记录了一段 Thought。第 3 章的 run() 里 msg.content 在工具轮是被忽略的(那里通常为空),ReAct 只是把这部分捡起来当成轨迹的一部分。换句话说,ReAct 不是另起炉灶,而是在既有循环上「承认文本也是信息」。

这里有个容易混淆的点:一轮里 content 和 tool_calls 可以同时非空,也可以只有其一。只有 content 没有 tool_calls,就是收敛的最终答案;两者都有,就是「一边想一边做」;只有 tool_calls 没有 content,就是第 3 章那种「闷头干活」的老形态。ReAct 鼓励第一种中间形态,但它并不排斥另外两种。

5.1.3 实现 ReActAgent

在朴素循环的骨架上,加两处:把 content 记成 thought,把工具执行拆成 action 与 observation 两个 Step。

# minagent/react.py
from __future__ import annotations

from .tools import ToolRegistry
from .types import Message, Model, RunResult, Step


class ReActAgent:
    """把 Thought / Action / Observation 显式写进轨迹的循环。"""

    def __init__(self, model: Model, registry: ToolRegistry,
                 system: str = "", max_steps: int = 8) -> None:
        self.model = model
        self.registry = registry
        self.system = system
        self.max_steps = max_steps

    def run(self, user_input: str) -> RunResult:
        messages: list[Message] = []
        if self.system:
            messages.append(Message(role="system", content=self.system))
        messages.append(Message(role="user", content=user_input))

        steps: list[Step] = []
        for i in range(self.max_steps):
            resp = self.model.complete(messages, self.registry.schemas())
            msg = resp.message
            messages.append(msg)

            # Thought:带工具调用的那一轮,content 就是模型的推理
            if msg.content and msg.tool_calls:
                steps.append(Step(index=i, kind="thought", detail=msg.content))

            # 没有工具调用 = 收敛,content 即最终答案
            if not msg.tool_calls:
                steps.append(Step(index=i, kind="final", detail=msg.content))
                return RunResult(messages=messages, steps=steps, final=msg.content)

            # Action -> Observation
            for tc in msg.tool_calls:
                steps.append(Step(index=i, kind="action",
                                  detail=f"{tc.name}({tc.arguments})"))
                result = self.registry.call(tc)
                messages.append(Message(role="tool", content=result,
                                        tool_call_id=tc.id))
                steps.append(Step(index=i, kind="observation", detail=result))

        steps.append(Step(index=self.max_steps, kind="error", detail="未收敛"))
        return RunResult(messages=messages, steps=steps, final="")

几处设计值得点明:

  • Thought 只在「带工具调用」时记录。收敛轮的那段 content 是最终答案,不是推理,所以用 msg.content and msg.tool_calls 把两种情形分开,避免同一段文字既当 thought 又当 final。
  • Action 与 Observation 拆成两个 Step。它们信息不同:前者是「请求」(工具名 + 参数),后者是「反馈」(结果或错误)。合并成一条会丢掉其中一半。第 15 章做 span 树时,这两者正好对应「客户端 span」与「服务端 span」。
  • messages 的装配逻辑一个字没改。assistant 消息带着它的 content 与 tool_calls 原样进历史,tool 结果照旧回灌。ReAct 的全部增量都在「怎么记轨迹」,不在「怎么转循环」。

这也是卷一一贯的取舍:推理模式是循环的变体,不是新引擎。三种模式(ReAct / Plan-and-Solve / Reflexion)共享同一套 Message / ToolCall / ToolRegistry,差别只在控制流与记录方式。理解了这一点,后面两节读起来会非常快。

5.1.4 跑一遍:两城天气的「想—做—看」

用一个天气工具,让假模型走「想 → 查北京 → 想 → 查上海 → 收敛」的完整轨迹。为了脚本清晰,写一个 thought() 辅助函数,把「一段推理 + 若干工具调用」拼成一条带 content 的 ModelResponse:

# demo_react.py
from minagent import FakeModel, Message, ModelResponse, Tool, ToolCall, ToolRegistry
from minagent.react import ReActAgent


def weather(city: str) -> str:
    data = {"北京": "晴 24°C", "上海": "多云 27°C"}
    return data.get(city, "未知")


reg = ToolRegistry()
reg.add(Tool(name="weather", description="查询某城市当前天气",
             parameters={"type": "object",
                         "properties": {"city": {"type": "string"}},
                         "required": ["city"]},
             func=weather))


def thought(text: str, *calls: ToolCall) -> ModelResponse:
    return ModelResponse(
        message=Message(role="assistant", content=text, tool_calls=list(calls)),
        stop_reason="tool_use" if calls else "end_turn",
    )


model = FakeModel([
    thought("Thought: 用户要比两城天气,先查北京。",
            ToolCall(id="c1", name="weather", arguments={"city": "北京"})),
    thought("Thought: 北京拿到了,再查上海。",
            ToolCall(id="c2", name="weather", arguments={"city": "上海"})),
    "北京晴 24°C,上海多云 27°C,两地都适合出行。",
])

agent = ReActAgent(model, reg, system="你是一个天气助手,先想再做。")
result = agent.run("对比北京和上海的天气")

for s in result.steps:
    print(f"[{s.index}] {s.kind:<11} {s.detail}")
print(f"最终回答: {result.final}")
print(f"消息条数: {len(result.messages)}")

本机实测输出(Python 3.14.6):

[0] thought     Thought: 用户要比两城天气,先查北京。
[0] action      weather({'city': '北京'})
[0] observation 晴 24°C
[1] thought     Thought: 北京拿到了,再查上海。
[1] action      weather({'city': '上海'})
[1] observation 多云 27°C
[2] final       北京晴 24°C,上海多云 27°C,两地都适合出行。
最终回答: 北京晴 24°C,上海多云 27°C,两地都适合出行。
消息条数: 7

轨迹的形状非常规整:第 0、1 轮各是「thought → action → observation」三连,第 2 轮只有一条 final。消息条数是 7——system + user + 两次「assistant + tool」共 4 条 + 最终 assistant,与第 3 章的账目完全一致。ReAct 没有多花一条消息,它只是把原来被忽略的 content 记进了轨迹。

顺带一个可断言的形态:[s.kind for s in result.steps] 得到 ["thought", "action", "observation", "thought", "action", "observation", "final"]。第 16 章写确定性测试时,这串序列就是「ReAct 确实在按三元组跑」的最直接证据——如果哪天重构把 thought 记丢了,这个断言立刻红。

为了把「Thought 住在 assistant 消息里」这件事彻底看清,把最终的消息列表打印出来(省略 system):

0 user      content='对比北京和上海的天气'
1 assistant content='Thought: 用户要比两城天气,先查北京。' tool_calls=[weather(北京)]
2 tool      content='晴 24°C' tool_call_id=c1
3 assistant content='Thought: 北京拿到了,再查上海。' tool_calls=[weather(上海)]
4 tool      content='多云 27°C' tool_call_id=c2
5 assistant content='北京晴 24°C,上海多云 27°C,两地都适合出行。'

对照第 3 章那份「content 为空」的消息列表,差别一目了然:第 1、3 行的 assistant 消息现在既有文本、又有 tool_calls。那段文本就是 Thought,tool_calls 就是 Action,紧随其后的 tool 消息就是 Observation。整份列表严格交替,而 ReAct 的全部魔法,只是把原来空着的那一列填上了文字。这也意味着,一个只认 tool_calls、忽略 content 的旧循环,其实已经「兼容」ReAct——它只是没把 Thought 当回事而已。

再看一个边界情形:第 5 行的 assistant 只有 content、没有 tool_calls,于是循环判定收敛。这个判据和朴素循环完全一致——ReAct 没有引入任何新的停止条件。它增加的只是「中间那些带文本的工具轮」被如实记录了下来。

5.1.5 与 3.1 朴素循环的差异

两者共享同一套消息协议与工具系统,差别集中在「记录什么」与「要求模型做什么」:

维度3.1 朴素循环5.1 ReAct
显式推理无,content 在工具轮被忽略content 即 Thought,记进轨迹
轨迹粒度tool_call / finalthought / action / observation / final
提示词要求无需引导「先想再做」
可解释性低,只看到做了什么高,每步都能读理由
token 成本每轮只有工具调用每轮多一段 Thought 文本
收敛判据没有 tool_calls完全相同

最后一行是关键:ReAct 的停止条件与朴素循环一模一样——模型不再发工具调用即收敛。所以它不是一个「更复杂的循环」,而是一个「记录更细、引导更明确的同一个循环」。多出来的成本是每轮一段 Thought 的 token,换来的是调试时能一眼看出模型为什么走错。

那什么时候该用 ReAct、什么时候用朴素循环?判据是「这一步为什么这么做,是否需要被解释和检查」:

场景建议
步骤固定、无需解释(如纯格式转换)朴素循环,省 token
步骤之间有依赖、需要临场判断ReAct,Thought 帮模型理清思路
需要审计「模型为什么调这个工具」ReAct,轨迹自带理由
成本极度敏感、任务又简单朴素循环,别为形式多花钱

ReAct 的 Thought 不只是「给人看的日志」,它也会反过来影响模型自己的下一步——把推理写出来再行动,本身就是一种让模型「想清楚再动手」的提示技巧。这是它相比「闷头调工具」在效果上的真实增益,而不只是可读性上的。所以哪怕你不打算看轨迹,加上 Thought 这一列往往也能让模型少犯几个错。

5.1.6 真实模型下会怎样

FakeModel 里的 Thought 是我们脚本写死的——「先查北京」「再查上海」这些句子,是我替模型想好的。真实模型不这样:它会读完整段历史,自己生成推理文本,而且质量天差地别。

  • 真实模型会自己决定想不想。提示词里写「先想再做」,Claude 通常会输出一段推理再调工具;但它也可能跳过 Thought 直接发 tool_calls(尤其任务很简单时),或在任务复杂时写出好几段推理。假模型里 Thought 是「必有」的,真实世界里它是「大概率有、不保证有」。
  • 真实模型的 Thought 可能「事后编造」。它偶尔会先决定调某个工具,再补一段听起来合理的理由。这类「先射箭后画靶」的推理,光看轨迹分不出真假——这是 ReAct 可解释性的天然上限,也是为什么第 16 章强调用轨迹断言、第 15 章强调用真实工具结果校验。
  • Thought 有真实的 token 成本与幻觉风险。每轮多出的推理文本会计费;更麻烦的是,模型可能在 Thought 里「幻觉」出一个不存在的工具或数据,然后真的按幻觉去调。假模型永远造不出这种坑,因为它不会「想错」。

一句话:假模型负责证明「三元组的装配逻辑对」,真实模型负责暴露「Thought 本身可能不可靠」。循环一行不用改,但你得知道那多出来的一列 thought,在真实场景里是需要被审视的。

5.1.7 小结

  • ReAct = 把 Thought → Action → Observation 三元组写进每轮,推理与行动交替。
  • 现代 tool-use API 下 Thought 就是 assistant 消息的 content,Action 是 tool_calls,Observation 是 tool 消息,无需自造解析协议。
  • ReActAgent 相对朴素循环只多两处:记录 Thought、把工具执行拆成 action 与 observation。
  • 收敛判据不变——没有 tool_calls 即结束;ReAct 不是新引擎,是同一循环的记录变体。
  • 真实模型的 Thought 由模型自生成,可能缺失、可能事后编造,可解释性有天然上限。

ReAct 是「边想边做」。下一节 5.2 Plan-and-Solve:先出计划再执行 会换一种思路——先让模型一次性产出完整计划,再逐步执行,看这种「先谋后动」适合什么任务。想回看被 ReAct 改造的那套循环,见 3.1 朴素循环:跑到没有工具调用为止 。

阅读导航:上一节:4.3 并行工具调用与结果合并 · 下一节:5.2 Plan-and-Solve:先出计划再执行 。

继续阅读

探索更多技术文章

浏览归档,发现更多关于系统设计、工具链和工程实践的内容。

全部文章 返回首页

「agent」更多文章

  1. 《AI Agent 高级编程》目录
  2. 《AI Agent 高级编程》11.3 全景回顾:从零到框架的完整路径与展望
  3. 《AI Agent 高级编程》11.2 真实 API 冒烟:假模型 vs 真模型量化差距