第 43 章:卡循环检测

"加个探测器抓重复调用"——可有记录以来最糟的那次卡循环,重复的那个调用每一次都报告成功。


⏱️ 快速通道(5 分钟掌握核心)

  1. 探测器是证据聚合,不是一条规则——公开实现里有九条检测路径
  2. 三种裁决:ContinueNudgeForceStop。不会升级的 Nudge 只是装饰
  3. 错误的预算成功更宽——重试一个失败是正当工作
  4. 检测约束的是重复,它挡不住第一次破坏性调用
  5. 那个问题的修复在工具契约里,不在探测器里

10 分钟路径:43.1-43.2 → 43.5 → Kocoro OSS Lab


公开源码快照:实现细节取自 Kocoro 公开仓库 origin/main4ec6772 提交,复核日期为 2026-07-27。章节讲的是不变量;具体常量应以当前源码为准。

43.1 从一次每次都报成功的写入说起

2026-05-13,生产环境。一个 Agent 调用 file_write。调用里有 path,没有 content 字段。

Go 的 json.Unmarshal 在强类型结构体上分不清「字段缺失」和「字段存在但是零值」。于是 content 到达时是 ""。而 os.WriteFile 非常乐意写一个空字符串——它把用户已有的文件截断成 0 字节,然后不返回任何错误。

工具返回 IsError: false

模型读到这个结果,看到成功,然后——因为它刚刚毁掉的那个文件仍然不包含它想要的内容——又试了一次。再一次。

现在问:循环探测器做了什么?

它触发了。复现这个模式的回归测试记录着:修复前,重复检测在第四次相同调用上就跳了。探测器既没瞎,也没坏。

它只是来得太晚了。文件在第一次调用时就已经没了。

这才是那条让人不舒服的教训,而且它比「给这个加个探测器」窄得多。循环检测约束的是重复——你把错事做了几遍。它约束不了第一次做错。一个「一边报成功一边截断文件」的工具,在任何探测器凑齐两个数据点之前,伤害就已经造成了。

也留意一下当时哪些信号根本用不上。无进展检测?每一次调用都报告了进展。同工具报错检测?根本没有错误。只有重复那条路径手上有东西可用,而重复本来就被刻意留了余地——重复调用恰恰是一个 Agent 顺着列表往下推进时的正常形状。

43.2 九条路径,因为一个信号不够用

公开实现里有九条检测路径,按顺序求值,先命中者胜。它们之所以是分开的探测器,是因为它们抓的是真正不同形状的「卡住」。

EmptyThink 在两次连续的 think({}) 上触发——原生交错思考之后的仪式性空思考。ToolModeSwitchSuccessAfterError 抓的是「事情已经成功了却还在做冗余的视觉确认」。ConsecutiveDuplicate 抓背靠背的相同调用;ExactDuplicate 抓同一个调用在一个窗口里分散复现,也就是「读→改→读→改→读」那个形状。SameToolError 抓同一个工具反复以同样方式失败。FamilyNoProgress 抓相关工具围着一个主题打转。SearchEscalation 抓尾部一串什么都没捞到的搜索。NoProgress 是通用兜底:同一个工具,次数太多,不看参数。

为什么是九个而不是一个:单一的「重复调用」规则,要么会在正当迭代上误报,要么会漏掉那些微妙的情况。用不同 query 搜四次不是循环——搜四次却一条都引不出来才是。读十个文件不是循环——每次编辑失败后都重读同一个文件才是。

每个探测器编码的是「无进展」的一种不同定义。 这也是为什么这份名单是从事故里长出来的,而不是从第一性原理推出来的。

43.3 三种裁决,以及错误为什么有更大的余地

探测器返回 ContinueNudgeForceStop

Nudge 注入一条消息,告诉模型它似乎在重复,给它一次改换思路的机会。ForceStop 注入一条消息,然后做最后一次不带工具的模型调用,让模型总结自己走到了哪里,再退出循环。最后这个细节很重要:不带总结就停,用户手上只剩一次被砍断的运行和零解释。一次体面的停止,是会自己解释的停止。

现在说那个有意思的不对称。构造函数里的默认值是背靠背重复 3 次、分散重复 5 次——分别从 2 和 3 提上来的,因为更严的值在正当的重构循环和重新检索上误报太多——但当这次运行里每一个调用都是错误时,预算翻倍。

为什么对失败更宽容?因为重试一个失败的操作是正当工作。一个网络调用失败两次、第三次成功,这是系统在正常工作。而同一个调用带着同样参数成功两次,要么是白干,要么是撒谎。重复的失败常常是进展;重复的成功很少是。

反方向还有一条配套规则:如果最近一次调用成功了,而这次运行更早的时候有过错误,重复探测器直接跳过。模型刚刚恢复过来,在它挣脱出来的那一刻惩罚它,恰恰是错的。

Nudge 自己也需要边界。没有上界的 Nudge 就是装饰——模型被告知在循环,它无视,再被告知一次。只有在一个滚动窗口内升级,Nudge 才从提示变成真正的控制。

43.4 43.1 的修复不在探测器里

回到那次零字节写入。

你可以试着靠调参绕过去——把重复阈值调低,或者给「参数相同的重复成功调用」开个特例。但重复探测器已经跳了,在第四次。把它收紧到 3、收紧到 2,也只是把尾巴剪短。第一次调用照样截断了文件,而在第一个数据点存在之前,任何阈值都够不着。收紧还要在别处付钱:那两个阈值之所以被提到 3 和 5,正是因为更严的值在正当的重构循环上误报。

真正的修复在上游:让失败可读。 每个工具的 Run 都必须在反序列化之后立即显式检查 Required 列表里的每个字段非零,并返回一个类型化的校验错误,而不是手搓一个错误结果。

而且这个错误的前缀是承重的[validation error] 标记让一个专门的检查可以把「同工具 + 同参数 + 连续三次校验错误」直接短路到 ForceStop——远早于通用的全错误预算(那个要到第七次调用才会触发)。返回一个不带前缀的手搓 IsError: true,你就丢掉了这个提前停止,退回到更慢的通用路径。

这条一般原则值得单独说:探测器只能看见工具层报告出来的东西。 一个「什么都没做却返回成功」的工具最终还是会被抓到——但只在它已经动过手之后。检测是第二道防线。第一道是说真话的工具

43.5 探测器也会被删掉

曾经有一个 Sleep 探测器——bash 命令里含 sleep N 就被当作卡循环信号,理由是模型写出 sleep 5 && retry 通常意味着它在等一个永远不会变的外部状态。

它在 2026 年 5 月被移除了。理由是:

produced false positives on legitimate parallel-sleep tasks. Real polling is now covered by ExactDup (same command repeated), NoProgress (many bash calls without progress), and the idle watchdog (silent long-running commands).

这里有两点值得带走。第一,这次删除的正当性来自证明那个失效模式已经被另外三个信号覆盖,而不是来自「觉得它不重要了」。第二,一个误报多于命中的探测器比没有更糟,因为它训练你去忽略这套机制。

这也是为什么第 32 章的探测器表被明确标注为历史内容。它记录的是更早的一份名单,而名单会随事故累积而变化。探测器数量不是架构不变量。 如果你系统里有个探测器,没人说得出它对应哪次事故,那它就是删除的候选。

43.6 快照证据

观察4ec6772 源码位置
九条检测路径,先命中者胜loopdetect.go L39
背靠背重复默认 3(从 2 提高)loopdetect.go L297
分散重复默认 5(从 3 提高)loopdetect.go L298
全错误时预算翻倍loopdetect.go L471
校验错误签名短路loopdetect.go L455
Sleep 探测器的移除及其覆盖论证loopdetect.go L55

以上描述的是一个有日期的实现快照,不是普遍契约。

43.7 常见的坑

把重复调用一律当可疑。 批处理会重复调用,恢复也会重复调用。只有无产出的重复才是循环。

Nudge 不升级。 如果一个 Nudge 可以无限次触发,它就是一行日志,不是一个控制。

ForceStop 不带总结。 用户拿到一次被砍断的运行和零解释。多花那一次不带工具的调用。

用检测代替修契约。 43.1 那个事故是工具校验的 bug。你为它造的任何探测器都是权宜之计,而且会在别处误伤。

留着没人说得清的探测器。 背后没有事故的探测器,是未经检验的直觉,早晚会在真实工作上误报。

豁免得太粗。 有些工具正当地重复,需要从重复检测里豁免——但一刀切的豁免也会把它们从真正的卡死检测里摘出去。豁免要窄。

Kocoro OSS Lab(10 分钟上手)

  1. Sleep 移除的注释。注意它的结构:它抓的失效、它的误报、以及现在覆盖它的三个信号。这就是一次有正当性的删除长什么样。
  2. 找到全错误预算那一行,想清楚为什么正确的方向是翻倍而不是减半。
  3. 审计你自己的一个工具:对它必填列表里的每个字段,问「如果它以零值到达会怎样?」只要有一个答案是「它会成功并且什么都不做」,你就有 43.1 那个 bug。

划重点

  1. 检测是证据聚合。 九条路径存在,是因为「无进展」有九种不同形状。
  2. 错误应该比成功有更宽的预算。 重复的失败常常是工作,重复的成功很少是。
  3. 停止必须自我解释。 ForceStop 花一次不带工具的调用,让用户知道这次运行走到了哪。
  4. 检测约束重复,管不了第一次。 那次零字节写入在第四次调用时被抓到——文件早就没了。
  5. 名单从事故里演进。 出事了就加,误报盖过命中了就删。

下一章:第 44 章讲工具层诚实的另一半——判断哪些调用真的可以同时跑。

引用本文 / Cite
Zhang, Wayland (2026). 第 43 章:卡循环检测. In AI Agent 架构:从单体到企业级多智能体. https://waylandz.com/ai-agent-book/%E7%AC%AC43%E7%AB%A0-%E5%8D%A1%E5%BE%AA%E7%8E%AF%E6%A3%80%E6%B5%8B/
@incollection{zhang2026aiagent_43_-,
  author = {Zhang, Wayland},
  title = {第 43 章:卡循环检测},
  booktitle = {AI Agent 架构:从单体到企业级多智能体},
  year = {2026},
  url = {https://waylandz.com/ai-agent-book/%E7%AC%AC43%E7%AB%A0-%E5%8D%A1%E5%BE%AA%E7%8E%AF%E6%A3%80%E6%B5%8B/}
}