写一个循环谁都会:调用模型、解析动作、执行工具、把结果塞回去。难的是四件事——知道它该停下来、知道它卡住了、知道它绕圈子、以及在它需要人拍板时把控制权交出去。这一章把循环从一个 while 语句升级成一个可控的执行系统。
编辑部注 · 「什么时候该停下来」是本刊认为最被低估的工程问题。绝大多数线上事故不是模型不够聪明,而是循环没有正确的刹车。
先看一个未经训练的写法:while True: plan = model(ctx); ctx += act(plan)。它的失败方式多得惊人——模型可能永远说「再查一次」,可能反复调用同一个工具,可能在一个空结果上打转,也可能已经完成了却不知道要停。这一章把每一种失败都对应上一个明确的终止条件。
一、循环的骨架
Agent 循环(Agent Loop) 的最小结构只有三步,这三步的命名在不同框架里不同,但语义一致——它们合起来就是 思考-行动-观察(Think-Act-Observe) 这三步骨架:
| 阶段 | 做什么 | 失败模式 |
|---|---|---|
| Think(思考) | 模型基于当前上下文决定下一步动作 | 动作格式非法;在信息不足时硬猜;重复同一个动作 |
| Act(行动) | 执行工具调用,产生副作用或获取信息 | 参数错误;超时;权限不足;副作用不可逆 |
| Observe(观察) | 把结果加工后回喂上下文 | 结果过长挤爆上下文;错误信息丢失;把外部内容当指令 |
看起来很清晰,但工程上的麻烦几乎全部集中在「Observe → Think」这一段的回喂内容上。同一段工具结果,怎么截断、怎么标注来源、怎么标记可信度,直接决定下一轮模型会不会做出荒唐决策。
二、五类终止条件:一个都不能少
这是本章的核心,也就是 终止条件(Termination Conditions)。只写「任务完成就停」的系统,一定会以三种方式之一失控。
三、无进展检测怎么做
不要指望模型自己意识到自己在绕圈。要在 Harness 侧用可计算的判据来检测——核心是 无进展检测(No-Progress Detection):对 (tool_name, normalized_args) 做哈希得到 调用签名(Call Signature),对工作区文件列表取 状态指纹(State Fingerprint),再配合 成本斜率(Cost Slope) 作为提前预警。常用手段按可靠性排序:
| 判据 | 计算方式 | 优点 | 局限 |
|---|---|---|---|
| 工具调用签名重复 | 对 (tool_name, normalized_args) 做哈希,统计重复次数 | 实现简单,几乎零误报 | 参数只要有微小变化就检测不到(但那种情况往往也不是真进展) |
| 观察结果哈希不变 | 对工具返回内容取哈希,连续 3 轮相同即判定停滞 | 能捕捉「换了参数但结果一样」 | 返回内容带时间戳等噪声时会失效 |
| 状态指纹不变 | 对「工作区文件列表 + 文件哈希」取指纹 | 最接近「实质进展」的定义 | 只适用于有文件系统产出的任务 |
| 模型自述停滞 | 让模型在每轮输出一个 progress 标记 | 能捕捉语义层面的绕圈 | 不可靠,模型经常自信地认为自己有进展 |
| 成本斜率异常 | 每轮 token 消耗持续上升但产出不变 | 能提前预警上下文膨胀 | 只能作为辅助信号 |
很多实现只统计「同一个工具调用了多少次」,超过阈值就报错退出。问题是:合法的搜索任务本来就会调用同一工具很多次(换不同关键词检索)。
正确的判据不是「调用了多少次」,而是「调用之后状态有没有变化」。同样是调用 search 十次,每次返回不同内容是新信息;每次返回同一批结果才是死循环。这个区别决定了你的检测器是帮了忙还是帮了倒忙。
四、动手:一个带完整刹车的循环
import hashlib, json, time
from dataclasses import dataclass, field
def sig(tool: str, args: dict) -> str:
"""工具调用签名:参数做稳定序列化,避免键序影响"""
return f"{tool}::{json.dumps(args, sort_keys=True, ensure_ascii=False)}"
@dataclass
class LoopGuard:
max_turns: int = 15
max_seconds: float = 300
stall_limit: int = 3 # 连续多少轮无进展就退出
repeat_limit: int = 3 # 同一调用签名最多重复几次
started: float = field(default_factory=time.time)
turns: int = 0
seen: dict = field(default_factory=dict) # 签名 -> 次数
last_obs: str = ""
stall: int = 0
def check(self) -> tuple[bool, str]:
"""返回 (是否应继续, 退出原因)"""
if self.turns >= self.max_turns:
return False, "BUDGET_TURNS"
if time.time() - self.started > self.max_seconds:
return False, "BUDGET_TIME"
if self.stall >= self.stall_limit:
return False, "NO_PROGRESS"
return True, ""
def note_call(self, tool: str, args: dict) -> tuple[bool, str]:
"""记录一次调用;返回是否允许执行"""
s = sig(tool, args)
self.seen[s] = self.seen.get(s, 0) + 1
if self.seen[s] > self.repeat_limit:
return False, f"同一调用已重复 {self.seen[s]} 次,请换策略或结束任务"
return True, ""
def note_observation(self, content: str) -> None:
"""观察结果没变化 → 记为一次停滞"""
h = hashlib.sha256(content.encode()).hexdigest()
if h == self.last_obs:
self.stall += 1
else:
self.stall = 0
self.last_obs = h
def run(task: str, call_model, exec_tool, ask_human):
guard = LoopGuard()
ctx = [{"role": "user", "content": task}]
while True:
ok, why = guard.check()
if not ok:
# 关键:退出时带上已完成的部分,而不是一句失败
return {"status": why, "turns": guard.turns,
"partial": summarize(ctx), "context": ctx}
guard.turns += 1
plan = call_model(ctx, temperature=0)
# ① 显式完成信号(不要靠「没调工具」隐式判断)
if plan["action"] == "finish":
return {"status": "DONE", "result": plan.get("answer"), "turns": guard.turns}
# ④ 需要人工:暂停而不是硬闯
if plan["action"] == "need_human":
return {"status": "NEED_HUMAN", "question": plan.get("question", ""),
"context": ctx, "resumable": True}
# ⑤ 输出无法解析:有限次重试后才放弃
if plan["action"] == "invalid":
ctx.append({"role": "user",
"content": "上一次输出无法解析为合法动作,请只输出 JSON。"})
continue
allowed, msg = guard.note_call(plan["tool"], plan.get("args", {}))
if not allowed:
ctx.append({"role": "user", "content": msg})
continue
result = exec_tool(plan["tool"], plan.get("args", {}))
guard.note_observation(str(result))
# 副作用操作必须确认(人工介入点)
if result.get("side_effect") and not ask_human(plan):
ctx.append({"role": "user", "content": "用户拒绝了该操作。"})
continue
ctx.append({"role": "tool", "content": str(result)[:2000]})- 给
LoopGuard加一个「成本斜率」信号:连续三轮 token 消耗上升但观察结果哈希不变,就提前退出; - 把
stall_limit从 3 调到 1,观察误杀率变化(很多合法任务会出现一轮「无变化」),思考阈值该怎么定; - 写一个测试:构造一个「每次都调用 search('同样的词')」的假模型,验证
NO_PROGRESS能被触发且只用了 3 轮。
五、Human-in-the-loop 的正确姿势
人工介入(Human-in-the-loop) 不是「弹个窗问一下」那么简单,它有三个必须设计清楚的点——其中最重要的是,暂停之后状态必须 可恢复(Resumable),否则一旦 预算耗尽(Budget Exhausted) 或用户离开,任务就彻底丢失:
第一,暂停必须可恢复。 用户拒绝或长时间不回应之后,任务不能就这么死掉。要把完整状态(上下文、已完成的步骤、待决策的问题)持久化,用户回来时能从中断点继续。
第二,问题必须包含决策所需的信息。 问「是否继续?」是没用的;问「我准备删除 orders_2025 表(共 12 万行,最后访问 3 天前),确认删除还是先备份?」才是可决策的。把选项和后果一起给出来。
第三,不是所有事都要问。 该自动化的自动化,该问的才问。判据是「可逆性 + 影响范围」:
| 操作类型 | 示例 | 策略 |
|---|---|---|
| 只读、无副作用 | 检索、读取、查询 | 直接执行,不问 |
| 可逆写操作 | 写文件到工作目录、创建草稿 | 自动执行 + 记录,可回滚 |
| 不可逆但有边界 | 发送邮件到内部群、创建线上工单 | 首次确认,同类操作可批量授权 |
| 不可逆且影响外部 | 删除数据、支付、发布公开内容 | 每次强制确认,且确认信息必须包含影响范围 |
六、常见误区与追问
6.1 误区:装了 max_turns 就算装了刹车
错在哪:把轮数上限当成终止条件的全部。为什么自然:一行代码就能加上,加上之后最吓人的「无限循环」确实不再发生,容易产生「刹车已装好」的错觉。判据:它只覆盖五类出口的第②类,保证的是「一定会停」,不是「停在该停的地方」——模型第 3 轮就完成了却继续重试、两个工具互相调用、连续几轮返回同一个空结果,这三种情况它都会跑满。算一笔账:上限 15 轮、每轮新增 4k token 输入,从第 4 轮空转到第 15 轮,累积输入约 45.6 万 token,全是浪费。
6.2 误区:无进展检测越灵敏越好,阈值设成 1
错在哪:把「漏检」当成唯一要防的错,于是把容忍度压到最低。为什么自然:漏检会烧钱,宁枉勿纵看着总是安全的。判据:合法任务里「一轮没变化」极其常见——先写文件、下一轮才校验;先检索、下一轮才用结果。阈值取 1 时,这些任务会在真正推进之前被杀掉,完成率反而下降,日志里只有一行「因无进展退出」。建议取 3,且只在「调用签名重复」与「观察结果哈希不变」同时成立时计数。代价对比:连续 3 轮的容忍成本约 12k token,而一次误杀意味着整个长任务重跑一遍。
6.3 误区:人工介入就是弹一个「是否继续?」
错在哪:把人工介入当成一个确认弹窗,而不是一个可恢复的状态。为什么自然:写起来最简单,两个按钮就能交差。第一,确认信息里没有决策依据:用户看到「是否继续?」只能猜,40 分钟前看过的上下文早就忘了,唯一理性的选择就是「否」;可决策的问法必须自带上文,例如「将删除 orders_2025(12 万行),不可逆;确认/先备份/跳过」。第二,暂停后状态没落盘,用户一离开任务就死。判据:把进程杀掉再拉起来,任务能不能从暂停那一轮继续,且等待期间不计入轮数与时间上限。
6.4 误区:退出时返回一句失败就够了
错在哪:把异常退出当成「任务不存在」,而不是「任务停在中途」。为什么自然:失败就是失败,抛个错误码最省事。判据:预算耗尽、需要人工、系统异常这三类退出,返回值里都必须带 partial——已完成的步骤、中间产物、未完成的原因、下一步该做什么。算一下:一个 12 轮的任务在第 11 轮因预算退出,一句 failed 意味着重跑要再花 12 轮,而带上 partial 续跑通常只需 1–2 轮。只有 partial 才能让用户判断该重跑还是该补预算。
6.5 误区:重试是无害的兜底
错在哪:把重试当成「多试一次不亏」的手段,既不分类也不设上限。为什么自然:重试在只读接口上几乎零成本,这个经验被带到了所有工具上。判据:重试前必须先分类——超时、限流、上游 5xx 可重试(指数退避 + 最多 2–3 次);参数错误、权限不足、业务拒绝不可重试,重试多少次都是同一个错,正确动作是改参数、换方案或上报。数字:每次重试都带完整历史时,三次无效重试就是 12k token 的净损失;更糟的是对不可逆操作重试——把「可能已执行」变成「执行了两次」。
七、自测
八、小结
| 要素 | 必须做到 |
|---|---|
| 循环骨架 | Think / Act / Observe 三步,重点在 Observe 的回喂加工 |
| 终止条件 | 五类齐全:完成、预算、无进展、需要人工、系统异常 |
| 无进展检测 | 用「调用签名 + 结果哈希」,不要用调用次数 |
| 人工介入 | 暂停可恢复;问题带选项与后果;按可逆性分级而非一律询问 |
| 退出时 | 返回部分结果与已完成步骤,不返回一句失败 |
循环的上限由模型的推理能力决定,循环的可靠性由刹车决定。而线上事故绝大多数出在刹车上。本刊编辑部
刹车装好了,下一章处理最容易出问题的那一层:工具。因为工具是模型唯一能真正改变世界的手。
九、参考与延伸
「什么时候该停下来」这件事没有一条公式可背,最好的材料是别人的实现和别人的评测。下面这几份按「先看结构、再看实现、最后看怎么量」排好。全站不做原文转载,这里只登记链接与「为什么值得读」。
先看结构(一个循环该有哪些部件)
- Anthropic · Building Effective Agents —— 它把「循环 + 工具 = Agent」讲成一个最小定义,并且反复提醒:大多数任务其实不需要自主循环,编排好的工作流更可控。读本章第二节之前先读它,你会更容易接受「五类终止条件不是过度设计」——它把循环的每一步都当成一个必须显式设计的部件。
再看实现(别人怎么装刹车)
- LangGraph · Durable Execution —— 把「暂停—持久化—恢复」做成框架级能力:每个节点之后落检查点,进程被杀也能从断点继续,并明确了恢复时如何避免重放已完成步骤。本章第五节说「暂停必须可恢复」,这里给出的是它在工程上长什么样(检查点粒度、状态写在哪、恢复时谁负责去重)。
- OpenAI Agents SDK 文档 —— 一个刻意做小的实现:显式动作、工具调用、任务移交、护栏各占一个概念,整篇读完就能在脑子里跑一遍。适合对照本章第四节那份 agent_loop.py 看:同样是刹车,别人把它放在了框架的哪一层、你又把它放在了哪一层。
最后看怎么量(刹车有没有效)
- τ-bench(sierra-research/tau-bench) —— 多轮工具任务上的评测,把「任务完成」与「是否违反规则」「是否该澄清」分开统计。要证明你的终止条件没有误杀,就需要这类分层指标;只看成功率会把「刹车踩过头导致中途放弃」和「模型确实做不到」混成一个数。
◇ 面试官会怎么问
共 7 条 · 其中 3 条高频 · 先自己答一遍,再展开对照
Agent Loop 的终止条件你会设计哪几类? 高频
① 显式完成——模型明确表示任务达成(要做好格式约束,不要靠正则猜自然语言);
② 资源用尽——最大轮数、token 预算上限、时间上限,三者都要有,因为不同任务会先撞到不同的墙;
③ 无进展检测——连续 N 轮动作或工具调用高度相似、或者没有产生新的有效信息,判定为原地打转并中断;
④ 循环检测——A→B→A 这类振荡模式;
⑤ 主动挂起——需要人工确认、缺少必要信息、或者触及高风险操作时,转 Human-in-the-loop 而不是硬着头皮往下走。
还要有一个兜底:异常终止也必须产出可读的中间结果,而不是把已经完成的工作丢掉。
追问链
- 如果只允许你保留一个条件,你留哪个?为什么?
- 无进展检测具体怎么实现?
模型开始重复同一个动作不收敛,你系统层面怎么发现? 高频
① 动作指纹去重——把每轮的工具名 + 规范化参数(排序后哈希)作为指纹,连续重复即判定打转;
② 结果熵检测——工具返回内容高度相似甚至完全一致,说明再调也拿不到新信息;
③ 目标相似度——对比相邻几轮的思考文本相似度,超过阈值说明在原地复述;
④ 进度信号——为任务维护"已完成子目标集合",若连续多轮集合不增长,即无进展。
检出之后的处理分三级:先注入提示纠偏(告诉它已经做过什么)→ 再强制换策略(禁用刚才那个工具)→ 最后中断并交出中间结果并说明卡在哪。
一次 loop 最多几轮?这个数怎么定?
简单查询型 3–5 轮(一次检索加一次总结足够);常规办事型 10–20 轮;代码修复类长任务可以放到 30–50 轮,但必须配预算上限兜底。
定这个数的依据是"分布 + 成本":看线上真实任务的轮数分布,取 P95 再留一点余量;同时算一下撞到上限时的最坏成本,确保可接受。
另外要区分软上限与硬上限:软上限触发时提示模型"轮数不多了,请收尾并给出当前结论",硬上限触发时直接中断。只设硬上限会让模型在最后一轮还去开一个新工具,交出一个半成品。
ReAct 和 Plan-and-Execute 该怎么选?
ReAct(边想边做)每轮根据最新观察决定下一步,适应性强、对信息不确定的任务友好,缺点是容易短视、可能反复试探、总轮数偏高。
Plan-and-Execute(先规划再执行)先生成步骤清单再逐步执行,优点是全局感强、可审计、可并行;缺点是计划一旦错误会一路错下去,且环境变化后计划失效。
实践中常见的组合是两层:外层用规划定大方向(3–6 步),内层用 ReAct 处理每一步的具体不确定性,并在关键节点重新规划。选择标准是:信息在开始时就基本齐备 → 偏规划;信息要边做边获得 → 偏 ReAct。
一次 loop 的 max_turns 设为 15,每轮新增输入约 4k token。如果模型在第 3 轮就该收尾却一直跑下去,最多浪费多少输入 token?这道题真正考的是什么? 高频
4k × (4+5+…+15) ≈ 45.6 万 token。真正考的是:只设了 max_turns、没有无进展检测与成本斜率的循环,刹车只作用于「一定会停」,不作用于「停在该停的地方」。轮数上限是安全阀,不是成本控制。
补一句会加分:这 45.6 万 token 在日志里全是正常的模型调用,没有任何异常记录——这就是为什么这类浪费能持续几个月不被发现,直到有人去画每轮的输入曲线。
追问链
- 加上「连续 3 轮调用签名重复且观察结果哈希不变就退出」之后,这个任务会在第几轮停?
- 你会用哪个指标在监控上盯住这类浪费?
把无进展检测的阈值从 3 调到 1,为什么任务完成率反而可能下降?
正确做法是把两个条件叠加:调用签名重复(同名同参数)且观察结果哈希连续多轮不变,两个条件同时成立才计为停滞,阈值取 3。单看哈希会被时间戳、随机 id 这类噪声骗到;单看调用次数又会误杀「换关键词继续检索」这种正常行为。
判据:把阈值调到 1 跑一遍回归集,逐条看被判定停滞的样本;只要其中存在「下一轮本来会成功」的样本,阈值就是设错了。
追问链
- 如果任务需要轮询外部状态直到它变化,你的停滞检测会误杀它,怎么改?
- 你用什么数据证明阈值从 1 改到 3 是对的?
任务跑到第 9 轮需要用户确认一次不可逆操作,用户 40 分钟后才回来。要让这 40 分钟不白等,需要满足哪几个条件?
① 状态持久化:上下文、前 8 步的结论、中间产物、待决策的问题必须落盘,进程重启也能接上。
② 等待不计入预算:轮数与时间上限的计时要把等待人工的时间排除,否则用户回来时任务已经因为时间上限被判预算耗尽。
③ 恢复后不重放副作用:已完成的有副作用步骤必须带幂等键,恢复时读回上次结果,而不是再执行一次。
验收方式:把进程直接杀掉再拉起来,任务从第 9 轮继续,且外部世界只被改了一次。顺带一提,确认信息本身也要能决策:不是问「是否继续」,而是给出影响范围与可选方案。
追问链
- 如果用户 3 天后才回来,保留状态的成本怎么控制?
- 恢复时怎么判断「这一步之前到底执行成功了没有」?
结论先行(一句话给出取舍)→ 机制(为什么是这样,涉及哪条链路)→ 代价或边界(这么做放弃了什么)→ 你的实践或数字(真实场景里怎么落地)。 只讲机制不讲代价,是背题;只讲取舍没有数字,是空谈。