这篇学完你能回答什么
- 手写一个最小 ReAct 循环,退出条件和死循环兜底怎么设计?
- 2026 年还需要手写 Thought/Action/Observation 的文本解析吗?为什么?
- Agent 卡在两个工具之间来回跳,你怎么在代码层面止损?
从一个真实故障讲起
周五晚上,一个内部运维 Agent 的告警群炸了:单个会话烧掉约 ¥300,跑了 40 多轮才被人工掐断。
拉出 trace 一看,模型在两个动作之间来回横跳:
step 07 query_order(id=A1001) → {"data": []} # 空结果
step 08 check_logistics(id=A1001) → {"error":"order not found"}
step 09 query_order(id=A1001) → {"data": []} # 一模一样的调用
step 10 check_logistics(id=A1001) → {"error":"order not found"}
... (重复 16 轮,参数完全相同)
step 39 query_order(id=A1001) → {"data": []}这个订单号根本不存在——用户打错了一位。但代码里的循环写成了 while True,只在模型给出最终回答时才 break。而模型看到空结果时的默认倾向是"再试一次",于是它一直试。
三个工程缺陷叠在一起造成了这次事故:
- 没有步数上限——唯一的终止条件掌握在模型手里;
- 没有无进展检测——连续 16 次完全相同的 (工具名, 参数) 组合,代码毫无察觉;
- 工具返回没有传达"这是终局"——
{"data": []}没告诉模型"该订单号不存在,重试无用,请向用户确认单号"。
这三条恰好就是 Q3-06 的三个得分点。面试官让你手写 ReAct,考的从来不是那十几行循环骨架,而是你有没有被这类事故教育过。
用户把订单号打错一位
A1001 在系统里根本不存在
query_order返回空{"data": []},没说清重试无用循环写成
while True断点唯一的终止条件握在模型手里
两个工具来回横跳 16 轮
参数完全相同,代码毫无察觉
40 多轮后人工掐断
单个会话烧掉约 ¥300
while True没有步数上限{"data": []}返回没传达「这是终局」核心概念:先打比方,再给定义
比喻:ReAct 是"边想边做的实习生",与之相对的是"一次性写完方案再执行的顾问"。
顾问式(Plan-and-Execute)先出完整计划再照做,计划一旦基于错误假设,后面全错。实习生式(ReAct)做一步看一眼:查了发现查不到,那就换个思路。它的力量来自"观察结果能改变下一步决定"——但代价是,如果实习生不懂什么时候该放弃,他会一直试下去。
定义:ReAct(Reasoning + Acting,推理与行动交替) 是一种 Agent 范式:模型交替产出「推理」与「行动」,每次行动的执行结果(Observation,观察)回填进上下文,作为下一轮推理的依据,直到模型认为任务完成。
核心机制只有一句话:把思考显式写进上下文,让下一轮的决策有据可依。
一个重要的时效更新:
截至 2026-08,你几乎不需要再手写
Thought:/Action:/Observation:的文本解析。 早期 ReAct 论文时代,模型没有原生工具能力,只能让它按固定文本格式输出、再用正则把 Action 抠出来——脆弱、易崩、格式一乱全盘皆输。现在主流模型的原生 tool use API 已经把「行动」结构化成了tool_calls字段,把「观察」结构化成了tool_result消息。ReAct 的思想活了下来,它的文本协议死了。推理模型(thinking 类)进一步把「推理」也内置了:模型自己产出推理内容,你不用再强迫它写
Thought:。面试里主动说清这个演变,是一个明确的加分点。
查了发现查不到,那就换个思路
不懂什么时候该放弃,他会一直试下去
观察结果回填,成为下一轮的依据
把思考显式写进上下文,让下一轮的决策有据可依
一次出完整计划,然后照做
计划一旦基于错误假设,后面步步都错
先规划出全部步骤,再逐条执行
中途的观察改不了既定计划,与 Reflection 的取舍见 T3-7
Thought: / Action: 的文本解析了 —— 原生 tool use 把「行动」结构化成 tool_calls、「观察」结构化成 tool_result。ReAct 的思想活了下来,它的文本协议死了。原理拆解
max_steps、闸 2 预算| # | 退出条件 | 触发方 | 不写会怎样 |
|---|---|---|---|
| 1 | 模型给出最终答案(无 tool_calls) | 模型 | —(这是正常出口) |
| 2 | 步数上限 max_steps | 代码 | 无限循环 |
| 3 | 成本 / token 预算上限 | 代码 | 步数没超但账单爆炸 |
| 4 | 无进展检测(重复动作) | 代码 | 开篇那种 16 轮空转 |
| 5 | 致命错误(依赖不可用、权限拒绝) | 代码 | 明知无望还继续烧钱 |
死循环有三种形态:A 完全重复,call(X) 连着来,靠动作指纹去重;B 两点震荡,call(X) → call(Y) → call(X),要对窗口内的指纹序列做周期检测;C 语义空转,每轮参数微调却毫无新信息,靠观察增益判断强制收敛。
注入的纠偏话术要具体。别写「请不要重复」,要写「你已经尝试过 X 和 Y 各两次,均未获得新信息。现在请:① 给出基于已有信息的结论,或 ② 明确说明还缺什么、需要用户提供什么。」
循环骨架
┌──────────────── messages(不断增长的上下文)────────────────┐
│ │
[user 请求] │
│ │
▼ │
┌───────────┐ tool_calls? ┌──────────┐ tool_result │
│ 调用模型 │──── 是 ─────→│ 执行工具 │──────────────────────────┘
└───────────┘ └──────────┘ (回填,进入下一轮)
│ │
│ 否(模型给出最终答案) └→ 每轮都过:预算 / 步数 / 重复 / 错误 四道闸
▼
返回结果最小实现(约 40 行,面试白板可写)
import hashlib, json
def run_agent(user_input, tools, dispatch,
max_steps=12, max_tokens_budget=120_000, repeat_window=3):
messages = [system(AGENT_PROMPT), user(user_input)]
used_tokens = 0
recent = [] # 最近的动作指纹,用于无进展检测
for step in range(max_steps): # 闸 1:步数上限
if used_tokens > max_tokens_budget: # 闸 2:预算上限
return finish("成本超预算,已转人工", messages)
resp = llm.chat(messages, tools=tools)
used_tokens += resp.usage.total_tokens
messages.append(resp)
if not resp.tool_calls: # 正常出口:模型给出最终答案
return resp.content
for call in resp.tool_calls:
fp = hashlib.md5( # 闸 3:动作指纹
f"{call.name}:{json.dumps(call.arguments, sort_keys=True)}".encode()
).hexdigest()
if recent.count(fp) >= repeat_window: # 连续重复同一动作 → 强制干预
messages.append(tool_result(
call.id,
"你已用完全相同的参数调用过该工具多次且结果相同。"
"不要再重复:请改变策略,或直接向用户说明当前信息不足以及缺什么。"
))
continue
recent = (recent + [fp])[-8:]
try:
result = dispatch(call) # 真正执行
except FatalToolError as e: # 闸 4:致命错误直接终止
return finish(f"工具不可用:{e}", messages)
except ToolError as e: # 可恢复错误:把可操作信息还给模型
result = f"调用失败:{e}。请检查参数或改用其他工具。"
messages.append(tool_result(call.id, truncate(result, 8000)))
return finish("已达最大步数,未能完成,转人工处理", messages) # 兜底出口五类退出条件,一个都不能少
| # | 退出条件 | 触发方 | 不写会怎样 |
|---|---|---|---|
| 1 | 模型给出最终答案(无 tool_calls) | 模型 | —(正常出口) |
| 2 | 步数上限 max_steps |
代码 | 无限循环 |
| 3 | 成本/token 预算上限 | 代码 | 步数没超但账单爆炸(单步可吃掉巨量上下文) |
| 4 | 无进展检测(重复动作/无新信息) | 代码 | 开篇那种 16 轮空转 |
| 5 | 致命错误(依赖不可用、权限拒绝) | 代码 | 明知无望还继续烧钱 |
面试白板题里,第 2 条几乎人人会写,第 3、4 条是分水岭。 第 5 条要能区分"可恢复错误"和"致命错误":参数格式错了应该把错误信息还给模型让它自己修(这是 ReAct 的自愈能力),下游服务 503 应该直接终止或退避重试,而不是让模型反复撞墙。
死循环的三种形态与对策
形态 A:完全重复 call(X) → call(X) → call(X)
对策:动作指纹去重(上面代码的闸 3)
形态 B:两点震荡 call(X) → call(Y) → call(X) → call(Y)
对策:把窗口内的指纹序列做周期检测,命中就注入纠偏
形态 C:语义空转 每轮参数微调但毫无新信息(改一个字重查)
对策:观察增益判断——若连续 N 轮工具返回的信息熵/去重后新内容为 0,
强制进入收敛阶段(要求模型基于现有信息给结论)对 B 和 C,纯代码判断有极限,实践中的组合拳是:代码检测 + 上下文注入纠偏。注入的话术要具体,别写"请不要重复",要写"你已经尝试过 X 和 Y 各两次,均未获得新信息。现在请:① 给出基于已有信息的结论,或 ② 明确说明还缺什么、需要用户提供什么。"
工程实践(截至 2026-08)
参数经验值
- max_steps:简单问答类 5~8;跨系统查询类 15~25;编码/长任务类 50+(并配合更强的预算控制与中间态持久化)。这些是起步值,必须用自己的评测集校准——统计成功任务的步数分布,取 P95 再留一点余量。
- repeat_window:连续 2~3 次相同指纹就干预。设太大等于没有。
- 单次工具返回截断:几千 token 量级起步(参见 T3-2)。
- 每轮超时:给单个工具调用设墙钟超时(如 30s),否则一个挂死的下游能拖垮整个会话。
必须持久化的东西
长任务 Agent 一定会遇到进程重启、网络中断。在每轮循环结束时把 messages 与游标落盘(或写入状态存储),这样才能续跑。这也是 LangGraph 这类框架强调「durable execution / checkpointing(可持久化执行 / 检查点)」的原因——它们把这件事变成了内置能力。自己手写循环时,这是最容易漏掉、上线后最痛的一块。
进阶技术三段式:ReAct 范式本身
- 解决什么:让模型根据真实观察调整下一步,处理"计划无法预先制定"的任务;错误可以在循环内自愈(工具报错 → 模型改参数重试)。
- 代价是什么:串行往返导致延迟成 N 倍;每轮重发历史导致成本超线性增长;上下文随步数膨胀,长循环末期模型容易忘掉最初的任务目标(context rot);不可复现,同一输入可能走出不同轨迹。
- 什么时候不该用:路径固定的任务(直接 workflow);有硬性低延迟要求的同步接口;任务能一次检索 + 一次生成解决时(很多所谓 Agent 场景其实是 RAG 场景);以及需要严格审计每一步依据的场景——ReAct 的轨迹虽可记录,但决策依据是概率性的。
避坑清单
- 循环里别忘了把模型的回复本身 append 回 messages。漏掉这一步,模型看不到自己刚发起的调用,会重复发起——这是新手最常见 bug。
- 工具返回为空要显式表达语义:
{"data": []}改成未找到匹配记录(订单号 A1001 在系统中不存在)。若单号来自用户输入,建议向用户确认。一句话就能消灭开篇那类事故。 - 不要在循环里无限制拼接历史:接近上下文上限时要触发压缩(T3-6),并保证关键决定不被压掉。
- 别用异常吞掉工具错误。
except: pass会让模型永远看不到失败原因,从而永远学不会改参数。 - 给每轮打 trace(step、tool、参数、耗时、token),出事时你需要的正是开篇那张表(T3-11)。
| 参数 | 起步值 | 怎么定 / 为什么 |
|---|---|---|
max_steps必须自己校准 | 简单问答 5~8;跨系统查询 15~25;编码 / 长任务 50+ | 统计成功任务的步数分布,取 P95 再留一点余量 |
repeat_window | 连续 2~3 次相同指纹就干预 | 设太大等于没有 |
max_tokens_budget | 示例实现里取 120,000 | 只有步数闸,挡不住单步吃掉巨量上下文 |
| 单次工具返回截断 | 几千 token 量级起步(参见 T3-2) | 超出要分页,且在返回里明说被截断了 |
| 每轮墙钟超时 | 单个工具调用设超时,如 30s | 一个挂死的下游能拖垮整个会话 |
- 必须持久化
- 每轮结束把
messages与游标落盘才能续跑;LangGraph 说的 durable execution / checkpointing 就是这件事 - 新手最常见 bug
- 循环里忘了把模型回复本身
append回 messages —— 模型看不到自己刚发起的调用,就会重复发起 - 空结果要有语义
{"data": []}改成「未找到匹配记录(订单号 A1001 不存在)。若单号来自用户输入,建议向用户确认。」- 别吞掉工具错误
except: pass会让模型永远看不到失败原因,从而永远学不会改参数- 接近上限要压缩
- 别在循环里无限制拼接历史;触发压缩(T3-6)时要保证关键决定不被压掉
- 每轮打 trace
- step、tool、参数、耗时、token —— 出事时你要的正是开篇那张表(T3-11)
面试视角
while True 当场出局,for step in range(N) 只算及格- 先说清骨架一个循环三件事:调模型、执行工具、回填结果
- 边写边报五类退出条件把四道闸直接在代码里标出来
- 主动交代时效「我保留的是思想,不是它的文本协议」
- 用一个真实死循环收尾说清你后来加了哪道闸
- 写
while True,只靠模型说「我完成了」来退出 - 还在手写正则解析
Thought:/Action:,也说不出为什么现在不必 - 说不清可恢复错误与致命错误的处理差异,一律
except: pass - 只提步数上限,从没考虑过 token 预算这一闸
- 从没想过工具返回空值也需要好好措辞
- 步数上限和预算上限同时给出,并解释清楚为什么两者都要
- 说得出「原生 tool use 之后,行动和观察都已经结构化了」
- 格式错了把错误还给模型自己修,下游 503 直接终止或退避重试
- 报得出自己项目里 max_steps 的取值依据:成功任务 P95 是 9,我设了 15
- 把工具返回的措辞当循环的一部分:空结果要说清「重试无用」
面试官怎么问
Q3-06 是典型的白板手写题,出现在一面或二面的动手环节。题面通常很简单:"写一个最小的 ReAct 循环"。陷阱在于题面不提退出条件——直接写 while True 的候选人当场出局;写了 for step in range(N) 的算及格;主动加预算闸和重复检测的,面试官会立刻换个眼神。
常见追问:"如果它在两个工具之间来回跳怎么办?"(考形态 B);"步数上限设多少?依据是什么?"(考有没有真跑过);"2026 年还要自己解析 Thought/Action 吗?"(考时效认知)。
答题结构建议
- 先说清骨架:一个循环,三件事——调模型、执行工具、回填结果。
- 边写边报出五类退出条件,把闸口在代码里标出来。这是最高效的展示方式。
- 主动交代时效:"原生 tool use 之后不需要手写文本解析了,我保留的是 ReAct 的思想而不是它的文本协议。"
- 用一个真实的死循环故事收尾,说清你加了哪道闸。
分水岭信号
只读过资料的回答:
- 写
while True,只靠模型说"我完成了"来退出。 - 还在手写正则解析
Thought:/Action:,且说不出为什么现在不需要了。 - 说不清"可恢复错误"和"致命错误"的处理差异,一律
except: pass。 - 只提步数上限,从没考虑过 token 预算。
- 从没想过工具返回空值也需要好好措辞。
真做过的回答:
- 同时给出步数上限 + 预算上限,并解释为什么两者都要。
- 提到动作指纹去重 / 周期检测这类无进展判断。
- 讲到注入纠偏话术,而且话术是具体的、给模型指了两条出路。
- 提到 messages 落盘续跑、每轮 trace。
- 报得出自己项目里 max_steps 的取值和取值依据(比如"成功任务步数 P95 是 9,我设了 15")。
- 能说出"很多我们以为需要 Agent 的场景,其实一次检索加一次生成就够了"——反向判断力。
小结与延伸
继续深入
本篇归属第 3 章「Agent 开发」,去做这一章的题。