Blog posts and course content are currently in Chinese. Translation is in progress.
Back to Blog

多 key 多模型怎么优雅地轮流试:LLM 供应商故障转移是怎么实现的

2026年8月23日32 min read
LLM 故障转移与 key 轮询

把同一个请求在 3 个 key 之间轮询、遇到特定错误跳过熔断、整组限流就停 30s——这套机制是 LLM 代理的「心脏」。源码解读 + 你自己的代理可以怎么照搬。

接入完 LLM 供应商(309 讲过怎么配多 key、313 讲过怎么看账本),第一次跑 agent 的人都会问同一个问题:我配了 3 个 key,是不是请求会把 3 个 key 都打一遍才返回? 答案是看情况——不同的错误分类在 src/llm/services/model-router.service.ts:316-330isRetryable 里走完全不同的分支,再加上 :343-368 的 executeWithRateLimitRetry 还会先做几次同 key 短退避重试,整套行为比"轮询 3 个 key"要精细得多。这篇就把这条决策树扒给你看,告诉你什么情况下换 key、什么情况下不换、什么情况下连打都别打。

一、错误先要分类:4 种上游故障 vs 3 种下游/参数问题

classifyErrorsrc/llm/services/model-router.service.ts:236)拿到原始异常后第一步不是重试,而是先判断错误到底是谁的锅。这一步直接决定后面的两个开关(要不要换 key、要不要计熔断)怎么走。源码里实际生效的分类有 7 种:

错误分类触发条件上游还是下游是否换 key是否累计熔断
bad_request (400)上游拒绝请求本身(上下文超限、不支持图片、字段不兼容)下游/客户端永远不:316 硬编码)
rate_limit (429)上游 QPM/QPS 配额耗尽上游
model_unavailable (404)上游对当前请求路径返回 404上游
auth_error (401/403)key 失效、没权限下游/配置
billing_error上游返回欠费上游整组停用 30~300s
server_error (5xx)上游临时崩了上游
timeout网络层超时上游/网络
provider_unavailable上游服务整体不可达上游整组停用

注意最后两列是不一样的——换不换 key 和要不要累计熔断是两件事:181-199 那段把这两个判断写得很清楚:换不换 key 取决于"换 key 有没有可能救这个请求"(400 换 key 解决不了,那就不换),但要不要把这个 key 标记为不健康,取决于"这次失败是不是 key 自己的锅"(下游/参数问题不应让 key 受连坐,否则客户端反复发错误请求会误熔断好 key)。

分类里两个容易踩错的判断

classifyError 的实现(:254-283)有两处跟直觉相反,都写在注释里:

第一处,ECONNRESET 被归成 server_error 而不是 provider_unavailable

// ECONNRESET 是常见的瞬时网络抖动(上游重启/LB切换),应该可重试
// 只有 ECONNREFUSED(服务没起) 和 ENOTFOUND(DNS错) 才视为 provider 不可达
if (code === 'ECONNREFUSED' || code === 'ENOTFOUND')
  return 'provider_unavailable';
if (code === 'ECONNRESET') return 'server_error';

差别很实际:provider_unavailable 会触发整组停用 60s(下一节讲),server_error 只是换个 key 继续。上游滚动重启或 LB 切换时 ECONNRESET 会成片出现,如果按"不可达"处理,整组一停 60s,一次正常的上游发版就把你的服务打停了。

第二处,billing_error 只认 402 和白名单关键词:

// 欠费 / 余额不足 / 账号停用 -> 整组停用
// 只匹配 HTTP 402 或明确的余额/欠费关键词,避免误伤
if (
  status === 402 ||
  msg.includes('insufficient balance') ||
  msg.includes('insufficient_quota') ||
  msg.includes('billing') ||
  ...

为什么要收窄?因为 billing_error 的后果是整组停用 60s 起、指数退避到 5 分钟,是全部分类里最重的处置。如果拿宽泛的关键词(比如 quotalimit)去匹配,一条普通的 429 限流文案就可能被误判成欠费,把 provider 停 5 分钟。这里的取舍是宁可漏判也不误判——真欠费漏判了下一次请求还会再来一遍,误判则是直接停服。

二、bad_request 永远不重试——为什么这条规则在代码里硬编码

isRetryable:316)的注释和实现长得像这样:

// bad_request 永远不重试:400 是请求本身有问题(上下文超限、不支持图片、
// 字段不兼容等),换 key 解决不了,只会白白浪费 3 个 key 各一份 token。
// 即使配置里误加了 bad_request,这里也硬拦截。
if (cls === 'bad_request') {
  return false;
}

注意最后那句"即使配置里误加了 bad_request,这里也硬拦截"——配置层(DB routing.failover.retryableErrors)可以塞任何字符串进去,但 isRetryable 这里做了一道硬防线,对 bad_request 永远返回 false

7 月 28 日那次修复 (e585c9401) 的 commit message 写得特别直白:当时 huoshan(火山)供应商的 failover.retryableErrors 里配了 bad_request,结果一个 glm-5.2 的 400 错误被跨 3 个 key 各打了一遍——Key(da67) 失败 400 → Key(4697) 失败 400 → Key(4053) 失败 400,每次都发了完整请求体到火山,token 在 3 个 key 上各消耗一遍。这不是个例,只要你把 bad_request 加进 retryableErrors 列表,这条规则就在 DB 侧被改坏——isRetryable 的硬编码就是给这种"配置误操作"买的保险。

你做自己的代理想照搬这条要注意:默认的 retryableErrors 列表(:317-322)只放 4 个分类——rate_limit/server_error/timeout/model_unavailable,加新的之前先想清楚"换 key 能不能救它"。

三、429 不是"换个 key 就行"——整组停用 30s 的设计

绝大多数人第一次看 LLM 代理代码的直觉都是"429 嘛,换个 key 继续试"。但 executeWithFailover:77)的尾部 :218-227 走了一条更激进的路径:

if (lastError) {
  const finalCls = this.classifyError(lastError);
  if (finalCls === 'rate_limit') {
    const haltKey = ctx.providerCtx || 'rate_limit_global';
    const haltMs = 30_000;
    this.providerHalts.set(haltKey, Date.now() + haltMs);
  }
}

如果3 个 key 都因 rate_limit 失败,代理不会再去换 key——它把这个 provider 整组停用 30 秒,期间所有请求直接 503,不发任何请求到上游。

为什么是"整组停用"而不是"轮询等恢复"?3f802979a(7-28)的 commit message 给了答案:上游的 QPM 限流窗口通常是 1-5s(不是 60s),如果代理这时还在轮询 3 个 key 逐个试,每个 key 都打一遍 429 才返回错误——然后 agent 立即重试、再打 3 遍——形成死循环,上游限流窗口期间白白消耗大量请求。30s 是个保守值,给限流窗口留足恢复时间。

同一段 :215-228 还对单次请求内部的"短退避"做了升级:原本 50/200/500ms 的 backoff 太短(等不到恢复就又打),改成 executeWithRateLimitRetry:343)里的 1000/2000/4000ms 三次重试,累计 7s——覆盖了上游 QPM 限流窗口的常见 1-5s 范围。这两个改动是一对的:单请求内给足时间跨过限流窗口,所有 key 都过不去时整组停用避免打爆。

四、欠费/不可达——比 rate_limit 更严重的"整组停用"

:171-178billing_errorprovider_unavailable 走的是另一条更激进的路径:

if (cls === 'billing_error' || cls === 'provider_unavailable') {
  const haltKey = ctx.providerCtx || circuitKey;
  const haltMs = this.getProviderHaltMs(haltKey);
  this.providerHalts.set(haltKey, Date.now() + haltMs);
  break;  // 不再走 retry 循环
}

注意这个 break——它直接跳出 executeWithFailover 的外层 for 循环,不再尝试下一个 key。为什么?因为欠费/不可达是 provider 整个层面的问题,剩下两个 key 大概率也是同一个 provider(同一个欠费账号),换 key 只会浪费请求。

getProviderHaltMs:422)做了指数退避:首次 60s,连续触发就翻倍到 120s/240s/...,上限 5 分钟。这比 rate_limit 的固定 30s 严苛得多——因为 rate_limit 是上游配额问题,30s 大概率恢复;欠费是你自己的问题,5 分钟是给你手动充值的最低窗口。

recordSuccess:411)会在任何一次成功调用时清掉这个 provider 的停用状态,所以充值完一发请求就恢复。

五、轮询的起点怎么选——熔断跳过 + 半开放行

pollNextIndex:298)实现的不是简单的"key 1 → key 2 → key 3"轮询,而是一个会跳过熔断 key 的轮询:

// 跳过熔断的轮询单元(key 级一致)
for (let i = 0; i < models.length; i++) {
  const candidateIdx = (idx + i) % models.length;
  if (!this.isCircuitOpen(this.circuitKeyFor(models[candidateIdx]))) {
    this.pollIndex.set(key, (candidateIdx + 1) % models.length);
    return candidateIdx;
  }
}
// 全熔断 -> 从当前位开始(半开会放行)

circuitKeyFor:288)的熔断 key 形如 key:尾号12——同一个 key 不管绑在多少个模型上,都共享一个熔断计数(这就是 7 月那次"3 key 共享限流"的天然解药)。isCircuitOpen:372)的冷却时间是 30s,30s 后会被"半开"放行 1 个请求试探——试探成功 recordSuccess 把整个 state 清掉,失败就重新计时。

六、给你的代理"另一条路":整组停用的开销怎么省

看完 providerHaltsMap<providerCtx, until>)你可能会担心:30s 整组停用期间,所有进到代理的请求都立刻 503——client 端可能因为这个 503 一直加重试,反而把上游刚恢复的窗口又打爆。

怡途Claw 的解法是分两层:

  1. 代理内部 30s 停用providerHalts)——纯进程内 Map,不发任何请求到上游,是上游恢复窗口的"绝对保护期"。
  2. 客户端层面的指数退避——下游 agent 拿到 503 后走自己那一层的 backoff(典型 1s → 2s → 4s → 8s),让上游真正恢复时再发起下一波请求。

这两个层加在一起才是完整答案:代理只管上游不被自己打爆,客户端只管不浪费自己的请求。你做自己的代理,如果只做一层——比如只做客户端退避但代理里没有整组停用——那 3 个 key 的轮询在 429 窗口里照样会打爆上游;反过来只做整组停用但客户端不知道 backoff 也会重复发请求。两个都得有。

写在最后

LLM 代理的故障转移不是"3 个 key 轮询"那么简单的循环——它是一棵根据错误分类走不同分支的决策树,bad_request 不重试rate_limit 整组停用 30s欠费/不可达 break 跳出 + 指数退避熔断按 key 尾号 12 共享计数——这四个设计点配合 recordSuccess 的恢复逻辑才把"代理自己不把自己打爆"这件事做扎实。

这一篇算把 309(多 key 配置)和 313(账本)之间的"代理层"补上:309 讲怎么配、313 讲怎么查、这一篇讲配完之后请求怎么流。下一篇打算把 circuitKey 的"key 尾号 12"这个魔数(为什么是 12 不是 4 也不是全部)和熔断计数恢复那条线单独拉一篇——那块和这次 7-28 修的"限流熔断"是同一条修复链。

研究文档(引用来源参考)

(no reference document available)