我的 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 直接掐死。我现在是三档:

  1. nudge:第一次命中,往上下文里塞一句「你已经用同样的方式试了 N 次,换个做法」,继续跑
  2. 禁工具:再命中,把周期里涉及的那个工具临时禁掉 3 步,逼它换路子
  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,超限返回 subtype error_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,以官方文档为准。)

添加新评论

文章目录