LLM Inference Systems:从 Prefill 到 vLLM / SGLang
Prefill/Decode、TTFT/ITL、Continuous Batching、PagedAttention、Prefix Cache、投机解码、Serving 指标模拟器。
对应课件:LLM Systems(第二批新增)
本章定位:第二批最重要的章节之一。目标不是「会用 vLLM」,而是回答:为什么需要 serving system? 学完你应该能解释:prefill/decode 为什么是两个性能阶段、continuous batching 与 PagedAttention 各自解决什么问题。
- 【Runnable】可直接运行(关键逻辑已在本课程验证脚本中实测)
- 【Runnable on CUDA】需要 GPU(当前验证环境无 CUDA,标注 NOT EXECUTED)
- 【Skeleton】工程骨架:逻辑完整,需要自备环境
20.1 先从一张图开始:Serving Pipeline
先懂直觉
一次「对话」在服务端要经过:排队 → 切词 → 调度 → 预填充(prefill)→ 逐字解码(decode)→ 采样 → 返回。Serving system 的全部工作,就是把这条流水线上的每个环节同时服务很多请求,并且在延迟与吞吐之间做取舍。
Request
↓ Tokenizer 文本 → token ids
↓ Scheduler 排队 / 组批 / 分配 KV 空间
↓ Prefill 一次处理完整 prompt(大矩阵计算)
↓ KV Cache 每层缓存 K/V
↓ Decode 每次生成 1 个 token(循环)
↓ Sampler temperature / top-p → 选出 token
↓ Detokenizer 拼回文本
Response
20.2 Prefill vs Decode:两个完全不同的性能阶段 ★
先懂直觉
Prefill:把整段 prompt 一次性喂进模型——序列长、矩阵大、并行度高,是「计算密集型」的大 GEMM。 Decode:每次只生成一个 token——序列长度是 1,但每一步都要读取全部历史 KV,是「带宽密集 + 串行依赖」。 两者对硬件的需求完全不同,现代 serving 的核心挑战就是把它们高效地混合调度。
| Prefill | Decode | |
|---|---|---|
| 输入 | 完整 prompt(S 个 token) | 1 个新 token(+ 历史 KV) |
| 主要矩阵形状 | (大) | (瘦长) |
| 并行度 | 高(序列维并行) | 低(每步串行依赖) |
| 瓶颈 | 对典型大模型、足够长 prompt 与合适 batch,往往更接近 compute-bound;具体仍取决于 shape、kernel 与硬件 | 常见 LLM decode 往往受权重/KV 读取与内存带宽影响显著;具体瓶颈随 batch、并行方式与 kernel 变化 |
| 典型指标 | TTFT | ITL / TPOT |
切换 Prefill / Decode,观察输入形状、KV 读取量、瓶颈类型的变化。
20.3 TTFT:Time To First Token
先懂直觉
用户发出请求到看到第一个字的时间。长 prompt 的 TTFT 会显著变大——这是用户「感知卡顿」的主要来源。
关键事实:prefill 处理完整个 prompt 后,最后一个 prompt 位置的 logits 就直接用于采样第一个生成 token——不需要额外执行一次 decode iteration。
Request arrival
↓ Queue 排队(并发高时的主要等待项)
↓ Tokenization 文本 → ids(大 prompt 时不可忽略)
↓ Prefill 一次算完整个 prompt;产出 logits[S-1] 与全部 KV
↓ Sample first token 直接用 logits[S-1] 采样(不是"再跑一步 decode")
↓ Detokenize / stream 拼回文本、流式返回
First Token(用户可见)
之后才进入 decode 循环:把第一个生成 token append 进序列(KV Cache 中已有 prompt),下一次 decode iteration 产出第二个 token。
不要写「TTFT = prefill + 一次 decode step」。prefill 本身已经产出了第一个 token 所需的 logits;decode 循环是从第二个 token 开始的续写。
20.4 ITL / TPOT:逐 token 延迟
先懂直觉
第一个字出来后,后面的字是一个个生成的:token1 → token2 → token3…… 相邻两个 token 的间隔叫 ITL(Inter-Token Latency),平均到每个输出 token 叫 TPOT。
自回归依赖:生成 token t+1 需要 token t 作为输入。同一序列的生成天然串行——这就是 decode 阶段 GPU 利用率上不去(每步矩阵太瘦)的根本原因,也是 batching 存在的意义。
20.5 Throughput vs Latency:serving 的核心取舍 ★
先懂直觉
批越大,GPU 越满,吞吐(tokens/s)越高;但每个请求要等更久才轮到处理,延迟变差。Serving system 的调度器就是在「一次服务多少人」和「每个人等多久」之间找平衡。
调整请求数 / prompt 长度 / 输出长度 / batch size / prefill 与 decode 速度,实时观察 TTFT、ITL、tokens/s 与 KV 显存占用的变化趋势。
⚠️ 该模拟器是教学模型(简化假设、不是真实硬件 benchmark),用于建立「参数怎么影响指标」的直觉。
20.6 Static Batching:GPU 空位的浪费
先懂直觉
最朴素的组批:把同时到达的请求凑成一批,一起 prefill、一起 decode,直到最长的那个生成完才释放整批资源。
Request A: 10 tokens
Request B: 100 tokens
Request C: 30 tokens
→ 固定批:B 还在生成时,A/C 已经结束但槽位仍被占用(空转)
切换两种策略,逐步播放生成过程,观察 GPU 槽位的空转与利用率差别。
20.7 Continuous Batching:批的「动态进出」★
先懂直觉
不等整批结束:哪个请求生成完了,立刻把它移出、把等待队列里的新请求塞进来。批的组成是流动的,提高动态请求下的 GPU 利用率(相比 static batching 显著减少槽位空转,但并非「永远不浪费」)。
这是 vLLM / SGLang 等现代 serving 系统最重要的调度思想之一:
- 每步 decode 后检查:谁完成了?释放它的资源(KV 块);队列里有谁可以加入?
- 新请求可以立即开始 prefill(或与 decode 交错——见 chunked prefill)。
20.8 KV Cache 的系统视角
先懂直觉
第 10 章从模型角度讲了 KV Cache(缓存历史 K/V,避免前缀重算)。在 serving 里,KV Cache 升级成一个资源管理问题:同时服务 N 个请求,每个请求都要一块可持续增长的 KV 空间。
注意: 的含义要统一,否则会把 batch 维乘两次。
① 单个请求(序列长度 ):
② 个等长请求(每个长度 ):
③ 变长 serving batch(第 个请求长度 ,总 token 数 ):
其中 = 每元素字节数(FP16 为 2)。口径 ③ 对理解 continuous batching 与分页 KV 最重要——资源消耗正比于「所有活跃请求的 token 总数」,而不是「请求数 × 某个固定长度」。
| 特性 | 影响 |
|---|---|
| 随序列动态增长 | 无法预知每个请求要多少(生成多久不知道) |
| 生命周期与请求绑定 | 请求结束才释放 |
| 体量巨大 | 长上下文 + 高并发时,KV 显存常常先于权重成为瓶颈 |
20.9 KV Cache 碎片化
先懂直觉
如果给每个请求预分配一整块连续的显存,问题立刻出现:A 要 0.8GB、B 要 0.3GB、C 要 1.2GB——申请大小未知、生命周期不同,很快产生大量无法利用的碎片。
连续内存布局(碎片化示意):
[ A(已占用) ][ 空洞 ][ B(已占用) ][ 空洞 ][ C … ]
→ 新请求 D 需要连续 1GB:虽然总空闲够,但没有连续块 → 失败或浪费预留
20.10 PagedAttention:从操作系统借来的思路 ★
先懂直觉
操作系统解决「内存碎片」的经典方案是虚拟内存分页:进程看到连续地址,物理内存却不连续。PagedAttention 把 KV Cache 也切成固定大小的 page/block(如 16 个 token 一页),逻辑上连续、物理上可以散落。
逻辑视图(请求看到的): block0 → block1 → block2 → …
物理视图(显存里的): block 在池子里任意位置分配
→ 按块按需分配:生成到哪、分配到哪;请求结束 → 整块回收(显著降低预分配与变长序列带来的浪费)
| 收益 | 说明 |
|---|---|
| 减少碎片 | 固定大小的块可被任意复用 |
| 按需增长 | 不再预分配「最大长度」 |
| 内存共享 | 相同前缀的块可以共享(→ Prefix Cache) |
切换「连续分配」与「分页分配」,观察碎片率与「能否放下新请求」的差别。
PagedAttention(vLLM 的核心机制之一)就是显式借用 OS 虚拟内存/分页的设计哲学。想通「为什么 OS 要用分页」,就理解了它在 serving 中要解决什么。
20.11 Prefix Cache:复用相同的开头
先懂直觉
很多请求共享同一段前缀:同一个 system prompt、同一篇长文档(问答场景)、同一个 few-shot 示例。前缀的 KV 完全一样——算一次、缓存起来、后续请求直接复用。
切换「无缓存 / 有前缀缓存」,看相同前缀的请求是否需要重新 prefill。
| 命中条件 | 说明 |
|---|---|
| 前缀 token 完全一致 | 通常按块匹配(paged blocks) |
| 多请求共享 | 引用计数 + 写时复制(copy-on-write) |
20.12 Chunked Prefill:把长 prompt 切开
先懂直觉
一个 8K token 的 prompt 如果一次性 prefill,会长时间占住 GPU,让其他正在 decode 的请求「卡住」——ITL 抖动。Chunked prefill 把它切成几个小块,与 decode 交错执行:prefill chunk → decode → prefill chunk → decode …,改善延迟公平性。
| 策略 | TTFT | 其他人的 ITL |
|---|---|---|
| 一次性 prefill | 最短 | 被长请求阻塞,抖动大 |
| Chunked prefill | 略长 | 更稳定(交错执行) |
20.13 Speculative Decoding:用小模型「试稿」
先懂直觉
Decode 每步只算一个 token,太浪费。投机解码让一个小模型(draft)先连续猜出 k 个 token,然后大模型(target)一次前向并行验证这 k 个位置:接受正确的、拒绝错误的。因为验证是并行的,只要接受率够高,等效速度提升。
draft: 猜 A B C D
target: 并行打分 → A B C 接受(D 拒绝)
→ 一次 target 前向推进了 3 个 token
验证阶段的接受/拒绝是按概率比进行的(拒绝时回退到 target 自己的采样),数学上保证最终输出分布与「完全用 target 逐步采样」一致。本课程只建立直觉;完整证明见原始论文。
逐步播放:draft 猜 4 个 → target 验证 → 接受前缀、拒绝并重采样。可以调接受率看平均推进速度。
20.14 CUDA Graph:省掉 launch 开销
先懂直觉
Decode 每步是一串很多小 kernel(每层好几个)。每个 kernel 的 CPU 启动(launch)都有固定开销;当 kernel 又小又多时,GPU 在等 CPU 发指令。CUDA Graph 把一串 kernel 的调用序列「录下来」,之后一键回放,跳过逐条 launch 的开销。
| 常规 | CUDA Graph | |
|---|---|---|
| 每步 | 逐个 launch kernel | replay 预录制的图 |
| 适合 | 形状多变 | 固定形状的重复循环(decode 典型) |
20.15 Quantization:Why it matters for serving
先懂直觉
推理的瓶颈常是显存容量与带宽。把权重从 FP16/BF16 降到 INT8/FP8/INT4:权重显存减半/再减半,每步读取的字节数下降,decode 直接受益——代价是精度与实现复杂度。
FP16/BF16 → INT8 / FP8 → INT4
权重显存 ↓ 带宽需求 ↓ 质量风险 ↑
本节只建立连接:量化是 serving 的关键优化方向之一。完整的量化理论与工程(校准、per-channel、KV 量化等)适合作为后续专题。
20.16 vLLM:它到底解决什么问题
先懂直觉
vLLM 不是「一个更快的模型」,而是一个推理引擎/服务系统。它的价值在于把前面几节的问题系统性地解决:
| 问题 | vLLM 的答案 |
|---|---|
| KV 显存碎片 | PagedAttention(分页化的 KV 管理) |
| 批利用率 | Continuous batching(动态进出) |
| 相同前缀重复算 | 前缀缓存(分页块共享) |
| 高并发调度 | 调度器 + 分块 prefill 等策略 |
| 分布式推理 | 多卡并行(张量并行等) |
不要把它当「pip install 教程」——真正要理解的是:它为什么需要这些机制(就是本章前面 15 节)。
20.17 SGLang:Serving / Runtime 的另一个方向
SGLang 关注的方向包括:
- 前缀复用(RadixAttention:用前缀树组织缓存,提升多轮/共享前缀场景的命中率);
- 结构化生成(约束解码:按 JSON/正则等约束采样,适合 agent/工具调用场景);
- 调度与运行时优化。
不比较「框架谁快」——不同系统的优化重心不同,且版本迭代很快。学完本章你应该能看懂它们的 README 在说什么。
20.18 生态对照:FlashInfer 与 TensorRT-LLM
| 项目 | 定位 | 一句话 |
|---|---|---|
| FlashInfer | 推理 kernel 库 | attention/采样等 serving 核心 kernel 的高性能实现,位于 kernel 层 |
| TensorRT-LLM | NVIDIA 的推理栈 | 编译式优化 + 融合 kernel + engine 构建 |
层次关系(重要):
你的应用
↓
Serving runtime(vLLM / SGLang / TensorRT-LLM runtime)
↓
Kernel 库(cuBLAS / FlashAttention / FlashInfer / Triton kernels)
↓
GPU
前面第 19 章的 kernel 知识在这里闭环:serving 系统的下限由 kernel 与调度的质量决定。
20.19 综合练习:三步定位性能问题
先懂直觉
给你一个「变慢了」的推理服务,按顺序问三个问题:
| 步骤 | 问题 | 可能结论 |
|---|---|---|
| ① 看指标 | TTFT 大还是 ITL 大? | TTFT 大 → prefill/排队问题;ITL 大 → decode/带宽问题 |
| ② 看资源 | KV 显存是否接近上限? | 是 → 并发被 KV 卡住(分页/量化/换更长序列策略) |
| ③ 看瓶颈 | GPU 利用率和带宽读数? | 双低 → 调度/launch 问题(batching、CUDA Graph) |
课堂案例(教学模拟)
案例 1:短 prompt 很快,长 prompt TTFT 暴涨 → prefill 计算随长度增长;考虑 chunked prefill 改善公平性,或限制最大 prompt。 案例 2:并发一高,吞吐不升反降,还出现请求失败 → KV 显存耗尽(OOM);分页管理 + 限制并发 + 量化。 案例 3:GPU 利用率只有 20%,ITL 却很大 → decode 每步太瘦(batch 太小 / launch 开销);continuous batching + CUDA Graph。
| 概念 | 一句话 |
|---|---|
| Prefill | 一次算完 prompt(大 GEMM),产出第一个生成 token 的 logits;决定 TTFT |
| Decode | 每步 1 token:瘦矩阵 + 读 KV,带宽受限,决定 ITL |
| Continuous batching | 批动态进出,消除 static batching 的空转 |
| KV 碎片 | 变长 + 生命周期不同 → 连续分配必碎 |
| PagedAttention | 分页化 KV:逻辑连续、物理分块,按需增长与共享 |
| Prefix Cache | 相同前缀的 KV 复用 |
| Chunked Prefill | 长 prompt 切开与 decode 交错,稳定 ITL |
| Speculative Decoding | 小模型试稿 + 大模型并行验证 |
| CUDA Graph | 录制重复的 kernel 序列,省 launch 开销 |
| vLLM / SGLang | 把上述机制工程化的 serving 系统 |
显示答案与解析
正确答案:B
prefill 通常是大矩阵计算(更接近 compute-bound);decode 每步输入只有 1 个 token,主要成本往往是 KV/权重的读取(更受内存带宽影响),计算单元利用率低。具体瓶颈随 batch、并行方式与 kernel 变化。显示答案与解析
正确答案:B
变长序列 + 不同生命周期导致连续分配产生碎片;分页化后固定大小的块可任意复用、按需分配、支持前缀共享(copy-on-write)。显示答案与解析
正确答案:B
static batching 要等整批最慢的请求结束才释放资源;continuous batching 让批「流动」起来,每个 decode 步后检查完成/加入,提高动态请求下的 GPU 利用率(显著减少空转,但不是「永远不浪费」)。