0. 这个系列讲什么
这个系列把过去一段时间每天跟踪 vLLM / SGLang 社区的内容,重新整理成一条循序渐进的主线:
- 为什么要用这两个框架(本篇)
- vLLM 的原理与结构:PagedAttention、Continuous Batching、V1 架构、Model Runner V2
- SGLang 的原理与结构:RadixAttention、结构化输出、零开销 Spec V2
- 两家共同的前沿战场:投机解码、消灭同步停顿、PD 分离、低比特量化
- 版本演进时间线:0.25 / 0.5.15 那一轮换代到底改了什么
- 支持的模型与选型指南:什么场景该选谁
不假设你读过前面的日报,但每一篇都会把当时日报里的关键事实(版本号、PR 编号、性能数字)落到对应的原理位置上。
1. 先看一段”能跑但不能用”的代码
几乎所有人第一次跑大模型推理,写的都是这样一段:
from transformers import AutoModelForCausalLM, AutoTokenizer
model = AutoModelForCausalLM.from_pretrained("Qwen/Qwen3-8B", device_map="cuda")
tok = AutoTokenizer.from_pretrained("Qwen/Qwen3-8B")
inputs = tok("解释一下什么是推测解码", return_tensors="pt").to("cuda")
out = model.generate(**inputs, max_new_tokens=512)
print(tok.decode(out[0]))
这段代码功能上完全正确。单人单请求,它能给你正确答案。
但只要你把它包成一个 HTTP 服务,让 50 个人同时用,它会以肉眼可见的速度垮掉:显存爆掉、延迟飙到几十秒、GPU 利用率却只有百分之十几。
问题不在模型,在服务方式。要理解 vLLM 和 SGLang 存在的意义,得先看清朴素方式到底浪费在哪。
2. 三个致命瓶颈
2.1 KV Cache 显存碎片:预留式分配的浪费
自回归生成时,每生成一个 token 都要用到之前所有 token 的 Key / Value 向量。为了避免重复计算,这些向量被缓存下来,就是 KV Cache。
KV Cache 有多大?粗略公式:
KV 显存 = 2(K和V) × 层数 × KV头数 × 头维度 × 序列长度 × batch × dtype字节数
以一个 32 层、8 个 KV 头(GQA)、头维 128、FP16 的 8B 模型为例,每个 token 大约占 128 KB。一条 8K 上下文的请求就是 1 GB 左右。跑 40 条并发,光 KV 就要 40 GB。
朴素实现的做法是:按 max_new_tokens 预留一块连续显存。你设 max_new_tokens=2048,它就先占满 2048 个 token 的空间——哪怕这条请求实际只生成了 30 个 token 就遇到了停止符。
于是显存里出现了大量”占着但没用”的空洞。业界测下来,朴素方式的 KV 显存有效利用率常常只有 20%–40%。剩下的 60% 以上,是纯粹的浪费。
2.2 静态批处理:队头阻塞
第二个问题是批(batch)的组织方式。
朴素服务通常做静态批(static batching):攒够 N 条请求 → 一起送进模型 → 等最长的那条跑完 → 整批返回 → 再攒下一批。
问题很直接:同一批里有的请求生成 20 个 token 就结束了,有的要生成 2000 个。短的那些跑完之后并不会释放槽位,它们的位置一直空转到最长那条结束。
这就是典型的队头阻塞(head-of-line blocking)。实际效果是:批越大,平均延迟越糟;而且新来的请求必须等整批结束才能进入。
2.3 GPU 空等:真正的隐形杀手
前两个问题至少是”显性”的。第三个更隐蔽:GPU 在等 CPU。
一次 decode step 里,GPU 真正做矩阵乘法的时间可能只有几百微秒,但 CPU 侧要做的事情不少:调度下一批请求、准备输入张量、拷贝元数据、决定采样结果、判断是否结束……这些都在 host(CPU)上,而且经常需要把结果从 GPU 拷回 CPU(D2H)再拷回去(H2D)。
每一次这样的同步,GPU 都在干等。单步几百微秒的计算,配上几百微秒的启动和同步开销——一半的时间 GPU 在空转。
3. 推理引擎登场:它们各自解决了什么
vLLM 的起点是显存:用操作系统虚拟内存的思路管理 KV Cache(分页 + 块表),配合连续批处理,把吞吐提上去。之后它一路往”通用生产底座”走——模型覆盖最全、硬件后端最多(CUDA / ROCm / XPU / TPU)、量化格式最杂。
SGLang 的起点是”程序结构”:它注意到真实的 LLM 应用(Agent、多轮对话、批量评测、思维树)里,大量请求共享相同前缀。于是用基数树(Radix Tree)把前缀 KV 组织起来跨请求复用,这就是 RadixAttention。此外它在结构化输出(JSON Schema / 正则约束)和前沿吞吐优化上推进得非常激进。
4. 衡量标准:你到底该优化哪个数字
选框架、调参数之前,先明确指标。推理服务只有三个真正重要的数字:
| 指标 | 含义 | 谁在乎 |
|---|---|---|
| TTFT(Time To First Token) | 从请求到吐出第一个字的时间,主要由 prefill 决定 | 聊天体验、Agent 首响 |
| TPOT / ITL(Time Per Output Token) | 后续每个 token 的间隔,由 decode 决定 | 流式阅读的”顺滑度” |
| Throughput | 整机每秒处理的总 token 数 | 成本(每百万 token 多少钱) |
这三者互相拉扯。批开得越大,吞吐越高、单请求的 TPOT 越差;PD 分离能让 TTFT 大降,但要额外传输 KV。所有的框架设计取舍,本质都在这个三角里选位置。
5. 两个概念:Prefill 与 Decode
后面几篇会反复出现这两个词,这里先说清楚。一次生成请求分成两个阶段,它们的计算特征完全相反:
| Prefill(预填充) | Decode(解码) | |
|---|---|---|
| 做什么 | 一次性处理整个 prompt,算出全部 KV | 每次只处理 1 个新 token |
| 并行度 | 高(几千 token 同时算) | 极低(一次一个) |
| 瓶颈 | 计算受限(compute-bound) | 访存受限(memory-bound) |
| 决定 | TTFT | TPOT |
理解这个差异,你就能理解后面所有的优化:
- 为什么 decode 要靠大 batch? 因为它访存受限,权重从显存搬一次可以服务很多请求,batch 越大摊得越薄。
- 为什么会有投机解码? 因为 decode 一次只出一个 token,算力大量闲置,那就”顺带”多验证几个候选。
- 为什么要 PD 分离? 因为两个阶段特征相反,混在一张卡上互相干扰——prefill 一来就把 decode 的延迟拖长。分开部署,各自用最合适的并行策略和硬件。
6. 小结
| 朴素方式的问题 | 引擎的解法 | 出自 |
|---|---|---|
| KV 显存碎片 | 分页 KV Cache + 块表 | vLLM PagedAttention |
| 队头阻塞 | 连续批处理(迭代级调度) | 两家都有 |
| 重复算相同前缀 | 基数树前缀复用 | SGLang RadixAttention |
| GPU 等 CPU | 零同步模型运行器 / CUDA Graph 全捕获 | vLLM MRv2 / SGLang Spec V2 |
| decode 算力闲置 | 投机解码(MTP / EAGLE / DFlash / DSpark) | 两家都在推 |
| P/D 互相干扰 | Prefill-Decode 分离 | Dynamo / Mooncake / NIXL |
下一篇进入 vLLM 内部:从 PagedAttention 的虚拟内存类比开始,一路讲到它在 0.25 版本里把 PagedAttention 删掉这件看似矛盾的事。
与本站其他系列的联系:这里说的”decode 算力闲置”,正是推测解码手记整个系列的出发点;而模型侧的 MoE、滑窗注意力等结构设计,可以参考多模态解码手记。
💬 留言
NaphJohn/LLM-blog尚未启用 Discussions:请在 GitHub 仓库 Settings → General → Features 勾选 Discussions 后刷新本页,评论区即自动显示。