首发于公众号 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
我们很快发现变量 user 是 undefined,接下来一个小时里,我们不断沿着数据链路排查:
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);
}
两级告警机制
检测器触发后不是直接终止,而是区分 warning 和 critical 两级:
- 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 系统的核心原则之一。
参考来源
- 工具循环检测:docs.openclaw.ai/tools/loop-...
- Agent loop:docs.openclaw.ai/concepts/ag...
- Openclaw源码:github.com/openclaw/op...