死循环、重复犯错、token 烧穿:给 Agent 装三道保险丝
前言
设想这样一个场景。
你给 Agent 派了个任务:「把项目里所有的 console.log 换成 logger.info」。
半小时后你回来一看,它还在跑。打开日志,发现它在反复改同一行代码------
改到第 8 遍的时候,它终于停下来告诉你:「我检查了一下,第 12 行还有个 console.log。」
你点开一看,那行早就是 logger.info 了。
这就是死循环。在人的视角里,这ai是不是偷懒呢?改好了非说自己没盖好,光烧token不干活。ai的想法恰恰相反------它在非常努力地做无用功。
上一篇我们搭好了 AgentLoop 的骨架,讲了五个阶段和状态追踪。但那篇留下了一个没解决的问题:循环一旦跑起来,谁来喊停。
今天就来说说这件事。Agent 出问题,最常见的就三种方式,我们逐一给它们装上保险丝。
Agent 出问题的三种方式
- 死循环 ------ 反复调用同一个工具,做同样的事
- Token 烧穿 ------ 模型无限续写,把预算耗尽
- 输出截断 ------ 模型输出到一半被截断,而它自己不知道
这三个问题的共同点很有意思:模型自己不觉得有问题。它每一次调用都认为自己在推进任务,每一次续写都认为自己还在正常输出。所以指望模型自己发现,是指望不上的,只能由我们在外面盯着。
保险丝一:工具死循环检测
先讲最要命的这个。
第一步:给每次工具调用打指纹
怎么判断「两次调用是不是同一次」?
直觉上,把工具名和参数拼起来比较就行了。但参数是个对象,直接比较会很脆弱。所以我们先做一个稳定序列化,再哈希:
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_file 和 edit_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_reason 是 max_tokens,就往上下文里塞一条消息,让它接着说:
输出 token 限制被触发,直接从断点继续,不要道歉,不要回顾你在做什么,
如果是说到一半被截断了,从那个思路接着说,把剩下的工作拆成更小的块。
这条提示词每一句都在解决问题,值得逐句看:
- 「直接从断点继续」------ 不说这句,模型会从头再来一遍
- 「不要道歉,不要回顾」------ 不堵住的话,模型会先花一大段篇幅道歉和复述,然后又被截断一次
- 「把剩下的工作拆成更小的块」------ 这是在解决根因。如果它继续说一大段,还会再撞一次上限
第三步:认栽。
恢复不能无限次尝试。第一次恢复、第二次恢复,第三次还是被截断,那就接受现实------把不完整的结果返回给用户,并且明确标记「输出被截断」。
diff
=== 场景五:输出截断检测与恢复 ===
第 1 次截断 → 第 1 次恢复:注入「输出被截断,直接从断点继续,不要道歉,不要回顾」
第 2 次截断 → 第 2 次恢复:注入「输出被截断,直接从断点继续,不要道歉,不要回顾」
第 3 次截断 → 第 3 次恢复:注入「输出被截断,直接从断点继续,不要道歉,不要回顾」
第 4 次截断 → 已恢复 3 次仍失败,认栽:把不完整结果返回用户并标记
最后那步「认栽」很多人会漏掉,但它很重要。假装成功比失败更糟------用户拿着一份残缺的结果,却不知道它是残缺的。
同样照实说:这一层目前是设计,还没有落到代码里。上面三段是 Claude Code 的做法加我的设计,不是「我已经实现并验证过」的东西。
三道保险丝,解决的是同一个问题
回头看这三件事,其实它们在解决同一个问题的三个侧面:
- 保险丝一管的是「模型在重复劳动」
- 保险丝二管的是「模型在空转烧钱」
- 保险丝三管的是「模型以为自己做完了,其实没有」
三个都是在模型自己感知不到 的地方加的护栏。这大概也是 Agent 工程和普通应用开发最不一样的地方------你要面对的不是一个会抛异常的组件,而是一个自信地犯错的东西。
所以判断标准也得换一换:不是「它在正常工作吗」,而是「它有没有可能在正常工作,但方向是错的」。
总结
做了什么
- 给工具调用打指纹:把工具名和参数做稳定序列化(key 排序)再哈希,保证参数顺序不同也能识别为同一次调用。
- 区分了「重复调用」和「无进展的重复调用」 :只看调用指纹会误伤那些需要轮询的工具,必须调用指纹和结果指纹同时对上才算卡住。
- 实现了三种检测器加一个总闸:通用重复检测、无进展检测、Ping-Pong 检测,以及全局熔断。其中 Ping-Pong 检测最巧妙,能在第 5 次调用就发现 A-B-A-B 的横跳,而普通计数器这时候还什么都看不出来。
- 用了三级阈值做渐进式干预:5 次警告(注入提醒让模型自己转弯)、8 次停止工具调用、10 次熔断整个循环。先给模型机会,再自己动手。
- 加了 Token 预算和递减回报检测:90% 时提醒模型收尾(并明确「不要总结」),连续两次增量变小则直接停。
核心价值
- 死循环的判定标准不能只是「重复」,而是「无进展」。有些工具本来就存在需要反复调用的情况,岂能错判好人?
- 保险丝的设计应该是渐进的,不是一个阈值一刀切。先提醒、再限制、最后熔断,这个梯度给了模型自我纠正的空间,就算误报,代价也很小,大不了重新调用一次工具。
- 这三道保险丝针对的都是模型自我感知不到的问题。指望模型自己发现自己在犯蠢,是指望不上的。
可延伸的方向
- 把 Token 预算的告警真正注入到上下文里,并把熔断接上------目前只做到了可见。
- 实现输出截断的检测与恢复,把
finish_reason判断和恢复消息接进主循环。 - 给检测器加一个「误报统计」,看看哪些工具最容易被冤枉,再针对性放宽阈值。
Agent 的可靠性,不在于它能做对多少事,而在于它做错的时候,你能不能在它把钱和时间烧完之前,把它拽回来。
这些措施本质还是大模型没发现自己在做无用功,随着大模型的能力越来越强,很多需要我们手动设计的钩子,它能自己理解并避开,不过,那都是后话了。