上一篇我们记录了一场 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 === DETERMINISTICfailureType ∈ 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/openclaw2026.4.14worker 实际分析/运行的环境
宿主 npm 全局包2026.4.29我自己的运行环境
宿主源码 openclaw-main2026.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.tsmodel-failover-router.ts 上一篇:从 4.5 小时空转故障到 Quota Guard 插件

Logo

欢迎加入 MCP 技术社区!与志同道合者携手前行,一同解锁 MCP 技术的无限可能!

更多推荐