第九周是大模型应用的补充知识:Tokenizer 原理、KV Cache、vLLM、约束解码。作业分两半——部署 vLLM 服务 + 做吞吐对比。
我跑出来的数字是 71 倍。但这周真正的收获不是这个数字,而是把它拆开看,搞清楚每一部分从哪来。
一、先解决环境:为什么必须用 WSL
vLLM 官方不支持原生 Windows,只能跑 Linux。两条路:
| 方案 | 评价 |
|---|---|
| 双系统装 Linux | 太重 |
| WSL2 | ✅ Windows 里的子系统,桌面/IDE/微信全不受影响 |
分工是这样的:
写代码、IDE、git → Windows(不变)
week1~8 的脚本 → Windows 的 conda 环境(不变)
只有 week9 跑 vLLM → WSL 的 Ubuntu 终端
文件不用拷——WSL 里用 /mnt/d/... 就能读到 D 盘的仓库,Windows 侧 git pull 拉下来的代码,WSL 里直接能跑。
最关键的一步验证是 GPU 桥接:
nvidia-smi能打印出显卡信息就说明驱动透传正常。不需要在 WSL 里另装 NVIDIA 驱动——Windows 侧的驱动会自动透传,很多教程在这里让人白折腾。
二、KV Cache:为什么生成能加速
自回归生成每一步都要重算整个序列的注意力,但前面 token 的 K、V 其实没变(这正是 Week5 讲的 Causal Mask 带来的性质——前文的表示不受后续 token 影响)。
KV Cache 就是把它们缓存下来,每步只算新 token 的 Q 与历史 K/V 的注意力。
代价是显存:缓存大小随 batch × seq_len × layers × heads × head_dim 增长,长序列 + 大 batch 很容易吃满显存。
PagedAttention
vLLM 的核心创新,思路直接借鉴了操作系统的虚拟内存分页:
传统做法:为每个序列预分配一整块连续显存 → 碎片化严重、利用率低
PagedAttention:把 KV Cache 切成固定大小的块,用页表管理 → 按需分配、可共享
这让显存利用率大幅提升,从而能塞下更大的 batch——这是吞吐提升的物质基础。
三、实测:三组对比
实验设计
| 组 | 方式 |
|---|---|
| [A] | transformers 串行,一条一条生成 |
| [B] | transformers 静态 batch=8 |
| [C] | vLLM 连续批处理 |
结果
[C] vLLM 连续批处理 1.43s 2442 tok/s 71.0×
vLLM 相对串行 71.0×,相对静态 batch=8 仍有 15.4×。
四、把 71 倍拆开
这是这周我最想记下来的部分。一个漂亮的倍数,不拆开看就是个营销数字。
拆解一:batch=8 为什么只有 4.6×,不是 8×
理论上 batch 开到 8 应该快 8 倍,实际只有 4.6 倍。三处损耗:
- padding 浪费 —— batch 内序列长度不一,短的要补齐到最长,补的部分是白算的
- 提前结束的位置继续参与计算 —— 有的序列早就生成完了,但还要陪着跑到最长那条结束
- 显存带宽而非算力成为瓶颈 —— 小 batch 时 GPU 大部分时间在等数据搬运
拆解二:71× 里,多少来自算子,多少来自调度
这个对比最能说明问题:
| 场景 | 吞吐 |
|---|---|
| transformers 串行(全程只有一条在跑) | ~35 tok/s |
| vLLM 单条请求(也只有一条在跑) | |
| vLLM 连续批处理 | 2442 tok/s |
单条对单条,vLLM 只快约 2.2 倍——这部分来自算子优化和 PagedAttention。
剩下的约 32 倍全部来自连续批处理调度。
所以「71×」这个数字的真正含义是:大约 2 倍来自算子,30 多倍来自调度。如果你的场景是单用户单请求,vLLM 带来的提升会小得多。
拆解三:分母决定倍数
还有一件事必须说清楚:
分母越慢,倍数越大。
我这次 vLLM 的绝对吞吐(2442 tok/s)其实比老师演示的(3042 tok/s)还低一些,但我的倍数更大——因为我的串行基线跑得更慢。
只报倍数不报绝对值,是可以「优化」出来的。 这条经验后来让我在看任何性能对比时都会先问:基线是什么?
五、流式:优化的不是吞吐,是感知延迟
实测首 token 延迟 0.030s。
这一点值得单独说:所有 Chat 类产品都做流式,优化的不是总耗时,是「感知延迟」。总时间可能一样,但用户 30 毫秒就看到第一个字,和等 3 秒看到完整回答,体验完全是两回事。
还有个细节:第一条请求只有 3.9 tok/s,后两条稳定在 75~83 tok/s。第一条是冷启动——首次请求要触发 CUDA kernel 的惰性编译和显存分配。做性能测试时必须丢掉第一条,否则数据全是错的。
六、约束解码:让输出格式可靠
这一节对后面做 Agent 特别有用。我系统对比了五种方案:
| 方案 | 机制 | 可移植性 |
|---|---|---|
guided_choice | 输出必须是给定枚举之一 | vLLM 专有 |
guided_regex | 输出必须匹配正则 | vLLM 专有 |
guided_json | 按 JSON Schema 逐 token 约束解码 | vLLM 专有 |
response_format | OpenAI 兼容的 JSON mode | 通用 |
| Function Call | 工具协议 | 通用 |
关键区别在于生效的阶段:
guided_*是在解码阶段做 token 级约束——不合法的 token 概率直接置零,所以是结构上不可能出错。而在提示词里写「请返回 JSON」只是建议,模型仍可能在前面加一句「好的,以下是结果:」。
这个认知后来直接影响了我做 Agent 的设计:能用协议保证的,就不要靠提示词祈祷。
这周的收获
1. 拆解比数字重要。
「71 倍」写在简历上很好看,但真正有价值的是知道其中 2 倍来自算子、30 多倍来自调度。面试官问一句「这个提升主要来自哪里」,能不能答上来完全是两回事。
2. 报倍数必须报基线。
我的绝对吞吐比老师低,倍数却更高——因为我的基线更慢。所有相对指标都有这个问题。
3. 环境问题也是技术问题。
WSL 那一节我原以为是「杂活」,但搞清楚「驱动透传不用重装」「/mnt/ 直接访问 Windows 文件」之后,省了大量时间。部署环境的坑,和算法的坑一样值得写下来。
下周是 RAG——这门课真正进入 Agent 领域的开始。