把同一个请求在 3 个 key 之间轮询、遇到特定错误跳过熔断、整组限流就停 30s——这套机制是 LLM 代理的「心脏」。源码解读 + 你自己的代理可以怎么照搬。
接入完 LLM 供应商(309 讲过怎么配多 key、313 讲过怎么看账本),第一次跑 agent 的人都会问同一个问题:我配了 3 个 key,是不是请求会把 3 个 key 都打一遍才返回? 答案是看情况——不同的错误分类在 src/llm/services/model-router.service.ts:316-330 的 isRetryable 里走完全不同的分支,再加上 :343-368 的 executeWithRateLimitRetry 还会先做几次同 key 短退避重试,整套行为比"轮询 3 个 key"要精细得多。这篇就把这条决策树扒给你看,告诉你什么情况下换 key、什么情况下不换、什么情况下连打都别打。
一、错误先要分类:4 种上游故障 vs 3 种下游/参数问题
classifyError(src/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 分钟,是全部分类里最重的处置。如果拿宽泛的关键词(比如 quota、limit)去匹配,一条普通的 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-178 对 billing_error 和 provider_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 清掉,失败就重新计时。
六、给你的代理"另一条路":整组停用的开销怎么省
看完 providerHalts(Map<providerCtx, until>)你可能会担心:30s 整组停用期间,所有进到代理的请求都立刻 503——client 端可能因为这个 503 一直加重试,反而把上游刚恢复的窗口又打爆。
怡途Claw 的解法是分两层:
- 代理内部 30s 停用(
providerHalts)——纯进程内 Map,不发任何请求到上游,是上游恢复窗口的"绝对保护期"。 - 客户端层面的指数退避——下游 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)