第 43 章:卡循环检测
"加个探测器抓重复调用"——可有记录以来最糟的那次卡循环,重复的那个调用每一次都报告成功。
⏱️ 快速通道(5 分钟掌握核心)
- 探测器是证据聚合,不是一条规则——公开实现里有九条检测路径
- 三种裁决:
Continue、Nudge、ForceStop。不会升级的 Nudge 只是装饰- 错误的预算比成功更宽——重试一个失败是正当工作
- 检测约束的是重复,它挡不住第一次破坏性调用
- 那个问题的修复在工具契约里,不在探测器里
10 分钟路径:43.1-43.2 → 43.5 → Kocoro OSS Lab
公开源码快照:实现细节取自 Kocoro 公开仓库
origin/main的4ec6772提交,复核日期为 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({}) 上触发——原生交错思考之后的仪式性空思考。ToolModeSwitch 和 SuccessAfterError 抓的是「事情已经成功了却还在做冗余的视觉确认」。ConsecutiveDuplicate 抓背靠背的相同调用;ExactDuplicate 抓同一个调用在一个窗口里分散复现,也就是「读→改→读→改→读」那个形状。SameToolError 抓同一个工具反复以同样方式失败。FamilyNoProgress 抓相关工具围着一个主题打转。SearchEscalation 抓尾部一串什么都没捞到的搜索。NoProgress 是通用兜底:同一个工具,次数太多,不看参数。
为什么是九个而不是一个:单一的「重复调用」规则,要么会在正当迭代上误报,要么会漏掉那些微妙的情况。用不同 query 搜四次不是循环——搜四次却一条都引不出来才是。读十个文件不是循环——每次编辑失败后都重读同一个文件才是。
每个探测器编码的是「无进展」的一种不同定义。 这也是为什么这份名单是从事故里长出来的,而不是从第一性原理推出来的。
43.3 三种裁决,以及错误为什么有更大的余地
探测器返回 Continue、Nudge 或 ForceStop。
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 分钟上手)
- 读
Sleep移除的注释。注意它的结构:它抓的失效、它的误报、以及现在覆盖它的三个信号。这就是一次有正当性的删除长什么样。 - 找到全错误预算那一行,想清楚为什么正确的方向是翻倍而不是减半。
- 审计你自己的一个工具:对它必填列表里的每个字段,问「如果它以零值到达会怎样?」只要有一个答案是「它会成功并且什么都不做」,你就有 43.1 那个 bug。
划重点
- 检测是证据聚合。 九条路径存在,是因为「无进展」有九种不同形状。
- 错误应该比成功有更宽的预算。 重复的失败常常是工作,重复的成功很少是。
- 停止必须自我解释。 ForceStop 花一次不带工具的调用,让用户知道这次运行走到了哪。
- 检测约束重复,管不了第一次。 那次零字节写入在第四次调用时被抓到——文件早就没了。
- 名单从事故里演进。 出事了就加,误报盖过命中了就删。
下一章:第 44 章讲工具层诚实的另一半——判断哪些调用真的可以同时跑。