延迟/吞吐指标体系 (M4)
B1→B18 能力曲线上,B9 刚把「部署 + 韧性 + 可观测 + 独立红队」做完——我们已经知道系统是否安全、是否会崩。
阶段: B10 · p95/cost 测量 + 成本级联 + tau2-bench(Day 91-100) 标签: #latency #ttft #tpot #serving
今日导引(由浅入深)
B1→B18 能力曲线上,B9 刚把「部署 + 韧性 + 可观测 + 独立红队」做完——我们已经知道系统是否安全、是否会崩。
B10 进入「量化运营」阶段:把可观测里那些时间戳变成可比的服务化指标。
昨天 Day 90 完成的是「红队修复闭环 + 回归」,本质是质量护栏;今天反过来量化性能护栏——LLM 服务的延迟到底有多快、慢在哪。
这一步在曲线上是「从能跑到能说清快慢」的过渡:一个 AI Solutions Architect 必须能用 p95/p99 而不是 mean 跟人谈延迟。
今天的「最小可判定产出」:用已建好的 summarizeLatency 把一组串行请求的原始延迟数组算成 p50/p95/p99,输出首张 raw JSON 延迟数组(真实数字待 key 跑出)。
1. 机理精读
服务化 LLM 的延迟不是单一数字,而是两段乘法的合成。 一次生成请求的端到端时间 ≈ TTFT + (TPOT × 输出 token 数)。 这个分解之所以重要,是因为两段分别由不同的系统瓶颈决定,混成一个数会丢掉诊断信息。
TTFT(time-to-first-token)衡量「交互首字响应」。 它涵盖请求排队等待、prefill 阶段(一次性吃完整个 prompt、算出 KV cache)的时间。 TTFT 决定用户「点了发送后多久看到第一个字」的体感——对话式产品里这是第一印象。 TTFT 主要受 prompt 长度(prefill 要算的 token 数)与 batch 排队深度影响。
TPOT(time-per-output-token)衡量进入 decode 阶段后的稳态生成速率。 它决定长回答要等多久——每多生成一个 token 就多花 TPOT 毫秒。 TPOT 主要受显存带宽(每步要把权重和 KV cache 读一遍)与 batch 内并发数影响。 把 TTFT 和 TPOT 混成一个 mean 延迟,会无法区分「首字快但生成慢」与「首字慢但生成快」这两种截然不同的体验。
为什么用 p95/p99 而非 mean? 因为 LLM 延迟分布是右偏长尾的:大量请求很快,少数请求(长 prompt、抢不到 batch 槽、被抢占 swap 出显存)非常慢。 mean 会被海量快请求拉低,把最差用户的体验直接抹平。 p95 的含义是「95% 的请求比这个值快」,p99 进一步暴露最差 1% 的尾部。 SLO(服务等级目标)几乎都按 p95/p99 定,因为承诺「平均很快」对那被卡住的 5% 用户毫无意义。
关键权衡:尾延迟与吞吐是对立的。 把 batch 开大、把队列拉长能提高 GPU 利用率和总吞吐,但代价是 p95/p99 上升(请求排队更久)。 这正是连续批处理(continuous batching,Day 92 主题)要平衡的张力。 今天先把「延迟分布怎么算」这个口径锚定,明天再看并发把它怎么推高。
与相邻概念的边界。 今天只测「延迟分布的口径」,不测并发下的相互影响(Day 92),也不把延迟换算成钱(Day 93)。 资源是 vLLM PagedAttention 博客(2026-XX,执行当周重验)——其 KV cache 分页是连续批处理吞吐的底层基础。 理解 TTFT/TPOT 拆分,必须知道 prefill(一次算完 prompt)/ decode(逐 token)两阶段对应不同的显存访问模式。
2. 推导 / 手算 / 代码走读
本项目复用 src/agent/eval/stats.ts 的 summarizeLatency。真实代码走读:
summarizeLatency(samplesMs: number[])定义在 stats.ts:76,返回LatencySummary { n, p50, p95, p99, mean }——seed 说的「已建并测试,stats.ts:76,返回 p50/p95/p99/mean」逐字属实。- 内部第一步
const sorted = [...samplesMs].sort((a, b) => a - b):先拷贝再升序排,不污染输入数组(纯函数特性)。 - p50/p95/p99 都走同一个
percentile(sorted, p)(stats.ts:17),用线性插值法:idx = (p/100) * (n-1),取lo=floor(idx)、hi=ceil(idx),按权重w = idx - lo在两点间插值。 - 这意味着百分位不是简单「第 N 个元素」,而是连续插值——小样本下更平滑、更稳。
mean直接用samplesMs原数组(不是 sorted),数学上等价但说明 mean 与百分位互不依赖。- 边界:
percentile对空数组返回NaN,单元素直接返回该元素——脚本侧要保证至少跑出若干样本才有意义。
手算示例(验证插值口径):
- 设 20 个延迟样本排序后第 19、20 名(0-indexed 18、19)分别是 1800ms、2400ms。
- p95 的
idx = 0.95 × 19 = 18.05,故lo=18, hi=19, w=0.05。 p95 = 1800×(1-0.05) + 2400×0.05 = 1710 + 120 = 1830ms。- 可见 p95 主要由排名靠后的那个样本决定——这正是「暴露尾部」的设计意图。
- 反观 mean:若前 18 个样本都在 200~400ms,mean 会被压到 ~600ms 左右,完全看不见那条 2400ms 的尾巴。
端到端延迟分解的手算(说明为什么要拆 TTFT/TPOT):
- 设某请求 prompt=2000 tokens(prefill)、输出=300 tokens(decode)。
- 若 prefill 吞吐 5000 tok/s,则 TTFT ≈ 2000/5000 = 0.4s = 400ms(外加排队)。
- 若 TPOT=25ms/token,则生成段 = 300×25 = 7500ms。
- 端到端 ≈ 400 + 7500 = 7900ms,其中 95% 时间花在 decode——优化重点应放在 TPOT/输出长度,而非 prefill。
- 若只看一个 mean 延迟,根本看不出「瓶颈在生成段」这个可执行结论。
LatencySummary 字段语义速查(来自 stats.ts:67-73 接口定义):
n:样本数,<20 时 p95/p99 仅作趋势参考。p50:中位延迟,代表「典型」体验。p95/p99:尾延迟,写进 SLO 的就是这两个。mean:仅用于和 p50 对比判断分布偏度(mean ≫ p50 即右偏严重)。
3. 今日实战
- 新建
bench/loadtest.ts(当前仓库bench/目录尚未建——这是本日要创建的脚手架,诚实标注「待建+待跑」)。 - 复用 provider-agnostic 的 runner,默认 provider=deepseek、模型
deepseek-v4-flash(与src/agent/shared/cost.ts里的现行 id 对齐,禁用 legacydeepseek-chat/-reasoner)。 - 串行发 20 个请求,每个 req 用
performance.now()记开始/结束时间,差值入延迟数组(毫秒)。 - 把延迟数组喂给
summarizeLatency(从src/agent/eval/stats.ts导入),输出 raw 延迟数组 JSON + p50/p95/p99/mean。 - 把 raw JSON 落盘(如
bench/out/loadtest-serial.json),作为后续并发压测(Day 92)的串行基线。
4. 今日实测 / 产出
summarizeLatency已建并测试(stats.ts:76,返回 p50/p95/p99/mean)——这是确定性、无需 key 的部分,可立即用单测断言验证。- loadtest 脚手架的 20-req 真实延迟数组 = 待跑(需 key 跑)。
- 跑后产出首张 raw JSON 延迟数组与 p95(ms) 数字。
- 状态如实:脚手架待建、真实延迟数字待 key,不升级为「已完成」。
为什么 prefill 和 decode 是两种硬件行为(理解 TTFT/TPOT 差异的根因):
- prefill 一次性把整个 prompt 喂进去算 KV,是 compute-bound(算力受限)——并行度高、GPU 算得很满。
- decode 每步只生成 1 个 token,但要把全部权重 + KV cache 从显存读一遍,是 memory-bandwidth-bound(带宽受限)。
- 所以 TTFT(含 prefill)和 TPOT(decode 稳态)受不同资源约束,优化手段也不同:prefill 靠 chunked-prefill/并行,decode 靠 KV 量化/投机解码。
- 这就是为什么不能用一个 mean 数掩盖两段——它们的优化杠杆根本不在一处。
5. 常见误区 / 陷阱
- 用 mean 当 SLO:会系统性低估尾延迟,承诺给用户的体验远好于实际最差情况。
- 把网络往返当 TPOT:通过 OpenRouter/远程 API 测时,首字延迟里混着网络排队,必须把 TTFT(首 chunk 时间)单独记,否则会把网络抖动算进模型生成速率。
- 样本太少:20 个串行样本算 p99 噪声极大(p99 几乎就是最大值)。串行基线主要看 p50/p95;p99 要等更大样本(Day 92 的 N=50 仍偏小,需注明置信度)。
- 复用了被污染的数组:手写百分位时若 in-place sort 输入数组会破坏后续计算——
summarizeLatency已用[...samplesMs]规避,自写脚本时勿忘。
6. 学习资源(每条带 YYYY-MM)
- vLLM PagedAttention 官方博客(2026-XX,执行当周重验)——KV cache 分页 + 连续批处理吞吐基础。
- vLLM V1 引擎文档(2026,当周确认版本)——2026 serving 主线引擎。
- 项目源码
src/agent/eval/stats.ts(本仓,summarizeLatencyat line 76)——p50/p95/p99/mean 实现与插值口径。 - Google SRE Book「Service Level Objectives」章(2016,方法论长期有效,尾延迟/SLO 口径仍 SOTA)。
SOTA检查 (2026-06 更新)
- 当前主流:PagedAttention 仍是 vLLM 核心,2026 serving 主线为 vLLM V1 引擎;TTFT/TPOT 拆分 + p95/p99 口径长期有效,是 SOTA。
- 过时黑名单:避免用已废弃的
deepseek-chat/deepseek-reasoner(2026-07-24 退役),统一deepseek-v4-flash/deepseek-v4-pro;不要用单一 mean 延迟做 SLO。 - 下次复查点:vLLM 博客的 PagedAttention/V1 引擎状态执行当周重验;DeepSeek 模型 id 在 2026-07-24 legacy 退役节点复查。
衔接
- 昨天:Day 90 — 修复闭环 + 回归(红队抓到真攻击后落策略修复 + 写回归用例,质量护栏闭环)
- 今天:把服务化 LLM 的延迟拆成 TTFT/TPOT 两段,用 p95/p99 暴露尾部,复用
summarizeLatency算串行基线 - 明天:Day 92 — 并发压测方法(M4 serving):加并发池跑 N=50 @ concurrency 1/4/8,看连续批处理下 TTFT 上升、吞吐提高的张力