乐于分享
好东西不私藏

OpenClaw 对话机制源码分析(三):从一次 $20 事故看 LLM 调用的容错机制

OpenClaw 对话机制源码分析(三):从一次 $20 事故看 LLM 调用的容错机制

本系列有三篇:

  • 上篇:消息怎么找到它的会话
  • 第二篇:找到会话之后的启动准备
  • 本篇:LLM 调用时的容错管理

上一篇说到,一条消息经过路由、会话创建、bootstrap、加锁之后,终于准备好提交给 LLM。但”准备好”还没到安全跑完。

如果 Agent 运行时没有任何容错会怎样?当LLM API 超时时,系统会重试,每次重试都要发送完整的 prompt——模型计费是在input token发出时,不管模型最终有没有给出完整回复,都会收费。

Openclaw曾遇到一个问题,GitHub Issue #76293,[Bug] LLM idle timeout triggers unbounded retry loop, causing massive cost spikes。报告者描述了两次生产事故,60 秒内产生数百到上千次 API 调用:

Incident 1 — April 25, 2026: Single heartbeat fire timed out. Retry loop generated 761 API call entries within 60 seconds. All retries logged at the same minute timestamp.

Incident 2 — April 29, 2026: Single heartbeat fire timed out. Retry loop generated 1,384 API call entries within 60 seconds.

Each retry processes the full session context (input tokens), so on a large session each retry can cost $0.50-$2.00 in API charges. A 1,384-retry loop running on a moderately-sized session costs $20-30 in 60 seconds with no user awareness until billing reflects it.

问题的根因是:

Single timeout triggers a tight retry loop that fires until something external stops it. No backoff between retries. No cap on attempts.

报告者的建议是加 3-5 次重试上限 + 指数退避。最终落地的不只是简单的重试,而是一套分层保护体系,源码里记录了这个改进:

// See issue #76293 for the original report (single heartbeat fire generating
// 761-1384 paid Anthropic calls in 60 seconds, costing $20-30 per incident).

本篇文章拆下Openclaw这套体系的设计。

入口:run loop

修复 PR #76345 引入的断路器,写在了 run.ts 的 while(true) 循环里。这个循环就是 Agent 运行时的主循环——我们从这里开始。

打开 src/agents/embedded-agent-runner/run.ts,找到 L1938:

while (true) {

这个 while(true) 就是 run loop。每次迭代叫一个 attempt(尝试)——构建 prompt、调 LLM、处理返回结果。正常情况下一次 attempt 成功就退出循环,失败时 continue 进入下一轮。

Issue事故的本质就是:这个循环没有退出条件,continue 无限执行。

下面这张图是修复后的 一次 attempt的过程。

第一关:总次数兜底

循环的第一行代码(L1939):

if (runLoopIterations >= MAX_RUN_LOOP_ITERATIONS) {
    // 超过总重试上限 → 走 retry_limit 阶段的 failover decision → 终止或换模型
}
runLoopIte

MAX_RUN_LOOP_ITERATIONS 是一个动态值,跟配置的 auth profile 数量有关——有 10 个 key 的用户比只有 1 个 key 的用户,合理的总尝试次数更多。但无论如何,这是一个有限值。

这是最外层的兜底,通过定义上限,MIN_RUN_RETRY_ITERATIONS 是 32,确保循环一定会终止。

第二关:断路器(Idle Timeout Breaker)

断路器借用了电路保护的概念——家里电闸过载会自动跳闸,切断电路防止烧坏设备。这里的”过载”是连续超时的次数,”跳闸”就是终止 run loop 不再重试。

attempt 调用 LLM 返回结果后,第一个检查的就是断路器(L2359):

const breakerStep = stepIdleTimeoutBreaker(idleTimeoutBreakerState, {
    idleTimedOut,
    completedModelProgress: hasCompletedModelProgressForIdleBreaker(attempt),
    outputTokens: attemptUsage?.output,
});
if (breakerStep.tripped) {
    // "Halting further attempts to bound paid model calls. See issue #76293."
    // → 直接终止 run loop
}

这就是上述事故的直接修复。它的核心设计是区分”活动”和”进展”——模型可能在输出 token(活动),但没有产出任何有意义的结果(进展)。比如模型开始写 tool_call 的 JSON 参数({"path": "/tmp/…),产生了 output token 被计费了,但参数没写完就超时了——这是有活动但没有进展。如果把 output token 当作”正常”的信号,断路器永远不会触发。

那什么算进展?源码定义很明确:

function hasCompletedModelProgressForIdleBreaker(attempt): boolean {
    return (
        attempt.assistantTexts.some((text) => text.trim().length > 0) ||
        attempt.toolMetas.length > 0 ||
        (attempt.clientToolCalls?.length ?? 0) > 0 ||
        hasOutboundDeliveryEvidence(attempt) ||
        attempt.itemLifecycle.completedCount > 0
    );
}

完整的文本输出、完整的 tool_call、发出了消息——这些才算。源码注释也强调了这一点:

// `outputTokens` is intentionally not part of the reset condition. Some
// transports can accumulate billed output tokens from partial tool-call
// argument deltas before the model stalls; those tokens are cost, not
// completed progress, so they must not keep the breaker disarmed.

有了这个定义,计数器逻辑就很简单:

  • 每次 attempt 结束,喂给断路器两个信号:是否 idle 超时(idleTimedOut)、是否有进展(completedModelProgress)
  • idle 超时且无进展 → 计数器 +1
  • 有进展 → 计数器归零
  • 计数器达到 5(MAX_CONSECUTIVE_IDLE_TIMEOUTS_BEFORE_OUTPUT)→ 熔断,终止循环

为什么断路器状态在 while(true) 之前创建

// 在 while(true) 之前创建(L1602)
const idleTimeoutBreakerState = createIdleTimeoutBreakerState();

如果放在循环内部,每次迭代都是新的计数器,永远不会触发。源码注释:

// Cost-runaway breaker for #76293. State lives at the run-loop level
// on purpose so it survives across attempt boundaries and across
// profile/auth retries within this embedded run

换 profile、换 auth、retry——都不会重置这个计数器。只有模型真的产出了有效内容,才归零。

第三关:超时后的压缩尝试

断路器检查完了,没有熔断。代码继续往下走。接下来是一个针对超时的修复尝试(L2510-2520):

if (timedOut && !timedOutDuringCompaction && !timedOutDuringToolExecution) {
    const tokenUsedRatio = lastTurnPromptTokens / ctxInfo.tokens;
    if (tokenUsedRatio > 0.65) {
        // 压缩后 continue → 下一轮 attempt 用更短的 prompt
    }
}

逻辑是:上下文太长可能是超时的原因之一(prompt 越长,模型处理越慢)。压缩后 prompt 变短了,超时可能就不会再发生。这是一个轻量的自修复尝试,最多做 MAX_TIMEOUT_COMPACTION_ATTEMPTS 次。

第四关:提交前的预防——Preemptive Compaction

在 attempt 内部,prompt 构建完成后、提交给 LLM 之前,preemptive-compaction.ts 会先估算总 token 数是否超出上下文窗口预算。

估算用的是启发式比率(preemptive-compaction.ts L26-28):

const ESTIMATED_CHARS_PER_TOKEN = ***       // 普通文本
const TOOL_RESULT_CHARS_PER_TOKEN = ***     // 工具调用结果
const JSON_PAYLOAD_CHARS_PER_TOKEN = ***    // JSON 载荷

乘以安全边际系数 SAFETY_MARGIN = 1.2compaction-planning.ts)。如果估算超了,就先压缩/截断再提交,不等到 provider 报错,通过预防尽量不让溢出发生。

但估算有误差,预防不可能 100% 有效。如果 LLM 还是返回了 context overflow 错误,系统按渐进式降级处理:

  1. 如果 attempt 内部已经压缩过了(SDK auto-compaction),直接重试一次看看够不够
  2. 没有内部压缩 → 做一次显式压缩(contextEngine.compact())
  3. 压缩不够 → 截断工具调用的冗余输出(oversized tool results)
  4. 都不行 → 告诉用户 “Context overflow,请 /reset 或换更大窗口的模型”

逻辑是先预防,当预防不住再渐进式降级。

第五关:Prompt 阶段失败

如果 prompt 提交时就失败了(还没收到模型任何响应),系统提取失败原因(failoverReason),然后做决策:

  • rate_limit → 尝试换 profile(同 provider 的另一个 API key)
  • timeout 且 replay-safe(重放安全——这次 attempt 没有产生不可逆的副作用,比如没发出消息、没执行工具,重试不会导致重复操作)→ 可能换模型
  • auth → 尝试 refresh token,成功则重试
  • 不可重试的(billing、model_not_found)→ 直接报错

决策函数是 resolveRunFailoverDecision,一个无状态的纯函数——接受当前所有状态作为参数,返回 rotate_profile / fallback_model / surface_error 之一。

Profile Rotation 到底是什么

这里要说清楚:rotate_profile 不是换模型。OpenClaw 支持给同一个 provider 配置多个 auth profile(比如你有两个 Anthropic API key,一个用量快到限额了,可以换另一个)。rotation 只在同 provider 的 key 之间切换。

换模型(fallback_model)是更重的操作,需要配置了 fallback 才会触发,且会改变输出风格和能力范围。

第六关:Assistant 阶段失败

上一关处理的是”请求没送到”或”刚送出去就被拒”的情况——模型还没开始处理,retry 成本低(没有产出任何 token)。

可当LLM 已经开始响应了,input token 已经被计费了,但响应最终没有成功完成。这时候重试的成本更高,需要更谨慎地判断”值不值得再试”。

为什么不共用一个重试计数器?因为不同类型的失败,恢复策略完全不同:

  • rate_limit 可能几秒后自动恢复(provider 的短窗口限流),等一等就好
  • idle 超时可能是 provider 整体卡住,同模型再试一次没意义,但换 profile 或模型可能有用
  • overloaded 说明 provider 容量不足,换 profile 试试,换完一轮都不行再升级到换模型

如果这些共享一个计数器,三次 rate_limit(等几秒就能恢复)就可能耗光预算,导致后面一次 timeout 直接报错——明明换个 profile 就能解决。所以每种错误类型有自己独立的计数器和上限(源码里叫 “cap”):

  • idle 超时:同模型只重试 1 次(same_model_idle_timeout_retry,cap=1,因为同模型连续超时说明问题不在”再试一次”能解决的范围)
  • rate_limit:先看是不是短窗口限流(resolveShortWindowRateLimitRetry),是就等一下重试;不是就换 profile
  • overloaded:换 profile,但有 rotation 次数上限,超了就升级到换模型
  • 空输出 / reasoning-only:加一条 retry instruction 提示模型重新输出

Stop-Reason Recovery

在进入上面这些判断之前,还有一层标准化。LLM 返回的 stop reason 格式不统一——不同 provider、不同版本可能返回系统不认识的值(比如 Anthropic 新版本引入了 max_tokens_reached_thinking)。

attempt.stop-reason-recovery.ts 在 attempt 最外层包了一个 wrapper:把所有不认识的 stop reason 标准化成 stopReason: "error" + 错误消息。这样不管 provider 怎么变,进入 failover 决策的输入格式永远是统一的。

成功退出

如果 attempt 成功——模型返回了完整的文本或 tool_call、没有错误——循环构建响应 payload,正常返回。断路器归零,下次 run 重新开始。


走到这里,你会发现这个 while(true) 已经不是 $20 事故时那个裸循环了。每一步失败都有明确的归宿——要么自修复后重试,要么升级到更重的策略,要么终止并告知用户。没有”不确定接下来该怎么办”的状态。

这套体系回答的核心问题不是”怎么让 LLM 不出错”,而是”出错时,在可控的损失下安全走完”。

回顾整个系列三篇:第一篇解决”一条消息怎么找到它的会话”——路由和隔离;第二篇解决”找到会话后怎么准备 LLM 的输入”——Store 查询、Bootstrap、写锁;这一篇解决”调用 LLM 时出错了怎么办”——从一个 $20 的事故出发,看系统如何用分层的保护机制把损失控制在可接受的范围内。

一条消息从到达到得到回复,经过的每一步都不是”碰巧能跑”,而是有明确的设计意图。这些设计不是提前画好的蓝图,而是在真实的生产事故和边界条件中长出来的。这也是读源码最值得关注的东西——不是”它怎么写的”,而是”它为什么变成了现在这个样子”。


本文涉及的源码文件:

  • Run Loop主循环:src/agents/embedded-agent-runner/run.ts
  • 断路器:src/agents/embedded-agent-runner/run/idle-timeout-breaker.ts
  • 预防性压缩:src/agents/embedded-agent-runner/run/preemptive-compaction.ts
  • Failover 决策:src/agents/embedded-agent-runner/run/failover-policy.ts
  • Stop-Reason Recovery:src/agents/embedded-agent-runner/run/attempt.stop-reason-recovery.ts

参考:GitHub Issue #76293 / PR #76345