两个都“有配额”的租户
「回声」的 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 | 身份与租户有效性 | 拒绝,不消耗配额 |
| 2 | token 配额 | 拒绝或降级到经济模型 |
| 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 仍判定可行。那意味着池子在峰值必然排队。
- 所有超限都走同一套降级。配额耗尽与预算耗尽的可用手段不同。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。