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

400 错误跨 3 个 key 各烧一遍 token?扒一下 llm-proxy 的错误分类和重试策略

2026年8月27日47 min read
错误分类与重试策略

同一份请求,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 里的 classifyErrorexecuteWithFailover,看代理层是怎么把上游错误分成 9 类,只对"换 key 真的能换到一份能跑的"那几类重试,以及剩下 4 类怎么通过熔断 + provider 停用避免密集轮询打爆上游的。

9 种错误分类,只看 status / code / 关键词三件事

classifyError(error)model-router.service.ts:236,只靠三件事判断:

  • HTTP 状态码 (error.response.status / error.getStatus())
  • Node 原生错误码 (error.code,比如 ECONNREFUSEDETIMEDOUT)
  • 错误消息里是否含 insufficient balanceinsufficient_quotaaccount 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             其他所有

两个反直觉的点:

  1. ECONNRESET 不算"上游不可达",归到 server_error:上游重启 / LB 切换时是常见的瞬时网络抖动,代理还能重试。只有 ECONNREFUSED("连接被拒",服务没起)和 ENOTFOUND("DNS 解析失败")才判 provider 真的不可用 — 这种换 key 也救不回来,直接整组停用。
  2. 404 单独成一类 model_unavailable:不是上游坏了,只是当前模型在当前 key 上没开 — 可能换一个 key 就有了。

bad_request 永远不重试,400 错误跨 3 个 key 各烧一遍 token 已修

isRetryablemodel-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 翻倍

executeWithRateLimitRetrymodel-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_unavailable 404 在 /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 不计入,避免误熔断

recordFailuremodel-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 个请求试探

isCircuitOpenmodel-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:63PROVIDER_HALT_MS60_000首次停用 60s
model-router.service.ts:64PROVIDER_HALT_MAX_MS5 * 60_000停用时长封顶 5min
model-router.service.ts:399 (recordFailure)熔断阈值failures >= 3连续 3 次上游真错误开熔断
model-router.service.ts:375 (isCircuitOpen)熔断 cooldown30_000熔断 30s 后半开
model-router.service.ts (executeWithRateLimitRetry)短重试 backoff[1000, 2000, 4000]同 key 首调 + 3 次重试,4 次尝试,backoff 累计 7s
model-router.service.ts:107 (executeWithFailover)跨 key backoffbackoffMs * 2^attemptbackoffMs 默认 500ms 起步,3 次重试,累加 ~7.5s
model-router.service.ts:317-321retryableErrorsrate_limit / server_error / timeout / model_unavailable重试白名单

日常排查 rate_limit 还在打爆上游:先看 providerHalts 是否按预期 set(日志里"提供商 X 恢复,清除停用状态"或"所有 key 均 rate_limit,暂停 30s"),再看 haltCounts 是不是已经在指数增长(连续 5 次以上就该查上游是不是该升配额了)。