一句“转圈很久”背后有三个数字
「回声」的私信助手改成流式输出后,客服收到的抱怨反而变多了。用户说不清哪里慢,只会说“转圈很久”或者“出字以后一顿一顿”。值班同学打开看板,只看到一个 P95 延迟 1.8s,既不知道这 1.8 秒花在等第一个字,还是花在把一句话吐完,也不知道是不是所有人都慢。
平均值和单一 P95 都无法回答这类问题。流式体验至少有三个独立口径:用户多久看到第一个字、首字之后每个字来得多快、整段输出多久结束。三个数字对应三种完全不同的优化手段:加 worker、换解码策略、缩短输出。把它们混成一个数,团队就只能靠猜。
本节把这三个口径写成可逐条断言的时间线,并给出一条预算约束:端到端预算必须覆盖首字与剩余 token 的解码,否则“三项都达标”只是一句自相矛盾的话。文中的耗时数字都来自 ai-lab 的确定性时间线样例,标注为“示例样本上的运行结果”,不是生产实测。
三个口径:TTFT、ITL/TPOT 与 E2E
先把定义钉死。同名指标在不同团队之间打架,通常不是算法错,而是起点和终点不一样。
| 指标 | 起点 | 终点 | 回答的问题 |
|---|---|---|---|
| TTFT | 网关接单 | 第一个 token 到达 | 用户多久看到第一个字 |
| ITL | 第 i-1 个 token | 第 i 个 token | 流式输出是否顺滑 |
| TPOT | 第一个 token | 最后一个 token,除以 token 数减一 | 解码阶段平均每个 token 多快 |
| E2E | 网关接单 | 最后一个 token | 整段输出多久完成 |
TTFT 有服务端与客户端两个版本。服务端从收到请求算起,客户端还要加上网络与首包传输。对产品承诺应当用客户端口径,对模型优化用服务端口径。混用会让“优化了 200ms”在用户那里完全不可见。
ITL 与 TPOT 也不是一回事。TPOT 是平均值,ITL 是逐 token 的分布。一个请求可以 TPOT 很漂亮,但中间卡了两次 300ms;平均看不出来,用户能感觉到。因此汇总时既报 TPOT 的 P95,也报 ITL 的 P95。
卷一 12.1 的观测字段把 providerMs 当成一个整体。本节把它继续拆成预填充与解码:只有拆开,才知道该优化首字还是优化吐字速度。
首字由三段组成
TTFT 不是一个不可再分的数字。从网关接单到第一个 token,至少经过三段:
TTFT = 排队(queue) + 冷启动(cold start) + 预填充(prefill)
- 排队:请求已经到达,但没有空闲 worker。它随到达率、worker 数和单请求占用时间变化。
- 冷启动:worker 已分配,但模型还没加载完、显存还没热身。它通常只在每个 worker 的第一条请求上出现。
- 预填充:模型开始处理输入,直到吐出第一个 token。它主要随输入长度增长。
三段都表现为“首字慢”,但处理方式完全不同。排队要加容量或限流;冷启动要预热、保留热池或把冷启动挪到发布阶段;预填充要缩短上下文、做前缀复用或换更快的模型。如果只看一个 TTFT 数字,团队很容易把冷启动误判成容量不足,然后加了一堆 worker,冷启动依旧。
区分它们不需要更复杂的监控,只需要四个时刻:接单、分配 worker、模型就绪、首 token。再加每个 token 的到达时刻,就能同时算出 ITL、TPOT 与 E2E。
预算关系:三项分别签,不能取平均
预算最容易犯的错,是给一个“平均延迟 1 秒”的目标。平均值既不能约束首字,也不能约束长输出。正确的做法是三项分别签,并让它们互相自洽:
E2E 预算 ≥ TTFT 预算 + TPOT 预算 × (最大输出 token 数 - 1)
例如首字预算 800ms、TPOT 预算 20ms、最大输出 512 token,端到端预算至少要 800 + 20×511 = 11020ms。如果产品只肯给 8 秒,那么要么降低最大输出,要么提高 TPOT,三者不能同时成立。
这条约束还有一个诊断价值:它能立刻抓出“首字预算 800ms、端到端预算 900ms、最大输出 512”的预算表。后者不是紧,而是不可能。预算表一旦不自洽,后面所有达标结论都不值得相信。
ADR-1.1:首字预算与解码预算分开审批
- 决策:交互式能力默认签
TTFT ≤ 800ms、TPOT ≤ 25ms,端到端预算由最大输出推导;三项分别作为发布门禁。 - 备选一:只签一个平均延迟。被否,因为平均值会把冷启动与长尾抹平。
- 备选二:只签端到端。被否,因为超标时无法判断该优化预填充还是解码。
- 后果:必须采集逐 token 时间戳;看板必须同时展示三个分位数,不能只留一个总分。
为什么用时间线而不是阶段声明耗时
卷一 13.3 的基线用六个阶段的“声明耗时”求和,好处是同一份样本在任何机器上都得到同一组指标。但声明耗时有一个盲区:它假设各段顺序正确、没有重叠、也没有漏记。一旦时钟不一致或某段被重复计算,求和仍然“看起来合理”。
本节改用同一时钟上的时间线,并在入口处校验四条约束:接单不晚于分配、分配不晚于就绪、就绪不晚于首 token、token 时刻严格递增。约束不能证明数字正确,但能拦住自相矛盾的记录。对性能数据来说,一条自相矛盾的样本比没有样本更危险,因为它会悄悄污染分位数。
实现
下面的模块只做三件事:把时间线拆成首字三段与解码段、校验预算是否覆盖最大输出、按分位数汇总。它不访问网络,也不依赖任何 APM 库。
// file: src/perf/metrics.ts
// tier: L1
// verify: node --test test/perf/metrics.test.ts
import { AiError } from "../core/errors.ts";
import { percentile } from "../observe/baseline.ts";
/**
* 一次流式请求的时间线。所有时刻来自同一个单调时钟。
* 拆成四个时刻,才能把「用户等首字」拆成排队、冷启动与预填充。
*/
export interface StreamTimeline {
requestId: string;
/** 网关接单时刻,用户感知的延迟从这里起算。 */
enqueuedAtMs: number;
/** 调度器把请求分配给 worker 的时刻。 */
admittedAtMs: number;
/** 模型可以开始预填充的时刻;冷启动会把它整体推迟。 */
readyAtMs: number;
/** 每个输出 token 的到达时刻,必须包含首 token 且严格递增。 */
tokenAtMs: readonly number[];
}
export interface FirstTokenSplit {
queueMs: number;
coldStartMs: number;
prefillMs: number;
/** 用户视角的 TTFT:排队 + 冷启动 + 预填充。 */
ttftMs: number;
}
export interface LatencyBreakdown {
requestId: string;
tokens: number;
firstToken: FirstTokenSplit;
/** 相邻 token 的间隔,长度等于 tokens - 1。 */
itlMs: readonly number[];
/** 解码阶段平均每个 token 的耗时。 */
tpotMs: number;
decodeMs: number;
e2eMs: number;
}
function fail(message: string): never {
throw new AiError("invalid-request", `metrics: ${message}`);
}
function assertTimestamp(value: number, label: string): void {
if (!Number.isFinite(value) || value < 0) fail(`${label} 必须是非负有限数`);
}
/**
* 把一条时间线拆成可解释的分段。
* 约束不只是“字段非负”,还包括时刻单调与 token 严格递增:
* 一条自相矛盾的时间线算出来的 P95 没有意义。
*/
export function decomposeTimeline(timeline: StreamTimeline): LatencyBreakdown {
assertTimestamp(timeline.enqueuedAtMs, "enqueuedAtMs");
assertTimestamp(timeline.admittedAtMs, "admittedAtMs");
assertTimestamp(timeline.readyAtMs, "readyAtMs");
for (const [index, at] of timeline.tokenAtMs.entries()) {
assertTimestamp(at, `tokenAtMs[${index}]`);
}
if (timeline.tokenAtMs.length === 0) {
fail(`${timeline.requestId}: 至少需要一个输出 token`);
}
if (timeline.enqueuedAtMs > timeline.admittedAtMs) {
fail(`${timeline.requestId}: admittedAtMs 早于 enqueuedAtMs`);
}
if (timeline.admittedAtMs > timeline.readyAtMs) {
fail(`${timeline.requestId}: readyAtMs 早于 admittedAtMs`);
}
const firstAt = timeline.tokenAtMs[0];
const lastAt = timeline.tokenAtMs[timeline.tokenAtMs.length - 1];
if (timeline.readyAtMs > firstAt) {
fail(`${timeline.requestId}: 首 token 早于模型就绪`);
}
for (let index = 1; index < timeline.tokenAtMs.length; index += 1) {
if (timeline.tokenAtMs[index] <= timeline.tokenAtMs[index - 1]) {
fail(`${timeline.requestId}: token 时刻必须严格递增`);
}
}
const queueMs = timeline.admittedAtMs - timeline.enqueuedAtMs;
const coldStartMs = timeline.readyAtMs - timeline.admittedAtMs;
const prefillMs = firstAt - timeline.readyAtMs;
const ttftMs = firstAt - timeline.enqueuedAtMs;
const decodeMs = lastAt - firstAt;
const tokens = timeline.tokenAtMs.length;
const itlMs = timeline.tokenAtMs.slice(1).map((at, index) => at - timeline.tokenAtMs[index]);
// TPOT 不含首 token:它衡量的是首字之后每个 token 的平均间隔。
const tpotMs = tokens === 1 ? 0 : decodeMs / (tokens - 1);
return {
requestId: timeline.requestId,
tokens,
firstToken: { queueMs, coldStartMs, prefillMs, ttftMs },
itlMs,
tpotMs,
decodeMs,
e2eMs: lastAt - timeline.enqueuedAtMs,
};
}
export interface LatencyBudget {
/** 首字预算:从接单到第一个 token。 */
ttftMs: number;
/** 单 token 预算:首字之后每个 token 的平均间隔。 */
tpotMs: number;
/** 端到端预算:从接单到最后一个 token。 */
e2eMs: number;
}
/**
* 预算之间必须自洽:端到端预算至少要覆盖首字预算与剩余 token 的解码预算。
* 否则一份“三项都达标”的预算在最大输出时必然违约。
*/
export function assertBudgetCoversTokens(budget: LatencyBudget, maxOutputTokens: number): void {
for (const [label, value] of Object.entries(budget)) assertTimestamp(value, label);
if (maxOutputTokens < 1 || !Number.isInteger(maxOutputTokens)) {
fail("maxOutputTokens 必须是正整数");
}
const minimum = budget.ttftMs + budget.tpotMs * (maxOutputTokens - 1);
if (budget.e2eMs < minimum) {
fail(`e2eMs=${budget.e2eMs} 覆盖不了 ttftMs + tpotMs×(tokens-1)=${minimum}`);
}
}
export interface BudgetViolation {
metric: "ttft" | "tpot" | "e2e";
actualMs: number;
budgetMs: number;
}
/** 三项分开判定,避免一个超标的平均值把首字违约掩盖掉。 */
export function checkLatencyBudget(
breakdown: LatencyBreakdown,
budget: LatencyBudget,
): { ok: boolean; violations: BudgetViolation[] } {
const violations: BudgetViolation[] = [];
if (breakdown.firstToken.ttftMs > budget.ttftMs) {
violations.push({ metric: "ttft", actualMs: breakdown.firstToken.ttftMs, budgetMs: budget.ttftMs });
}
if (breakdown.tpotMs > budget.tpotMs) {
violations.push({ metric: "tpot", actualMs: breakdown.tpotMs, budgetMs: budget.tpotMs });
}
if (breakdown.e2eMs > budget.e2eMs) {
violations.push({ metric: "e2e", actualMs: breakdown.e2eMs, budgetMs: budget.e2eMs });
}
return { ok: violations.length === 0, violations };
}
export interface LatencySummary {
samples: number;
ttftP50Ms: number;
ttftP95Ms: number;
itlP95Ms: number;
tpotP50Ms: number;
tpotP95Ms: number;
e2eP50Ms: number;
e2eP95Ms: number;
}
function pick(values: readonly number[], ratio: number): number {
return percentile(values, ratio);
}
/**
* 汇总只报分位数,不报平均值。
* 流式体验由首字和长尾决定,平均值会把一次 3 秒的冷启动抹成“还不错”。
*/
export function summarizeLatency(breakdowns: readonly LatencyBreakdown[]): LatencySummary {
const ttft = breakdowns.map((item) => item.firstToken.ttftMs);
const tpot = breakdowns.map((item) => item.tpotMs);
const e2e = breakdowns.map((item) => item.e2eMs);
const itl = breakdowns.flatMap((item) => item.itlMs);
return {
samples: breakdowns.length,
ttftP50Ms: pick(ttft, 0.5),
ttftP95Ms: pick(ttft, 0.95),
itlP95Ms: pick(itl, 0.95),
tpotP50Ms: pick(tpot, 0.5),
tpotP95Ms: pick(tpot, 0.95),
e2eP50Ms: pick(e2e, 0.5),
e2eP95Ms: pick(e2e, 0.95),
};
}
decomposeTimeline 是唯一的拆分入口,TTFT 与 E2E 都由时刻相减得到,不单独传参,避免“总数与分段各记一套”的经典错误。checkLatencyBudget 返回违约列表而不是直接抛错,是因为一次请求可能同时违反首字与端到端;只报第一个会掩盖第二个。
percentile 直接复用卷一 13.3 的最近秩实现。性能与基线必须使用同一个分位数定义,否则同一个 P95 在两个看板上会得到不同数字。
测试与示例样本结果
测试覆盖拆分、ITL/TPOT 语义、时刻倒置、token 不递增、逐项预算、预算覆盖与分位数汇总。执行:
node --test test/perf/metrics.test.ts
本机一次运行的真实输出摘要如下,耗时随机器变化:
ℹ tests 6
ℹ pass 6
ℹ fail 0
ℹ duration_ms 177.025208
示例样本由 8 条热请求、2 条冷启动请求、2 条排队请求组成,得到:
| 样本 | 排队 | 冷启动 | 预填充 | TTFT | TPOT | E2E |
|---|---|---|---|---|---|---|
| warm-0 | 12 | 3 | 360 | 375 | 16 | 551 |
| cold-0 | 18 | 1500 | 620 | 2138 | 20 | 2358 |
| queued-0 | 520 | 3 | 420 | 943 | 18 | 1141 |
汇总后 ttftP50=418ms、ttftP95=2204ms、itlP95=20ms、tpotP95=20ms、e2eP95=2424ms。这组数字说明两件事:12 条样本里只有 2 条冷启动,就把 P95 从 400ms 级拉到 2.2 秒;2 条排队样本的 TTFT 分别到 943ms 与 1223ms。若只看 P50,这两个问题都不存在。
小结
流式延迟要拆成三个口径:TTFT 回答“多久看到第一个字”,ITL/TPOT 回答“字来得顺不顺”,E2E 回答“整段多久结束”。TTFT 再由排队、冷启动、预填充三段组成,三段对应三种不同的处理手段。预算必须三项分别签,并满足“端到端覆盖首字加剩余解码”的约束。
时间线的价值不在于字段多,而在于每条记录都能被复算,且自相矛盾的记录进不来。下一节把单请求延迟扩展到系统层面:当并发上升时,吞吐、P95 与有效吞吐会以不同方式变化。
阅读导航:上一节:目录与学习路径 · 下一节:1.2 吞吐、并发与 goodput 。
常见错误与边界
- 只报平均延迟。平均值会同时抹平冷启动与长尾,两个问题都看不见。
- TTFT 混用服务端与客户端口径。优化了服务端 200ms,用户可能毫无感知。
- 把 ITL 与 TPOT 当同一个指标。TPOT 是平均值,ITL 才能暴露中途卡顿。
- 把排队、冷启动、预填充合成一个黑盒。三者处理方式不同,混在一起只能靠猜。
- 预算只签端到端。超标时无法判断该优化首字还是解码。
- 预算表不自洽。端到端覆盖不了最大输出时,“达标”只是样本太短。
- 用平均值算 TPOT。长输出请求会被短请求稀释,解码变慢看不出来。
- 接受时刻倒置的时间线。一条坏样本会悄悄污染整个分位数。
- 用随机采样决定保留哪些延迟样本。问题请求可能在复现前就被抽没。
- 把示例样本结果写成生产实测。数字必须有出处,来源不明的结论比没有结论更危险。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。