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.tsL26-28):const ESTIMATED_CHARS_PER_TOKEN = *** // 普通文本
const TOOL_RESULT_CHARS_PER_TOKEN = *** // 工具调用结果
const JSON_PAYLOAD_CHARS_PER_TOKEN = *** // JSON 载荷乘以安全边际系数
SAFETY_MARGIN = 1.2(compaction-planning.ts)。如果估算超了,就先压缩/截断再提交,不等到 provider 报错,通过预防尽量不让溢出发生。但估算有误差,预防不可能 100% 有效。如果 LLM 还是返回了 context overflow 错误,系统按渐进式降级处理:
如果 attempt 内部已经压缩过了(SDK auto-compaction),直接重试一次看看够不够 没有内部压缩 → 做一次显式压缩(contextEngine.compact()) 压缩不够 → 截断工具调用的冗余输出(oversized tool results) 都不行 → 告诉用户 “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
夜雨聆风
