OpenClaw Agent 执行引擎的 10 级自愈: 从 Auth 失败到崩溃恢复的源码拆解
#AI Agent#OpenClaw#架构解析#稳定性工程#自愈系统
上一篇文章拆解了 OpenClaw 的 Gateway 架构和 Agent 运行循环。但一个 Agent 能跑起来只是及格线——真正考验工程功底的,是它在各种故障下怎么活下来。本文从源码出发,拆解 OpenClaw 的 10 级自愈体系:从 Auth 失败到 Crash Recovery,每一级都是一道防线。
1. 引子:Agent 不是”能跑就行”
一个模型调用的链路里,处处是坑:
-
API 密钥过期 / 限流 / 过载 → 静默失败 -
上下文窗口溢出 → 丢关键信息 -
模型返回空响应 → Agent 傻等 -
Compaction 后陷入死循环 → 烧钱
大多数 Agent 框架处理这些问题的方式是:try-catch + 重试 + 祈祷。
OpenClaw 选择了一条不同的路:10 级自愈链。每一级都是独立防线,级联协同,覆盖从 API 调用到进程崩溃的全链路。核心实现集中在 pi-embedded-runner/ 目录下——70+ 个 .ts 文件,主执行循环 run.ts 就超过 1270 行。每一行的存在都有一个答案:什么会出错?
2. 执行引擎全景:runEmbeddedPiAgent 的主循环
在深入每一级自愈之前,先看全景。整个 Agent 的运行时架构如下:

下面是 Agent 执行循环的详细骨架:

这个循环的核心哲学是:不假设任何一次调用会成功。每一次模型调用都被包裹在一个 retry loop 中,每一层自愈都在这个 loop 的特定位置发挥作用。
迭代次数上限由 resolveMaxRunRetryIterations() 动态计算:
上限 = BASE(24) + profiles_count × 8, 限制在 [32, 160]
每多配置一个 auth profile,就多 8 次尝试机会——这是一个典型的”配置换容错”设计。
3. 10 级自愈逐级拆解
L1 Auth 认证失败自愈 —”换一把钥匙”
故障场景:API Key 过期、被禁用、或临时不可用。Agent 不能直接报错——用户看到 “Authentication Error” 是最差的 UX。
设计决策:多凭证轮换(Profile Rotation)。OpenClaw 支持为每个 Agent/Provider 配置多个 auth profile,运行时自动遍历。
// pi-embedded-runner/run.ts: profileCandidates 逻辑const profileOrder = resolveAuthProfileOrder({...});const profileCandidates = lockedProfileId ? [lockedProfileId] : providerOrderedProfiles.length > 0 ? providerOrderedProfiles : [undefined];
advanceAuthProfile() 在每次认证失败后切换到下一个 profile。遇到限流/过载时,通过 markAuthProfileFailure() 标记 cooldown,isProfileInCooldown() 阻止短时间内重复尝试同一个 profile。
💡 类比:嵌入式系统里多路电源冗余——主电源故障,自动切到备用。唯一的区别是这里的”电源”是 API 密钥。
L2 Rate Limit 限流自愈 —”先等等,别硬刚”
故障场景:免费 API 层的 rate limit 非常紧(比如每分钟 3 次)。硬重试只会越试越惨。
设计决策:分级响应。旋转 profile 有一个硬上限,超过即自动降级。
// pi-embedded-runner: Rate Limit 处理逻辑rateLimitProfileRotations += 1;if (rateLimitProfileRotations <= rateLimitProfileRotationLimit || !fallbackConfigured) { return; // 继续旋转 profile}// 升级: 切换到 fallback modelthrow new FailoverError( "The AI service is temporarily rate-limited...", { reason: "rate_limit", ... });
rateLimitProfileRotationLimit 默认值为 1——意味着只给你一次换钥匙的机会。第二次撞上限流,直接跳到 L6(Failover),切换到备用模型。
💡 类比:TCP 拥塞控制——连续丢包不是让你更频繁地重传,而是让你退避。
L3 超时保护(多层)—”死等?不存在的”
故障场景:模型响应卡死、Provider 无响应、工具执行挂起。
设计决策:三层超时,层层递进。
|
|
|
|
|---|---|---|
|
|
|
|
|
|
|
|
|
|
|
|
// 关键常量定义const EMBEDDED_RUN_LANE_TIMEOUT_GRACE_MS = 30_000;// per-chat 任务超过 300s 直接驱逐
三层超时不是简单的”超了就 kill”。Lane Timeout Grace 给了 30 秒的优雅退出窗口——Agent 可以在此时做最后的一次模型调用,把当前状态整理成输出,而不是直接丢一个 [Timeout]。
💡 类比:MCU 内部 WDT + 外部 WDT。per-chat 硬上限是外部看门狗(不可绕过),空闲超时是内部看门狗(精细控制)。
L4 Token 溢出 → Compaction 自动触发 —”自动瘦身”
故障场景:长对话积累,context window 满了。模型要么拒绝处理,要么丢最老的信息(通常是 system prompt 和早期工具结果——这是最宝贵的信息)。
设计决策:三级策略,从轻到重逐级升级。
Level 1: 工具结果截断 └── MAX_TOOL_RESULT_CONTEXT_SHARE = 0.3 单个工具返回最多占上下文窗口 30% Level 2: Preemptive Compaction(预防性压缩) └── PREEMPTIVE_OVERFLOW_RATIO = 0.9 上下文使用率达到 90% 即触发 Level 3: Full Compaction(全量压缩) └── 保留 MUST PRESERVE: • system prompt(完整保留) • 最后 2 轮 tool_call/result 对 • 用户原始输入
// compaction 重试上限const MAX_OVERFLOW_COMPACTION_ATTEMPTS = 3;const MAX_TIMEOUT_COMPACTION_ATTEMPTS = 2;
这个设计的精髓在于”保留什么”:最后 2 轮 tool_call/result 对。这是模型在当前时刻最需要的上下文——它需要知道”我刚才做了什么,结果是什么”。更早的工具调用历史,对”下一步行动”的决策价值已经显著降低。
💡 类比:Linux 的 OOM Killer。内存不够不是直接崩溃,而是选最不重要的进程杀掉。
L5 空响应重试 —”喂,你倒是说话啊”
故障场景:模型返回空内容;或者只输出了一段推理文字(reasoning_only),没有任何实际 action;或者只做了计划(planning_only),没有 tool_call。
设计决策:分类 + 重试 + 注入纠正提示词。
// result-fallback-classifier.ts: 三种异常分类classifyEmbeddedPiRunResultForModelFallback(result): ├── "empty" → 完全空响应 ├── "reasoning_only" → 只有推理,没有 tool_call/文本 └── "planning_only" → 只有计划,没有行动
每种异常类型有独立的重试上限,并且重试时通过 resolveEmptyResponseRetryInstruction() 注入特定的纠正提示词——告诉模型”你刚才没有说话,请重新回答”。
💡 类比:I2C 通信中的 NACK 重试。设备没回应?不是报错,是重试。但一直 NACK 就有上限。
L6 Overload 过载保护 —”排队,别挤”
故障场景:并发请求超过 Provider 或本地系统承载能力。
设计决策:三级决策链 + Lane Queue 机制。
// command-queue.ts: Lane Queue 过载控制const DEFAULT_OVERLOAD_FAILOVER_BACKOFF_MS = 0;const DEFAULT_MAX_OVERLOAD_PROFILE_ROTATIONS = 1;
Failover Policy 的三级决策链:
rotate_profile → fallback_model → surface_error / return_error_payload ↓ ↓ ↓ 换钥匙 换模型 给用户/上级一个交代
Lane Queue 的核心逻辑:超过 maxConcurrent 时,新任务排队而非直接执行。网关关闭时通过 GatewayDrainingError 优雅拒绝新请求——不会出现”关了还在排队”的僵死状态。
💡 类比:中断优先级调度。高优先级中断抢占低优先级,每个中断都有自己的优先级上限。
L7 Circuit Breaker(空闲超时熔断)— 来自一个 $30 的教训
故障场景:(真实 Issue #76293)单次用户心跳触发了 761–1384 次付费 API 调用,60 秒内烧掉 $20–30。
这个问题的根源是:Agent 在 idle timeout 后自动重试,但模型始终没有输出任何实质性进展,导致了一个 retry → timeout → retry 的死循环。761 次调用没有一次产生有效输出,但每一笔都计费。
设计决策:连续空闲计数 + 熔断机制。
// run/idle-timeout-breaker.ts: Circuit Breaker 状态机const MAX_CONSECUTIVE_IDLE_TIMEOUTS_BEFORE_OUTPUT = 5;// 状态转移:// idleTimedOut=true, completedModelProgress=false → count += 1// completedModelProgress=true → count = 0 (重置)// count >= 5 → 熔断整个 run loop
这个设计的精妙在于“有进展就重置”。不是累计超时次数——只要模型产生过一次有效输出(哪怕只是说了一句”我还在处理”),计数器就归零。只有连续 5 次完全无输出,才触发熔断。
💡 类比:电源的过流保护。不是电流一高一毫就跳闸——是持续过流。但一旦跳闸,必须手动复位。
L8 Loop Guard(死循环检测)— Compaction 之后的最危险时刻
故障场景:(Issue #77474)Compaction 压缩历史后,模型丢失了对”我已经试过了”的认知,开始重复同一个 tool_call——形成了”调用 → 失败 → compaction → 再调用 → 再失败”的死循环。
设计决策:滑动窗口检测。
// post-compaction-loop-guard.ts: 死循环检测const DEFAULT_WINDOW_SIZE = 3;// 在 post-compaction 窗口中:// 相同 (toolName, argsHash, resultHash) // 出现 >= windowSize 次 → abort
同一 tool_call 在 compaction 后最多重试 2 次——第三次还不成功,直接中断并注入打断提示词:
"CRITICAL: tool XXX repeated N times with identical arguments and identical results within N attempts after auto-compaction."
这个提示词不是给开发者看的日志——是直接注入到模型的上下文中,告诉它”你卡住了,换个思路”。
💡 类比:TCP Fast Retransmit——同样的包丢了 3 次,不要再发这个包了,窗口砍半,换策略。
L9 工具执行超时与结果截断 —”别把整个互联网塞进上下文”
故障场景:Agent 执行 web_search 返回了 50KB 的结果,或者 cat 了一个 10MB 的日志文件。如果不加控制,会瞬间填爆 context window。
设计决策:硬截断 + 标记通知。
// tool-result-context-guard.ts: 工具结果上限const MAX_TOOL_RESULT_CONTEXT_SHARE = 0.3;const DEFAULT_MAX_LIVE_TOOL_RESULT_CHARS = 16_000;// 截断后通知模型formatContextLimitTruncationNotice() → "结果已被截断至前 N 个字符"
关键设计点:截断后通知模型,而不是静默丢弃。模型需要知道”你给我看的结果是不完整的,如果需要更多信息,请用更精确的查询重新获取”。此外还有全局标记 sessionLikelyHasOversizedToolResults()——如果该会话经常产生过大工具结果,Agent 会在 system prompt 中增加警告。
💡 类比:显示屏分辨率限制。不能在一屏显示 4K 内容——需要 scaler,并告诉 GPU”画面降级了,别按 4K 渲染”。
L10 崩溃恢复 —”死了也能拉回来”
故障场景:进程被 SIGKILL、OOM Killer 杀掉、节点意外断连。
设计决策:四层防护。
1. Session Write Lock(跨进程文件锁) └── 默认 maxHoldMs = 300s └── Watchdog 强制释放过期锁 2. Lane 重置 (resetAllLanes) └── SIGUSR1 重启后保留队列、清除执行计数器 3. Session 恢复 (diagnostic-stuck-session-recovery) └── 检测僵死 session → 清理 → 恢复可运行状态 4. 每次 Run 后清理 (runAgentCleanupStep) └── retireSessionMcpRuntimeForSessionKey() └── 清理 MCP 运行时、释放资源
// command-queue.ts: 跨进程写锁// session.writeLock.maxHoldMs: 300_000 (default)// 超时 → watchdog 强制释放// run-cleanup-timeout.ts: 每次运行后await runAgentCleanupStep(sessionKey);
💡 类比:BSP watchdog。硬件狗(跨进程锁的 watchdog)+ 软狗(Lane 重置)+ 上电复位(Session 恢复)+ 外设初始化(MCP Runtime 清理)。一模一样。嵌入式工程师看这套机制,会有一种”这不就是把 Linux 的 init 流程搬到了 JS 上吗”的亲切感。
4. 总结:稳定性三角 + 对工程师的启发
4.1 稳定性三角
把 10 级自愈映射到一个三维坐标系,你会发现它们构成了一个”稳定性三角”:

- 容错性
(L1/L2/L4/L5/L9):出错后自动换路走 - 可观测性
(L3/L6/L10):出问题要知道,要有超时和监控 - 弹性恢复
(L7/L8/L10):出大问题要从最坏状态拉回来
三角形里最容易被忽略的是可观测性。很多 Agent 框架实现了容错(try-catch + 重试),但没有可观测性(超时设置、熔断计数、状态标记)——结果就是容错机制本身成了新的故障源(如 L7 的 Circuit Breaker 要防御的那样)。
4.2 三点可以带走的工程实践
回顾整个 10 级自愈体系,可以看到三条通用的稳定性工程原则:
- 永远不假设成功
:每一条 API 调用路径都预设它会失败,然后为失败设计降级路径 - 分层设防
:不是最严密的层在最外层。从轻到重:重试 → 换路 → 降级 → 熔断 → 恢复 - 可观测性优先于恢复
:不知道哪里坏了,就不可能修好。日志、计数、状态机——这些不是负担,是基础设施
这些原则不限于 Agent 框架。任何需要在高故障率环境下长时间运行的软件系统——分布式服务、边缘设备、甚至你手上的 CI/CD pipeline——都能从这套”预设失败”的设计哲学中受益。
本文基于 OpenClaw 源码分析。所有代码引用来自 pi-embedded-runner/ 目录,分析日期 2026-07-18。
系列第一篇:《一个 Node 进程托管 N 个 AI Agent:OpenClaw 架构硬核拆解》
夜雨聆风