一张对不上的容量预算表
「回声」在一季度规划会上讨论 AI 容量。营销给出的输入只有两行:日活 50 万,人均每天 6 次 AI 请求。财务问三件事:一天要买多少 token、峰值要多少并发、预算按哪个数字报。
工程同学当场算了一遍:50 万乘 6 等于 300 万次请求,再乘每次 2000 个输入 token,得到 60 亿输入 token。他把这个数字写进表格,会议就结束了。
两周后账单出来,配额消耗比表格高 24%,峰值时段仍然排队。问题不在乘法,而在他漏掉了四个乘数:长度不是单一值、失败会重试、缓存只省成本不省配额、峰值不是日均。任何一个漏掉,容量表都会在真实流量面前失真。
本节把这些乘数拆开,给出一组可以逐项代入的算式。文中业务量是案例假设值,计算结果是公式代入值或 ai-lab 示例样本上的运行结果,不是任何生产系统的实测。
业务量的五个乘数
日活与人均请求
DAU 与 requestsPerUserPerDay 相乘得到日请求数。两个输入都必须写清口径:日活是“打开过产品的人”还是“调用过 AI 的人”,人均请求是全体用户平均还是仅 AI 用户平均。口径不同,结果可能差一倍。
如果只有月活,不能直接除以 30 当默认日活。月活与日活之间有一个活跃度比例,这个比例应该从产品自己的数据取,而不是拍一个 20%。容量表可以接受估算,但不能接受没有出处的估算。
长度分布不能用一个平均值
同一个能力下,短问答、带历史对话的追问、带长文档的总结,输入长度可能相差十倍。用平均值会把长尾的成本摊薄,也会让峰值 token 速率被低估。
可行的做法是分三到五档,每档给出占比与平均输入、输出长度,再按占比加权。档位不需要很细,但必须覆盖最长的那一档,因为容量风险通常来自长尾而不是中位数。
重试系数
失败重试会让实际尝试次数高于业务请求数。可重试错误包括限流、超时、提供方 5xx;不可重试错误包括参数错误、内容拦截。只有可重试错误才应该进入系数。
attemptsPerDay = requestsPerDay × (1 + retryRate)。retryRate 是“额外尝试占原请求的比例”,不是“失败率”。失败率 8% 且每次失败重试一次,额外尝试约为 8%;如果失败后重试两次,系数还要再乘。把失败率直接当重试率,会少算第二、三次尝试。
峰值系数
peakFactor 是峰值速率相对日均速率的倍数。它回答的是“最忙的那一小时有多忙”,不是“一天里有多少比例发生在峰值”。如果团队只有小时级分布,可以先算峰值小时请求数除以 24 小时平均数,得到这个倍数。
峰值系数应该按能力分别取。客服回复在工作时间集中,营销文案生成可能在活动开始前集中,两者的峰值形状完全不同。全书用一个统一系数,等于假设所有能力的用户行为一样。
可代入的算式
把五个乘数写成一条链:
requestsPerDay = DAU × requestsPerUserPerDay
attemptsPerDay = requestsPerDay × (1 + retryRate)
providerCallsPerDay = attemptsPerDay × (1 - cacheHitRate)
avgInputTokens = Σ share_i × inputTokens_i
avgOutputTokens = Σ share_i × outputTokens_i
dailyBillableTokens = attemptsPerDay × (avgInputTokens + avgOutputTokens)
avgRps = attemptsPerDay / 86400
peakRps = avgRps × peakFactor
peakTokensPerSecond = peakRps × (avgInputTokens + avgOutputTokens)
concurrency ≈ peakRps × latencyMs / 1000
最后一行的 concurrency 是 Little 定律的简化形式:并发等于到达速率乘单次停留时间。这里的停留时间应该是端到端延迟,包含排队、预填充与解码;只拿模型推理时间代入,会低估并发。
代入一组示例值:日活 50 万、人均 6 次、重试率 8%、缓存命中率 25%、峰值系数 4.5。长度分布取三档:
| 档位 | 占比 | 输入 token | 输出 token |
|---|---|---|---|
| 短问答 | 60% | 800 | 150 |
| 标准对话 | 30% | 2400 | 400 |
| 长文档总结 | 10% | 8000 | 900 |
加权后平均输入 2000、平均输出 300。公式代入值如下,全部是示例样本上的计算结果:
| 指标 | 公式代入值 |
|---|---|
| 日请求数 | 3,000,000 |
| 日尝试数 | 3,240,000 |
| 提供方调用数 | 2,430,000 |
| 日均 RPS | 37.5 |
| 峰值 RPS | 168.75 |
| 峰值输入 token/秒 | 337,500 |
| 峰值输出 token/秒 | 50,625 |
| 计费 token/天 | 7,452,000,000 |
| 提供方 token/天 | 5,589,000,000 |
这张表解释了开头为什么会对不上:工程同学只算了 60 亿输入 token,既没有加输出,也没有区分计费与提供方消耗。财务看到的配额是 74.52 亿,采购看到的提供方消耗是 55.89 亿,两者相差的正是 25% 的缓存命中。
缓存改变成本,不改变配额
缓存命中率是最容易被误用的乘数。命中缓存时,请求没有真正调用模型,提供方 token 不应该计入成本;但用户仍然发起了这次请求,配额系统通常仍按请求或按预估 token 扣减。
因此同一份业务量要产出两个不同的 token 数字:
dailyBillableTokens用于配额、租户限额与账单上限,按attempts计。dailyProviderTokens用于模型采购与毛利测算,按providerCalls计。
把两者混成一个数字,会出现两种相反的错:用提供方消耗去报配额,配额永远用不完;用计费 token 去谈采购价,会多买 25% 的容量。两个数字都要进容量表,并注明各自服务的决策。
从 token 到并发
容量规划最终要落到并发。token/秒描述的是吞吐,并发描述的是同时有多少请求停在系统里。两者通过延迟连接:
并发 = 峰值 RPS × 端到端延迟(秒)
示例里峰值 RPS 是 168.75,端到端延迟取 1800 毫秒,得到 303.75 个并发。再留 20% 余量,向上取整为 365。这个数字不是“模型能同时跑 365 个”,而是“网关在峰值时需要允许 365 个请求处于排队、推理或流式返回的某个阶段”。
延迟口径必须和容量口径一致。如果延迟只统计模型调用,不含排队,那么算出来的并发是“模型内并发”,不是“网关并发”。前者用于推理集群,后者用于准入控制,不能互换。
实现
下面的模块只做需求换算与并发换算,不读取监控,也不访问模型。它把“请求、尝试、提供方调用”三个数量分开,避免后面每一步都重新解释口径。
// file: src/perf/capacity.ts
// tier: L1
// verify: node --test test/perf/capacity.test.ts
import { AiError } from "../core/errors.ts";
export interface LengthBucket {
/** 该长度档占请求的比例,全部档位之和必须为 1。 */
share: number;
inputTokens: number;
outputTokens: number;
}
export interface DemandInput {
dau: number;
requestsPerUserPerDay: number;
lengthMix: readonly LengthBucket[];
/** 可重试失败带来的额外尝试比例,0.08 表示请求量乘以 1.08。 */
retryRate: number;
/** 峰值速率相对日均速率的倍数。 */
peakFactor: number;
/** 结果缓存命中率;命中请求不消耗提供方 token,但仍占配额。 */
cacheHitRate?: number;
}
export interface TokenDemand {
requestsPerDay: number;
attemptsPerDay: number;
providerCallsPerDay: number;
avgInputTokens: number;
avgOutputTokens: number;
dailyInputTokens: number;
dailyOutputTokens: number;
dailyBillableTokens: number;
avgRps: number;
peakRps: number;
peakInputTokensPerSecond: number;
peakOutputTokensPerSecond: number;
}
function fail(message: string): never {
throw new AiError("invalid-request", `capacity: ${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;
}
/** 长度分布按档位加权,避免用一个平均值抹掉长尾对 token 总量的贡献。 */
export function weightedAverage(mix: readonly LengthBucket[], pick: (bucket: LengthBucket) => number): number {
if (mix.length === 0) fail("lengthMix must not be empty");
let shareSum = 0;
let total = 0;
for (const bucket of mix) {
finite(bucket.share, "lengthMix.share");
finite(bucket.inputTokens, "lengthMix.inputTokens");
finite(bucket.outputTokens, "lengthMix.outputTokens");
shareSum += bucket.share;
total += bucket.share * pick(bucket);
}
if (Math.abs(shareSum - 1) > 1e-9) fail(`lengthMix shares must sum to 1, got ${shareSum}`);
return total;
}
/**
* 业务量到 token 需求:先算请求,再算尝试,再按缓存命中折算提供方调用。
* 配额按 attempts 计,成本按 providerCalls 计,两者不能混用。
*/
export function estimateTokenDemand(input: DemandInput): TokenDemand {
finite(input.dau, "dau");
finite(input.requestsPerUserPerDay, "requestsPerUserPerDay");
finite(input.retryRate, "retryRate");
if (input.peakFactor < 1) fail("peakFactor must be >= 1");
const cacheHitRate = input.cacheHitRate ?? 0;
if (cacheHitRate < 0 || cacheHitRate >= 1) fail("cacheHitRate must be in [0, 1)");
const avgInputTokens = weightedAverage(input.lengthMix, (bucket) => bucket.inputTokens);
const avgOutputTokens = weightedAverage(input.lengthMix, (bucket) => bucket.outputTokens);
const requestsPerDay = Math.round(input.dau * input.requestsPerUserPerDay);
const attemptsPerDay = Math.round(requestsPerDay * (1 + input.retryRate));
const providerCallsPerDay = Math.round(attemptsPerDay * (1 - cacheHitRate));
const dailyInputTokens = Math.round(providerCallsPerDay * avgInputTokens);
const dailyOutputTokens = Math.round(providerCallsPerDay * avgOutputTokens);
const avgRps = attemptsPerDay / 86_400;
const peakRps = avgRps * input.peakFactor;
return {
requestsPerDay,
attemptsPerDay,
providerCallsPerDay,
avgInputTokens,
avgOutputTokens,
dailyInputTokens,
dailyOutputTokens,
dailyBillableTokens: Math.round(attemptsPerDay * (avgInputTokens + avgOutputTokens)),
avgRps,
peakRps,
peakInputTokensPerSecond: peakRps * avgInputTokens,
peakOutputTokensPerSecond: peakRps * avgOutputTokens,
};
}
export interface ConcurrencyInput {
peakRps: number;
latencyMs: number;
/** 在 Little 定律结果之上额外预留的比例。 */
headroomRatio?: number;
}
export interface ConcurrencyEstimate {
baseConcurrency: number;
plannedConcurrency: number;
headroomConcurrency: number;
}
/** Little 定律:并发 = 到达速率 × 单次停留时间。延迟包含排队与生成。 */
export function estimateConcurrency(input: ConcurrencyInput): ConcurrencyEstimate {
finite(input.peakRps, "peakRps");
finite(input.latencyMs, "latencyMs");
const headroom = input.headroomRatio ?? 0;
if (headroom < 0) fail("headroomRatio must be >= 0");
const base = (input.peakRps * input.latencyMs) / 1000;
const planned = Math.ceil(base * (1 + headroom));
return { baseConcurrency: base, plannedConcurrency: planned, headroomConcurrency: planned - base };
}
export * from "./capacity-peak.ts";
export * from "./capacity-quota.ts";
weightedAverage 强制所有档位占比之和为 1。这个约束看起来苛刻,但容量表最常见的错误就是“只写了 80% 的流量”,剩下 20% 不知去向。与其让错误悄悄进入乘法,不如在入口拒绝。
estimateTokenDemand 把计费与提供方调用分成两个字段,调用方必须显式选择用哪一个。estimateConcurrency 则只接受 RPS 与毫秒,单位写死在函数签名里,减少“秒与毫秒混用”的经典错误。
示例样本上的运行结果
测试用上面那组示例值跑完整条链,断言每个中间量。执行:
node --test test/perf/capacity.test.ts
本机一次运行的真实输出摘要如下,耗时随机器变化:
ℹ tests 15
ℹ pass 15
ℹ fail 0
ℹ duration_ms 183.887334
需求用例得到的断言值与公式代入值一致:日请求 300 万、尝试 324 万、提供方调用 243 万、计费 token 74.52 亿、峰值 RPS 168.75。并发用例在 1800 毫秒延迟下得到基础并发 303.75,预留 20% 后为 365。
这些数字的意义不是“照抄到生产”,而是证明算式可以被逐项复核。换成自己产品的日活、长度分布与延迟,同一套函数应该给出另一组同样可解释的数字。
三个数字,三种用途
容量表最终要交付给三类人,他们需要的不是同一个数:
| 角色 | 需要的数字 | 用途 |
|---|---|---|
| 财务 | 计费 token/天与月预算 | 配额、账单上限、租户报价 |
| 采购 | 提供方 token/天与峰值 token/秒 | 模型额度、区域容量、单价谈判 |
| 工程 | 峰值 RPS 与并发 | 实例数、准入上限、扩容策略 |
如果只交付一个“总 token 数”,三方会各自解释,最后在事故复盘时才发现口径不同。把三个数字并排写进同一张表,并在表头注明是否含重试、是否扣缓存,比任何口头说明都有效。
小结
从业务量到 token 需求是一条乘法链:日活乘人均请求得到请求数,重试系数把它放大成尝试数,缓存命中率再把它折算成提供方调用。长度分布决定平均 token,峰值系数把日均速率放大成峰值速率,Little 定律把峰值速率与延迟换算成并发。
示例样本给出的结论是:50 万日活对应峰值 168.75 RPS、约 365 个并发、每天 74.52 亿计费 token。这个结果只有在长度分布、重试率与峰值系数都写清楚时才有意义。下一节讨论峰值本身怎么估、余量留多少,以及弹性扩容的冷启动会把多少请求变成积压。
阅读导航:上一节:7.3 批处理调度与成本模型 · 下一节:8.2 峰值、余量与弹性 。
常见错误与边界
- 用月活除以 30 当日活。活跃度比例因产品而异,除出来的数字没有出处。
- 长度只取平均值。长尾档位被摊薄,峰值 token 速率被低估。
- 把失败率当重试率。失败后重试两次时,额外尝试不止一份。
- 峰值系数全站统一。不同能力的用户行为不同,峰值形状也不同。
- 用提供方 token 报配额。缓存命中被重复扣除,配额永远用不满。
- 用计费 token 谈采购。多买的那部分容量来自缓存命中,不是真实消耗。
- 并发只按模型推理时间算。排队与流式返回同样占用网关并发。
- 秒与毫秒混用。Little 定律的结果会差一千倍。
- 把公式代入值写成生产实测。数字必须有出处,示例值只能用于验证算式。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。