第 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() 背后,基本看不到上手快,插手每一步难
PydanticAIrun() 包起来,工具和输出都带类型适合“每步进出都要规整”的场景
Agnoagent.run() 隐藏循环,多 Agent 交给上层 Team 编排偏应用视角
pi循环是能读能改的代码,可在生命周期点插入逻辑(第 8 章)介于全隐藏与全手写之间

小结

  1. ReAct = Reasoning + Acting,核心是让模型“先推理、后行动、再观察”,以克服模型信息过时、不会自我核实的毛病。
  2. 每一轮循环三个标准阶段:Thought(想)→ Action(做)→ Observation(看),带着 Observation 进入下一轮,直到输出 Answer。
  3. pi 的运行时内核就是这个循环:调模型 → 有工具调用就执行并回填 Observation → 没有就结束。
  4. 循环必须装硬刹车:最大轮数 + 预算上限,再加重复、报错防呆,否则会空转烧钱。
  5. 它的实战价值在于每一步都踩在真实 Observation 上——查竞品价、排线上故障,都是同一套顺藤摸瓜。

下一章进入第 2 部分。既然 Agent 的能力上限取决于它手上有什么工具,我们就先讲清楚:工具到底是怎么定义、怎么被调用的——也就是 Function Calling