ReAct 与 Agent Loop

ReAct 是 Reason + Act 的缩写:让模型先想一步、再做一步、看看结果接着想——把推理和行动交替起来,而不是一口气把答案编完。

落地到代码,这个交替就是一个 while 循环:模型自己决定调几次工具、每次调哪个、什么时候停。这个循环通常叫 Agent Loop。

1. ReAct:先想,再做,看了结果再想

这个套路不是凭空来的。2022 年 10 月,Princeton 和 Google 的几位作者发了篇论文,把它命名为 ReAct。当时的背景是:推理和行动被当成两个独立的问题在研究。

  • 一边是 chain-of-thought:让模型把推理过程写出来,效果确实好,但它只能靠脑子里的知识——知识过时了、记错了,推理再顺也是错的,而且错误会一路传播下去
  • 另一边是 action plan generation:让模型输出动作、去调外部工具。但它不”想”——拿到什么结果就用什么结果,不会根据结果调整策略

ReAct 把两件事交替起来:模型先写一段 Thought(自由文本的推理),再输出一个 Action(真正去调工具),环境返回 Observation(执行结果),然后进入下一轮 Thought。

论文里有两句话把这个分工说得很准:

  • Thought 不改变环境。 它的作用是”归纳、追踪、更新计划,以及处理异常”
  • Action 才改变环境。 它是模型和外部世界之间唯一的接口

效果(PaLM-540B,few-shot):

任务 ReAct 对照
HotpotQA(多跳问答) EM 27.4 纯 Action 25.7;CoT 29.4(但 CoT 是编的)
FEVER(事实核查) Acc 60.9 纯 Action 58.9,高于 CoT 的 56.3
ALFWorld(文字游戏) 成功率 71% 比模仿学习 / 强化学习高 34 个百分点,后者训练用了 10³~10⁵ 条数据
WebShop(网页购物) — 比模仿学习 / 强化学习高 10 个百分点

ALFWorld 那行最值得看:ReAct 只用了一两个示例(写进 prompt 里),就打赢了用几万条数据训练出来的方法。

HotpotQA 那行则要连着看:CoT 的分数更高,但它没有外部依据——答对了可能是因为记住了,答错了你也看不出来。ReAct 分数低一点,但每一句都能追溯到一次搜索。论文的结论是两者结合最好(ReAct 检索完再让 CoT 总结,EM 35.1),内部知识和外部信息各管一段。

2. 从提示词约定到 API 的一等公民

ReAct 当年是纯提示词的:给了几个示例,模型照着格式往下写。harness 那一边干的事,说白了就是一个文本解析器——从模型输出里把 Action: 那一行抠出来,解析出工具名和参数,执行,再把结果拼成 Observation: 塞回上下文。

所以那时候最脆的地方就在解析:模型多写一个空格、换个措辞、把参数写成中文引号,整套就崩了。

tool_use 把这件事变成了 API 的一等公民。 同一个三件套,现在有了结构化字段:

ReAct(2022,文本约定) Claude Messages API(现在)
Thought: ... thinking 块(开了 extended thinking 才有)
Action: search[Apple] tool_use 块(name + input)
Observation: ... tool_result 块(content)

最大的变化是解析这一步消失了:input 直接就是解析好的对象(Anthropic、Gemini),或者一个规规矩矩的 JSON 字符串(OpenAI,自己 json.loads 一下)。

但有一个东西退回去了:可解释性。ReAct 论文专门强调 Thought 的好处之一是”人能看懂模型为什么这么做”。而现在 thinking 块能不能看到,取决于 display 参数——Claude 5 上它默认是 omitted,你拿到的只有签名,没有内容(上一篇 §4)。想看见得显式写 "display": "summarized"。

3. Agent Loop 的核心:一个 while 循环

伪代码长这样:

1
2
3
4
5
6
7
8
9
10
11
12
function agentLoop(userMessage):
messages = [...历史消息, userMessage]
while true:
response = callLLM(systemPrompt, messages, tools)
if response 没有 tool_use:
return response
messages.append({role: "assistant", content: response.content})
results = []
for each toolUse in response.toolUses:
result = executeTool(toolUse.name, toolUse.input)
results.append(tool_result(toolUse.id, result))
messages.append({role: "user", content: results})

拆开看只有三件事:

  1. 调模型
  2. 没要工具就返回 —— 这是唯一的出口
  3. 要了工具就执行,把结果塞回 messages,回到第 1 步

写成真的代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
async def agent_loop(client, system, messages, tools, max_turns=20):
for _ in range(max_turns): # 不是 while true
response = await client.create(
system=system, messages=messages, tools=tools
)

tool_uses = [b for b in response.content if b.type == "tool_use"]
if not tool_uses: # 唯一的出口
return response

# 模型这一轮说的话原样存进历史(thinking 块和签名也在里面)
messages.append({"role": "assistant", "content": response.content})

results = []
for tool_use in tool_uses:
try:
output = await execute_tool(tool_use.name, tool_use.input)
results.append(tool_result(tool_use.id, output))
except Exception as e:
# 工具失败不往外抛,包成 is_error 的结果回传
results.append(tool_result(tool_use.id, str(e), is_error=True))

messages.append({"role": "user", "content": results})

raise RuntimeError(f"跑了 {max_turns} 轮还没收敛")

对照着看,有四处在伪代码里看不出来,下面一节逐个说。

4. 这个循环里最容易写错的四件事

出口条件只有一个:没有 tool_use

循环的终止不看执行了几轮、也不看模型说了什么话,只看这一轮的 content 里还有没有 tool_use。

这带来一个反直觉的结论:模型可能”什么都没干”就结束了。 它返回一段文字、没有工具调用,循环立刻退出——哪怕你的任务压根没完成。

循环不判断”任务做完了没有”,它只判断”还要不要调工具”。 任务完成与否是语义判断,代码层面看不到;只能靠提示词和工具设计去引导。这也是为什么 Agent 的”我帮你做完了”经常不可信——它说这话的依据只是”我不打算再调工具了”。

轮数上限不是可选项

while true 加一个会判断失误的模型,就是无限循环。每转一圈都是一次完整的 API 调用,烧的是钱和时间。

上限设多少看任务,常见的是十几到几十轮。触顶之后有两种处理:

  • 直接抛错 —— 简单,但用户只拿到一个异常
  • 把”我已经试了 N 轮”作为一条消息喂回去,让模型收尾 —— 多花一次调用,但用户至少拿到一句交代

第二种体验明显更好,尤其是上限本来就是”正常情况够用、异常情况兜底”的数字。

工具失败不要抛异常

tool_result 有一个 is_error 字段,就是为这件事准备的。工具执行失败时,正确做法是把它当成一次成功的结果回传,只是把 is_error 置为 true:

1
2
except Exception as e:
results.append(tool_result(tool_use.id, str(e), is_error=True))

为什么?因为模型看到错误才有机会自己纠正——文件不存在就换个路径去找,参数写错了就改参数,命令报权限不足就换一条。要是你在 execute_tool 里直接把异常抛出去让整个循环崩掉,模型连”我错在哪”都看不到,什么也学不到。

「让模型知道失败」比「让程序知道失败」更重要。 异常是给人看的,is_error 是给模型看的——两条通道都要有。

一轮里的多个工具要一起回

模型可能一次返回多个 tool_use(并行工具调用)。这时候有三个约束:

  • 所有结果放在同一条 user 消息里。 一条结果发一条 user 消息,就会变成 user、user 连在一起,直接撞上上一篇 §3 那个”角色必须交替”的报错
  • 每个结果的 tool_use_id 必须对上各自的 tool_use.id。 模型可能同时要查三个城市的天气,配错一个它就拿着北京的天气回答上海的问题
  • 执行顺序可以自己定(并发跑也行),配对靠的是 id 不是位置

5. 上下文为什么越滚越长

messages 是一直往后追加、从不清空的。跑到第 10 轮时,这个数组里躺着:

  • 前 9 轮所有的 assistant 消息(含 thinking 块和签名)
  • 前 9 轮所有的工具结果
  • 加上这一轮新加的

而且每一轮都要整个发一遍——API 是无状态的,它不记得你上一轮发过什么。 你看到的”对话”,从头到尾都是你自己在维护的一个数组。

这就是 Agent 项目里 token 消耗曲线的形状:不是线性的,是每一轮都比上一轮更贵的平方级。 一个跑了 30 轮的 Agent,第 30 轮的输入 token 可能是第 1 轮的几十倍——不是因为模型变啰嗦了,是因为历史一直在那儿。

缓解手段有四条,代价各不相同:

手段 做法 代价
提示词缓存 在 system 和历史前段挂 cache_control 要求前缀完全稳定——改一个字整段缓存失效
截断旧工具结果 把早期冗长的工具输出换成一句摘要 模型可能”忘了”自己看过什么,开始重复劳动
压缩上下文 到阈值时让模型总结前文,替换掉历史 细节丢失,而且总结本身要花一次调用
子 Agent 把探索型任务丢给子 Agent,只把结论带回来 架构复杂度上一个台阶

第一条有个连带约束值得单独拎出来:提示词缓存和”原样回传 thinking”是同一个方向的——缓存要求你别改历史的前缀,签名要求你别改 thinking 块。两件事都在说同一句话:

历史一旦写进去,就当它不可变。

6. AgentEvent:为什么不直接把模型事件往上抛

上一篇的流式部分停在”把碎片拼回一条完整消息”。但站在 UI 的角度,它要的恰好不是一条完整消息——它要的是”现在有字出来了,先显示出来”。

所以模型事件和 UI 之间要再加一层:把 LLM 客户端的原始事件,翻译成上层能理解的 AgentEvent。

1
2
3
4
5
6
7
8
9
模型原始流                     ← SDK 给的 SSE 事件
↓
StreamEvent ← LLM 客户端的第一层:SSE 事件 → 有类型的对象
↓
收集器拼接 ← 碎片 → 完整的块 / 完整的消息
↓
AgentEvent ← 上层的语言:文字、工具、第几轮
↓
async for event in ... ← UI / 调用方

两层事件的分工:

StreamEvent AgentEvent
对应的东西 一次 API 响应 一整轮 Agent 循环
谁产生的 模型(供应商定义的形状) 你自己(领域定义)
粒度 碎片 语义
换供应商 全变 不变

为什么不省掉这一层,让 UI 直接消费 StreamEvent。 三个理由:

一、StreamEvent 是供应商的形状。 里面全是 content_block_delta、partial_json 这种 Anthropic 的说法。UI 一旦认了这些字段,换一家模型就要改 UI——把供应商的词汇表挡在客户端这一层,是这层最重要的职责之一。

二、StreamEvent 是碎片的,AgentEvent 是语义的。 一次工具调用在上游是 content_block_start + 二十个 input_json_delta + content_block_stop,到下游应该只剩有意义的那几个:工具调用开始 / 开始执行 / 执行完成。

三、有些事件模型根本不知道。 轮次开始、轮次结束、工具执行完成、整个任务结束、错误——这些不是模型发的,是你的循环产生的。它们只能在这一层补。

一张对照表(接上一篇 §6 那四种 delta):

上游 StreamEvent 下游 AgentEvent
text_delta 文本增量
thinking_delta 思考增量
signature_delta 不往上抛,只在客户端内部存着
input_json_delta 工具参数增量(或者干脆等拼完整,一次性抛”工具调用”)
content_block_start / stop 块开始 / 块结束,也可以合并成轮次开始 / 轮次结束
— 工具开始执行(上游没有这个)
— 工具执行完成(上游没有这个)
— 任务结束 / 错误

两个设计要点:

signature_delta 不该往上抛。 它是给下一轮请求用的,UI 拿它一点用没有。这类”只在 API 之间搬运”的数据要拦在客户端内部——漏到上层,只会让别人以为它是什么重要的东西,然后开始小心翼翼地存它、传它、最后在某处改坏它。

「工具开始执行」必须自己补一个事件。 上游只告诉你”模型想调这个工具”,真正去执行的是你的循环。不补这个事件,UI 就会停在”模型说要调”和”结果出来了”中间空白几秒——看起来就是卡住了,而实际上它正在老老实实地跑。

7. 完整的数据流

把这一篇和上一篇接起来,一次 Agent 请求从头到尾是这样流的:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
用户输入
↓
agent_loop 第 N 轮
↓
LLM 客户端:内部消息 → API 形状(role + content)
↓
HTTP / SSE
↓
模型原始流(SSE 事件)
↓ StreamEvent
↓ 收集器拼接 ← 碎片拼回完整的块
↓
AgentEvent 流 → UI 实时渲染
↓
这一轮结束:有 tool_use 吗?
├─ 没有 → 返回,任务结束
└─ 有 → 执行工具 → 结果包成 tool_result
→ 追加进 messages → 下一轮
↑ │
└────────────────────┘

三条线在这里汇合,各自对应一类问题:

  • 消息的线 —— messages 只增不减,每轮整个发一遍 → 上下文管理和成本
  • 事件的线 —— SSE 碎片 → StreamEvent → 收集 → AgentEvent → UI → 实时渲染
  • 控制的线 —— while 循环 + 出口条件 + 轮数上限 → 什么时候停

8. 小结

一、ReAct 的核心是交替,不是并行。 Thought 不改环境,Action 才改——当年靠提示词约定 + 文本解析,现在 thinking / tool_use / tool_result 是 API 的一等公民。

二、Agent Loop 就是一个 while 循环,出口只有一个。 判断依据是”这一轮还有没有 tool_use“,不是”任务做完了没有”。轮数上限必须有。

三、工具失败要回传,不要抛出。 包成 is_error=True 的 tool_result——让模型知道失败,比让程序知道失败更重要。

四、上下文是平方级增长的,因为 messages 只增不减、每轮全发。缓存、截断、压缩、子 Agent 是四条路,代价各不相同。历史和签名指向同一个约束:写进去就别改。

五、AgentEvent 是给上层的语言。 上游是供应商的碎片,下游要是语义的事件;签名这类搬运数据拦在客户端内部,工具的执行开始 / 结束要自己补。

参考

作者

wangyawei

发布于

2026-09-15

更新于

2026-09-15

许可协议