《互联网产品的 AI 架构·推理性能与成本工程》8.1 从业务量到 token 需求

「回声」要为一季度做容量预算,营销只给日活与人均请求,财务只问 token 总量和峰值。本节把业务量拆成请求数、长度分布、重试系数与峰值系数,给出可代入的算式、缓存对配额与成本的不同影响,以及从峰值 RPS 到并发的 Little 定律实现;所有数字都标注为公式代入值或示例样本运行结果。

一张对不上的容量预算表

「回声」在一季度规划会上讨论 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%800150
标准对话30%2400400
长文档总结10%8000900

加权后平均输入 2000、平均输出 300。公式代入值如下,全部是示例样本上的计算结果:

指标公式代入值
日请求数3,000,000
日尝试数3,240,000
提供方调用数2,430,000
日均 RPS37.5
峰值 RPS168.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 定律的结果会差一千倍。
  • 把公式代入值写成生产实测。数字必须有出处,示例值只能用于验证算式。

继续阅读

探索更多技术文章

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

全部文章 返回首页

「books」更多文章

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