Day 07 / 共 20 天 · 第 2 周 Agent 大脑

模型故障转移(Failover)

昨天知道了大脑靠内嵌 Pi 来调模型。可"调模型"随时会失败——限流、key 用尽、token 过期。今天讲 OpenClaw 怎么"永不掉线":README 那句"Auth profile rotation (OAuth vs API keys) + fallbacks",在代码里是两层嵌套循环:外层逐个换模型、内层逐个换鉴权 profile。为明天讲执行循环里"上下文超长自动压缩"这类恢复策略打底。

📍 你在整门课的位置(第 2 周 · Agent 大脑)
W1 全景· Day6 Pi 内核 Day7 故障转移 Day8 执行循环 Day9 提示词 Day10 记忆· W3 技能/渠道
L01

为什么要故障转移

🤔 痛点:凌晨 3 点模型限流了,助理就该罢工吗? 你的助理号称 7×24 在线。可某个模型突然 429 限流、某把 API key 额度当月用尽、OAuth token 半夜过期……只要"当前这条路"一断,助理就失联——对一个"always-on"的产品是致命的。
💡 本质:像开车的导航,堵了自动改道 导航发现前方堵车,会自动切备用路线,而不是让你干等。故障转移就是给助理配两层备用路线:先"换条路"(同一模型换一把钥匙 profile),还不行就"换目的地"(换一个模型)。用户几乎无感。今天全天沿用这个"导航改道"的类比。

一个 24 小时在线的个人助理,随时可能遇到:模型限流(429)、某把 API key 额度用尽、OAuth token 过期、某 provider 宕机、请求过载。如果一遇错就放弃,助理三天两头"失联"。故障转移让它自动换下一个可用的模型/凭据继续。

像"多条备用路线的导航" 你开车时导航发现前方堵车,会自动切到备用路线,而不是让你停在原地。OpenClaw 也一样:当前模型/凭据不通,就自动切到下一个备选,用户几乎无感。而且它有两层备选——"换一条路"(换鉴权 profile)不行,就"换个目的地"(换模型)。这一整套让助理具备生产级的韧性。
L02

两层结构总览

外层循环:模型级 failover — runWithModelFallback(model-fallback.ts:505)

遍历候选模型链(主模型 + 配置的 fallbacks,可跨 provider)。每个候选模型 →

内层循环:鉴权 profile 轮换 — runEmbeddedPiAgent(run.ts:256, while 在 :809)

对当前模型的 provider,遍历它的所有鉴权 profile(OAuth / token / API key),逐个尝试;失败就换下一个 profile 重试。
读法:外层"这个模型不行换下个模型",内层"这个模型的这把钥匙不行换下把钥匙"。顶层把两者拼起来的地方在 agent-runner-execution.ts:202-205:把 runEmbeddedPiAgent(内层)作为回调传给 runWithModelFallback(外层)。
外层:换模型(换目的地)runWithModelFallback Claude主模型 GPT-4fallback 1 本地 Ollamafallback 2 全挂→ 内层:换 profile(换条路)每个模型内部 OAuth先用(省钱) token api_key按量·最后 每个模型都先在内层把自己的 几把钥匙轮一遍,全废才升到外层
两层嵌套:内层先把当前模型的几把钥匙轮完(换条路),全失败才由外层换到下一个模型(换目的地)。
L03

外层:换模型

// resolveFallbackCandidates(model-fallback.ts:252):主模型 + agents.defaults.model.fallbacks
for (let i = 0; i < candidates.length; i++) {          // model-fallback.ts:531
  const candidate = candidates[i];
  // 先检查该 provider 的所有鉴权 profile 是否都在冷却(:539-560)
  //   全冷却 → 按策略 skip 或 probe
  // 否则调用内层 run(= runEmbeddedPiAgent)
}
📝 举个例子:配一条跨家的备用链openclaw.jsonagents.defaults.model.fallbacks = ["gpt-4o", "ollama/llama3"],主模型是 Claude:
→ 平时用 Claude(订阅 OAuth,省钱);Claude 全家限流 → 自动退到 GPT-4o;连 GPT 也挂了(比如断网付费服务全不可用)→ 退到本机 Ollama,哪怕降智也能应急。"目的地"从云端一路退到本地。
读法:候选模型链来自配置 agents.defaults.model.fallbacks,可以跨 provider(如"先 Claude,挂了退 GPT,再退本地 Ollama")。还有一条 CLI provider 分支(agent-runner-execution.ts:214isCliProvider):有些 provider 由外部 CLI 承载(如 Claude CLI),走 runCliAgent 而非内嵌 Pi。
L04

内层:换 profile

// resolveAuthProfileOrder(run.ts:423 → order.ts:67)算出 profile 尝试顺序
const profileCandidates = ...;                 // run.ts:432-436
while (true) {                                  // run.ts:809 主重试循环
  // 上限 MAX_RUN_LOOP_ITERATIONS(run.ts:741,随 profile 数增长)
  await runEmbeddedAttempt(...);                // run.ts:851 一次完整对话尝试
  // 失败 → advanceAuthProfile(跳到下个可用 profile,跳过冷却中的)
  //        markAuthProfileFailure(记失败 + 设冷却)
  // 成功 → markAuthProfileGood / markAuthProfileUsed
}
读法:内层对"同一个模型"尝试它的多个鉴权 profile。一个 profile 失败 → 记冷却、跳下一个。profile 存储/类型在 src/agents/auth-profiles/types.ts:ApiKeyCredential / TokenCredential / OAuthCredential)。

像单步调试一样,看一次真实故障转移的"改道过程"(假设 Claude 有 2 个 profile 都限流、退到 GPT-4):

尝试此刻发生什么状态变化
1Claude + OAuth 发请求返回 429 → markAuthProfileFailure 设冷却
2内层 advanceAuthProfile → Claude + api_key又 429 → 该 key 也进冷却
3Claude 的 profile 全冷却 → 外层升级换到候选链下一个:GPT-4
4GPT-4 + OAuth 发请求成功 → markAuthProfileGood
用户拿到回复全程无感,只是慢了一两秒
L05

OAuth > token > key 排序

profile 顺序由 orderProfilesByModeorder.ts:162-196)决定:

const typeScore = type === "oauth" ? 0 : type === "token" ? 1 : type === "api_key" ? 2 : 3;
const lastUsed = store.usageStats?.[profileId]?.lastUsed ?? 0;
// 先按类型分:oauth 优先于 token 优先于 api_key
// 同类型内:lastUsed 最旧的优先(round-robin 轮换,均摊负载)
// 冷却中的:排到最后(按冷却到期时间升序)
为什么 OAuth 排在 API key 前面? OAuth(如 ChatGPT/Codex 订阅登录)通常是"包月订阅"——多用不额外花钱;API key 往往按量计费所以优先用 OAuth(省钱),实在不行才用按量付费的 key。同类型内部又按"最久没用的先用"轮换——把负载均摊到多把钥匙上,避免单把 key 被打限流。这套排序把"成本优化 + 负载均衡 + 冷却规避"揉进了一个打分函数里,非常见工程巧思。
L06

冷却与退避

失败的 profile 会被设置冷却期markAuthProfileFailurerun.ts:762),一段时间内不再尝试。过载错误(provider 说"我忙")还有指数退避

// OVERLOAD_FAILOVER_BACKOFF_POLICY(run.ts:89-94)
// maybeBackoffBeforeOverloadFailover(run.ts:781):过载时先等一会儿再切,避免雪崩
读法:冷却 = "这把钥匙刚失败,先晾一会儿别再试";指数退避 = "越试越等更久",防止把已经过载的服务打得更狠。这是分布式系统标准的容错手段,OpenClaw 用在鉴权凭据上。GitHub Copilot 还有专门的 token 刷新状态机(refreshCopilotToken)。
L07

错误分类

不是所有错误都该"换 profile"。错误分类在 pi-embedded-helpers.tsrun.ts:40-57 导入):

  • isAuthAssistantError:鉴权错 → 换 profile。
  • isRateLimitAssistantError:限流 → 冷却 + 换 profile。
  • isBillingAssistantError:额度/账单 → 换。
  • isLikelyContextOverflowError:上下文超长 → 触发压缩重试(Day 08)。
读法:根据错误类型走不同恢复策略,统一抛 FailoverErrorfailover-error.ts)。比如"上下文超长"不该换 key(换了还是超长),而该压缩历史。精准的错误分类是有效故障转移的前提——否则会瞎切一通。
⚠️ 常见误解:以为"报错就换一把 key/换个模型准没错"。其实换错方向反而更糟:上下文超长(isLikelyContextOverflowError)时换 key,新 key 一样超长、白白烧掉一把好钥匙——正确做法是压缩历史(Day 08)。这就是为什么必须先分类错误、再决定改道方式,而不是无脑改道。
L08

今日小结 + 动手

🧠 今天你应该能回答

  • 故障转移解决什么问题?为什么个人助理特别需要它?
  • 两层循环各自换什么?在哪拼起来?
  • profile 排序为什么 OAuth > token > api_key?同类型内怎么排?
  • 冷却和指数退避各防什么?
  • 为什么要先做错误分类再决定恢复策略?
🎵 记忆口诀(故障转移一句话) "先换钥匙(内层 profile),再换模型(外层 fallback);OAuth 先上、按量垫后;失败晾冷却、过载先退避;错误先分类、别瞎改道"——一条导航改道的完整规矩。

✋ 动手

cd /Users/bitmart/work/codes/github/openclaw
sed -n '500,560p' src/agents/model-fallback.ts          # 外层
sed -n '805,860p' src/agents/pi-embedded-runner/run.ts  # 内层 while
sed -n '160,196p' src/agents/auth-profiles/order.ts     # 排序打分
明天预告 · Day 08Agent 执行循环——一条消息怎么变成 LLM 调用、工具调用、回复?会话怎么串行排队防并发写坏?session.prompt() 触发点 + 订阅式输出观察 + 上下文溢出自动压缩。
← Day 06 Pi 内核 Day 08 · Agent 执行循环 →