
2026-10-06·终止与自愈·循环检测
循环'不是一种模式------至少是三种。完全相同的调用(同一个 grep 重复 7 次)、工具模式重复(read→edit→exec 交替 50 次)、状态回退(做了一圈操作回到起点)。这篇详解 CycleDetector 的完整算法------三个检测维度、工具四级分类、参数变化减半计数、以及 per-tool 阈值覆盖。
为什么不能只检测"完全相同的调用"
"Agent 陷入死循环怎么办"一章讲了为什么 max_turns 不是答案------正确的终止判据是"有没有在循环"。但"循环"本身就是一个模糊的概念。
考虑这个场景:
bash
轮次 1: read("auth.go")
轮次 2: edit("auth.go", patch_v1)
轮次 3: exec("go build") → 编译错误 A
轮次 4: read("auth.go")
轮次 5: edit("auth.go", patch_v2)
轮次 6: exec("go build") → 编译错误 B
轮次 7: read("auth.go")
轮次 8: edit("auth.go", patch_v3)
轮次 9: exec("go build") → 编译错误 C
...重复 10 次
没有一次调用和前一次"完全相同"------每次的 patch 不同、每次的编译错误不同。Identical Call 检测器不会触发。
但从全局看,Agent 在 read → edit → exec 的模式中循环了 10 次。每次都改 auth.go,每次编译都报不同的错------它在"打地鼠":修了错误 A 引入了错误 B,修了 B 引入了 C,修了 C 又引出了 A。
这是循环吗? 答案取决于"10 次"是否超出了合理范围。对于一个简单函数------5 次迭代应该够了;对于一个涉及 15 个文件的大型重构------30 次迭代可能是正常的。
"循环"不是一种模式------是多种模式的统称,每种需要不同的检测策略和不同的阈值。
三个检测维度

CycleDetector 同时维护三个独立的 streak 计数器------每个维度捕捉一种不同的循环形态。
维度一:Identical Call Streak------完全相同的调用
最确定的循环信号:工具名 + 参数 + 返回结果完全相同。
检测方式:对 (工具名, 参数JSON) 计算 hash,对返回结果计算 hash。两个 hash 与上一次都相同 → streak +1;任一不同 → streak 重置为 1。
bash
grep("TODO", "src/") → hash(params)=a1b2, hash(result)=c3d4 streak=1
grep("TODO", "src/") → hash(params)=a1b2, hash(result)=c3d4 streak=2 ✓
grep("TODO", "src/") → hash(params)=a1b2, hash(result)=c3d4 streak=3 ✓
grep("TODO", "src/") → hash(params)=a1b2, hash(result)=c3d4 streak=4 → Warn
grep("TODO", "src/") → hash(params)=a1b2, hash(result)=c3d4 streak=5 → Term
为什么阈值这么低(默认 Warn=3, Term=5): 完全相同的调用产出完全相同的结果------第二次就已经没有信息增益了。连续做 5 次完全一样的事情,几乎不可能是有意义的行为。
为什么要同时检查参数和结果: 只检查参数会误判"合法的轮询"------比如 exec("git status") 每次参数相同但结果可能不同(代码在被修改)。同时检查结果 hash 确保"不仅做了同样的事,还得到了同样的答案"------这才是真正的死循环。
维度二:Tool Category Streak------工具类别连续计数
检测同一类工具(exploration 或 action)被连续调用的次数。
所有工具被分为两大类:
- Exploration(探索类) :
read、grep、search、find等------不改变系统状态 - Action(操作类) :
edit、write、exec、delete等------改变系统状态
当同一类工具被连续调用时 streak +1;切换类别时 streak 重置。
bash
read("a.go") → exploration streak=1
read("b.go") → exploration streak=2
grep("func") → exploration streak=3
read("c.go") → exploration streak=4
...连续读了 30 个文件
read("z.go") → exploration streak=30 → Warn(默认 30)
...继续读
read("zz.go") → exploration streak=50 → Term(默认 50)
为什么探索类阈值更高(默认 30/50 vs 操作类 15/25): 连续读 30 个文件可能是正常的------比如 Agent 在理解一个大型代码库的架构。但连续执行 15 次写操作而中间没有任何读取或验证------这更可能是在盲目修改。
为什么操作类阈值更低: 操作会改变系统状态。错误的操作需要被撤销。连续操作意味着连续的状态变更------每一次变更都增加了"如果出错需要回滚"的成本。
维度三:Per-Tool Overrides------特定工具独立阈值
某些工具的合理重复次数和通用阈值差距很大。Per-tool override 允许为特定工具设置独立的 Warn/Term 值:
| 工具 | 默认 Warn | 默认 Term | 为什么 |
|---|---|---|---|
exec |
15 | 30 | 编译→测试循环天然需要重复 exec |
exec:readonly |
25 | 40 | 只读 exec(如 grep、cat)风险更低 |
edit |
10 | 20 | 编辑同一文件多次是正常的重构模式 |
write |
10 | 20 | 写新文件的重复通常意味着重复创建 |
apply_patch |
10 | 20 | 类似 edit |
plan |
8 | 15 | 反复规划意味着决策不清------不是在执行 |
override 优先于通用阈值 ------exec 被归类为 action(通用阈值 15/25),但 override 把它调高到 15/30。这反映了 exec 在编程场景中的特殊性------"改代码 → 编译 → 修错 → 再编译"是正常的工作模式。
工具四级分类 Fallback

CycleDetector 需要知道一个工具是 exploration 还是 action------这决定了用哪套阈值。分类通过四级 fallback 确定:
第一级:硬编码 map(~20 个内置工具)
引擎内置的标准工具有确定的分类:
| 工具 | 分类 | 理由 |
|---|---|---|
read / grep / search_files |
exploration | 不改变状态 |
edit / write / apply_patch |
action | 改变文件 |
exec |
action | 可能改变状态 |
exec:readonly |
exploration | 只读命令 |
plan |
exploration | 规划不产生副作用 |
memory |
exploration | 读写记忆不算系统状态变更 |
delegate_task |
action | 启动子 Agent 是重量级操作 |
第二级:ExtraCategories(消费方声明)
消费方可以在配置中为自定义工具声明分类:
bash
CycleDetectConfig{
ExtraCategories: {
"search_database": CategoryExploration,
"send_notification": CategoryAction,
"list_pages": CategoryExploration,
"write_block": CategoryAction,
}
}
这比让框架猜更可靠------只有消费方知道 search_database 是只读的还是有副作用的。
第三级:ToolMeta 自动推断
如果工具没有在前两级被分类,检查工具自身声明的元数据:
ReadOnly=true→ explorationDimension="perceive"→ exploration- 其他 → 进入第四级
第四级:默认 action
未分类的工具默认为 action(更严格的阈值)。 这是一个 fail-safe 设计------不确定时选择更安全的选项。一个被误分为 exploration 的 action 工具可以在高阈值下跑很多次才触发;被误分为 action 的 exploration 工具只是更快触发(产出一条纠正消息),但不会导致真正的损失。
错误分类的代价是不对称的 ------把 action 当 exploration(阈值过高)可能让危险操作多跑 30 次;把 exploration 当 action(阈值过低)只是早一点触发自愈纠正。不确定时选阈值低的那个。
Halving:区分"盲目重复"和"在尝试不同方法"

CycleDetector 最精巧的机制是 halving ------当工具被标记为 ProgressAware(进度感知)时,参数发生变化会使 streak 计数减半。
为什么需要 halving
不加 halving 的问题:
bash
edit("auth.go", patch_v1) → streak=1
edit("auth.go", patch_v2) → streak=2 ← 参数不同!模型在尝试新方案
edit("auth.go", patch_v3) → streak=3 ← 又不同!还在尝试
...
edit("auth.go", patch_v20) → streak=20 → Term ← 但每次都是不同的修改!
模型在 auth.go 上尝试了 20 种不同的修改方案------这不是"盲目重复",是迭代改进。但纯计数把它当成了和"同一个 patch 重复 20 次"一样的事。
Halving 怎么工作
对 ProgressAware 工具------参数变化时 streak /= 2(最小值为 1):
bash
edit("auth.go", patch_v1) → streak=1
edit("auth.go", patch_v2) → streak=1 ← 参数变了,1/2=0.5→1
edit("auth.go", patch_v2) → streak=2 ← 完全相同
edit("auth.go", patch_v3) → streak=1 ← 参数变了,2/2=1
edit("auth.go", patch_v3) → streak=2 ← 完全相同
edit("auth.go", patch_v3) → streak=3 ← 完全相同
edit("auth.go", patch_v4) → streak=2 ← 参数变了,3/2=1.5→2
效果:如果模型每次都尝试不同的参数------streak 几乎不增长,不会触发阈值。如果模型在某些参数上重复------streak 增长,最终触发。
这区分了两种行为:
| 行为模式 | streak 增长速度 | 触发可能性 |
|---|---|---|
| 每次不同参数(迭代改进) | 极慢(每次 /2) | 极低 |
| 同一参数重复 2-3 次再换(部分重复) | 中等 | 中等 |
| 完全相同的参数反复重复(死循环) | 快(线性增长) | 高 |
哪些工具是 ProgressAware
默认的 ProgressAware 工具是会改变系统状态的写操作------edit、write、apply_patch。因为对这些工具来说"参数在变"是最重要的进展信号------不同的 patch 意味着不同的修改尝试。
消费方可以通过 ExtraProgressAware 声明额外的进度感知工具。
完整的检测流程
把三个维度、四级分类、halving 组合在一起:
bash
每次工具调用完成后:
1. 确定工具分类
├─ 硬编码 map → 找到分类
├─ ExtraCategories → 找到分类
├─ ToolMeta (ReadOnly/Dimension) → 推断分类
└─ 默认 action
2. 更新 Identical Call Streak
├─ hash(工具名+参数) == 上次 && hash(结果) == 上次
│ → identical_streak++
└─ 不同 → identical_streak = 1
3. 更新 Category Streak
├─ 分类 == 上一次分类
│ ├─ ProgressAware && 参数变化
│ │ → category_streak /= 2 (min 1)
│ └─ 否则
│ → category_streak++
└─ 分类不同 → category_streak = 1
4. 查找阈值
├─ ToolOverrides 有该工具 → 用 override 阈值
└─ 否则 → 用分类的通用阈值
5. 判断信号
├─ identical_streak >= IdenticalCallTermThreshold → CycleTerm
├─ category_streak >= CategoryTermThreshold → CycleTerm
├─ identical_streak >= IdenticalCallWarnThreshold → CycleWarn
├─ category_streak >= CategoryWarnThreshold → CycleWarn
└─ 未达任何阈值 → 无信号
6. CycleTerm 进入两阶段协议
├─ 首次(该维度未自愈过)→ 自愈(注入纠正消息 + 重置 counter)
└─ 二次(该维度已自愈过)→ HITL → 超时终止
关键:三个维度独立运行。 Identical Call 触发自愈不影响 Category Streak 的状态------两者互不干扰。一个维度的自愈机会不被另一个维度消耗。
编程场景的阈值调优
不同的 Agent 产品场景需要不同的阈值配置。编程场景是最需要精细调优的------因为"改代码→编译→修错→再编译"是天然的重复模式。
截至本文写作时几种编程 Agent 的阈值范围(包括实际产品和我们的配置):
| 维度 | 通用默认值 | 编程场景推荐值 | 理由 |
|---|---|---|---|
| Exploration Warn/Term | 30 / 50 | 50 / 100 | 大代码库分析 40+ 文件很正常 |
| Action Warn/Term | 15 / 25 | 25 / 50 | 大重构 edit 同一文件 20+ 次正常 |
| Identical Call Warn/Term | 3 / 5 | 4 / 7 | 保持低------完全相同的调用极少有意义 |
| exec override | 15 / 30 | 30 / 60 | 编译-测试循环 |
| edit override | 10 / 20 | 30 / 60 | 同一文件反复编辑 |
委派子 Run 可以使用更严格的阈值------子 Agent 的任务范围通常更窄,合理的迭代次数更少。

你现在能做什么
1. 至少实现 Identical Call 检测。 对 (工具名, 参数JSON) 做 hash 比较,连续 N 次相同 → 终止或告警。N=5 是合理起点。实现量 ~20 行代码。
2. 区分探索类和操作类工具。 读文件/搜索 = 探索(高阈值),写文件/执行命令 = 操作(低阈值)。不确定的默认为操作。
3. 写操作加 halving。 对 edit/write/patch 类工具------参数变化时 streak /= 2。这一个机制就能消除大多数"正常迭代被误判为循环"的误报。
4. 给高频工具设独立阈值。 exec(编译测试)和 edit(代码修改)的合理重复次数远高于通用阈值。用 per-tool override 而非提高全局阈值。
5. 监控 Warn 但不一定 Term。 Warn 阈值触发时注入一条提示给模型------"你可能在重复操作"。很多时候 Warn 就够了------模型收到提示后自行换策略。Term 是最后手段。
引用来源
本文所有数据均可通过以下链接验证。
- Hermes StallDetector 签名级检测(MD5 hash + 滑动窗口) --- github.com/NousResearc...
- Hermes ToolLoopDetector 三策略(generic_repeat / poll_no_progress / ping_pong) --- github.com/NousResearc...
- LangGraph recursion_limit 默认 1000 与 RemainingSteps --- docs.langchain.com/oss/python/...
- AutoGen TerminationCondition 10 种组合器 --- Termination --- AutoGen
- OpenAI Agents SDK reset_tool_choice 防工具循环 --- github.com/openai/open...
- Agent 循环终止评分方法论(检测 vs 遏制 vs 响应三阶段) --- Stop an agent before a repeated tool call becomes a loop | QABattle
- vectara/awesome-agent-failures --- $47K 死循环 11 天
- Cursor Plan Mode 死循环(Opus 4.7 已知 bug) --- Persistent "Extension Host Timeout" & Agent Execution Hang - Bug Reports - Cursor - Community Forum