乐于分享
好东西不私藏

OpenClaw Agent 执行引擎的 10 级自愈: 从 Auth 失败到崩溃恢复的源码拆解

OpenClaw Agent 执行引擎的 10 级自愈: 从 Auth 失败到崩溃恢复的源码拆解

Carapace Huang · 2026-07-18 · 预估阅读 9 分钟

#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 无响应、工具执行挂起。

设计决策:三层超时,层层递进。

超时类型
默认值
作用域
Per-chat 硬上限
300s
单个命令队列任务
Lane Timeout Grace
30s
超时后的优雅退出窗口
模型空闲超时
120s
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 级自愈体系,可以看到三条通用的稳定性工程原则:

  1. 永远不假设成功
    :每一条 API 调用路径都预设它会失败,然后为失败设计降级路径
  2. 分层设防
    :不是最严密的层在最外层。从轻到重:重试 → 换路 → 降级 → 熔断 → 恢复
  3. 可观测性优先于恢复
    :不知道哪里坏了,就不可能修好。日志、计数、状态机——这些不是负担,是基础设施

这些原则不限于 Agent 框架。任何需要在高故障率环境下长时间运行的软件系统——分布式服务、边缘设备、甚至你手上的 CI/CD pipeline——都能从这套”预设失败”的设计哲学中受益。


本文基于 OpenClaw 源码分析。所有代码引用来自 pi-embedded-runner/ 目录,分析日期 2026-07-18。

系列第一篇:《一个 Node 进程托管 N 个 AI Agent:OpenClaw 架构硬核拆解》