三道保险丝,最后只能放弃治疗? 一文聊聊我的agent是怎么做死循环检测的

死循环、重复犯错、token 烧穿:给 Agent 装三道保险丝

前言

设想这样一个场景。

你给 Agent 派了个任务:「把项目里所有的 console.log 换成 logger.info」。

半小时后你回来一看,它还在跑。打开日志,发现它在反复改同一行代码------

改到第 8 遍的时候,它终于停下来告诉你:「我检查了一下,第 12 行还有个 console.log。」

你点开一看,那行早就是 logger.info 了。

这就是死循环。在人的视角里,这ai是不是偷懒呢?改好了非说自己没盖好,光烧token不干活。ai的想法恰恰相反------它在非常努力地做无用功

上一篇我们搭好了 AgentLoop 的骨架,讲了五个阶段和状态追踪。但那篇留下了一个没解决的问题:循环一旦跑起来,谁来喊停

今天就来说说这件事。Agent 出问题,最常见的就三种方式,我们逐一给它们装上保险丝。

Agent 出问题的三种方式

  1. 死循环 ------ 反复调用同一个工具,做同样的事
  2. Token 烧穿 ------ 模型无限续写,把预算耗尽
  3. 输出截断 ------ 模型输出到一半被截断,而它自己不知道

这三个问题的共同点很有意思:模型自己不觉得有问题。它每一次调用都认为自己在推进任务,每一次续写都认为自己还在正常输出。所以指望模型自己发现,是指望不上的,只能由我们在外面盯着。

保险丝一:工具死循环检测

先讲最要命的这个。

第一步:给每次工具调用打指纹

怎么判断「两次调用是不是同一次」?

直觉上,把工具名和参数拼起来比较就行了。但参数是个对象,直接比较会很脆弱。所以我们先做一个稳定序列化,再哈希:

javascript 复制代码
import { createHash } from 'node:crypto'

// 稳定序列化:把对象的 key 排序后序列化
// 保证 {a:1, b:2} 和 {b:2, a:1} 得到同样的字符串
function stableStringify(value) {
  if (value === null || typeof value !== 'object') return JSON.stringify(value)
  if (Array.isArray(value)) return `[${value.map(stableStringify).join(',')}]`
  const keys = Object.keys(value).sort()
  return `{${keys.map(k => `${JSON.stringify(k)}:${stableStringify(value[k])}`).join(',')}}`
}

function hash(input) {
  return createHash('sha256').update(input).digest('hex').slice(0, 16)
}

// 调用指纹 = 工具名 + 参数
export function hashToolCall(toolName, params) {
  return `${toolName}:${hash(stableStringify(params))}`
}

这里有个容易被忽略的细节:为什么一定要先做稳定序列化?

因为同一个工具调用,参数顺序可能不一样。比如 read_file({ path: 'src/a.ts', encoding: 'utf8' })read_file({ encoding: 'utf8', path: 'src/a.ts' }),从语义上讲完全是同一次调用。但如果直接 JSON.stringify,得到的字符串是不同的,指纹也就不同,检测就漏掉了。

排序之后就一致了:

lua 复制代码
{path, encoding} 的指纹:read_file:b690f6e5e8d2297d
{encoding, path} 的指纹:read_file:b690f6e5e8d2297d
两个指纹是否相同:true

第二步:光有调用指纹还不够

这里是最关键的一个认知转弯。

假设模型连续 10 次调用了 check_build_status 这个工具,参数完全一样。这算死循环吗?

不一定。 它可能就是在等构建完成,每次结果都不同------从 running 变成 success。这种情况下,重复调用是完全合理的。

所以真正要判定的不是「重复调用」,而是「无进展的重复调用」:

同样的调用 + 同样的参数 + 同样的结果 === 无进展

于是我们还需要一个结果指纹

javascript 复制代码
// 结果指纹 = 工具返回的内容
export function hashResult(result) {
  return hash(stableStringify(result))
}

一个调用记录里同时存两个指纹,判定的时候两个都要对上,才认为是卡住了。

第三步:滑动窗口

指纹不可能无限记录下去,那样内存会爆。所以加一个窗口,只记最近 30 轮:

scss 复制代码
const HISTORY_SIZE = 30

const history = []

export function recordCall(toolName, params) {
  history.push({
    toolName,
    argsHash: hashToolCall(toolName, params),
    timestamp: Date.now(),
  })
  if (history.length > HISTORY_SIZE) history.shift()  // 超窗口就丢掉最老的
}

滑动窗口这件事看着简单,但它是防止误伤的关键。有些工具确实需要频繁使用,如果指纹无限累积,跑到第 200 轮的时候,某个倒霉的工具可能会因为「历史上出现过 10 次」而被冤枉。

只记最近 30 轮,就只关心「最近是不是卡住了」,不翻旧账。

第四步:三种检测器 + 一个总闸

检测逻辑我一共做了四个,参考了 openClaw 那套四种检测机制的思路。

检测器一:通用重复检测。 最简单粗暴的,同一个工具、同样的参数,出现了多少次。不看结果。

检测器二:无进展检测。 上面说的那个,既要同工具同参数,还要同结果。

csharp 复制代码
// 无进展 ------ 同一个调用、同样的结果,连续发生了多少次
function getNoProgressStreak(toolName, argsHash) {
  let streak = 0
  let lastResultHash

  // 从最近一条往前扫
  for (let i = history.length - 1; i >= 0; i--) {
    const r = history[i]
    if (r.toolName !== toolName || r.argsHash !== argsHash) continue
    if (!r.resultHash) continue                      // 还没出结果的跳过
    if (!lastResultHash) { lastResultHash = r.resultHash; streak = 1; continue }
    if (r.resultHash !== lastResultHash) break        // 结果变了,说明有进展
    streak++
  }
  return streak
}

检测器三:Ping-Pong 检测。 这是最巧妙的一个,也是我认为最值得抄的一个。

它专门抓那种「两个工具来回横跳」的循环:读一下、改一下、再读、再改......

ini 复制代码
// 乒乓循环 ------ A-B-A-B 交替了多少次
function getPingPongCount(currentHash) {
  if (history.length < 3) return 0

  const last = history[history.length - 1]

  // 先找出「另一个」指纹是什么
  let otherHash
  for (let i = history.length - 2; i >= 0; i--) {
    if (history[i].argsHash !== last.argsHash) { otherHash = history[i].argsHash; break }
  }
  if (!otherHash) return 0

  // 从后往前数,看交替模式能延续多长
  let count = 0
  for (let i = history.length - 1; i >= 0; i--) {
    const expected = count % 2 === 0 ? last.argsHash : otherHash
    if (history[i].argsHash !== expected) break      // 交替被打断了
    count++
  }

  if (currentHash === otherHash && count >= 2) return count + 1
  return 0
}

为什么说它巧妙?因为它能比通用重复检测更早发现

看这个对比就明白了:

css 复制代码
=== 场景一:反复调用同一个工具 ===
  第 6 次调用 grep  <<< [警告] grep 相同参数已调用 5 次,你可能陷入了重复

=== 场景二:两个工具交替调用 ===
  第 5 次调用 read_file  <<< [警告] 检测到乒乓循环(5 次交替),建议换个思路

场景二里,read_fileedit_file 各自都只被调用了两三次。如果用通用重复检测去数,两边都远没到阈值,完全发现不了。

但乒乓检测看的是交替模式,一眼就看出「这俩在来回横跳」,于是在第 5 次就报警了。

检测器四:全局熔断。 前面三个检测器都在判断「是不是某一种特定的卡住」,而全局熔断是个总闸------只要无进展累计到阈值,不管属于哪种情况,一律强制停。

第五步:三级阈值,渐进式干预

有了检测器,接下来是判了之后怎么办

这里我用的是三级阈值,而不是一发现就熔断:

arduino 复制代码
const WARNING_THRESHOLD  = 5    // 警告:往上下文里注入提醒,让模型换思路
const CRITICAL_THRESHOLD = 8    // 严重:停止工具调用
const BREAKER_THRESHOLD  = 10   // 熔断:直接停掉整个循环

为什么不直接一刀切?因为恢复的机会应该留给模型

第 5 次的时候,往上下文里塞一条提醒:

css 复制代码
[系统提醒] grep 相同参数已调用 5 次,你可能陷入了重复,请换一个思路解决问题,不要重复同样的操作。

很多情况下,模型看到这条提醒就自己转弯了------它会换个搜索关键词,或者换个工具。这是最理想的结果:不用我们出手,问题自己解决了

如果提醒了三次还是不听,第 8 次就停止工具调用;再往上到第 10 次,直接熔断整个循环。

实际跑起来是这样:

perl 复制代码
=== 场景一:反复调用同一个工具、同样的参数、同样的结果 ===

  第 1 次调用 grep
  第 2 次调用 grep
  第 3 次调用 grep
  第 4 次调用 grep
  第 5 次调用 grep
  第 6 次调用 grep  <<< [警告] grep 相同参数已调用 5 次,你可能陷入了重复
  第 7 次调用 grep  <<< [警告] grep 相同参数已调用 6 次,你可能陷入了重复
  第 8 次调用 grep  <<< [警告] grep 相同参数已调用 7 次,你可能陷入了重复
  第 9 次调用 grep  <<< [熔断] grep 相同参数已调用 8 次,强制停止

  -> 触发熔断,循环终止

注意这里有个实现上的顺序问题,很容易写错:先检测,再记录。

scss 复制代码
// 先检测、再记录。顺序反了乒乓检测器就永远不触发
// 因为它要判断的是「这次调用会不会接上交替模式」
const detection = detect(toolName, params)
recordCall(toolName, params)

我最开始就是先记录再检测的,结果乒乓检测器一次都没触发过。因为它的判定依赖「当前这次调用」在历史里还不存在这个前提,先记录进去就把自己算进去了。

保险丝二:Token 预算控制

第二个问题是 token 烧穿。模型有时候会无限往下续写,如果不设预算,等你发现的时候账单已经不好看了。

思路是给每次任务定一个预算,然后做两件事。

第一件事:到 90% 就提醒。

javascript 复制代码
const TOKEN_BUDGET = 20000
const BUDGET_WARN_RATIO = 0.9

if (used >= TOKEN_BUDGET * BUDGET_WARN_RATIO) {
  // 注入一条消息告诉模型:快到预算了,抓紧收尾
  messages.push({
    role: 'user',
    content: `已完成 token 目标的 ${pct}%(${used}/${TOKEN_BUDGET}),继续工作,不要总结。`
  })
}

这条提示的措辞有点讲究。为什么要特意写「不要总结」?

因为模型听到「快没预算了」的第一反应,往往是「那我赶紧总结一下交差吧」。但我们想要的是它把手上那件事做完,而不是草草收场。所以得明确堵住这条路。

第二件事:检测递减回报。

有时候模型不是在推进任务,只是在原地打转。这种时候看它每轮新增的 token 量,会呈现一个很典型的衰减:

erlang 复制代码
=== 场景四:Token 预算控制 ===

  第 1 轮 +6000 token,累计 6000/20000(30%)
  第 2 轮 +6000 token,累计 12000/20000(60%)
  第 3 轮 +6000 token,累计 18000/20000(90%)  <<< [预算] 已达 90%,注入提醒:「继续工作,不要总结」
  第 4 轮 +1200 token,累计 19200/20000(96%)  <<< [预算] 已达 90%,注入提醒:「继续工作,不要总结」
  第 5 轮 +400 token,累计 19600/20000(98%)  <<< [预算] 已达 90%,注入提醒:「继续工作,不要总结」  <<< [递减回报] 连续两次增量变小,停止

第一次 +6000 是正常的,第二次 +1200 就开始不对了,第三次 +400 基本可以确定它在做无用功。连续两次增量变小,就该停了,不用等预算真的烧完。

这两个手段有个区别值得注意:90% 那个是让模型自己收尾 ,递减回报这个是我们发现它在空转,直接替它停

说句实话:这一层我目前只做到了「可见」。

代码里实现了 token 累计和 90% 的告警打印,但告警还没有真正注入到上下文里去干预模型,熔断也还没接上。所以现在它能让你看见烧了多少钱,但还不能替你踩刹车。

这和保险丝一不一样------那个是完整实现并且验证过的。我把这个差异写出来,是想说清楚哪部分是「已经能用的」,哪部分还是「设计」。

保险丝三:输出截断检测与恢复

第三个问题最隐蔽:模型输出到一半被截断了,而它自己不知道。

每个模型都有一个 max_output_tokens 参数。Claude 早期默认是 8192,如果模型要说的话超过这个限制,输出就会被硬生生切断。但模型并不知道自己被切了,它会以为「我说完了」。

于是你看到的现象就是:任务执行到一半,模型忽然不说话了,也没有报错,就这么停在那儿。

Claude Code 对这个问题有三步处理,我觉得层次很清楚。

第一步:把上限提上去。

8192 太低了,一个稍微复杂的重构方案都说不完。所以直接提到 64k。这是最省事的办法,但只能缓解,不能根治。

第二步:注入恢复消息。

检测到 finish_reasonmax_tokens,就往上下文里塞一条消息,让它接着说:

复制代码
输出 token 限制被触发,直接从断点继续,不要道歉,不要回顾你在做什么,
如果是说到一半被截断了,从那个思路接着说,把剩下的工作拆成更小的块。

这条提示词每一句都在解决问题,值得逐句看:

  • 直接从断点继续」------ 不说这句,模型会从头再来一遍
  • 不要道歉,不要回顾」------ 不堵住的话,模型会先花一大段篇幅道歉和复述,然后又被截断一次
  • 把剩下的工作拆成更小的块」------ 这是在解决根因。如果它继续说一大段,还会再撞一次上限

第三步:认栽。

恢复不能无限次尝试。第一次恢复、第二次恢复,第三次还是被截断,那就接受现实------把不完整的结果返回给用户,并且明确标记「输出被截断」

diff 复制代码
=== 场景五:输出截断检测与恢复 ===

  第 1 次截断 → 第 1 次恢复:注入「输出被截断,直接从断点继续,不要道歉,不要回顾」
  第 2 次截断 → 第 2 次恢复:注入「输出被截断,直接从断点继续,不要道歉,不要回顾」
  第 3 次截断 → 第 3 次恢复:注入「输出被截断,直接从断点继续,不要道歉,不要回顾」
  第 4 次截断 → 已恢复 3 次仍失败,认栽:把不完整结果返回用户并标记

最后那步「认栽」很多人会漏掉,但它很重要。假装成功比失败更糟------用户拿着一份残缺的结果,却不知道它是残缺的。

同样照实说:这一层目前是设计,还没有落到代码里。上面三段是 Claude Code 的做法加我的设计,不是「我已经实现并验证过」的东西。

三道保险丝,解决的是同一个问题

回头看这三件事,其实它们在解决同一个问题的三个侧面:

  • 保险丝一管的是「模型在重复劳动」
  • 保险丝二管的是「模型在空转烧钱」
  • 保险丝三管的是「模型以为自己做完了,其实没有」

三个都是在模型自己感知不到 的地方加的护栏。这大概也是 Agent 工程和普通应用开发最不一样的地方------你要面对的不是一个会抛异常的组件,而是一个自信地犯错的东西。

所以判断标准也得换一换:不是「它在正常工作吗」,而是「它有没有可能在正常工作,但方向是错的」。

总结

做了什么

  1. 给工具调用打指纹:把工具名和参数做稳定序列化(key 排序)再哈希,保证参数顺序不同也能识别为同一次调用。
  2. 区分了「重复调用」和「无进展的重复调用」 :只看调用指纹会误伤那些需要轮询的工具,必须调用指纹和结果指纹同时对上才算卡住。
  3. 实现了三种检测器加一个总闸:通用重复检测、无进展检测、Ping-Pong 检测,以及全局熔断。其中 Ping-Pong 检测最巧妙,能在第 5 次调用就发现 A-B-A-B 的横跳,而普通计数器这时候还什么都看不出来。
  4. 用了三级阈值做渐进式干预:5 次警告(注入提醒让模型自己转弯)、8 次停止工具调用、10 次熔断整个循环。先给模型机会,再自己动手。
  5. 加了 Token 预算和递减回报检测:90% 时提醒模型收尾(并明确「不要总结」),连续两次增量变小则直接停。

核心价值

  • 死循环的判定标准不能只是「重复」,而是「无进展」。有些工具本来就存在需要反复调用的情况,岂能错判好人?
  • 保险丝的设计应该是渐进的,不是一个阈值一刀切。先提醒、再限制、最后熔断,这个梯度给了模型自我纠正的空间,就算误报,代价也很小,大不了重新调用一次工具。
  • 这三道保险丝针对的都是模型自我感知不到的问题。指望模型自己发现自己在犯蠢,是指望不上的。

可延伸的方向

  • 把 Token 预算的告警真正注入到上下文里,并把熔断接上------目前只做到了可见。
  • 实现输出截断的检测与恢复,把 finish_reason 判断和恢复消息接进主循环。
  • 给检测器加一个「误报统计」,看看哪些工具最容易被冤枉,再针对性放宽阈值。

Agent 的可靠性,不在于它能做对多少事,而在于它做错的时候,你能不能在它把钱和时间烧完之前,把它拽回来。

这些措施本质还是大模型没发现自己在做无用功,随着大模型的能力越来越强,很多需要我们手动设计的钩子,它能自己理解并避开,不过,那都是后话了。

相关推荐
不好听6131 小时前
Advanced RAG:把检索流水线打磨到极致——Agentic RAG 系列之二
llm
程序员萤火1 小时前
精简MCP Client 实现 Java版
agent
掰头战士3 小时前
AgentLoop: 从 while(true) 到生产级循环
typescript·llm·agent
张忠琳3 小时前
【deepseek-harness】DeepSeek Harness Agent Loop 模块深度架构分析之二
ai·agent·deepseek·harness·dsh
Albart5753 小时前
大模型无限循环输出、重复生成文本:参数层面规避幻觉输出实战
大模型·llm·vllm·大模型推理·幻觉·重复输出
用户8082598666873 小时前
给 Agent 装上记忆:多轮对话的历史管理——token 预算、按轮裁剪与滚雪球摘要
agent
用户8082598666873 小时前
给 Agent 接上知识库:RAG 检索链路——分块策略、混合检索与重排
agent
玉宇夕落3 小时前
llm模块二 结构化输出 LangChain 结构化输出完全指南:从 JSON 解析到 withStructuredOutput
langchain·llm
山间小僧3 小时前
「AI学习笔记」Agent Memory(一)会话内记忆
aigc·agent·vibecoding