《互联网产品的 AI 架构·推理性能与成本工程》1.1 首字、单 token 与整体延迟

从用户只看到「转圈很久」的投诉出发,把流式推理拆成首字、单 token 与端到端三段:排队、冷启动、预填充各占多少,TTFT、ITL、TPOT 与 E2E 如何互相约束,预算为什么必须逐项签而不是取平均;附可逐条断言的 TypeScript 时间线模块与示例样本上的运行结果。

一句“转圈很久”背后有三个数字

「回声」的私信助手改成流式输出后,客服收到的抱怨反而变多了。用户说不清哪里慢,只会说“转圈很久”或者“出字以后一顿一顿”。值班同学打开看板,只看到一个 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 条排队请求组成,得到:

样本排队冷启动预填充TTFTTPOTE2E
warm-012336037516551
cold-01815006202138202358
queued-05203420943181141

汇总后 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。长输出请求会被短请求稀释,解码变慢看不出来。
  • 接受时刻倒置的时间线。一条坏样本会悄悄污染整个分位数。
  • 用随机采样决定保留哪些延迟样本。问题请求可能在复现前就被抽没。
  • 把示例样本结果写成生产实测。数字必须有出处,来源不明的结论比没有结论更危险。

继续阅读

探索更多技术文章

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

全部文章 返回首页

「books」更多文章

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