Agent 为什么会陷入 Doom Loop?OpenClaw 的破解之道

首发于公众号 code进化论,欢迎关注。

前言

在介绍 Doom Loop(死循环)之前,先介绍 Harness 工程的核心发动机------Agent Loop。

在 2022 年 10 月,普林斯顿大学博士生 Shunyu Yao(在 Google 实习期间)与 Google 研究人员联合发表了预印本论文《ReAct: Synergizing Reasoning and Acting in Language Models》,这篇论文提出了一个极其优雅但影响深远的范式------ReAct。

ReAct 范式认为,一个真正的智能体,必须像人类解决问题一样,在每次行动前先思考,在每次行动后观察结果。在 Harness 中,将这套理论抽象为一个底层的循环,工程上称为 Agent Loop,整体流程如下:

在 Claude Code、OpenClaw 这些具有代表性的 Agent 中,这个 Agent Loop 的设计有几个极其鲜明的特征:

  • 极度纯粹,没有预设分支

    循环中没有业务逻辑,全凭模型决定走向。

  • 不设硬性的最大步骤限制

    传统的框架会设置 max_turns=10,但真实的工业任务可能需要 50 步或者更多。顶级 Agent 不在此处做生硬的截断,而是依赖 Context Compaction(内存压缩) 、Tool-loop detection(工具循环检测)来维持稳定。

  • 上下文(Context)是唯一的记忆载体

    在这个循环中,数据会像滚雪球一样不断累加,记录下每一次的思考、动作和观察结果。

但也正因如此衍生出了一个新的问题------Doom Loop(死循环)。

什么是 Doom Loop?

在前端开发中,可能会经常遇到这种报错:

jsx 复制代码
Cannot read properties of undefined

我们很快发现变量 userundefined,接下来一个小时里,我们不断沿着数据链路排查:

jsx 复制代码
Props
↓
Context
↓
Store
↓
API Response

每次排查都指向同一个结论user===undefined,由于始终没有跳出当前思路,一直在做无意义的尝试,消耗了大量的时间和人力。最后才发现,真正的问题只是组件挂载时机异常,数据尚未加载完成。

Agent 在执行复杂任务时,也会陷入类似的状态,即 Doom Loop(死循环) ,它指的是 Agent 持续执行任务,却始终无法获得有效进展,最终陷入无限重复的状态。

从外部看,Agent 一直在工作:

erlang 复制代码
Thinking...
Acting...
Thinking...
Acting...

但从任务视角看:

复制代码
任务进度 = 0

Agent 的行为不断重复,却没有产生新的信息、新的认知或新的结果。

哪些场景会触发 Doom Loop?

容错策略本身成为循环来源

在生产环境,Agent 运行常常会遇到令牌会过期、上下文会溢出、API 会限流、服务会过载等问题。为了让任务在这些异常下仍能继续推进,OpenClaw 在 Agent Loop 内嵌了一套容错策略------当检测到特定错误信号时,自动触发对应的恢复动作,然后重试当前轮次,而不是直接失败退出。

例如当 Token 超限时,系统触发 contextEngine.compact() 对历史进行摘要压缩,然后重试。

这套机制在正常情况下是有益的,但如果错误本身是不可恢复的,容错重试就会变成驱动 Doom Loop 的引擎------每一次恢复都只是在原地打转,消耗资源而不产生进展。没有上限的容错,本质上就是另一种 Doom Loop。

工具调用

工具调用是 Doom Loop 最直接的来源,Agent 在 ReAct 循环中依赖工具与外部环境交互,一旦工具调用陷入重复,整个循环便失去推进力,OpenClaw 归纳了以下四种常见场景:

  • 相同调用,相同结果

    Agent 以完全相同的参数反复调用同一个工具,每次得到相同的返回,却始终无法从中提取新信息。模型在下一轮 Thinking 时依据相同的观察再次做出相同的决策------形成严格意义上的确定性死循环。

  • 调用根本不存在的工具。

    模型幻觉出一个工具名,每次调用都返回"工具不存在"的错误,但模型仍将其视为"可能是暂时不可用"而持续重试。这类错误无法通过重试解决,每一次调用都是无效消耗。

  • 轮询工具卡死。

    部分工具天然具有轮询语义------检查任务状态、等待进程结束。Agent 反复调用这类工具,每次得到的都是 { status: "running" },因为它在等一个永远不会发生的事件------被轮询的进程已经死锁或僵尸化,状态不会再变化。Agent 自己并不知道该何时放弃,只能继续轮询。

  • 两个操作交替振荡(Ping-Pong)

    Agent 同时执行两个互相依赖但永远无法同时满足的操作,在二者之间反复切换。例如:读文件时发现文件不存在,转而创建文件;创建文件时因权限不足失败,转而重新读文件......如此 A→B→A→B 无限交替,每一侧都在"工作",但整体任务毫无推进。

这四种场景有一个共同特征:工具在执行,但信息熵没有增加,每次调用带来的新信息量为零,Agent 的认知状态没有任何改变。

如何避免无限循环?

重试端:断路器

OpenClaw 的容错重试逻辑集中在 src/agents/embedded-agent-runner/run.ts 的主循环中。每种容错策略都内置了明确的终止条件------这是断路器模式的体现,下面列举了两个典型的断路器。

  • 上下文压缩熔断

    jsx 复制代码
    // run.ts:1145
    const MAX_OVERFLOW_COMPACTION_ATTEMPTS = 3;
    
    // run.ts:2044-2048
    if (
      !isCompactionFailure &&
      hadAttemptLevelCompaction &&
      overflowCompactionAttempts < MAX_OVERFLOW_COMPACTION_ATTEMPTS
    ) {
      overflowCompactionAttempts++;
      // 继续重试,但不再额外触发压缩
    }

    每次上下文溢出时触发一次压缩,最多允许压缩 3 次,防止无限压缩导致信息损失过多。

  • 总重试次数熔断

    jsx 复制代码
    // run.ts:1146-1150
    const MAX_RUN_LOOP_ITERATIONS = resolveMaxRunRetryIterations(
      profileCandidates.length,
      params.config,
      sessionAgentId,
    );
    
    // run.ts:1417-1420
    if (runLoopIterations >= MAX_RUN_LOOP_ITERATIONS) {
      const message =
        `Exceeded retry limit after ${runLoopIterations} attempts ` +
        `(max=${MAX_RUN_LOOP_ITERATIONS}).`;
    }

    resolveMaxRunRetryIterations 根据候选模型数量动态计算上限,范围在 32--160 次之间。无论是网络错误、模型拒绝还是工具失败,所有重试路径共用同一个计数器,超出即终止整个运行。

工具循环检测

基础原语:哈希指纹 + 滑动窗口

OpenClaw 所有检测器都建立在两个共同的基础之上:把工具调用转化为可比对的指纹,以及在固定长度的历史窗口中积累记录。

工具调用哈希
tsx 复制代码
// 生成工具调用的唯一指纹:工具名 + 参数哈希
// 相同的工具名和参数始终产生相同的字符串,用于后续比对是否重复调用
export function hashToolCall(toolName: string, params: unknown): string {
  return `${toolName}:${digestStable(params)}`;
}

// 对任意值做确定性哈希:先用 stableStringify 保证对象字段排序一致,
// 再用 SHA-256 压缩为固定长度的十六进制字符串
function digestStable(value: unknown): string {
  const serialized = stableStringify(value);           // 确定性 JSON 序列化
  return createHash("sha256").update(serialized).digest("hex");
}
滑动窗口记录
tsx 复制代码
// 每次工具调用后追加记录
state.toolCallHistory.push({
  toolName,
  argsHash: hashToolCall(toolName, params),
  toolCallId,
  timestamp: Date.now(),
});

// 超出窗口大小时从头部裁剪,默认保留最近 30 条
if (state.toolCallHistory.length > resolvedConfig.historySize) {
  state.toolCallHistory.splice(0, state.toolCallHistory.length - resolvedConfig.historySize);
}

两级告警机制

检测器触发后不是直接终止,而是区分 warningcritical 两级:

  • warning(≥ 10 次):向 Agent 返回提示消息,给它一次自主纠正的机会------增加等待间隔、换个策略或主动放弃任务
  • critical(≥ 20 次):忽略 Agent 的判断,直接终止会话

这个设计比硬断更符合 Agent 的自主决策模式。unknown_tool_repeat 是个例外------调用不存在的工具没有纠正余地,≥ 10 次直接 critical,不经过 warning。

四种检测器

调用不存在的工具

从错误文本中用正则提取工具名,追踪连续重试次数:

tsx 复制代码
// tool-loop-detection.ts:183-188
const match =
  raw.match(/unknown tool[:\\s]+["']?([a-z0-9_.-]+)["']?/i) ??
  raw.match(/tool\\s+["']?([a-z0-9_.-]+)["']?\\s+(?:not found|is not available)/i);

连续调用同一个不存在的工具 ≥ 10 次,直接 critical,不经过 warning 阶段。

tsx 复制代码
if (unknownToolStreak.count >= resolvedConfig.unknownToolThreshold) {
  return {
    stuck: true,
    level: "critical",
    detector: "unknown_tool_repeat",
    count: unknownToolStreak.count,
    message: `CRITICAL: attempted unavailable tool ${unknownToolStreak.unknownToolName} ...`,
  };
}
轮询工具无进展

轮询工具由 isKnownPollToolCall 标识

tsx 复制代码
// tool-loop-detection.ts:140-149
function isKnownPollToolCall(toolName: string, params: unknown): boolean {
  if (toolName === "command_status") return true;
  if (toolName !== "process" || !isPlainObject(params)) return false;
  const action = params.action;
  return action === "poll" || action === "log";
}

无进展的判定依赖结果哈希,以 process.poll 为例,只要 status / exitCode / aggregated 任一字段变化,哈希就会不同,计数重置:

tsx 复制代码
if (action === "poll") {
  return {
    resultHash: digestStable({
      action,
      status: details.status,
      exitCode: details.exitCode ?? null,
      aggregated: details.aggregated ?? null,
      text,
    }),
  };
}

触发阈值遵循两级机制,warning 阶段返回的消息会主动提示 Agent 操作方向。

tsx 复制代码
// critical:≥ 20 次,直接终止会话
if (knownPollTool && noProgressStreak >= resolvedConfig.criticalThreshold) {
  return {
    stuck: true,
    level: "critical",
    detector: "known_poll_no_progress",
    message: `CRITICAL: Called ${toolName} with identical arguments and no progress ${noProgressStreak} times. This appears to be a stuck polling loop. Session execution blocked to prevent resource waste.`,
  };
}

// warning:≥ 10 次,向 Agent 发出警告并建议操作
if (knownPollTool && noProgressStreak >= resolvedConfig.warningThreshold) {
  return {
    stuck: true,
    level: "warning",
    detector: "known_poll_no_progress",
    message: `WARNING: You have called ${toolName} ${noProgressStreak} times with identical arguments and no progress. Stop polling and either (1) increase wait time between checks, or (2) report the task as failed if the process is stuck.`,
  };
}
通用重复调用

非轮询工具的通用检测策略更为保守:warning 阶段只看调用次数recentCount,critical 阶段要求同时满足同参数 + 同结果的无进展条件,以避免误杀合理的重试行为。

tsx 复制代码
// warning: 同参数调用次数达到阈值
if (!knownPollTool && recentCount >= resolvedConfig.warningThreshold) {
  return { stuck: true, level: "warning", detector: "generic_repeat", ... };
}

// critical: 同参数 + 同结果,无进展次数达到阈值
if (!knownPollTool && noProgressStreak >= resolvedConfig.criticalThreshold) {
  return { stuck: true, level: "critical", detector: "generic_repeat", ... };
}
两种调用交替振荡(ping_pong)

getPingPongStreak 在历史中寻找 A-B-A-B 交替序列,触发 critical 的条件更严格------必须同时满足交替次数 ≥ 20 两侧结果哈希均保持稳定,避免正常的两步操作被误判。

tsx 复制代码
// tool-loop-detection.ts:538-555
if (
  resolvedConfig.detectors.pingPong &&
  pingPong.count >= resolvedConfig.criticalThreshold &&
  pingPong.noProgressEvidence   // 两侧结果均无变化
) {
  return { stuck: true, level: "critical", detector: "ping_pong", ... };
}
全局兜底

在所有专项检测器之上,任意工具无进展重复 ≥ 30 次直接 critical 阻断。

tsx 复制代码
if (noProgressStreak >= resolvedConfig.globalCircuitBreakerThreshold) {
  return {
    stuck: true,
    level: "critical",
    detector: "global_circuit_breaker",
    message: `CRITICAL: ... repeated ${noProgressStreak} times. Session execution blocked by global circuit breaker ...`,
  };
}

压缩后守卫

OpenClaw 工具循环检测器默认关闭。在未开启检测的情况下,Agent 可能因轮询卡死把上下文塞满,触发一次压缩。但压缩只是把历史摘要化,并不改变 Agent 的行为意图------压缩后 Agent 会继续执行同样的工具调用,再次卡死,再次压缩,周而复始。压缩本身并没有打破循环,只是推迟了崩溃。

因此 Openclaw 在压缩成功后立刻开启一个观测窗口(默认 3 次机会),如果窗口内某个工具以完全相同的参数产出完全相同的结果,说明循环依然存在,直接终止会话。

压缩成功后,run.ts 调用 armPostCompaction() 重置观测窗口:

tsx 复制代码
// post-compaction-loop-guard.ts:57-63
const armPostCompaction = (): void => {
  state.remainingAttempts = state.windowSize; // 默认 3
  state.history = [];
};

窗口内每次工具调用完成后,observe() 检查三元组 (toolName, argsHash, resultHash) 是否完全一致:

tsx 复制代码
// post-compaction-loop-guard.ts:76-95
const matches = state.history.filter(
  (entry) =>
    entry.toolName === call.toolName &&
    entry.argsHash === call.argsHash &&
    entry.resultHash === call.resultHash,
);

if (matches.length >= state.windowSize) {
  // 压缩没能打破循环,直接终止
  return {
    shouldAbort: true,
    detector: "compaction_loop_persisted",
    message: `CRITICAL: tool ${call.toolName} repeated ${matches.length} times with identical arguments and identical results within ${state.windowSize} attempts after auto-compaction. The compaction did not break the loop. Aborting ...`,
  };
}

三元组中 resultHash 是关键------同样的参数调出同样的结果,意味着 Agent 完全没有任何进展,触发后抛出 PostCompactionLoopPersistedError,会话终止。

总结

Doom Loop 的本质是 Agent 在 ReAct 循环中失去了前进能力,但又缺乏放弃的判断------每一次重试看起来都是合理的下一步,却永远不会有结果。

OpenClaw 针对这一问题构建了两道防线,分别作用在不同阶段:

  • 重试端断路器

    容错系统自身必须有上限。上下文压缩最多触发 3 次,总重试次数硬限 32--160 次,任何路径下的错误都在这个计数器的约束之内。没有上限的容错是危险的------它本身就会成为 Doom Loop。

  • 工具循环检测

    在每次工具调用前,主循环检测器通过滑动窗口识别四类卡死模式:反复调用不存在的工具、等待永远不会完成的事件、同一三元组重复出现、两个工具互相依赖形成乒乓。压缩后还有独立的守卫,专门拦截压缩没有打破循环的场景。

两道防线的设计取向一致:主动识别零进展状态,果断终止,而不是寄希望于 Agent 自己意识到问题。这是构建可靠 Agent 系统的核心原则之一。

参考来源

相关推荐
飞哥数智坊1 小时前
动动嘴皮子就把事干了,Mic Air + TRAE SOLO 让我越来越懒
人工智能
喜欢踢足球的老罗1 小时前
从移动开发转型 AI Agent 工程师:我做了一个开源学习系统
人工智能·学习
武汉唯众智创1 小时前
AI智能心理筛查拆解:三级漏斗式筛查算法+行业理论落地
人工智能·ai心理健康·校园心理健康·学生心理健康解决方案·校园心理健康平台·心理筛查
云天AI实战派2 小时前
AI 智能体全流程实战:从 0 搭一个门店运营助手,用 API + 工具搜索 + 编码代理做出可复现闭环
人工智能·ai·智能体
大连好光景2 小时前
BCELoss + sigmoid 换成 BCEWithLogitsLoss
人工智能·深度学习·机器学习
Hyyy2 小时前
普通前端续命周报——第2周
前端
OpenApi.cc2 小时前
神经网络结构驱动+数据结构分析
数据结构·人工智能·神经网络
向量引擎2 小时前
告别多源向量API适配噩梦:一套通用中转层的设计与实践
人工智能·gpt·aigc·agi·api调用
wuxinyan1232 小时前
工业级大模型学习之路030:Streamlit 企业级智能体前端工作台
前端·学习·streamlit·智能体