《互联网产品的 AI 架构·推理性能与成本工程》12.1 供应商限流与分流

「回声」的主供应商在区域高峰返回 429,网关把重试铺到三家供应商,流量放大到 1.7 倍,备胎也被打到限流。本节把 429 冷却、本地配额池、确定性分流与全局重试预算放在一起,给出三种策略在 600 条示例请求上的运行结果,并说明怎样按卷一的能力分级决定隐藏、降级还是阻塞。

一次 429 引发的三倍流量

「回声」的私信回复在晚高峰接入了三家供应商:A 承担六成流量,B 三成,C 一成。某天 A 在华东区域开始返回 429,网关的判断很简单:429 是可重试错误,换一家再发。

十分钟后,B 和 C 也开始返回 429。值班同学把流量图拉出来,发现原始请求只有每分钟 600 次,实际打出去的尝试却是 1023 次。多出来的 423 次不是新用户,而是重试。A 的故障没有把请求打挂,重试把它们放大后,把三家供应商一起打挂了。

卷一 10.2 已经说过,降级不是“再找一个模型”,而是一条有预算、有顺序的链。到了规模化阶段,这句话还要加一个前提:备用供应商也有自己的配额,而且它不知道你把它当备胎。限流与分流要解决的不是“哪家还能用”,而是“在每家都不够用时,怎样让放大倍数可控”。

文中比例、请求数与成本,除非标注为示例样本上的运行结果,都是案例假设值,用来演示判断过程。

429 不是普通失败

把 429 和 500 放在同一个重试分支,是成本失控的起点。500 表示这次调用坏了,换一次可能成功;429 表示你已经超过了对方允许的速率,立刻重试只会继续超。两者的正确反应完全不同。

429 至少要读出三个信息:多久之后可以再试、当前窗口还剩多少额度、这次超限是请求数还是 token 数。前两个通常来自 Retry-After 与响应头,第三个要结合自己的请求画像判断。拿不到 Retry-After 时,用基准冷却,但必须设上限;否则对方给一个异常大的值,供应商会被冻结到天亮。

冷却不是“暂停整个供应商”,而是“在这一段时间内不再准入”。已经发出去的请求仍然要正常收尾,冷却结束后自动恢复。把冷却写成永久熔断,会把一次分钟级限流放大成小时级不可用。

三种放大,乘起来很快

重试的放大不是加法,是乘法。单请求最多尝试 maxAttempts 次,这是第一层上限;跨供应商重试会把失败流量倒给下一家,这是第二层;如果下一家也 429,而重试策略没有全局预算,失败会继续在供应商之间弹,这是第三层。

放大来源触发条件上限来自哪里失控表现
单请求重试任意可重试错误maxAttempts一次用户请求打三次
跨供应商转移首选供应商 429备用供应商配额备胎比主供应商先挂
重试风暴多家同时 429全局重试预算尝试数随故障时长持续上涨

全局重试预算按原始请求数计算,而不是按失败数计算。示例里 600 条原始请求、10% 预算,意味着整批最多 60 次重试;不管失败是 30 条还是 300 条,重试总量都不会超过 60。按失败数计算则相反:故障越严重,允许的重试越多,正好在最不该加流量的时刻加流量。

配额池:剩余量要逐家相加

供应商配额至少有四个维度:每分钟请求数、每分钟 token 数、同时在途请求数、健康状态。四个维度要分别检查,不能用一个“剩余额度”概括。一个供应商可能请求数还有富余,但 token 已经打满;也可能 token 够,但在途连接已经到顶。

配额池不是把所有配额加成一个总数再平均分。正确的是逐家维护窗口用量,准入时检查目标供应商自己的剩余量;池子的总剩余量只是各家剩余量之和,用于判断“这一分钟还接不接得下”,不能反过来替代逐家检查。

冷却也必须进入配额视图。一个正在冷却的供应商,剩余量应该按零算,而不是按窗口额度算。否则池子会显示“还有额度”,调度却一家都发不出去。

分流顺序必须可复现

多供应商分流常用随机数或轮询。随机数的问题不是不公平,而是不可复现:同一个请求在两次重放里落到不同供应商,复盘时无法回答“当时为什么走了 B”。轮询在供应商数量变化时会整体错位,同样难以逐条解释。

用 requestId 做确定性哈希,再按权重换算分数,可以同时得到两个性质:同一个请求永远得到同一个首选顺序;权重大的供应商整体上更常排在第一。故障注入、回放和账单归因都依赖第一个性质。

权重不是配额。权重决定“正常情况下更可能选谁”,配额决定“选到之后能不能发”。把两者混成一个数字,会出现“权重高的供应商被过度使用,直到 429”的结果。

实现

下面的模块只做四件事:逐家准入、429 冷却、确定性分流、带全局预算的重试模拟。它不访问网络,供应商响应通过纯函数注入,因此同一份样本在任何机器上都会得到同一组数字。

// file: src/perf/stability.ts
// tier: L1
// verify: node --test test/perf/stability.test.ts

import { AiError } from "../core/errors.ts";

/** 一个供应商在当前窗口内的硬容量,rpm/tpm 都是每分钟口径。 */
export interface ProviderCapacity {
  providerId: string; rpm: number; tpm: number; maxInflight: number;
  /** 健康时的分流权重;权重不是配额,只是概率。 */
  weight: number; healthy: boolean;
}

/** 单请求上限与全局重试预算必须同时存在。 */
export interface RetryPolicy {
  maxAttempts: number; maxRetryRatio: number; crossProvider: boolean;
  baseCooldownMs: number; maxCooldownMs: number;
}

export const DEFAULT_RETRY: RetryPolicy = {
  maxAttempts: 3, maxRetryRatio: 0.1, crossProvider: true,
  baseCooldownMs: 1_000, maxCooldownMs: 60_000,
};

export interface FleetUsage { requests: number; tokens: number; inflight: number; cooldownUntilMs: number; }
export interface Fleet { capacities: Map<string, ProviderCapacity>; usage: Map<string, FleetUsage>; }
export type AdmissionReason = "cooldown" | "rpm" | "tpm" | "inflight" | "unhealthy";
export type Admission = { ok: true } | { ok: false; reason: AdmissionReason; retryAfterMs: number };

function fail(message: string): never {
  throw new AiError("invalid-request", `stability: ${message}`);
}

function integer(value: number, field: string, min = 0): number {
  if (!Number.isInteger(value) || value < min) fail(`${field} must be an integer >= ${min}`);
  return value;
}

function finite(value: number, field: string, min = 0): number {
  if (!Number.isFinite(value) || value < min) fail(`${field} must be a finite number >= ${min}`);
  return value;
}

export function createFleet(providers: readonly ProviderCapacity[]): Fleet {
  if (providers.length === 0) fail("providers must not be empty");
  const capacities = new Map<string, ProviderCapacity>();
  const usage = new Map<string, FleetUsage>();
  for (const provider of providers) {
    if (provider.providerId.trim() === "") fail("providerId must not be empty");
    if (capacities.has(provider.providerId)) fail(`duplicate provider ${provider.providerId}`);
    integer(provider.rpm, `${provider.providerId}.rpm`, 1);
    integer(provider.tpm, `${provider.providerId}.tpm`, 1);
    integer(provider.maxInflight, `${provider.providerId}.maxInflight`, 1);
    finite(provider.weight, `${provider.providerId}.weight`);
    if (provider.weight <= 0) fail(`${provider.providerId}.weight must be > 0`);
    capacities.set(provider.providerId, { ...provider });
    usage.set(provider.providerId, { requests: 0, tokens: 0, inflight: 0, cooldownUntilMs: 0 });
  }
  return { capacities, usage };
}

function usageOf(fleet: Fleet, providerId: string): FleetUsage {
  const usage = fleet.usage.get(providerId);
  if (!usage) fail(`unknown provider ${providerId}`);
  return usage;
}

/** 检查不修改状态;调用方可以先判断,再决定是否消耗一次重试预算。 */
export function checkAdmission(fleet: Fleet, providerId: string, tokens: number, nowMs: number): Admission {
  const capacity = fleet.capacities.get(providerId);
  const usage = usageOf(fleet, providerId);
  if (!capacity.healthy) return { ok: false, reason: "unhealthy", retryAfterMs: 0 };
  if (nowMs < usage.cooldownUntilMs) {
    return { ok: false, reason: "cooldown", retryAfterMs: usage.cooldownUntilMs - nowMs };
  }
  if (usage.inflight >= capacity.maxInflight) return { ok: false, reason: "inflight", retryAfterMs: 0 };
  if (usage.requests + 1 > capacity.rpm) return { ok: false, reason: "rpm", retryAfterMs: 60_000 };
  if (usage.tokens + tokens > capacity.tpm) return { ok: false, reason: "tpm", retryAfterMs: 60_000 };
  return { ok: true };
}

export function commitAdmission(fleet: Fleet, providerId: string, tokens: number): void {
  const usage = usageOf(fleet, providerId);
  usage.requests += 1; usage.tokens += tokens; usage.inflight += 1;
}

export function release(fleet: Fleet, providerId: string): void {
  const usage = usageOf(fleet, providerId);
  if (usage.inflight > 0) usage.inflight -= 1;
}

/** 429 之后让供应商休息;Retry-After 优先,缺失时用基准值,二者都被上限截断。 */
export function markRateLimited(
  fleet: Fleet, providerId: string, nowMs: number,
  retryAfterMs: number | undefined, policy: RetryPolicy = DEFAULT_RETRY,
): number {
  const delay = Math.max(0, Math.min(retryAfterMs ?? policy.baseCooldownMs, policy.maxCooldownMs));
  const usage = usageOf(fleet, providerId);
  usage.cooldownUntilMs = Math.max(usage.cooldownUntilMs, nowMs + delay);
  return delay;
}

/** 解析 Retry-After:秒数与 HTTP 日期都接受,非法值回退到基准冷却。 */
export function parseRetryAfter(value: string | null, nowMs: number, policy: RetryPolicy = DEFAULT_RETRY): number {
  if (value === null || value.trim() === "") return policy.baseCooldownMs;
  const seconds = Number(value.trim());
  if (Number.isFinite(seconds) && seconds >= 0) return Math.min(seconds * 1000, policy.maxCooldownMs);
  const at = Date.parse(value);
  if (Number.isFinite(at)) return Math.max(0, Math.min(at - nowMs, policy.maxCooldownMs));
  return policy.baseCooldownMs;
}

function fnv1a(text: string): number {
  let hash = 0x811c9dc5;
  for (let index = 0; index < text.length; index += 1) {
    hash ^= text.charCodeAt(index);
    hash = Math.imul(hash, 0x01000193) >>> 0;
  }
  return hash;
}

/** 确定性哈希分流:同一个 requestId 重放多少次,首选顺序都不变。 */
export function orderProviders(fleet: Fleet, requestId: string): string[] {
  return [...fleet.capacities.values()]
    .filter((provider) => provider.healthy)
    .map((provider) => ({
      providerId: provider.providerId,
      score: (fnv1a(`${requestId}:${provider.providerId}`) % 10_000) / provider.weight,
    }))
    .sort((left, right) => left.score - right.score || left.providerId.localeCompare(right.providerId))
    .map((item) => item.providerId);
}

export interface RetryLedger { originalRequests: number; retryLimit: number; retryUsed: number; }

/** 全局重试预算按原始请求数计算,而不是按失败数计算。 */
export function createRetryLedger(originalRequests: number, policy: RetryPolicy = DEFAULT_RETRY): RetryLedger {
  integer(originalRequests, "originalRequests");
  finite(policy.maxRetryRatio, "maxRetryRatio");
  if (policy.maxRetryRatio < 0 || policy.maxRetryRatio > 1) fail("maxRetryRatio must be in [0, 1]");
  integer(policy.maxAttempts, "maxAttempts", 1);
  return { originalRequests, retryLimit: Math.floor(originalRequests * policy.maxRetryRatio), retryUsed: 0 };
}

export function takeRetry(ledger: RetryLedger): boolean {
  if (ledger.retryUsed >= ledger.retryLimit) return false;
  ledger.retryUsed += 1;
  return true;
}

/** 配额池的剩余量:把每个供应商这一分钟还能接的请求数相加。 */
export function poolHeadroom(fleet: Fleet, nowMs: number): number {
  let total = 0;
  for (const capacity of fleet.capacities.values()) {
    const usage = usageOf(fleet, capacity.providerId);
    if (capacity.healthy && nowMs >= usage.cooldownUntilMs) {
      total += Math.max(0, capacity.rpm - usage.requests);
    }
  }
  return total;
}

export interface StormRequest { requestId: string; tokens: number; }
export type StormReplyKind = "ok" | "rate-limit" | "unavailable";
export interface StormReplyInput { providerId: string; request: StormRequest; index: number; attempt: number; }
export interface StormProviderStats {
  attempts: number; ok: number; rateLimited: number; unavailable: number; admissionSkips: number;
}
export interface StormReport {
  original: number; attempts: number; served: number; shed: number;
  rateLimited: number; unavailable: number; admissionSkips: number;
  retryAttempts: number; retryLimit: number; retryBudgetExhausted: number;
  amplification: number; perProvider: Record<string, StormProviderStats>;
}
export interface StormInput {
  fleet: Fleet; requests: readonly StormRequest[]; policy: RetryPolicy;
  /** 必须是确定性纯函数:同一个输入返回同一个结果。 */
  reply(input: StormReplyInput): StormReplyKind;
  nowMs?: number; cooldownMs?: number;
}

/**
 * 跑一批请求,同时观察单请求上限与全局重试预算。
 * 准入失败不算一次尝试,也不消耗重试预算;只有真正发出去的请求才计数。
 */
export function simulateStorm(input: StormInput): StormReport {
  const nowMs = input.nowMs ?? 0;
  const cooldownMs = input.cooldownMs ?? 0;
  const ledger = createRetryLedger(input.requests.length, input.policy);
  const perProvider: Record<string, StormProviderStats> = {};
  for (const providerId of input.fleet.capacities.keys()) {
    perProvider[providerId] = { attempts: 0, ok: 0, rateLimited: 0, unavailable: 0, admissionSkips: 0 };
  }
  let attempts = 0; let served = 0; let shed = 0; let rateLimited = 0;
  let unavailable = 0; let admissionSkips = 0; let retryBudgetExhausted = 0;

  input.requests.forEach((request, index) => {
    let used = 0; let done = false;
    for (const providerId of orderProviders(input.fleet, request.requestId)) {
      if (used >= input.policy.maxAttempts) break;
      if (used > 0 && !input.policy.crossProvider) break;
      if (!checkAdmission(input.fleet, providerId, request.tokens, nowMs).ok) {
        admissionSkips += 1; perProvider[providerId].admissionSkips += 1; continue;
      }
      if (used > 0 && !takeRetry(ledger)) { retryBudgetExhausted += 1; break; }
      commitAdmission(input.fleet, providerId, request.tokens);
      used += 1; attempts += 1; perProvider[providerId].attempts += 1;
      const reply = input.reply({ providerId, request, index, attempt: used });
      if (reply === "ok") {
        served += 1; perProvider[providerId].ok += 1; release(input.fleet, providerId); done = true; break;
      }
      if (reply === "rate-limit") {
        rateLimited += 1; perProvider[providerId].rateLimited += 1;
        markRateLimited(input.fleet, providerId, nowMs, cooldownMs, input.policy);
      } else {
        unavailable += 1; perProvider[providerId].unavailable += 1;
      }
      release(input.fleet, providerId);
    }
    if (!done) shed += 1;
  });

  return {
    original: input.requests.length, attempts, served, shed, rateLimited, unavailable, admissionSkips,
    retryAttempts: ledger.retryUsed, retryLimit: ledger.retryLimit, retryBudgetExhausted,
    amplification: input.requests.length === 0 ? 0 : attempts / input.requests.length, perProvider,
  };
}

checkAdmission 与 commitAdmission 分开,是为了让调用方在真正发请求前决定是否消耗重试预算;准入失败不算一次尝试,也不应该占用预算。simulateStorm 里每一次重试都要先过 takeRetry,这是全局预算唯一的扣减点。

示例样本上的运行结果

样本是 600 条请求,每条 1000 token,三家供应商权重 6:3:1。场景固定:A 对 60% 的下标返回 429,B 对 30% 返回 429,C 永远成功。为了单独观察重试放大,场景把冷却设为 0;冷却本身由另一条测试覆盖。

策略尝试成功放弃重试放大倍数
不重试60032527501.000
宽松重试(预算 100%)102360004231.705
有界重试(预算 10%)660361239601.100

这组数字说明三件事。第一,宽松重试确实把 600 条全部救回来了,但代价是 1023 次尝试;多出来的 423 次全部打在备用供应商身上。第二,10% 预算只多花 60 次尝试,救回 36 条(361 对 325),每次重试的成功率约 60%,不是“重试一定划算”。第三,放大倍数从 1.705 压到 1.100,靠的不是减少单请求上限,而是全局预算。

分供应商看,宽松策略把 C 的尝试从 56 推到 233,是原来的 4.16 倍;B 从 142 推到 336,是 2.37 倍。备胎不是被“用了一下”,而是被当成了第二主供应商。这正是跨供应商重试最容易被忽略的成本:它消耗的是另一家的配额,而那家可能正在服务自己的正常流量。

接到卷一的能力分级上

卷一 10.1 把能力分成可缺、可弱、必须阻塞。限流策略必须跟着分级走,不能所有能力共用一套重试。

分级429 时的动作理由
可缺 optional直接隐藏入口,不重试用户自己也能完成主任务,重试只是在浪费配额
可弱 degradable允许一次跨供应商重试,失败后切规则或模板质量可以下降,但不能没有结果
必须阻塞 blocking不跨供应商重试,直接阻塞并告警备用通道的质量未经该场景验证,放行比停住更危险

还有一条边界:429 不是质量信号。它只说明“现在太快”,不说明“这家模型不适合这个任务”。因为一次 429 就把请求永久切到更差的模型,会把分钟级的容量问题变成长期的质量问题。

小结

供应商限流的核心不是“再找一家”,而是把放大倍数关进预算。429 要按减速信号处理:读 Retry-After、进入冷却、冷却期内不准入。配额池要逐家检查请求数、token 数、在途数与健康状态,剩余量相加只用于判断总量,不替代逐家准入。分流顺序用确定性哈希,保证同一请求可复现。重试同时受单请求上限与全局预算约束,预算按原始请求数计算。

示例样本的结论是:宽松重试用 1.705 倍尝试换回全部 600 条,有界重试用 1.100 倍尝试换回 361 条,不重试保留 325 条。没有一种策略永远正确,但每一种的代价都可以提前算出来。

下一节把同样的预算思路用到降级链上:降级不能无限重试,也不能越降越贵。

阅读导航:上一节:11.3 灰度发布与自动回滚 · 下一节:12.2 降级链的成本约束 。

常见错误与边界

  • 把 429 和 500 放进同一个重试分支。前者要继续减速,后者才适合立即换一次。
  • 忽略 Retry-After 的秒数与日期两种格式。解析失败时静默使用零,会立刻再次撞上限流。
  • 不设冷却上限。对方给一个异常大的值,供应商会被冻结远超必要时间。
  • 冷却按全局而不是按供应商。一家限流导致所有供应商暂停,故障范围被放大。
  • 全局重试预算按失败数计算。故障越严重,允许的重试越多,正好在最不该加流量时加流量。
  • 权重当配额。权重只决定概率,不决定对方允许你发多少。
  • 用随机分流。同一个请求重放落到不同供应商,复盘无法逐条解释。
  • 跨供应商重试不看备用供应商的剩余配额。备胎被打挂,正常流量一起受损。
  • 所有能力共用一套重试策略。可缺能力在 429 时应该隐藏,而不是替用户重试。
  • 把 429 当成质量信号。容量问题被误判为模型不适配,分钟级故障变成长期质量下降。

继续阅读

探索更多技术文章

浏览归档,发现更多关于系统设计、工具链和工程实践的内容。

全部文章 返回首页

「books」更多文章

  1. 《互联网产品的 AI 架构·平台化与治理》附录 C 平台化评审清单
  2. 《互联网产品的 AI 架构·平台化与治理》附录 B 能力目录与配额字段速查
  3. 《互联网产品的 AI 架构·平台化与治理》附录 A 治理角色与职责速查