2026.09.02

模型池的两种失败:额度耗尽是永久的,限流是暂时的

多 Provider 模型池里,把所有错误一视同仁地重试,只会让故障扩散得更快。这篇记录怎么按「可恢复性」给失败分类,并在四条链路上自动降级。

约 3 分钟

接了三家模型供应商之后,我以为冗余就做完了。实际上真正的难点不是"接了几个",而是失败之后怎么办。

两种失败,两种处置#

所有失败看起来都是"这次调用没成功",但它们的可恢复性完全不同:

失败类型本质正确处置
额度耗尽 / 欠费这个 Key 永久不可用从池中摘除,等人工处理
限流(429)只是此刻太挤定时冷却,到点自动放回
网络超时瞬时抖动立即重试下一个

把它们混在一起处理是灾难的开始。最典型的错误是:额度耗尽的 Key 被当成限流处理,每隔 30 秒冷却结束就再试一次——于是系统在明知会失败的调用上持续消耗时间,用户的请求排在后面一起变慢。

反过来,把限流当成永久失败摘除掉,池子会在一波流量高峰后只剩下一个节点,然后它也挂了。

池子的状态机#

每个 Provider 实例带一个小状态:

type Health =
  | { kind: "ready" }
  | { kind: "cooling"; until: number; reason: string }
  | { kind: "retired"; reason: string };
 
type Provider = {
  id: string;
  call: (req: ChatRequest) => Promise<ChatResponse>;
  health: Health;
};
  • ready → 正常参与
  • cooling → 暂时跳过,until 到点自动回到 ready
  • retired → 永久跳过,只能人工恢复

分类逻辑就一行判断:

function classify(err: unknown): Health {
  if (isQuotaExhausted(err)) return { kind: "retired", reason: "quota" };
  if (isRateLimited(err)) {
    const retryAfter = readRetryAfter(err) ?? 30_000;
    return { kind: "cooling", until: Date.now() + retryAfter, reason: "429" };
  }
  return { kind: "cooling", until: Date.now() + 5_000, reason: "transient" };
}

优先读响应头里的 Retry-After,读不到再给一个保守的默认值。不要凭感觉拍一个固定的冷却时长——供应商明确告诉了你多久之后可以再来,就该听它的。

显式关掉 SDK 自带的重试#

这一点反直觉,但很关键:很多模型 SDK 默认会自己重试。

如果不关掉,一次业务调用在 SDK 内部就会重试若干次,每次都有自己的退避。上层看到的是"这个 Provider 很慢",实际上它正在对着一个已经限流的端点猛敲。

更糟的是重试叠加:SDK 重试 3 次 × 上层换 3 个 Provider = 9 次实际请求。限流场景下这等于自己给自己制造雪崩。

const client = new OpenAI({
  apiKey,
  maxRetries: 0,   // 重试策略由我们的池子统一负责
});

原则是:重试必须只有一层,且由掌握全局信息的那一层来做。SDK 看不到池子里其他节点的状态,它没有做这个决定的信息。

四条链路都要降级#

同一套池子逻辑要覆盖四条性质不同的链路,它们的失败代价差别很大:

  1. 对话(非流式)——失败可以整体换 Provider 重来,用户几乎无感
  2. 流式输出——已经吐出去的 token 收不回来。只能在输出第一个 token 之前换;一旦开始流式,就只能中断并告知用户
  3. 向量化——离线批处理,失败可以整体重排到队尾再来
  4. 重排——在检索链路中间,失败应该降级为"不精排",而不是让整个检索失败

第 4 条是最容易被忽略的:精排只是一个优化,它挂了不该导致检索不可用。所以这里的处置不是"换个 Provider 再来",而是"跳过这一步,直接用召回顺序"。

async function rerankOrPassthrough(candidates: Chunk[], query: string) {
  try {
    return await pool.withFallback((p) => p.rerank(candidates, query));
  } catch {
    // 精排是优化项,不是必需项 —— 降级为原始召回顺序
    return candidates;
  }
}

结果#

  • 额度耗尽的 Key 不会反复消耗请求时间
  • 限流节点按供应商给的时间自动恢复,不需要人工干预
  • 单一 Provider 故障时,四条链路各自按自己的语义降级
  • 实际请求数不再被 SDK 重试放大

事后看,这套东西的核心只有一句:失败必须先分类,再处置。重试、摘除、降级是三种完全不同的动作,用同一个 catch 兜住它们,等于放弃了所有判断。