上一篇聊了上下文压缩,那是「空间」问题;这篇聊一个「时间」问题------Agent 为什么会卡在原地打转,以及怎么让它自己停下来。
前言
如果你在学习Agent或者对function感兴趣的话,一定见过这样的场景:Agent 调一次工具,结果不满意,于是又调一次同样的工具、同样的参数,结果还是一样的,可它就是不换思路,一遍遍重复,直到把 token 耗尽。
这就是 Agent 最经典的问题之一------死循环。根本在于模型是无状态 的:它不知道「我上一轮已经这么干过了、还没用」,它只会看着当前的上下文继续接龙。所以「意识到自己在重复」这件事,只能从外部注入。
这篇就讲清楚两件事:怎么检测死循环,检测到之后怎么办。核心就是三个关键词------指纹、滑动窗口、分层熔断。
一、先看清:死循环长什么样
把 Agent 的死循环归个类,最常见的是两种:
- 原地踏步型:同一个工具、同一组参数,反反复复调用,不管结果如何就是不停。
- 来回横跳型:在 A、B 两个操作之间交替,比如「读文件 → 报错 → 再读同一个文件 → 再报错」,两个动作互相把对方顶回去。
要检测这两种模式,第一步是给每一次工具调用算一个「指纹」。
二、指纹:把一次调用压成一段哈希(或者你想加密成其他的)
指纹要回答一个问题:这次调用,和之前哪次调用是「同一件事」?
思路很朴素:工具名 + 参数的规范字符串,做一次哈希:
ts
const hash = `${toolName}:${sha256(stableStringify(params)).slice(0, 16)}`
取舍点 :这里有个特别容易忽略的细节------参数必须规范化 之后再哈希。JSON 里 {a:1, b:2} 和 {b:2, a:1} 是两个不同的对象,但对 Agent 来说其实是「同一个调用」。如果直接 JSON.stringify,两个 key 顺序不同就会算出不同的指纹,导致漏检。所以要先对对象的 key 排序,再序列化,保证「语义相同 → 指纹相同」。
三、滑动窗口:只记住「最近」的历史
光有指纹还不够,还得记住历史。但不能全记------Agent 可能跑几十上百轮,全记下来既占内存,又会让「很久以前的一次重复」误触发检测。
所以用一个滑动窗口,只保留最近 30 轮(或者你自己自定义):
ts
const HISTORY_SIZE = 30
history.push({ toolName, argsHash, timestamp })
if (history.length > HISTORY_SIZE) history.shift() // 丢掉最老的
取舍点:窗口大小是个权衡。太小,检测不出「慢速重复」;太大,又容易误伤。30 轮是一个经验值------足够覆盖「同一件事反复折腾」的典型场景,又不至于把早该翻篇的历史翻出来。
四、三种检测器:重复、横跳、无进展
有了指纹和历史,就可以上检测逻辑了。这里其实是三个独立的检测器,分别对应三种「卡住」:
① 原地踏步:同一调用出现太多次。 直接数窗口里「工具名 + 参数指纹」完全相同的调用出现了几次:
ts
const recentCount = history.filter(
h => h.toolName === toolName && h.argsHash === argsHash
).length
② 来回横跳:两个操作交替。 从尾部往前找,看是不是 A、B、A、B 这样的交替模式。
③ 无进展:调用相同,结果也相同。 这是最狠的一种------不但调用一样,连结果都一样,说明这一轮是彻彻底底的白做。实现上要额外给每次调用记一个「结果指纹」,然后数「相同调用、相同结果」连续出现了几次。
取舍点 :为什么要分成三种,而不是一个「重复计数」一锅端?因为它们的危险程度不一样。「原地踏步」可能是模型在「多试几次」;「来回横跳」是思路卡死;「无进展」是结果都证明没用了还在试------最后一种最该立刻停。分开检测,才能分层响应。
五、分层响应:先劝,再停
检测到之后,不是一刀切直接停,而是分两层:
ts
const detection = detect(toolName, input)
if (detection.level === 'critical') {
shouldBreak = true // 硬熔断:停止整个循环
} else {
messages.push({ // 软提醒:注入一句提示
role: 'user',
content: '[系统提醒] 你可能陷入了重复,请换一个思路。'
})
}
具体是这样一个梯度(次数都是作者自定义的,没有一定的标准,看使用场景而论):
| 阈值 | 级别 | 动作 |
|---|---|---|
| ≥ 5 次 | warning | 注入提示,劝模型换思路 |
| ≥ 8 次 | critical | 直接熔断,停止循环 |
取舍点 :为什么「警告」是往上下文塞一句提醒,而不是直接停?因为模型是有概率性的,偶尔重复一两次未必是真卡死,硬停会误伤正常的重试。所以先给它一个自我纠偏的机会------软约束先行,硬熔断兜底。这就是给无状态函数加「负反馈」的核心:它自己根本没有「我在重复」的意识,只能靠外部提醒。
顺带一提,这个熔断只是「循环级」的一道闸。再往外,还有两道硬闸兜底:最多跑 N 轮 (超过就退),以及 token 预算(烧超了就强制停)。三层叠起来,才能保证 Agent 无论怎么使用,都不会真的失控。
结语
死循环检测,本质上是给 Agent 补上一块它天生缺失的东西------自我意识。模型不知道自己在重复,我们就用「指纹」替它记住历史,用「检测器」替它判断卡没卡,用「熔断」替它踩下刹车。