OpenClaw 源码解读——入门与破局:6从“错误分类“到“模型路由“:多 Agent 集群的容灾进化
上一篇我们记录了一场 4.5 小时空转的故障:token 配额耗尽被当普通超时,无限重试。 这一篇是它的"续集":从昨天的错误分类,到今天落地的模型故障自动切换—— 目标不再是"挡住故障",而是"故障发生时,集群自己活下去"。
一、开场:同一个故障,两种处理哲学
2026-08-19 的教训很痛:insufficient_quota 这种重试一万次也不会好的错误,被当成普通超时,4 个 Worker + Manager 空转了 4.5 小时。
当时我们做的 Quota Guard 是"防守":
确定性错误(欠费/权限/模型不存在)
→ 立即熔断 → 阻断 LLM 调用(fail-fast)
→ 任务 BLOCKED → CRITICAL 通知人工 → 人工确认后恢复
但复盘时我们意识到一个更深的问题:熔断只是"停下来",不是"继续干活"。配额耗尽时,如果集群配置了多个模型(A 欠费了还有 B、C),为什么不让它自己切过去?
于是 2026-08-20 的需求来了(admin 原话):
配置多个模型(按优先级 A → B → C):模型 A 欠费 → 自动切换到 B → B 欠费 → 切换到 C,直到有一个可用的模型继续工作;如果所有模型都欠费 → 汇总报告给 admin(哪些模型欠费、什么原因、预计恢复时间)。
这就是本篇的主角:ModelFailoverRouter(模型故障自动切换)。它和 Quota Guard 的分工一句话讲清:
QuotaGuard = 单服务熔断:确定性错误 → fail-fast 阻断空转 → 人工介入
FailoverRouter = 多模型容灾:先自动切换可用模型,全挂才上报人工
一个负责"停",一个负责"绕"。
二、昨天的地基:ErrorClassifier(错误分类)
模型路由的心脏是错误分类——不知道错误"能不能重试",就无法决定"切不切换"。昨天(2026-08-19)我们从故障里提炼出 ErrorClassifier,规则很简单:错误分三类,处理路径彻底分离。
TRANSIENT(瞬时): 429 / 5xx / timeout / 网络抖动 → 走重试链(现状不变)
DETERMINISTIC(确定): insufficient_quota / AccessDenied / ModelNotFound / 401
→ 重试无意义 → 立即熔断/切换 → 短路人工
UNKNOWN(未知): → 保守处理,走原有重试
2.1 识别规则:正则匹配错误消息
export const DETERMINISTIC_ERROR_PATTERNS = [
{ failureType: 'QUOTA_EXHAUSTED', patterns: [
/quota\s+has\s+been\s+exhausted/i,
/insufficient_quota/i,
/token[-_ ]?plan/i,
/out\s+of\s+quota/i,
]},
{ failureType: 'ACCESS_DENIED', patterns: [/* access denied / unpurchased / forbidden */]},
{ failureType: 'AUTH_INVALID', patterns: [/* invalid api key / unauthorized / 401 */]},
{ failureType: 'MODEL_NOT_FOUND', patterns: [/* model not exist / unknown model */]},
{ failureType: 'ACCOUNT_SUSPENDED', patterns: [/* account suspended / billing issue */]},
];
注意一个顺序细节:先匹配确定性签名,再匹配瞬时签名。因为 429 这类正则太宽泛,而错误消息可能同时命中多条规则(比如 "rate limit ... quota ... reset")——确定性优先,避免把"配额耗尽"误判成"限流可重试"。
2.2 恢复时间提取:一个 V8 的坑
阿里云的配额错误消息长这样:
"Your token-plan 1-week quota has been exhausted.
The quota will reset at 08-25 01:37:00 UTC."
把 08-25 01:37:00 UTC 提取出来告诉人工"什么时候恢复",比干巴巴一句"欠费了"有用得多。但这里有个 V8 的经典坑:
new Date("08-25 01:37:00 UTC")
// → 2001-08-25 01:37:00 UTC 😱 无年份日期被解析成 2001 年!
解法:先匹配 year-less 格式(MM-DD HH:mm:ss),用 Date.UTC(当前年, ...) 手工构造;如果构造出的时间已经过去,自动 +1 年(配额按周循环,下一年才会再匹配到同一日期):
const yearless = raw.match(/^(\d{2})-(\d{2})\s+(\d{2}:\d{2}:\d{2})\s*([A-Za-z]+)?$/);
if (yearless) {
const candidate = new Date(Date.UTC(now.getUTCFullYear(), month - 1, day, h, m, s));
if (candidate.getTime() < now.getTime()) {
candidate.setUTCFullYear(now.getUTCFullYear() + 1); // 已过期 → 下一年
}
return candidate;
}
2.3 一句话 API
class ErrorClassifier {
classify(error): { errorClass, failureType, estimatedRecoveryAt? }
isRetryable(error): boolean // DETERMINISTIC 之外的都可重试
}
分类器 26 个测试全绿。它是后面所有容灾逻辑的"裁判"。
三、今天的核心:ModelFailoverRouter(模型故障自动切换)
3.1 需求与一个"狡猾"的真实场景
需求本身很直接:模型按优先级排列,A 挂了切 B,B 挂了切 C,全挂出汇总报告。
但 2026-08-20 17:59 用户补了一个关键信息,直接改变设计:
阿里云 token-plan 欠费后,控制台模型状态仍显示"生效中"(按周结算,到点才转"欠费"),但请求已全部超时(request timed out)。
也就是说:欠费可能表现为持续超时,而不是 insufficient_quota!
如果只按错误类型切换,这类"隐身欠费"根本触发不了切换——错误是 request timed out,被分类成 TRANSIENT,走重试链,又回到 4.5 小时空转的老路。
于是有了 v2 设计:连续瞬时错误熔断(CONSECUTIVE_TIMEOUT)
同一模型连续 N 次瞬时错误(默认 3 次,可配 timeoutSwitchThreshold)
→ 判定"疑似不可用" → 标记 CONSECUTIVE_TIMEOUT → 自动切换
→ 避免把"已欠费的模型"当网络抖动反复重试
3.2 核心逻辑:executeWithFailover
async executeWithFailover<T>(
call: (modelId: string) => Promise<T>,
onSwitch?: (from, to, reason) => void,
): Promise<FailoverResult<T>> {
const candidates = this.getAvailableModels(); // 按优先级,跳过已标记不可用的
for (const model of candidates) {
try {
const result = await this.withAttemptTimeout(model.id, call); // 单次超时包裹
this.transientFailures.delete(model.id); // 成功 → 清零连续失败计数
return { ok: true, result, modelId: model.id, switched: attempts.length > 1, attempts };
} catch (err) {
const classification = this.classifier.classify(err);
// A. 确定性模型/账号级故障 → 立即切换(欠费/权限/模型不存在)
if (this.shouldSwitch(classification)) {
this.markUnavailable(model, classification, message);
continue; // 切下一个
}
// B. 瞬时错误 → 连续计数,达到阈值判定疑似不可用并切换
if (classification.errorClass === ErrorClass.TRANSIENT) {
const count = (this.transientFailures.get(model.id) ?? 0) + 1;
if (count >= this.timeoutSwitchThreshold) {
this.markUnavailable(model, {...classification, failureType: `CONSECUTIVE_TIMEOUT×${count}`}, ...);
continue;
}
}
// 其他情况(瞬时未达阈值 / 未知)→ 不切换,原样抛出(上层重试)
throw err;
}
}
// 全部不可用 → 汇总报告
return { ok: false, attempts, allUnavailableReport: this.buildAllUnavailableReport() };
}
两个关键设计点:
① 只有"换模型就能解决"的确定性错误才切换
const SWITCHABLE_FAILURE_TYPES = new Set([
'QUOTA_EXHAUSTED', 'ACCESS_DENIED', 'AUTH_INVALID',
'MODEL_NOT_FOUND', 'ACCOUNT_SUSPENDED',
]);
注意:不是所有 DETERMINISTIC 错误都切换。比如某个错误是"任务参数非法"(也是确定性),换了模型照样失败——切了白切。shouldSwitch() 同时检查 errorClass === DETERMINISTIC 和 failureType ∈ SWITCHABLE_FAILURE_TYPES。
② 单次尝试超时(attemptTimeoutMs,默认 5 分钟)
OpenClaw run 级兜底超时是 30 分钟(timeoutSeconds=1800)。如果放任不管,一个坏模型能让用户等半小时。所以每次调用包一层 Promise.race 超时:
private async withAttemptTimeout(modelId, call) {
return await Promise.race([
call(modelId),
new Promise((_, reject) => setTimeout(() => reject(
new Error(`[ModelFailoverRouter] ${modelId} attempt timed out after ${this.attemptTimeoutMs}ms`)
), this.attemptTimeoutMs)),
]);
}
超时按 1 次瞬时失败计数 → 3 次后切换 → 最多 15 分钟出结果(而不是 30 分钟 × 无限重试)。超时用的 timer 记得 unref(),防止 timer 阻止进程退出。
3.3 全挂时的汇总报告(给 admin 看的东西)
[ClawForge ModelFailoverRouter] 所有模型均不可用,已暂停 LLM 调用,请人工处理:
❌ MiniMax-M3 (minimax) — QUOTA_EXHAUSTED — Your token-plan 1-week quota
has been exhausted... — 预计恢复: 2026-08-25T01:37:00.000Z
❌ qwen3.8-max (alibaba) — CONSECUTIVE_TIMEOUT×3 — request timed out
(连续 3 次瞬时错误,疑似欠费/不可用)— 预计恢复: 未知(需人工确认)
处理建议:充值 / 更换 API Key / 在配置中加入新的可用模型,然后调用
markAvailable() 或 probeRecovery() 恢复。
每个模型一行:哪个模型、什么故障类型、原始错误、预计恢复时间。恢复时间来自 2.2 的提取逻辑——欠费类错误直接告诉 admin"8/25 01:37 才恢复",比"请处理"有信息量得多。
3.4 恢复机制:手动 + 自动探测
// 手动恢复(充值完成后)
router.markAvailable('MiniMax-M3');
// 自动探测恢复:未到预计恢复时间的跳过;探测成功的自动恢复
const recovered = await router.probeRecovery(async (modelId) => {
await callLLM(modelId, { maxTokens: 1 }); // 轻量探测,1 个 token
});
probeRecovery 有个细节:如果错误消息里带预计恢复时间且还没到,直接跳过探测——别拿探测请求去骚扰一个注定失败的模型。
3.5 测试:15 个用例覆盖全部分支
✅ 主模型成功 → 不切换 ✅ 主模型确定性失败 → 切备用模型 ✅ 主模型连续 3 次超时 → CONSECUTIVE_TIMEOUT 切换 ✅ 瞬时错误未达阈值 → 原样抛出(不切换) ✅ 全部不可用 → allUnavailableReport 含每个模型的故障明细 ✅ markAvailable / probeRecovery 恢复路径 ✅ attemptTimeoutMs 超时按瞬时失败计数 ✅ 空模型列表 → 构造抛错 ...
本次改动 9 个新测试,全量 211 个测试全绿,git 提交 d2036dc / 52bf725 / fede7ee / 90ad2c9。
四、落地:把路由接进真实的 AgentTeams
代码全绿只是第一步。把模型路由接到 AgentTeams 集群,又踩了三个"配置真相源"的坑——每一个都是"改完重启就被打回原形"。
坑 1:Manager 反复回退,真凶是 Controller 的 CR
改 Manager 模型 → 重启 → 回退成旧模型。改文件、改 MinIO、重建容器,全挡不住。
排查到最后发现:Controller 的 Manager CR(SQLite)里存着旧模型,容器/gateway 重启时 Controller 重新生成旧配置推 MinIO。文件/MinIO 都是"下游产物",CR 才是真相源。
# 正解:直接改 CR
agt update manager --name default --model MiniMax-M3
改完 Controller 自动重新生成配置 → 推送 MinIO → Manager 重启生效。
坑 2:worker 的 file-sync 是 local-first
agt update worker 不重建容器,而 worker 的 file-sync 合并策略是 local-first:本地 primary 配置不会被 MinIO 覆盖。所以改完 MinIO 上 worker 的配置,本地还是旧的。
解法:agt update worker 之外,还要手动改 worker 本地 openclaw.json 的 primary 模型。
坑 3:Manager 有 5 分钟 fallback pull
Manager 有个 start-mc-mirror.sh 兜底任务:每 5 分钟把 MinIO 的旧配置拉回本地覆盖!所以必须三步齐改,缺一步都会被 5 分钟后的 fallback 覆盖回去:
# 1. 本地配置
# 2. MinIO(mc cp)
# 3. agentteams-manager.env 的 AGENTTEAMS_DEFAULT_MODEL
这套操作最终沉淀成了 knowledge/model-switch-guide-2026-08-20.md 操作手册。
五、环境治理:版本不一致,让"分析-修复"闭环失效
模型路由落地后,我们回头处理了一个一直存在的隐患:AgentTeams 环境里 OpenClaw 版本不一致。
翻 worker 的会话记录,实锤了问题机制:
任务 spec 引用 /host-share/openclaw-main/...(2026.7.2 源码,分支 test-pr-workflow)
↓
但 worker 容器没挂 /host-share(只有 manager 挂了)
↓
worker 退回分析 /opt/openclaw(2026.4.14)
↓
issue 描述的符号(currentTurnFence / commitTurn / transcriptRecorder)
在 2026.4.14 里 0 匹配
↓
worker 只能写"推测 issue 在描述一个尚未实现的协议"——分析跑偏
当时的版本矩阵:
| 位置 | 版本 | 说明 |
|---|---|---|
AgentTeams 容器 /opt/openclaw | 2026.4.14 | worker 实际分析/运行的环境 |
| 宿主 npm 全局包 | 2026.4.29 | 我自己的运行环境 |
| 宿主源码 openclaw-main | 2026.7.2 | 研究用源码 |
最小改动方案(用户拍板):把 Windows 侧 /host-share/openclaw-main/... 的源码(2026.7.2,且不完整——只有 apps/ 结构、36M)替换成与容器一致的 2026.4.14。这样 spec 引用的源码 = worker 实际分析的源码,闭环恢复。
从容器直接导出(docker exec tar 排除 node_modules/dist,198M/18996 文件),关键文件 md5 与容器逐一比对一致。中间还踩了 Windows 侧 root 属主权限的坑(PowerShell icacls /grant 'qinyi:(OI)(CI)F' /T 修复)和 NTFS 大小写不敏感的文件冲突坑。
六、收尾:这一轮的"进化"清单
| 层 | 2026-08-19(上一篇) | 2026-08-20(本篇) |
|---|---|---|
| 错误认知 | 确定性错误要短路人工 | 确定性错误要自动切换,全挂才人工 |
| 故障形态 | 只看 insufficient_quota | 连续超时也可能是欠费(隐身形态) |
| 恢复路径 | 人工 reset → 探针 → 恢复 | markAvailable / probeRecovery 自动恢复 |
| 配置管理 | 改文件/MinIO | 认 CR 为真相源,三步齐改 |
| 源码一致性 | — | 环境版本对齐,分析-修复闭环 |
最深的感悟:容灾设计里最难的往往不是"识别故障",而是"故障的形态比你想的多"。昨天的教训是"确定性错误被当瞬时错误重试",今天的新教训是"瞬时错误的表象下可能藏着确定性故障"(欠费 → 超时)。所以模型路由不能只信错误分类,还要有"连续失败阈值"这种行为级兜底——不看错误说什么,看它连续失败多少次。
下一篇预告:模型路由与 Quota Guard 如何作为 OpenClaw Plugin 同时挂进 Worker 执行链路,以及 Manager 侧任务编排(BLOCKED 状态转换 + 人工通知投递)的联动设计。
本文为《OpenClaw 源码解读》系列第 24 篇 · 实战篇 配套代码:C:\Users\ThinkPad\clawforge\src\harness\error-classifier.ts、model-failover-router.ts 上一篇:从 4.5 小时空转故障到 Quota Guard 插件
更多推荐

所有评论(0)