我的 Agent 会绕圈吗?我造了 11 条轨迹测了一遍,前两版检测器都被自己打脸了
前面我给工具调用层加了退避重试、加了幂等键、加了工具路由。这几层都在管"这一次调用怎么发",但没人管一件事:它会不会一直在做同一件事,做到天荒地老。
我不想等它真绕圈了才发现。所以我的做法是反过来:先造一批轨迹(有正常的、有绕圈的),拿这批轨迹去打我的检测器,看它几轮之内能抓出来、又会把多少正常任务误判成死循环。
下面三个版本都是我本地真跑出来的,输出直接贴的终端。
第一版:数窗口内重复调用次数
最直觉的做法——给每次调用算个指纹(工具名 + 归一化参数),滑动窗口 8 步,同一个指纹出现 3 次就报警。
function detectV1(steps) {
const win = steps.slice(-8);
const cnt = {};
for (const c of win.map(fp)) cnt[c] = (cnt[c] || 0) + 1;
const repeat = Math.max(0, ...Object.values(cnt));
return { loop: repeat >= 3, repeat };
}跑出来的结果(11 条轨迹,5 条正常 6 条绕圈):
ID 真相 结果 报警@步/总步 判据 场景
N1 normal normal -/30 - 批量下载 30 个文件
N2 normal normal -/9 - 查资料 -> 读 3 篇 -> 改三版报告
✗ N3 normal loop 3/5 repeat-x3 fetch 重试两次后成功
N4 normal normal -/40 - 批量重命名 40 个文件
✗ N5 normal loop 3/6 repeat-x3 连续 6 次失败重试(真重试,不是死循环)
L1 loop loop 3/14 repeat-x3 同一个 search 反复调用
L2 loop loop 5/12 repeat-x3 search / open 两节点交替
L3 loop loop 5/12 repeat-x3 参数微变:query 反复加同一个词
L4 loop loop 5/12 repeat-x3 写入-回读震荡:每次写回同样内容
L5 loop loop 7/12 repeat-x3 三节点周期:读 -> 改成 X -> 改回空
L6 loop loop 5/16 repeat-x3 四节点周期:翻页 1-2-3-1-2-3
=> TP=6 FP=2 TN=3 FN=0 召回=100% 误报=40%召回 100% 看着漂亮,误报 40% 直接没法用。N3 和 N5 都是重试——调同一个接口、同样的参数,撞了 429 / 503,重试是完全正确的行为。我把合法重试判成死循环然后熔断,等于把系统的容错能力给干掉了。
而且这条误报很隐蔽:N1(批量下载 30 个文件)没被误报,是因为 30 个 URL 各不相同、指纹全不一样。换成"批量下载同一个 URL 的 30 个分片"或者"分页拉取 page=1..30 但参数归一化后只剩页码",v1 照样炸。
第二版:加上「世界状态有没有动」
所以关键不是"调了几次",而是调完之后世界有没有变化。我加了个世界状态:写过的文件(路径 + 内容哈希)、已处理过的 id 集合,每步算一个签名。窗口内签名全程不变才算原地踏步。
跑了之后,两个误报没了,但漏了一个:
N3 normal normal -/5 fetch 重试两次后成功
N5 normal normal -/6 连续 6 次失败重试
✗ L5 loop normal -/12 三节点周期:读 -> 改成 X -> 改回空
=> TP=5 FP=1 TN=4 FN=1 召回=83% 误报=20%L5 是我特意造的:读文件 → 把它改成 X → 再把它改回空。每次调用都成功,世界每次都变了,但它变回去了。我的"状态完全不变"判据根本抓不到这种。
这个场景一点都不玄学:Agent 觉得配置文件格式不对,改一版;读回来发现 lint 还是报错;又改回原来的样子;再读……真实项目里这种来回改能绕几十轮。
顺带说一句,v2 里 N5 能被救回来,是因为我给 applyStep 加了「失败的调用不推进世界」+「窗口内全是失败就归为重试,不判死循环」。连续失败该交给退避和熔断那层管,不该混进循环检测。
最终版:调用成周期 × 状态也成周期
L5 让我改了整个思路。我不再问"状态变没变",我问:
整个系统是不是回到了之前到过的那个状态?
判据变成两条同时成立:调用序列存在周期 p,且状态序列也存在周期。这就是状态空间里绕圈的定义。
function detect(steps, opts = {}) {
const W = opts.W || 8, maxP = opts.cycleMaxP || 4;
const { calls, sigs, rawSteps } = scan(steps, W);
const callP = findCycle(calls, 1, maxP);
if (!callP) return { loop: false, reason: 'no-cycle' };
const sigP = findCycle(sigs, 1, maxP);
if (!sigP) return { loop: false, reason: 'state-advancing' };
// 连续失败 = 重试,不是死循环,交给退避/熔断模块
if (rawSteps.slice(-callP * 2).every(s => s.result !== 'ok'))
return { loop: false, reason: 'retrying' };
return { loop: true, callP, sigP, reason: `cycle call-p${callP}/state-p${sigP}` };
}findCycle 干的事是找最小周期 p,让窗口最后 2p 项等于前 p 项重复:
function findCycle(seq, minP, maxP) {
for (let p = minP; p <= maxP; p++) {
if (seq.length < p * 2) continue;
const tail = seq.slice(-p * 2);
let ok = true;
for (let i = 0; i < p; i++) if (tail[i] !== tail[i + p]) { ok = false; break; }
if (ok) return p;
}
return 0;
}L5 现在能被抓到了,因为它的状态序列是 空 → X → 空 → 空 → X → 空,自己也是个周期。而 N1 批量下载的状态序列一路单调增长,永远凑不出周期,天然安全。
ID 真相 结果 报警@步/总步 判据 场景
N1 normal normal -/30 - 批量下载 30 个文件
N2 normal normal -/9 - 查资料 -> 读 3 篇 -> 改三版报告
N3 normal normal -/5 - fetch 重试两次后成功
N4 normal normal -/40 - 批量重命名 40 个文件
N5 normal normal -/6 - 连续 6 次失败重试
L1 loop loop 2/14 cycle call-p1/state-p1 同一个 search 反复调用
L2 loop loop 4/12 cycle call-p2/state-p1 search / open 两节点交替
L3 loop loop 4/12 cycle call-p2/state-p1 参数微变:query 反复加同一个词
L4 loop loop 4/12 cycle call-p2/state-p1 写入-回读震荡
L5 loop loop 7/12 cycle call-p3/state-p1 三节点周期:改了又改回去
L6 loop loop 5/16 cycle call-p1/state-p1 四节点周期:翻页 1-2-3-1
=> TP=6 FP=0 TN=5 FN=0 召回=100% 误报=0%11 条全对。最绕的 L5(三节点)第 7 步才报,其余都在第 2~5 步就报警——基本是两个周期刚走完就认出来,这个时机是我想要的。
三个版本放一起:
| 版本 | 判据 | 召回 | 误报 | |
|---|---|---|---|---|
| v1 | 窗口内同指纹 ≥3 次 | 100% | 40% | |
| v2 | 重复\ | 周期 + 状态完全不变 | 83% | 20% |
| v3 | 调用成周期 × 状态成周期 | 100% | 0% |
抓到之后别一刀切
我一开始想的是"检测到就终止"。试了一下觉得太粗暴——两个周期刚走完就 kill,很可能把一个本来能自己拐出来的 Agent 直接掐死。我现在是三档:
- nudge:第一次命中,往上下文里塞一句「你已经用同样的方式试了 N 次,换个做法」,继续跑
- 禁工具:再命中,把周期里涉及的那个工具临时禁掉 3 步,逼它换路子
- 熔断:第三次还命中,才真终止
收益有多少我做了个蒙特卡洛。这里要说实话:检测器那部分是真跑的,但"LLM 会不会听 nudge 换动作"我没法真测,只能给个概率假设跑模拟。 所以下面的数字只说明机制量级,别当线上指标看:
--- nudge 生效率 0.6 / 禁工具生效率 0.85 ---
ID 不干预步数 干预后平均步数 节省 收敛方式分布
L1 40 2.4 94% nudge:1216 ban:677 break:107
L2 40 4.4 89% nudge:1216 ban:671 break:113
L3 40 4.5 89% nudge:1151 ban:728 break:121
L4 40 4.4 89% nudge:1237 ban:633 break:130
L5 40 7.4 82% nudge:1251 ban:641 break:108
合计 200 -> 23.1 步,上下文 token(估算模型)2905k -> 65k,省 98%就算把 nudge 生效率压到 0.3(LLM 基本不听劝),平均步数也只从 23 涨到 26,节省还是 87%。真正起作用的是"有个人在旁边戳它",不是哪一档具体怎么实现的。
token 那列是我的估算模型(base + k*per 线性增长,第 k 步要把整个历史重发一遍),不是实测。但结论的方向我信:死循环的成本是平方级的,不是线性——每多绕一圈,前面所有上下文都要再付一次钱。
顺手核对了一下各家框架的默认值(核对日期 2026-10-06)
我去翻了一圈,发现主流框架给的全是保险丝,不是检测器:
- OpenAI Agents SDK:
Runner.run有max_turns,超出抛agents.exceptions.MaxTurnsExceeded,传max_turns=None可关闭。我把官方 running agents 这页翻完了,没找到"默认 10"这个数字的出处——它在不少二手文章里被当默认值引用,但官方这页只写了怎么关闭、没写默认是多少。别照抄 - LangGraph:superstep 超限抛
GRAPH_RECURSION_LIMIT,recursion_limit要自己传 - LangChain
AgentExecutor:max_iterations默认 15、设成None可能无限循环——这条我是从二手文章看的,官方那页我这轮没翻到,按未确认处理 - Claude Agent SDK:
maxTurns/maxBudgetUsd默认 no limit,超限返回 subtypeerror_max_turns/error_max_budget_usd——同样来自二手来源,未确认
四条里两条我没亲自核实,我就不硬装作核实过了。要落到生产上,这两条你自己去翻一遍官方文档。
保险丝和检测器是两回事。保险丝只告诉你"烧到 40 步了",它既不知道你是在绕圈还是在干正事,也不知道该在哪一步停。我的检测器是在保险丝之前多给一层判断,两者不冲突。
我的判断
- 别用"调用次数"当主判据。 我从 v1 走到 v3,最大的收获是把问题从「它重复了吗」换成「系统回到老地方了吗」。前者是个计数问题,后者是个状态空间问题,后者才是对的
- 写操作尤其要看内容,不能只看有没有写。 L4、L5 这两种震荡,光看"这步是 write"完全看不出来,得比对写进去的内容跟原来是否一样。我现在的世界状态签名里带的是文件内容哈希,不是"写过没有"
- 重试和死循环必须分开管。 我在这上面栽了一次(v1 的 40% 误报)。失败重试是协议内行为,归退避和熔断;循环检测只管"成功了但没进展"。两者判据完全不同,混在一起必然互相污染
- 窗口 8 步、周期上限 4 是我拍的,按我自己 Agent 的步数规模调的。你工具多、单步粒度粗,窗口得往大调;周期上限 4 意味着我放弃抓"5 步以上才闭合的长循环"——那种我靠
max_turns兜 - 这层大概 60 行,零依赖,我觉得值得写。 它防的不是"Agent 变傻了",是"Agent 很努力地在做一件已经做过的事"。这类失败不会报错、不会崩,只会在半夜默默烧完你的额度
实验脚本在 loop-core.js / loop-demo.js / loop-intervene.js,11 条轨迹和三个版本的检测器都在里面,可以直接 node loop-demo.js 复现上面所有数字。
(文中检测器输出、混淆矩阵为本地 Node 22 实跑结果;干预收益为概率假设下的蒙特卡洛模拟;框架默认值核对日期 2026-10-06,以官方文档为准。)