手写最小 ReAct 循环——退出条件才是真正的考点

T3-3模块 3 · Agent 开发面试权重 更新于 2026-08-19
前置T3-2
关联题目Q3-06

这篇学完你能回答什么

  1. 手写一个最小 ReAct 循环,退出条件和死循环兜底怎么设计?
  2. 2026 年还需要手写 Thought/Action/Observation 的文本解析吗?为什么?
  3. 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。而模型看到空结果时的默认倾向是"再试一次",于是它一直试。

三个工程缺陷叠在一起造成了这次事故:

  1. 没有步数上限——唯一的终止条件掌握在模型手里;
  2. 没有无进展检测——连续 16 次完全相同的 (工具名, 参数) 组合,代码毫无察觉;
  3. 工具返回没有传达"这是终局"——{"data": []} 没告诉模型"该订单号不存在,重试无用,请向用户确认单号"。

这三条恰好就是 Q3-06 的三个得分点。面试官让你手写 ReAct,考的从来不是那十几行循环骨架,而是你有没有被这类事故教育过。

三个工程缺陷叠在一起,烧了 ¥300
周五晚上告警群炸了:模型在两个工具之间横跳 16 轮,参数一模一样

  1. 用户把订单号打错一位

    A1001 在系统里根本不存在

  2. query_order 返回空

    {"data": []},没说清重试无用

  3. 循环写成 while True断点

    唯一的终止条件握在模型手里

  4. 两个工具来回横跳 16 轮

    参数完全相同,代码毫无察觉

  5. 40 多轮后人工掐断

    单个会话烧掉约 ¥300

为什么停不下来while True没有步数上限
为什么没人察觉连续 16 次同一指纹没有无进展检测
为什么模型一直试{"data": []}返回没传达「这是终局」
这一次的代价单个会话约 ¥300,跑了 40 多轮
这三条恰好就是 Q3-06 的三个得分点。面试官让你手写 ReAct,考的从来不是那十几行循环骨架,而是你有没有被这类事故教育过。

核心概念:先打比方,再给定义

比喻: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:。面试里主动说清这个演变,是一个明确的加分点。

力量来自观察能改变下一步
实习生做一步看一眼;顾问先出完整计划,计划一旦基于错误假设,后面全错

实习生 · 边想边做

查了发现查不到,那就换个思路

不懂什么时候该放弃,他会一直试下去

对应
ReAct · 推理与行动交替

观察结果回填,成为下一轮的依据

把思考显式写进上下文,让下一轮的决策有据可依

tool_callsObservationtool_result
顾问 · 先写完方案再动

一次出完整计划,然后照做

计划一旦基于错误假设,后面步步都错

对应
Plan-and-Execute

先规划出全部步骤,再逐条执行

中途的观察改不了既定计划,与 Reflection 的取舍见 T3-7

先规划后执行假设错了全错取舍见 T3-7
截至 2026-08,你几乎不用再手写 Thought: / Action: 的文本解析了 —— 原生 tool use 把「行动」结构化成 tool_calls、「观察」结构化成 tool_resultReAct 的思想活了下来,它的文本协议死了。

原理拆解

第 2 条人人会写,第 3、4 条是分水岭
循环只有三件事:调模型、执行工具、回填结果 —— 难的是什么时候停

user 请求messages 从两条起步
每轮开头先过闸
步数 / 预算闸 1 max_steps、闸 2 预算
调用模型
llm.chat(tools)回复本身要 append 回去
看有没有 tool_calls
执行工具闸 3 指纹、闸 4 致命错误
没有返回最终答案唯一的正常出口
回填,进入下一轮
结果写回 messages截断到几千 token 量级
五类退出条件,一个都不能少
#退出条件触发方不写会怎样
1模型给出最终答案(无 tool_calls)模型—(这是正常出口)
2步数上限 max_steps代码无限循环
3成本 / token 预算上限代码步数没超但账单爆炸
4无进展检测(重复动作)代码开篇那种 16 轮空转
5致命错误(依赖不可用、权限拒绝)代码明知无望还继续烧钱

死循环有三种形态:A 完全重复,call(X) 连着来,靠动作指纹去重;B 两点震荡,call(X) → call(Y) → call(X),要对窗口内的指纹序列做周期检测;C 语义空转,每轮参数微调却毫无新信息,靠观察增益判断强制收敛。

注入的纠偏话术要具体。别写「请不要重复」,要写「你已经尝试过 X 和 Y 各两次,均未获得新信息。现在请:① 给出基于已有信息的结论,或 ② 明确说明还缺什么、需要用户提供什么。」

第 5 条要能区分两种错误:参数格式错了,把错误信息还给模型让它自己修,这是 ReAct 的自愈能力;下游服务 503,应该直接终止或退避重试,而不是让模型反复撞墙。

循环骨架

原文示意
        ┌──────────────── 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)。
起步值抄得走,P95 得你自己统计
五个参数、一条持久化纪律 —— 落盘续跑是手写循环最容易漏的一块

参数起步值
参数起步值怎么定 / 为什么
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)
还有一类该退回去的场景:一次检索加一次生成就能解决的任务(很多所谓 Agent 场景其实是 RAG 场景)、路径固定、有硬性低延迟 SLA、需要严格审计每一步依据 —— 轨迹虽可记录,决策依据却是概率性的。

面试视角

题面不提退出条件,那正是陷阱
Q3-06 白板手写题:while True 当场出局,for step in range(N) 只算及格

  1. 先说清骨架一个循环三件事:调模型、执行工具、回填结果
  2. 边写边报五类退出条件把四道闸直接在代码里标出来
  3. 主动交代时效「我保留的是思想,不是它的文本协议」
  4. 用一个真实死循环收尾说清你后来加了哪道闸
只读过:这些回答会暴露你
  • while True,只靠模型说「我完成了」来退出
  • 还在手写正则解析 Thought: / Action:,也说不出为什么现在不必
  • 说不清可恢复错误与致命错误的处理差异,一律 except: pass
  • 只提步数上限,从没考虑过 token 预算这一闸
  • 从没想过工具返回空值也需要好好措辞
真做过:这些细节骗不了人
  • 步数上限和预算上限同时给出,并解释清楚为什么两者都要
  • 说得出「原生 tool use 之后,行动和观察都已经结构化了」
  • 格式错了把错误还给模型自己修,下游 503 直接终止或退避重试
  • 报得出自己项目里 max_steps 的取值依据:成功任务 P95 是 9,我设了 15
  • 把工具返回的措辞当循环的一部分:空结果要说清「重试无用」
常见追问三连:「它在两个工具之间来回跳怎么办」考的是形态 B;「步数上限设多少、依据是什么」考的是有没有真跑过;「2026 年还要自己解析 Thought/Action 吗」考的是时效认知。

面试官怎么问

Q3-06 是典型的白板手写题,出现在一面或二面的动手环节。题面通常很简单:"写一个最小的 ReAct 循环"。陷阱在于题面不提退出条件——直接写 while True 的候选人当场出局;写了 for step in range(N) 的算及格;主动加预算闸和重复检测的,面试官会立刻换个眼神。

常见追问:"如果它在两个工具之间来回跳怎么办?"(考形态 B);"步数上限设多少?依据是什么?"(考有没有真跑过);"2026 年还要自己解析 Thought/Action 吗?"(考时效认知)。

答题结构建议

  1. 先说清骨架:一个循环,三件事——调模型、执行工具、回填结果。
  2. 边写边报出五类退出条件,把闸口在代码里标出来。这是最高效的展示方式。
  3. 主动交代时效:"原生 tool use 之后不需要手写文本解析了,我保留的是 ReAct 的思想而不是它的文本协议。"
  4. 用一个真实的死循环故事收尾,说清你加了哪道闸。

分水岭信号

只读过资料的回答

  • while True,只靠模型说"我完成了"来退出。
  • 还在手写正则解析 Thought: / Action:,且说不出为什么现在不需要了。
  • 说不清"可恢复错误"和"致命错误"的处理差异,一律 except: pass
  • 只提步数上限,从没考虑过 token 预算。
  • 从没想过工具返回空值也需要好好措辞。

真做过的回答

  • 同时给出步数上限 + 预算上限,并解释为什么两者都要。
  • 提到动作指纹去重 / 周期检测这类无进展判断。
  • 讲到注入纠偏话术,而且话术是具体的、给模型指了两条出路。
  • 提到 messages 落盘续跑、每轮 trace。
  • 报得出自己项目里 max_steps 的取值和取值依据(比如"成功任务步数 P95 是 9,我设了 15")。
  • 能说出"很多我们以为需要 Agent 的场景,其实一次检索加一次生成就够了"——反向判断力。

小结与延伸

  • ReAct 的核心是让观察改变下一步决策;它的文本协议已被原生 tool use 取代,思想仍然成立。
  • 手写循环的真正考点是五类退出条件:模型收工、步数、预算、无进展、致命错误。
  • 死循环有完全重复、两点震荡、语义空转三种形态,靠"代码检测 + 上下文注入纠偏"组合应对。
  • 工具返回的措辞是循环行为的一部分:空结果要说清"重试无用"。
  • 长任务必须持久化中间状态才能续跑。

延伸:ReAct 只是范式之一,Plan-and-Execute 与 Reflection 的取舍见 T3-7。循环变长后上下文怎么压缩而不丢关键决定,见 T3-6。给循环加护栏、把高危动作卡在人工确认上,见 T3-10

继续深入

本篇归属第 3 章「Agent 开发」,去做这一章的题