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)对比,两者确实有亲缘关系,但有一个本质差别:
| 维度 | 纯思维链 CoT | ReAct |
|---|---|---|
| 推理是否落地 | 否,全程只在脑子里想 | 是,想完就调工具 |
| 能否获得新信息 | 不能,只靠已有上下文 | 能,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 / final | thought / 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:先出计划再执行 。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。