第 3 章 · ReAct 循环
Harness 锚点|判别清单第 1 项「执行循环」——模型输出→工具执行→结果回填的最小闭环。
一个查价格的小任务
接着上一章那个卖家。他想知道:某个竞品这周是不是降价了。
这个问题,普通聊天机器人答不了——它够不到实时价格,只能跟你说“价格会波动,建议关注官方渠道”。
但一个 Agent 能答,而且它的思路特别像一个认真的人在做这件事:先想“我得去竞品页看看现在标价”,去查,看到“现在 299、一周前是 359”,再想“降了但会不会是限时活动”,再去查活动说明,看到“会员日特价仅限今日”,最后收尾“是限时活动,不是长期降价”。
它没有比谁更聪明,它只是一步步地想、一步步地查、拿看到的事实说话。这个“想—查—再想”的节奏有个正式名字,也是当今几乎所有 Agent 的底层范式:ReAct。
ReAct 是什么:推理 + 行动
ReAct 这个词,是 Reasoning(推理)+ Acting(行动) 的合写,出自 2022 年的同名论文。它的主张很简单:让模型在每次行动之前,先把推理“说出来”;在每次行动之后,必须看真实结果。
为什么要这么设计?因为大模型单独用时有两个天生的毛病:
- 信息会过时:训练截止之后的事它不知道,却常常不承认,而是编一个像样的答案。
- 不会自我核实:给结论时它不会停下来想“这个我确定吗?要不要查一下”,而是顺着往下说。
ReAct 用“先推理、后行动、再观察”把这两个毛病都堵住了:推理让它有据可依,观察让它没法凭记忆瞎编。
论文里有个经典对比,很能说明问题:
| 做法 | 特点 | 短板 |
|---|---|---|
| 只推理(Chain-of-Thought) | 光在脑子里想 | 想得再好也够不到真实数据 |
| 只行动 | 光调工具不思考 | 乱调一气,不知为何 |
| 推理 + 行动(ReAct) | 想一步、做一步、看一步 | 既有依据,又能核实 ✅ |
一句话记住:把推理显式地“说出来”,等于给模型一块草稿纸——它能在上面掂量、改主意,而不是凭第一反应硬冲。
三个阶段:Thought、Action、Observation
ReAct 循环的每一轮,都由三个标准阶段组成,它们有固定的英文名,值得记牢:
- Thought(推理):分析当前状况,决定下一步做什么、为什么。
- Action(行动):选一个工具并给出参数去执行。行动的花样很多——向用户追问、搜索、抓网页、读写文件、调 API、跑代码……
- Observation(观察):系统执行工具,把真实结果返回给模型。
带着这一轮 Observation 到手的信息,进入下一轮的 Thought。把开头那个查价格的过程按这三个阶段写出来,就是标准的 ReAct 轨迹:
Thought → 我得先看竞品页现在标价多少
Action → fetch: https://competitor.com/product/123
Observation → 现价 ¥299(一周前记录是 ¥359)
Thought → 降了,但要确认不是限时活动
Action → fetch: 该页面的活动说明区块
Observation → "会员日特价,仅限今日"
Thought → 是限时活动,非长期降价,结论要讲清楚
Answer → 竞品今日会员日临时降到 ¥299(平时 ¥359),明天大概率恢复
循环什么时候结束?当模型判断“目标达成、不需要再行动了”,它就不再请求任何工具,直接给出最终答案(Answer)——循环自然收尾。
┌─────────────────────────────────────────┐
↓ │
Thought ──→ Action ──→ Observation ─────┘
│
└──→ 判断任务完成 → 输出 Answer,跳出循环
用 pi 来看这个循环长什么样
道理讲完,看真代码。在 pi 里,这个循环住在 packages/agent 的运行时代码里。剥掉工程细节,它的骨架就是一个 while 循环,反复执行 Thought/Action/Observation:
async function agentLoop(session, tools) {
while (true) {
// Thought + Action:把当前对话发给模型,它要么回一段话,要么请求调用某个工具
const response = await llm.complete({
messages: session.messages,
tools: tools.map((t) => t.schema),
});
session.messages.push(response.message);
// 没有工具调用 = 模型认为做完了,返回最终答案、结束
if (!response.toolCalls?.length) {
return response.message.content;
}
// Observation:把每个工具真正执行一遍,结果作为新消息塞回对话
for (const call of response.toolCalls) {
const tool = tools.find((t) => t.name === call.name);
const result = await tool.execute(call.arguments);
session.messages.push({
role: "tool",
toolCallId: call.id,
content: JSON.stringify(result),
});
}
// 回到循环开头,模型带着刚拿到的 Observation 继续 Thought
}
}
三点值得留意:
- 是否结束由模型自己决定——它不再请求工具,就等于说“我做完了”。
- 工具结果以
role: "tool"的消息回填——这一步就是 ReAct 里的 Observation。 - Thought 藏在哪? 现在的模型把推理内化了:它在决定调哪个工具之前的思考、或专门的推理过程里就完成了 Thought。pi 也会在系统提示里引导模型先规划再动手(第 13 章)。
真实的循环代码要处理流式输出、并行调用、失败重试、上下文过长时的压缩,但骨子里就是这个反复 Thought → Action → Observation 的循环。读懂它,pi 的其余部分就都好懂了。
循环最怕停不下来
这个循环有个致命风险:转个不停。模型可能反复调同一个工具却始终“觉得没做完”,一晚上就能烧掉一大笔钱。所以任何能上生产的运行时,都必须给循环装几道刹车:
| 什么时候停 | 说明 |
|---|---|
| 模型给出最终答案 | 正常结束 |
| 转够了设定的最大轮数 | 到上限强制停——必备硬刹车 |
| 花的钱 / token 超预算 | 超支就中断——必备硬刹车 |
| 连着几次调用一模一样 | 判定它卡住了 |
| 工具接连报错 | 环境有问题,别硬撑 |
| 用户喊停 | 正常结束 |
其中“最大轮数”和“预算上限”是两道绝不能省的硬刹车。判断逻辑大致是这样:
function shouldStop(state) {
if (state.iterations >= state.maxIterations) return "到达最大轮数";
if (state.costUSD >= state.budgetUSD) return "超出预算";
if (state.sameCallInARow >= 3) return "疑似原地打转";
return null; // 没触发,继续转
}
再看一个例子:排查线上报错
换个更硬核的场景,感受 ReAct 在真实工作里的样子。任务:“下单接口报 500 了,帮我查查为什么。”
Thought → 先定位报错在哪个接口、什么时间
Action → shell: grep "500" /var/log/app.log | tail -5
Observation → 都指向 POST /api/orders,时间集中在 14:03
Thought → 看看那个时间点的详细堆栈
Action → shell: grep -A20 "14:03" /var/log/app.log
Observation → NullPointerException at OrderService.java:88
Thought → 去看那行代码到底怎么回事
Action → read_file: OrderService.java (80-95 行)
Observation → user.getAddress().getCity():address 可能为 null,没判空
Thought → 根因清楚了
Answer → 500 根因:OrderService.java:88 未对 address 判空。
建议:加 null 检查,或下单前强制校验地址必填
从“接口报 500”这个模糊现象,一路 Thought/Action/Observation 逼到“第 88 行少了个判空”这个精确根因——每一步都踩在上一步的 Observation 上。这种顺藤摸瓜的能力,正是 ReAct 最实用的地方。
动手看看
打开 pi 的运行时源码(packages/agent 里的循环文件),对照上面那段骨架,看看真实代码在骨架之外多做了哪些事——流式、重试、压缩触发分别在哪里。
再给自己出个思考题:如果要加一条“同一个工具连着调 3 次就停”的刹车,该加在循环的哪个位置?(提示:在 Observation 回填之后、下一轮 Thought 之前,是最自然的落点。)
实战中的几个坑
坑一:原地打转停不下来。
- 现象:反复调同一个工具、token 猛涨。
- 原因:没设轮数/重复刹车,或工具老返回让模型觉得“还没完”的结果。
- 对策:轮数上限 + 重复检测。
坑二:还没做完就撂挑子。
- 现象:任务没完成,它就说“我做不了”。
- 原因:对它太宽容、没鼓励它多试几步。
- 对策:设最小轮数,在提示里明确“先动手试,别急着下结论”。
坑三:上下文越滚越大。
- 现象:长任务里每轮都把巨大的 Observation 原样塞回去,越跑越慢越贵。
- 原因:工具输出没做裁剪就回填。
- 对策:把工具输出截断或摘要,配合上下文压缩(第 9 章)。
坑四:Thought 和 Action 对不上。
- 现象:它推理里说要做 A,行动却调了 B。
- 原因:工具描述不清或模型能力不够。
- 对策:把工具描述写清楚(第 4 章),必要时换更强的模型(第 34 章)。
对比:其他框架
同样是 ReAct 循环,各家把它“露给你看”的程度不一样:
| 框架 | 循环怎么呈现 | 特点 |
|---|---|---|
| LangGraph | 明确画成图:思考节点、调工具节点来回跳,何时停由条件边决定 | 最清楚,也最繁琐 |
| CrewAI | 藏在一次 kickoff() 背后,基本看不到 | 上手快,插手每一步难 |
| PydanticAI | 用 run() 包起来,工具和输出都带类型 | 适合“每步进出都要规整”的场景 |
| Agno | agent.run() 隐藏循环,多 Agent 交给上层 Team 编排 | 偏应用视角 |
| pi | 循环是能读能改的代码,可在生命周期点插入逻辑(第 8 章) | 介于全隐藏与全手写之间 |
小结
- ReAct = Reasoning + Acting,核心是让模型“先推理、后行动、再观察”,以克服模型信息过时、不会自我核实的毛病。
- 每一轮循环三个标准阶段:Thought(想)→ Action(做)→ Observation(看),带着 Observation 进入下一轮,直到输出 Answer。
- pi 的运行时内核就是这个循环:调模型 → 有工具调用就执行并回填 Observation → 没有就结束。
- 循环必须装硬刹车:最大轮数 + 预算上限,再加重复、报错防呆,否则会空转烧钱。
- 它的实战价值在于每一步都踩在真实 Observation 上——查竞品价、排线上故障,都是同一套顺藤摸瓜。
下一章进入第 2 部分。既然 Agent 的能力上限取决于它手上有什么工具,我们就先讲清楚:工具到底是怎么定义、怎么被调用的——也就是 Function Calling。