同一份请求,3 个 API key 排队 400 失败 3 次才告诉你请求体有问题,白白消耗 3 份 token。429 限流时又密集轮询 3 个 key 把上游打爆。扒 model-router.service.ts:classifyError 把上游错误分成 9 类,bad_request 硬拦截不重试、rate_limit 走 1/2/4s 阶梯 + provider 停用 60s 起指数封顶 5 分钟,以及为什么 billing_error 要整组停用而不是只换一个 key。
跑长上下文多 key 池子的同学大概率撞过这三种"明明问题不在 key,但 token 还是哗哗流"的事:
请求 A -> key1 失败 -> 换 key2 失败 -> 换 key3 失败 -> 终于告诉客户端: 400
请求 B -> key1 429 -> 换 key2 429 -> 换 key3 429 -> 客户端 5s 后又重试再来 3 遍
请求 C -> key1 欠费 -> 换 key2 欠费 -> 换 key3 欠费 -> 三个 key 都白费一次 token
换 key 解决不了的事,换了 3 次还是解决不了 — 但 token 已经按失败调用付过了。扒一下 src/llm/services/model-router.service.ts 里的 classifyError 和 executeWithFailover,看代理层是怎么把上游错误分成 9 类,只对"换 key 真的能换到一份能跑的"那几类重试,以及剩下 4 类怎么通过熔断 + provider 停用避免密集轮询打爆上游的。
9 种错误分类,只看 status / code / 关键词三件事
classifyError(error) 在 model-router.service.ts:236,只靠三件事判断:
- HTTP 状态码 (
error.response.status/error.getStatus()) - Node 原生错误码 (
error.code,比如ECONNREFUSED、ETIMEDOUT) - 错误消息里是否含
insufficient balance、insufficient_quota、account has been deactivated这类关键词
不会去看上游返回的 JSON 里的 error.type 字段(各家命名都不一样),也只看上面 9 类:
rate_limit 429
timeout code === ETIMEDOUT | ECONNABORTED | msg 含 timeout
auth_error 401 | 403
billing_error 402 | msg 含 insufficient balance / billing / payment required /
account has been deactivated
provider_unavailable code === ECONNREFUSED | ENOTFOUND
server_error code === ECONNRESET | status >= 500
model_unavailable 404
bad_request 400
unknown 其他所有
两个反直觉的点:
- ECONNRESET 不算"上游不可达",归到
server_error:上游重启 / LB 切换时是常见的瞬时网络抖动,代理还能重试。只有ECONNREFUSED("连接被拒",服务没起)和ENOTFOUND("DNS 解析失败")才判 provider 真的不可用 — 这种换 key 也救不回来,直接整组停用。 - 404 单独成一类
model_unavailable:不是上游坏了,只是当前模型在当前 key 上没开 — 可能换一个 key 就有了。
bad_request 永远不重试,400 错误跨 3 个 key 各烧一遍 token 已修
isRetryable 在 model-router.service.ts:316:
private isRetryable(cls: ErrorClass, config: RoutingConfig): boolean {
const allowed = config.failover?.retryableErrors || [
'rate_limit',
'server_error',
'timeout',
'model_unavailable',
];
// bad_request 永远不重试:400 是请求本身有问题(上下文超限、不支持图片、
// 字段不兼容等),换 key 解决不了,只会白白浪费 3 个 key 各一份 token。
// 即使配置里误加了 bad_request,这里也硬拦截。
if (cls === 'bad_request') {
return false;
}
return allowed.includes(cls);
}
默认重试白名单只有 4 类,bad_request / auth_error / billing_error / provider_unavailable / unknown 都不在白名单里。其中 bad_request 还被显式硬拦截 — 即使上游 YAML 配置里手贱加进了 retryableErrors,if (cls === 'bad_request') return false 这一行会盖住。这是 commit e585c9401 加的。
实际效果:同一个请求体 ctx 超限 → 火山 key1 返 400 → key2 返 400 → key3 返 400,代理直接抛最后一个 400 给客户端,中间两次不再多烧 token。
rate_limit 同 key 短重试 1/2/4s,跨 key 长重试 backoff 翻倍
executeWithRateLimitRetry 在 model-router.service.ts 里,只对两类 (rate_limit + model_unavailable) 在同一个 key 上做阶梯重试:
const backoffs = [1000, 2000, 4000]; // 4 次尝试(首调 + 3 次重试),backoff 累计 1+2+4 = 7s
const SHORT_RETRY_CLASSES: ErrorClass[] = ['rate_limit', 'model_unavailable'];
为什么单独写一个 executeWithRateLimitRetry、而不是沿用外层 executeWithFailover 的 backoff?
- 火山方舟的 QPM 限流窗口通常 1-5s 短,旧版的 50/200/500ms 阶梯根本等不到恢复就又打一次。
model_unavailable404 在/api/coding/v3这个 endpoint 上会被火山同步打到所有 key 2-3s — 跨 key 换没用,反而同 key 短 backoff 容易撞进恢复窗口。
外层 executeWithFailover 的跨 key backoff 是 backoffMs * Math.pow(2, attempt),backoffMs 默认 500ms(config.failover?.backoffMs ?? 500),maxRetries 默认 min(3, models.length - 1),即 3 个 key 池子最多 3 次跨 key 重试,4 次指数退避累加 ≈ 7.5s。两次 backoff 解决的是不同问题:同 key 等限流窗口、跨 key 等熔断器冷却。
billing_error / provider_unavailable 整组停用 60s,指数封顶 5 分钟
executeWithFailover 里的关键分支:
// 欠费/不可达 -> 整组停用,不再重试
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;
}
-
haltKey优先用ctx.providerCtx(resolvedProvider.id),同一厂商所有 key 共用一个停用状态。 -
getProviderHaltMs是指数退避:private getProviderHaltMs(providerCtx: string): number { const count = this.haltCounts.get(providerCtx) || 0; this.haltCounts.set(providerCtx, count + 1); return Math.min( this.PROVIDER_HALT_MS * Math.pow(2, count), // 60s, 120s, 240s, 480s this.PROVIDER_HALT_MAX_MS, // 5 * 60_000 ); }首次 60s,二次 120s,三次 240s,封顶 5 分钟(300s)。
-
停用期间
llm-proxy.service.ts:4290在请求入口直接 503,根本不打上游:if (routingCtx.providerCtx && this.modelRouter.isProviderHalted(routingCtx.providerCtx)) { throw new ServiceUnavailableException('上游暂时不可用(限流/维护中),请稍后重试'); }
为什么欠费要整组停?因为同一厂商的 3 个 key 大概率是同一个账户 — 3 个 key 一起欠费,再换也换不出能付钱的。
熔断器只计上游真问题,bad_request 不计入,避免误熔断
recordFailure 在 model-router.service.ts:390,是 key 级熔断的核心,只有这 4 类错误会被记成熔断失败:
const UPSTREAM_FAULT_CLASSES: ErrorClass[] = [
'rate_limit',
'server_error',
'timeout',
'provider_unavailable',
];
if (UPSTREAM_FAULT_CLASSES.includes(cls)) {
this.recordFailure(circuitKey);
} else {
this.logger.debug(
`[${ctx.instanceId}] 错误分类 '${cls}' 不计入熔断(下游/参数问题,非上游故障)`,
);
}
为什么 bad_request / auth_error / model_unavailable / unknown 不计?这四类的根因都不在 key 健康上:
bad_request:请求体有问题auth_error:key 是好的,只是鉴权没过model_unavailable:key 有效,只是该 key 没开通此模型unknown:未知错误,不该累积
如果把它们也计入,客户端反复发错误请求 → key 被误熔断 30s → 之后正常请求也打不到这个 key,白白损失一轮正常请求。recordFailure 内部连续 3 次失败才开熔断,加上停用 30s,误熔断一次的代价很大,只信任"上游确实有问题"的那 4 类。
rate_limit 全 key 失败再追加一次 30s provider 停用
executeWithFailover 末尾,所有 key 都试过都失败的情况下,做最后一次兜底:
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);
}
}
这条和上面的 billing_error / provider_unavailable 整组停用不冲突 — 上面那条是"已经知道上游病了(欠费 / 不可达)",这条是"还不确定,但所有 key 都 429,大概率是上游账号级限流"。给个 30s 短停用,后续请求直接 503 不打上游,给限流窗口恢复时间,避免 agent 立即重试把 3 个 key 又打一遍 — 这是 commit 3f802979a 加的。
成功请求自动清除停用和熔断
recordSuccess(model-router.service.ts:409):
private recordSuccess(circuitKey: string, providerCtx?: string): void {
this.circuits.delete(circuitKey);
if (providerCtx && this.providerHalts.has(providerCtx)) {
this.providerHalts.delete(providerCtx);
this.haltCounts.delete(providerCtx);
this.logger.log(`提供商 ${providerCtx} 恢复,清除停用状态`);
}
}
两条都清:
circuits.delete:key 级熔断状态重置(无冷却,直接恢复)providerHalts.delete+haltCounts.delete:厂商级停用连同指数计数一并清零 — 下次再停用从 60s 重新起,不会被历史累加影响
只清"当前成功"那个厂商的停用,其他厂商的停用状态不受影响(多厂商池子里 A 厂商恢复了 B 厂商继续停用)。
半开状态:30s 冷却后只让 1 个请求试探
isCircuitOpen 在 model-router.service.ts:372:
const cooldown = 30_000;
if (Date.now() - c.openedAt > cooldown) {
c.open = false;
c.failures = 0; // 计数也清掉
return false; // 放行
}
return true; // 没到时间继续拦
不是简单"30s 后自动关闭",而是"30s 后下一次请求假装从没熔断过进去试,失败了就 recordFailure 重新从 1 开始累积"。如果试探成功,recordSuccess 会把整个 state 一起清掉。如果试探失败,要再攒 3 次才再次熔断 30s。
整套流程串起来:一次失败请求到底走完哪些分支
以"火山方舟账号级限流,3 个 key 都 429"为例,走一遍 executeWithFailover + 每次 key 进 failover 前先 executeWithRateLimitRetry 的合并耗时:
0ms 请求进来
llm-proxy.service.ts 入口: isProviderHalted? 否 → 继续
0ms executeWithFailover 第 1 个 key
→ executeWithRateLimitRetry 同 key 短重试:
0ms 试 1 次 → 429
1s 试 2 次 → 429
3s 试 3 次 → 429
7s 试 4 次 → 429 抛给外层
classifyError → 'rate_limit'
recordFailure(circuitKey) 失败计数 1
rate_limit 可重试 → 走跨 key backoff 500 * 2^0 = 500ms
7.5s 换第 2 个 key,同样 short retry 4 次共 7s
recordFailure 失败计数 2
跨 key backoff 500 * 2^1 = 1000ms
15s 换第 3 个 key,同样 short retry 4 次共 7s
recordFailure 失败计数 3 → 熔断 30s
22s 所有 key 都 rate_limit
finalCls === 'rate_limit' → providerHalts.set(60s 起)
throw lastError
22s 客户端拿到 429
22.x s agent 立即重试
llm-proxy.service.ts 入口: isProviderHalted? 是 → 直接 503
"上游暂时不可用(限流/维护中),请稍后重试"
0 次打上游,0 token 消耗
82s 第一次 providerHalts 过期,下一次请求放行
仍 429 → 再次 set(120s,haltCounts 已 +1)
或成功 → recordSuccess 把 haltCounts 和 providerHalts 一起清
注意:这是"3 个 key 都 rate_limit"这种最坏路径下的时间线 — 每个 key 实际是把 executeWithRateLimitRetry 跑完 4 次(累计 7s)再换 key,跨 key backoff 又叠 1.5s,所以单次失败到客户端拿到 429 大概 ~22s 而不是 ~7s。如果中途某个 key 成功就不进 provider 停用分支。
调优时改的其实是两个 timeout 和两个白名单
如果以后要调这套,动的是这几处常量,不是分支逻辑:
| 文件:行 | 常量 / 配置 | 默认值 | 含义 |
|---|---|---|---|
model-router.service.ts:63 | PROVIDER_HALT_MS | 60_000 | 首次停用 60s |
model-router.service.ts:64 | PROVIDER_HALT_MAX_MS | 5 * 60_000 | 停用时长封顶 5min |
model-router.service.ts:399 (recordFailure) | 熔断阈值 | failures >= 3 | 连续 3 次上游真错误开熔断 |
model-router.service.ts:375 (isCircuitOpen) | 熔断 cooldown | 30_000 | 熔断 30s 后半开 |
model-router.service.ts (executeWithRateLimitRetry) | 短重试 backoff | [1000, 2000, 4000] | 同 key 首调 + 3 次重试,4 次尝试,backoff 累计 7s |
model-router.service.ts:107 (executeWithFailover) | 跨 key backoff | backoffMs * 2^attempt | backoffMs 默认 500ms 起步,3 次重试,累加 ~7.5s |
model-router.service.ts:317-321 | retryableErrors | rate_limit / server_error / timeout / model_unavailable | 重试白名单 |
日常排查 rate_limit 还在打爆上游:先看 providerHalts 是否按预期 set(日志里"提供商 X 恢复,清除停用状态"或"所有 key 均 rate_limit,暂停 30s"),再看 haltCounts 是不是已经在指数增长(连续 5 次以上就该查上游是不是该升配额了)。