《互联网产品的 AI 架构·推理性能与成本工程》8.3 配额与预算的容量映射

大客户买了每天 20 亿 token 的配额,却在峰值把共享池挤满;另一个客户的预算只够撑 23 天,配额却看起来绰绰有余。本节把 token 配额、预算配额与并发配额分开,给出配额到峰值并发、预算到可购买 token 的两条换算链,并说明保底额度、共享池与超配比例怎样决定一个池子是否可行。

两个都“有配额”的租户

「回声」的 AI 平台按租户售卖套餐。大客户 A 签了每天 20 亿 token 的配额,小客户 B 签了每天 5 亿。销售认为两个客户都“有配额”,容量团队按总量准备了一个共享池。

上线后出现两个问题。第一,A 在每天上午的峰值把池子占满,B 的请求开始排队,尽管 B 全天的配额只用了三成。第二,B 的月度预算只够覆盖 23 天的实际消耗,但配额系统从不看预算,到第 24 天账单已经超了,请求还在继续。

两个问题来自同一个混淆:配额、预算与并发是三套不同的约束,却经常被当成一个数字管理。token 配额回答“一天能用多少”,预算回答“一个月能花多少”,并发回答“峰值能同时跑多少”。任何一个缺失,另外两个都会失真。

文中配额与预算是案例假设值,计算结果是公式代入值或示例样本上的运行结果,不是生产实测。

配额有三种口径

口径单位约束的是什么超限后的典型动作
token 配额token/天用量拒绝或降级到小模型
预算配额微元/月花费拒绝、暂停或转人工审批
并发配额并发数同时占用排队、限流或排队超时

token 配额最大的租户不一定最花钱,因为输入输出比例与模型档位不同;预算最大的租户不一定最能占并发,因为请求长度与延迟不同。三套口径必须分别映射到同一套资源,再取最紧的那个约束。

示例里 A 的日配额是 20 亿 token,B 是 5 亿。按每天 2300 token 一次请求折算,A 每天约 87 万次请求,B 约 21.7 万次。这个换算只回答“能做多少次”,还没有回答“峰值同时能做多少次”。

从 token 配额到并发

配额是日总量,并发是瞬时占用,中间需要两个参数:峰值小时占比与单次请求的 token 数。

peakRps     = dailyTokens × peakHourShare / 3600 / avgTokensPerRequest
concurrency = peakRps × latencyMs / 1000

peakHourShare 是峰值小时承载的请求占全天的比例。它不能直接复用 8.1 的 peakFactor:后者是峰值速率相对日均速率的倍数,前者是峰值小时的量占全天的份额,两者定义不同。示例取 12%,表示最忙的一小时承担全天 12% 的请求。

代入 A 的配额:20 亿 × 12% ÷ 3600 ÷ 2300,得到 28.986 RPS;延迟 1800 毫秒,对应 52.174 个并发。B 的 5 亿配额对应 7.246 RPS、13.043 个并发。

这组数字说明一个反直觉的事实:日配额相差 4 倍,峰值并发也相差 4 倍,但绝对值都不大。原因是 2300 token 一次的请求,20 亿 token 也只够 87 万次,摊到一天并不高。容量团队如果只看“20 亿”这个量级,很容易高估它对并发的占用。

从预算到 token

预算与 token 之间隔着输入输出价格。不能用一个“平均单价”,因为输入与输出价格通常差数倍。

blendedPrice = inputShare × inputPrice + outputShare × outputPrice
maxTokens    = floor(monthlyBudget / blendedPrice × 1,000,000)

示例价格是输入 250 微元/百万 token、输出 1000 微元/百万 token;长度分布对应输入占 2000/2300、输出占 300/2300。混合价是:

0.869565 × 250 + 0.130435 × 1000 = 347.826 微元/百万 token

A 的 2500 万微元月预算可以买约 718.75 亿 token,看起来远高于每天 20 亿的配额。但预算要按实际消耗算:A 每天 20 亿 token 的日成本是 695,652 微元,30 天是 20,869,560 微元,预算够用,可支撑约 35.94 天。

B 的日成本是 173,913 微元,30 天需要 5,217,390 微元,月预算只有 4,000,000 微元,只够 23.00 天。配额系统如果只检查“今天有没有超过 5 亿 token”,B 在第 24 天之后仍然会被放行,直到财务发现超支。

预算映射还要回答“最多能做多少次请求”。A 的单次成本是 0.8 微元,2500 万微元对应 3125 万次请求。这个数字应该与 token 配额换算出的请求数取较小值,才是租户真正可用的上限。

共享池与保底额度

多个租户共享一个池子时,最公平的做法不是“谁先到谁用”,而是先保底、再共享。

  • 每个租户有一个 floorTokens,无论池子多紧张都要保留。
  • 池子总日额度减去所有保底额度,剩下的才是共享额度。
  • 共享额度按权重分配,权重通常来自合同或付费档位。
  • 分配后的总 token 再映射成并发,所有租户的并发之和必须装进池子的并发上限。

示例池子有 80 个并发、每天 28 亿 token。A 的保底是 12 亿,B 是 3 亿,保底合计 15 亿,共享额度 13 亿。权重 3:1 分给 A 9.75 亿、B 3.25 亿,最终 A 21.75 亿、B 6.25 亿。

映射到并发后,A 占 56.739,B 占 16.304,合计 73.043,池子上限 80,超配比例 0.913。这个比例小于 1,说明即使所有租户同时打到峰值,池子仍然有富余。保底额度对应的并发是 39.130,占池子的一半左右,这是 B 在峰值不被饿死的底线。

如果保底额度之和超过池子日额度,计划必须判定为不可行,而不是把缺口悄悄摊到共享额度里。示例把池子日额度降到 14 亿,保底合计 15 亿,缺口 1 亿,feasible 直接为 false。这时只有三个选择:提高池子额度、降低保底、或者把某个租户迁到独立池。

实现

下面的模块把价格、预算、租户配额与共享池放在一起。它不访问计费系统,也不发请求,只做确定性的换算与可行性判断。

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

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

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

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;
}

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

export interface TokenPrice {
  inputMicrosPerMTok: number;
  outputMicrosPerMTok: number;
}

export interface TokenMix {
  inputShare: number;
  outputShare: number;
}

export function blendedMicrosPerMTok(price: TokenPrice, mix: TokenMix): number {
  finite(price.inputMicrosPerMTok, "price.inputMicrosPerMTok");
  finite(price.outputMicrosPerMTok, "price.outputMicrosPerMTok");
  finite(mix.inputShare, "mix.inputShare");
  finite(mix.outputShare, "mix.outputShare");
  if (Math.abs(mix.inputShare + mix.outputShare - 1) > 1e-9) fail("token mix shares must sum to 1");
  return mix.inputShare * price.inputMicrosPerMTok + mix.outputShare * price.outputMicrosPerMTok;
}

/** 预算是硬约束:先按输入输出混合价算每百万 token 的微元,再向下取整。 */
export function budgetToTokens(budgetMicros: number, price: TokenPrice, mix: TokenMix): number {
  integer(budgetMicros, "budgetMicros");
  const blended = blendedMicrosPerMTok(price, mix);
  if (blended <= 0) fail("blended price must be > 0");
  return Math.floor((budgetMicros / blended) * 1_000_000);
}

export interface TenantQuota {
  tenantId: string;
  /** 每日 token 配额,按 attempts 计,不扣缓存命中。 */
  dailyTokens: number;
  monthlyBudgetMicros: number;
  /** 保底额度:共享池再紧张也要保留的日 token 数。 */
  floorTokens: number;
  /** 共享池剩余额度的分配权重。 */
  weight: number;
}

export interface QuotaProfile {
  avgTokensPerRequest: number;
  /** 峰值小时承载的请求占全天的比例,例如 0.12。 */
  peakHourShare: number;
  latencyMs: number;
  price: TokenPrice;
  mix: TokenMix;
}

export interface QuotaCapacity {
  tenantId: string;
  avgRps: number;
  peakRps: number;
  peakConcurrency: number;
  dailyCostMicros: number;
  budgetDays: number | null;
  budgetSufficient: boolean;
  maxRequestsFromBudget: number;
}

/** token 配额先换算成峰值 RPS,再用 Little 定律换算成并发。 */
export function peakConcurrencyForTokens(tokensPerDay: number, profile: QuotaProfile): number {
  finite(tokensPerDay, "tokensPerDay");
  finite(profile.avgTokensPerRequest, "avgTokensPerRequest");
  if (profile.avgTokensPerRequest <= 0) fail("avgTokensPerRequest must be > 0");
  if (!(profile.peakHourShare > 0 && profile.peakHourShare <= 1)) fail("peakHourShare must be in (0, 1]");
  finite(profile.latencyMs, "latencyMs");
  const peakRps = (tokensPerDay * profile.peakHourShare) / 3600 / profile.avgTokensPerRequest;
  return (peakRps * profile.latencyMs) / 1000;
}

export function mapQuotaToCapacity(quota: TenantQuota, profile: QuotaProfile): QuotaCapacity {
  if (quota.tenantId.trim() === "") fail("tenantId must not be empty");
  integer(quota.dailyTokens, "dailyTokens");
  integer(quota.monthlyBudgetMicros, "monthlyBudgetMicros");
  integer(quota.floorTokens, "floorTokens");
  if (quota.floorTokens > quota.dailyTokens) fail(`${quota.tenantId}: floorTokens exceeds dailyTokens`);
  finite(quota.weight, "weight");
  if (quota.weight <= 0) fail("weight must be > 0");

  const blended = blendedMicrosPerMTok(profile.price, profile.mix);
  const peakRps = (quota.dailyTokens * profile.peakHourShare) / 3600 / profile.avgTokensPerRequest;
  const dailyCostMicros = Math.round((quota.dailyTokens * blended) / 1_000_000);
  const costPerRequest = (profile.avgTokensPerRequest * blended) / 1_000_000;
  return {
    tenantId: quota.tenantId,
    avgRps: quota.dailyTokens / 86_400 / profile.avgTokensPerRequest,
    peakRps,
    peakConcurrency: peakConcurrencyForTokens(quota.dailyTokens, profile),
    dailyCostMicros,
    budgetDays: dailyCostMicros === 0 ? null : quota.monthlyBudgetMicros / dailyCostMicros,
    budgetSufficient: dailyCostMicros * 30 <= quota.monthlyBudgetMicros,
    maxRequestsFromBudget: costPerRequest === 0 ? 0 : Math.floor(quota.monthlyBudgetMicros / costPerRequest),
  };
}

export interface PoolInput {
  poolConcurrency: number;
  poolDailyTokens: number;
  tenants: readonly TenantQuota[];
  profile: QuotaProfile;
}

export interface TenantAllocation {
  tenantId: string;
  floorTokens: number;
  sharedTokens: number;
  totalTokens: number;
  guaranteedConcurrency: number;
  allocatedConcurrency: number;
}

export interface PoolPlan {
  tenantCount: number;
  floorTokens: number;
  sharedTokens: number;
  floorShortfallTokens: number;
  guaranteedConcurrency: number;
  allocatedConcurrency: number;
  overcommitRatio: number;
  feasible: boolean;
  allocations: TenantAllocation[];
}

function splitByWeight(pool: number, tenants: readonly TenantQuota[]): number[] {
  const weightSum = tenants.reduce((sum, tenant) => sum + tenant.weight, 0);
  const exact = tenants.map((tenant) => (pool * tenant.weight) / weightSum);
  const shares = exact.map((value) => Math.floor(value));
  let remainder = pool - shares.reduce((sum, value) => sum + value, 0);
  const order = exact
    .map((value, index) => ({ index, fraction: value - Math.floor(value) }))
    .sort((left, right) => right.fraction - left.fraction || left.index - right.index);
  for (const item of order) {
    if (remainder <= 0) break;
    shares[item.index] += 1;
    remainder -= 1;
  }
  return shares;
}

/** 保底额度先扣,剩余共享额度按权重分配;映射到并发后必须装进池子。 */
export function planSharedPool(input: PoolInput): PoolPlan {
  integer(input.poolConcurrency, "poolConcurrency");
  integer(input.poolDailyTokens, "poolDailyTokens");
  if (input.tenants.length === 0) fail("tenants must not be empty");
  const floorTokens = input.tenants.reduce((sum, tenant) => sum + tenant.floorTokens, 0);
  const floorShortfallTokens = Math.max(0, floorTokens - input.poolDailyTokens);
  const sharedTokens = Math.max(0, input.poolDailyTokens - floorTokens);
  const shares = floorShortfallTokens > 0 ? input.tenants.map(() => 0) : splitByWeight(sharedTokens, input.tenants);
  const allocations = input.tenants.map((tenant, index) => {
    const totalTokens = tenant.floorTokens + shares[index];
    return {
      tenantId: tenant.tenantId,
      floorTokens: tenant.floorTokens,
      sharedTokens: shares[index],
      totalTokens,
      guaranteedConcurrency: peakConcurrencyForTokens(tenant.floorTokens, input.profile),
      allocatedConcurrency: peakConcurrencyForTokens(totalTokens, input.profile),
    };
  });
  const guaranteedConcurrency = allocations.reduce((sum, item) => sum + item.guaranteedConcurrency, 0);
  const allocatedConcurrency = allocations.reduce((sum, item) => sum + item.allocatedConcurrency, 0);
  return {
    tenantCount: input.tenants.length,
    floorTokens,
    sharedTokens,
    floorShortfallTokens,
    guaranteedConcurrency,
    allocatedConcurrency,
    overcommitRatio: input.poolConcurrency === 0 ? 0 : allocatedConcurrency / input.poolConcurrency,
    feasible: floorShortfallTokens === 0 && allocatedConcurrency <= input.poolConcurrency,
    allocations,
  };
}

blendedMicrosPerMTok 强制输入输出占比之和为 1,避免“只算了输入价”的低估。mapQuotaToCapacity 同时返回峰值并发、日成本、预算可支撑天数与预算内最大请求数,四个数字分别服务容量、财务与配额系统。planSharedPool 先扣保底再分共享,并把并发可行性作为独立字段返回。

示例样本上的运行结果

示例脚本用两个租户、一个 80 并发的共享池跑完整条链。执行:

node examples/perf/quota-map.ts

本机一次运行的真实输出如下:

tenant-a peak_rps=28.986 concurrency=52.174 daily_cost=695652 budget_days=35.94 budget_ok=true
tenant-b peak_rps=7.246 concurrency=13.043 daily_cost=173913 budget_days=23.00 budget_ok=false
floors=1500000000 shared=1300000000
allocated_concurrency=73.043 overcommit=0.913 feasible=true
tenant-a floor=1200000000 shared=975000000 total=2175000000
tenant-b floor=300000000 shared=325000000 total=625000000

这组数字给出三个判断。第一,A 的配额大,但峰值并发只有 52.174,不到池子的七成。第二,B 的预算只够 23 天,budget_ok=false,配额系统必须结合预算做拦截,不能只看 token。第三,池子在峰值下的超配比例是 0.913,仍有 6.957 个并发的富余;这个富余不是无限的,租户新增或权重调整都要重新计算。

配额执行点与降级顺序

配额最终要在网关执行。执行顺序会决定用户看到什么错误:

顺序检查失败后的动作
1身份与租户有效性拒绝,不消耗配额
2token 配额拒绝或降级到经济模型
3并发配额排队,排队超时后拒绝
4预算配额拒绝或转人工审批

把预算检查放在最后,是因为它最贵:查预算通常要访问计费存储,而 token 与并发检查可以在本地完成。先做便宜的检查,能挡掉大部分无效请求。

降级顺序也要写清楚。token 配额耗尽时,降级到更小的模型仍然消耗 token,只是单价更低,不能解决配额问题;预算耗尽时,降级模型可以延长可用时间,但不能无限延长。两种超限的降级策略不应该共用一套逻辑。

小结

配额、预算与并发是三套约束。token 配额通过峰值小时占比与单次 token 数映射成峰值 RPS,再通过 Little 定律映射成并发;预算通过输入输出混合价映射成可购买 token 与可支撑天数。共享池先扣保底额度,再按权重分配剩余部分,最后用并发之和与池子上限比较,得出超配比例与可行性。

示例样本的结论是:A 的 20 亿日配额对应 52.174 个峰值并发、每天 695,652 微元;B 的预算只够 23 天,必须在配额之外单独拦截;80 并发的池子在 73.043 的峰值占用下可行,超配比例 0.913。下一节进入成本模型,讨论这些微元数字背后还有哪些隐形成本。

阅读导航:上一节:8.2 峰值、余量与弹性 · 下一节:9.1 成本模型与隐形成本 。

常见错误与边界

  • 把 token 配额当预算。配额没用完不代表没超支,单价与输入输出比例都会改变花费。
  • 把预算当并发。预算只约束总量,不约束峰值同时占用。
  • 用日配额除以 86400 当峰值。没有峰值小时占比,得到的只是日均速率。
  • 复用 8.1 的峰值系数当峰值小时占比。两个参数定义不同,不能互换。
  • 用平均单价算预算。输入与输出价格差数倍,混合价必须按实际比例算。
  • 只看配额不看预算可支撑天数。23 天的预算会在月底前变成超支。
  • 共享池先到先得。大租户的峰值会把小租户的保底额度挤掉。
  • 保底额度超过池子容量时继续分配。缺口必须显式报错,不能悄悄摊薄。
  • 超配比例大于 1 仍判定可行。那意味着池子在峰值必然排队。
  • 所有超限都走同一套降级。配额耗尽与预算耗尽的可用手段不同。

继续阅读

探索更多技术文章

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

全部文章 返回首页

「books」更多文章

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