三维度 × 四分类——CycleDetector 的完整检测算法

0 阅读11分钟

三维度 × 四分类——CycleDetector 的完整检测算法

2026-10-06·终止与自愈·循环检测

循环'不是一种模式——至少是三种。完全相同的调用(同一个 grep 重复 7 次)、工具模式重复(read→edit→exec 交替 50 次)、状态回退(做了一圈操作回到起点)。这篇详解 CycleDetector 的完整算法——三个检测维度、工具四级分类、参数变化减半计数、以及 per-tool 阈值覆盖。

为什么不能只检测"完全相同的调用"

"Agent 陷入死循环怎么办"一章讲了为什么 max_turns 不是答案——正确的终止判据是"有没有在循环"。但"循环"本身就是一个模糊的概念。

考虑这个场景:

轮次 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。

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 重置。

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为什么
exec1530编译→测试循环天然需要重复 exec
exec:readonly2540只读 exec(如 grep、cat)风险更低
edit1020编辑同一文件多次是正常的重构模式
write1020写新文件的重复通常意味着重复创建
apply_patch1020类似 edit
plan815反复规划意味着决策不清——不是在执行

override 优先于通用阈值——exec 被归类为 action(通用阈值 15/25),但 override 把它调高到 15/30。这反映了 exec 在编程场景中的特殊性——"改代码 → 编译 → 修错 → 再编译"是正常的工作模式。


工具四级分类 Fallback

工具四级分类——探索100 / 动作50 / 规划30 / 自定义

CycleDetector 需要知道一个工具是 exploration 还是 action——这决定了用哪套阈值。分类通过四级 fallback 确定:

第一级:硬编码 map(~20 个内置工具)

引擎内置的标准工具有确定的分类:

工具分类理由
read / grep / search_filesexploration不改变状态
edit / write / apply_patchaction改变文件
execaction可能改变状态
exec:readonlyexploration只读命令
planexploration规划不产生副作用
memoryexploration读写记忆不算系统状态变更
delegate_taskaction启动子 Agent 是重量级操作

第二级:ExtraCategories(消费方声明)

消费方可以在配置中为自定义工具声明分类:

CycleDetectConfig{
  ExtraCategories: {
    "search_database":   CategoryExploration,
    "send_notification": CategoryAction,
    "list_pages":        CategoryExploration,
    "write_block":       CategoryAction,
  }
}

这比让框架猜更可靠——只有消费方知道 search_database 是只读的还是有副作用的。

第三级:ToolMeta 自动推断

如果工具没有在前两级被分类,检查工具自身声明的元数据:

  • ReadOnly=true → exploration
  • Dimension="perceive" → exploration
  • 其他 → 进入第四级

第四级:默认 action

未分类的工具默认为 action(更严格的阈值)。 这是一个 fail-safe 设计——不确定时选择更安全的选项。一个被误分为 exploration 的 action 工具可以在高阈值下跑很多次才触发;被误分为 action 的 exploration 工具只是更快触发(产出一条纠正消息),但不会导致真正的损失。

错误分类的代价是不对称的——把 action 当 exploration(阈值过高)可能让危险操作多跑 30 次;把 exploration 当 action(阈值过低)只是早一点触发自愈纠正。不确定时选阈值低的那个。


Halving:区分"盲目重复"和"在尝试不同方法"

Halving 减半机制——参数变化时计数减半,防止正常迭代被误判

CycleDetector 最精巧的机制是 halving——当工具被标记为 ProgressAware(进度感知)时,参数发生变化会使 streak 计数减半。

为什么需要 halving

不加 halving 的问题:

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):

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 组合在一起:

每次工具调用完成后:

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/Term30 / 5050 / 100大代码库分析 40+ 文件很正常
Action Warn/Term15 / 2525 / 50大重构 edit 同一文件 20+ 次正常
Identical Call Warn/Term3 / 54 / 7保持低——完全相同的调用极少有意义
exec override15 / 3030 / 60编译-测试循环
edit override10 / 2030 / 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 是最后手段。


引用来源

本文所有数据均可通过以下链接验证。