2026.09.02
模型池的两种失败:额度耗尽是永久的,限流是暂时的
多 Provider 模型池里,把所有错误一视同仁地重试,只会让故障扩散得更快。这篇记录怎么按「可恢复性」给失败分类,并在四条链路上自动降级。
接了三家模型供应商之后,我以为冗余就做完了。实际上真正的难点不是"接了几个",而是失败之后怎么办。
两种失败,两种处置#
所有失败看起来都是"这次调用没成功",但它们的可恢复性完全不同:
| 失败类型 | 本质 | 正确处置 |
|---|---|---|
| 额度耗尽 / 欠费 | 这个 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到点自动回到readyretired→ 永久跳过,只能人工恢复
分类逻辑就一行判断:
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 看不到池子里其他节点的状态,它没有做这个决定的信息。
四条链路都要降级#
同一套池子逻辑要覆盖四条性质不同的链路,它们的失败代价差别很大:
- 对话(非流式)——失败可以整体换 Provider 重来,用户几乎无感
- 流式输出——已经吐出去的 token 收不回来。只能在输出第一个 token 之前换;一旦开始流式,就只能中断并告知用户
- 向量化——离线批处理,失败可以整体重排到队尾再来
- 重排——在检索链路中间,失败应该降级为"不精排",而不是让整个检索失败
第 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 兜住它们,等于放弃了所有判断。