CycleDetector 升级为 HITL——两阶段协议的完整旅程

2026-10-05·编排与协作·人机协同

Agent 进入死循环怎么办?不是'检测到就停'——是先给 AI 第二次机会(自愈),再问人类(HITL),人类不响应就安全终止。这个两阶段协议的每个阶段都有不同的行为和取舍——自愈阶段按维度独立记状态,HITL 阶段 fail-closed,终止后保存进度并执行认知结算。

不是"检测到就停"

"Agent 什么时候该问人"一章讲了 HITL 的四个来源和审批疲劳,"超时必须是拒绝"一章讲了 fail-closed 原则。这篇把两者串起来——用 CycleDetector(死循环检测器)作为线索,走一遍从"检测到异常"到"最终处理"的完整决策链。

为什么不直接停

一个 Agent 在重构代码时可能出现这样的模式:

轮次 N:   exec("go build ./...")  → 编译错误
轮次 N+1: edit("fix.go", ...)     → 修改代码
轮次 N+2: exec("go build ./...")  → 另一个编译错误
轮次 N+3: edit("fix.go", ...)     → 修改代码
轮次 N+4: exec("go build ./...")  → 又一个编译错误
...

CycleDetector 可能把这识别为"工具 streak"——exec 和 edit 交替出现,看起来像循环。

但实际上模型在修不同的编译错误——每次修改的代码位置不同、每次编译的错误信息不同。这不是死循环——是正常的"修改-验证"迭代。

如果"检测到就停",Agent 会在修到第三个编译错误时被终止——丢掉已经修好的两个错误的工作。 这对用户来说更像 Bug 而非保护。

所以 CycleDetector 的正确行为不是"检测到就停"——是分层升级。


两阶段协议完整流程——自愈→HITL→超时终止

两阶段协议

第一阶段:自愈(首次触发)

首次达到终止阈值时,引擎做两件事:

1. 注入纠正消息

一条 system 级的纠正消息被注入到模型的下一轮上下文中——不修改已有消息(保护 prompt cache),作为新的 system 消息追加。

纠正消息的内容大致是:"你最近的几次操作看起来在重复同样的模式。请尝试不同的策略——比如换一种工具、换一个文件、或重新分析问题。"

这条消息足以打破大多数"临时困惑"。 模型可能陷入了一个局部最优("改 A 文件 → 测试 → 失败 → 再改 A 文件"),纠正消息提醒它"退一步看看"("也许问题不在 A 文件,试试 B 文件")。

2. 重置 counter

CycleDetector 的计数器归零——给模型一个"全新的配额"。如果纠正消息有效,模型换了策略,后续的工具调用不会立即再次触发阈值。

可选的降级操作:在某些维度的自愈中,引擎可以关闭 thinking(模型的内部推理过程)。在一些模型中,thinking 模式下的推理可能导致"过度思考"循环——模型在 thinking 中反复推理同一个问题。关闭 thinking 强制模型直接行动。

第二阶段:HITL(二次触发)

如果自愈后同一个维度再次达到终止阈值——说明纠正消息没用,这是一个模型无法自行打破的真循环。

引擎升级到 HITL——发出事件通知前端弹出选项给用户:

  • 继续:给模型更多机会(再次重置 counter)
  • 终止:安全终止,保存进度
  • 修改策略:用户输入一条指示(比如"问题不在 auth.go,试试 session.go"),作为新的用户消息注入

如果用户不响应——60 秒后超时 → 自动终止 + 保存进度(fail-closed,见"超时必须是拒绝"一章)。


四个维度的独立跟踪

approval fatigue

CycleDetector 不是一个单一的计数器——它按四个维度独立跟踪。

维度一:state_hash——状态回到了同一个点

检测上下文的状态哈希是否重复出现——模型执行了一系列操作后,系统状态回到了之前的某个快照。说明这些操作互相抵消了——做了等于没做。

维度二:identical_call——完全相同的工具调用

检测模型是否完全相同地调用了同一个工具——工具名相同、参数完全相同。这是最确定的"死循环"信号——模型在做完全相同的事情并期待不同的结果。

默认阈值:7 次完全相同的调用 = 确认为死循环。这个阈值较低,因为"完全相同的调用"几乎不可能是有意义的重复。

维度三:tool_streak——工具调用模式重复

检测工具调用的序列模式是否重复——比如 "read → edit → exec" 反复出现。这比 identical_call 更宽泛——参数可能不同,但调用模式在重复。

阈值更高(默认 50 次探索类 / 25 次操作类),因为模式重复可能是正常的——"读文件 → 改文件 → 测试" 确实是一个合理的重复模式。只有当重复次数远超正常范围时才触发。

halving 机制:如果工具参数发生了变化(不是完全相同的调用),streak 计数器 /= 2。这是"进展信号"——参数变化说明模型在尝试不同的输入,不是完全的死循环。

维度四:text_loop——输出文本重复

检测模型的输出文本是否在重复——比如连续三轮输出几乎相同的"我来分析这个问题..."然后做同样的操作。

为什么分维度

关键设计选择:一个维度的自愈不消耗另一个维度的机会。

场景:Agent 在工具选择上出现了 streak(反复交替 read 和 exec),CycleDetector 的 tool_streak 维度触发自愈,注入纠正消息。模型换了策略——不再交替 read/exec,改用了 grep。

但几轮后,模型开始用完全相同的 grep 参数反复搜索——identical_call 维度触发。

如果只有一个全局计数器,tool_streak 的自愈已经"用掉了"第一次机会,identical_call 触发时直接升级到 HITL——但 identical_call 是一种不同性质的重复,模型可能只需要一条不同内容的纠正消息就能解决。

分维度让每种循环类型都有独立的自愈机会——四维度 = 四次自愈机会 + 四次 HITL 机会。 全局计数器只有一次自愈机会 + 一次 HITL 机会。


ErrorStreak:另一条升级路径

代码知识图谱

除了 CycleDetector,还有一条通向 HITL 的路径——ErrorStreak(连续错误检测)。

什么是 ErrorStreak

Agent 的工具调用连续 N 次返回错误——编译失败、测试不通过、API 返回 500、文件不存在。

和 CycleDetector 不同——CycleDetector 检测的是"重复"(做同样的事),ErrorStreak 检测的是"伪进展"(看起来在做不同的事,但每次都失败)。

场景:

轮次 1: exec("go build ./...")           → 错误:missing import
轮次 2: edit("fix_import.go", ...)       → 成功
轮次 3: exec("go build ./...")           → 错误:type mismatch
轮次 4: edit("fix_type.go", ...)         → 成功
轮次 5: exec("go build ./...")           → 错误:undefined function
轮次 6: edit("fix_func.go", ...)         → 成功
轮次 7: exec("go build ./...")           → 错误:missing import(又回来了!)
轮次 8: edit("fix_import2.go", ...)      → 成功
轮次 9: exec("go build ./...")           → 错误:type mismatch(又回来了!)
...

CycleDetector 可能不触发——因为每次的错误消息不同、每次修改的文件不同。但从全局看,Agent 在"打地鼠"——修好一个错误引入另一个,修好另一个引入第三个。

ErrorStreak 检测的是"工具调用成功率持续低于阈值"——不是重复模式,是缺乏进展。

ErrorStreak 的两阶段

和 CycleDetector 一样——先自愈("你最近的工具调用成功率很低,请重新评估策略"),再 HITL("Agent 的工具调用连续失败。继续/终止/提供建议?")。


从行业角度看:为什么这条路很少有人走

大多数 Agent 框架选了更简单的路径——max_turns / max_iterations。数到 N 就停。

LangGraph 的 Agent Mode 有 recursion_limit。CrewAI 的 Crew 有 max_iterations。OpenAI Agents SDK 有 max_turns。

这些都是一维的、不分层的硬性截止——达到数字就停,不区分"正常的长任务"和"死循环"。

问题在于:N 应该是多少?

  • 一次简单问答:3 步够了
  • 一次代码重构:200 步很正常
  • 合同审查任务:300+ 步在企业场景中常见

同一个 Agent 面对不同任务,合理的 N 差两个数量级。设太低——正常任务被截断。设太高——死循环跑完 N 步才停(如果 N=1000 且每步消耗 100 tokens,那就是 100K tokens 的浪费)。

两阶段协议不设 N——它检测的不是"跑了多久"而是"有没有在循环"。一个 500 步的正常任务不会触发(因为每步都有进展),一个 20 步的死循环会在第 7 步触发(因为完全相同的调用)。

Claude Code auto mode 的分类器模式

Claude Code 的 auto mode 用分类器替代了循环检测——每个操作由分类器判断"是否偏离用户意图"。

这是不同的思路——CycleDetector 问"是不是在循环",分类器问"是不是偏离了"。

分类器的优势:不需要等到循环出现才拦截——一个不循环但偏离方向的操作(比如模型开始删除不相关的文件)也能被拦截。

分类器的劣势:需要额外的 LLM 调用(成本)、可能误判(阻止正常操作)、需要"用户意图"的准确理解(intent drift 检测本身是困难的)。

两种方法不互斥——分类器抓"偏离",CycleDetector 抓"循环"。最安全的系统两者都有。

$47K 事故:如果有两阶段协议

那个 LangChain 四 Agent 管线的 Analyzer-Verifier 循环——264 小时、$47,000。

如果有两阶段协议:

  • 第 10 次 Verifier 请求"进一步分析"时:identical_call 维度可能触发(虽然每次的具体请求文本不同,但模式高度相似)。或者 tool_streak 维度触发(Analyzer → Verifier → Analyzer → Verifier 的模式反复出现)。
  • 自愈阶段:纠正消息注入——"你和 Verifier 之间的交互已经重复了 10 次。请总结你的分析结论并提交最终报告,而不是继续分析。"
  • 如果纠正有效——循环在第 10 分钟打破。
  • 如果纠正无效——HITL 阶段:弹出提示给负责这个管线的工程师。如果工程师在场——看到提示,选择终止。如果工程师不在(下班了)——60 秒超时,自动终止。

最坏情况:10 分钟(自愈)+ 60 秒(HITL 超时)= 11 分钟。 而非 264 小时。

成本对比:

指标无检测(实际发生)有两阶段协议(假设)
检测到循环的时间264 小时后(靠账单)~10 分钟(自愈触发)
循环被打破的时间264 小时(手动停止)~11 分钟(自愈 + HITL 超时)
API 成本~$47,000~$33
有用产出0可能在自愈后产出结果
成本比1,400×1×

差距是 1400 倍。 而两阶段协议的实现复杂度——一个 hash 比较 + 一条纠正消息 + 一个 60 秒超时——不到 200 行代码。


终止后的善后

两阶段协议的终止不是"直接杀掉"——是优雅的善后:

1. 保存进度

TaskMemory 中的 Plan、Findings、Decisions、Checkpoints 全部持久化。Agent 做了 100 轮的有用工作不会因为第 101 轮的循环而丢失。

2. 执行认知结算

即使是异常终止(CycleDetector 触发),认知结算(CognitiveSettlement)仍然执行——产出 SessionState、MemoryEntries、Reflections、Learnings。

这意味着 Agent 在被终止前最后一刻还在"做笔记"——记录下循环的原因和尝试过的策略。下次恢复时,这些信息帮助 Agent(或用户)避免走同一条死路。

3. 标记为可恢复

CycleDetector 和 ErrorStreak 触发的终止被标记为 resumable——用户可以后续恢复 Run。恢复后 Agent 读取 TaskMemory(包含认知结算产出的 SessionState)从断点继续。

这和 panic_stop(进程级崩溃)不同——panic 后无法做认知结算(进程已经在恢复中)。CycleDetector/ErrorStreak 触发的终止是受控的终止——有足够的时间做善后。

4. 前端通知

终止事件带有清晰的原因分类和用户友好的消息:

  • cycle_detected:"Agent 在重复操作后停止。你可以恢复任务或查看已完成的工作。"
  • error_streak_terminated:"Agent 的工具调用连续失败后停止。可能需要你提供额外的上下文或修改策略。"

不是一个通用的"出错了"——是精确的原因分类 + 具体的建议操作。让用户知道发生了什么、可以做什么。


两阶段协议的核心洞察

把这三篇文章串起来,HITL 的完整旅程是:

异常检测(CycleDetector / ErrorStreak)
  │
  ├─ 首次触发 → 自愈(纠正消息 + 重置 counter)
  │   ├─ 自愈成功 → 继续
  │   └─ 自愈失败 → 二次触发
  │
  ├─ 二次触发 → HITL(问用户)
  │   ├─ 用户选择继续 → 再次重置
  │   ├─ 用户选择终止 → 优雅终止
  │   ├─ 用户提供建议 → 注入建议继续
  │   └─ 超时 → 自动终止(fail-closed)
  │
  └─ 终止后
      ├─ 保存 TaskMemory
      ├─ 执行认知结算
      ├─ 标记 resumable
      └─ 前端通知(精确原因 + 建议操作)

这个链路的核心洞察是:

1. 给 AI 第二次机会比直接停止更好——大多数"看起来像循环"的行为是临时困惑,一条纠正消息就能打破。

2. 问人是最后手段,不是默认操作——只有自愈失败后才升级到 HITL。

3. 超时=拒绝——在 HITL 中没有"默许"。

4. 终止不等于丢弃——保存进度 + 认知结算 + 可恢复。

5. 分维度让不同类型的循环各有各的处理机会。

这五条原则合在一起构成了一个比 max_turns 更精细、比"每步都审批"更高效的安全网——Agent 在正常工作时不被打扰,在异常时逐步升级,在终止时保存成果。


你现在能做什么

1. 给 Agent 设循环检测——哪怕只是最简单的。 检测"连续 N 次完全相同的工具调用"只需要一个 hash 比较。N=5 是合理的起点——5 次完全相同的调用几乎不可能是有意义的重复。

2. 检测到循环先注入纠正消息,不直接停。 纠正消息:"你最近的 5 次操作完全相同。请尝试不同的策略——比如换一个文件、换一种工具、或重新分析问题。"大多数"循环"是临时困惑,一条纠正就打破了。

3. 循环检测按维度独立跟踪。 "完全相同的调用"和"工具模式重复"是两种不同的循环——前者阈值低(5 次),后者阈值高(50 次)。用一个全局计数器会导致正常的"read→edit→test"模式重复消耗"完全相同调用"的配额。

4. 终止后保存进度。 循环终止的 Agent 可能已经完成了 80% 的有用工作——只有最后 20% 进入了循环。保存 TaskMemory、持久化 Plan 状态,用户下次可以从断点恢复。

5. 前端显示精确的终止原因和建议操作。 不要用通用的"出错了"——用"Agent 重复操作后停止,你可以恢复任务或查看已完成的工作"。精确的原因让用户知道发生了什么、该做什么。


引用来源

  • Anthropic Engineering Blog, "How we built Claude Code auto mode" — 分类器模式 + 3/20 硬性上限
  • Cursor Blog, "Governing agent autonomy with Auto-review" — 分类器阻止 → Agent 自调整 → 升级
  • vectara/awesome-agent-failures, "$47K infinite loop case study" — 264 小时无终止检测
  • arXiv:2607.14166, "Stop Means Stop" — 取消传播缺口
  • Policylayer, "Runaway Tool Loops: When Agents Never Stop Calling" — 多 Agent ping-pong 循环
  • "Silent Starvation: Dependency Deadlock and Planning Loop Failure Modes" — 452 次选择 / 0 次执行
Logo

更多推荐