vLLM 与 PagedAttention vLLM & PagedAttention
所属:01-fundamentals / 06-agent-infra
面试权重:★★★
来源:通用领域知识;未做一手考证
相关项目:Own — QueWI(基于 vLLM 的推理部署)→ 见02-interview/projects/quewi-chatbot.md
相关知识点:01-fundamentals/01-llm-core/kv-cache.md
一句话#
KV Cache 的大小事先未知、长度可变、随请求动态进出,而传统做法要求为每个请求预留一整块连续显存,于是产生大量碎片、装不下更多并发;PagedAttention 把操作系统的分页思想搬过来 —— 把 KV Cache 切成固定大小的 block 按需分配、用 block table 映射逻辑与物理块,把浪费压到不足一个 block,从而在同样显存里塞进更多请求、把吞吐拉上去。
为什么重要(面试角度)#
这是推理服务的头部问题:你部署过 vLLM,面试官一定会顺这条线往下挖。想听的是:
- 你能不能说清 KV Cache 为什么难以管理 —— 不是"显存不够",而是碎片与预分配
- 你知不知道 PagedAttention 的关键机制是 block + block table,以及它带来的前缀共享 / copy-on-write
- 你能不能说清这到底买到了什么 —— 更多并发 → 更高吞吐,而不是笼统说"变快了"
- 你有没有踩过坑(OOM、长请求尾延迟、参数调优),以及知道哪些收益是版本与硬件相关的
框架会变、默认行为会变,所以讲机制比背参数重要。你能在 QueWI 讲出"我怎么用的、遇到什么瓶颈",比复述原理加分得多。
核心内容#
问题:KV Cache 为什么难以管理#
先回顾 KV Cache 的特性(→ 见 01-fundamentals/01-llm-core/kv-cache.md):
- 大小事先未知:生成长度取决于模型何时停下,请求进来时你不知道它要占多少。
- 长度极不均衡:同一个 batch 里可能有 20 token 的短回复和 4000 token 的长回复。
- 请求动态进出:连续批处理下,每步都有请求完成、有新请求进入。
- 它通常是显存大头:在长上下文 / 高并发下,KV Cache 的占用可以远超权重本身(具体比例随模型、上下文长度、并发数变化,不能给一个通用数字)。
传统做法(早期 serving 实现)是给每个请求预分配一块连续的、按最大长度算的显存。这造成两类浪费:
| 浪费类型 | 成因 |
|---|---|
| 内部碎片(reservation waste) | 预分配了 max_len,但实际只用了 300 token,剩余全部闲置到请求结束 |
| 外部碎片(fragmentation waste) | 各请求块大小不一,释放后留下的空洞难以被后续请求利用 |
后果是显存利用率低 → 可容纳的并发数小 → batch 小 → GPU 利用率低 → 吞吐低。在 decode 阶段(显存带宽受限,每步都要把全部权重和 KV Cache 读一遍)这个问题被进一步放大。
PagedAttention 的机制#
核心类比:操作系统的虚拟内存分页。
- 固定大小的 block(page):把每个序列的 KV Cache 切成固定 token 数的块(如每块存 16 个 token 的 KV,具体块大小可配)。块内连续,块与块之间不需要物理连续。
- 按需分配:来了新 token 就追加一块,不再预留整段。
- block table(页表):每个序列维护一张「逻辑块号 → 物理块号」的映射表,attention 计算时按表去取实际显存位置。
- 碎片上限:显存浪费只发生在最后一个块(内部碎片 ≤ 一个 block),外部碎片基本消失。
这一层机制直接带来的第二个能力是共享:
- 前缀共享 / 前缀缓存(prefix caching):多个请求共享同一段前缀(长 system prompt、few-shot 示例、多轮对话的公共历史)时,可以让它们的逻辑块指向同一批物理块,不必各存一份。省的不只是显存,还省了这段前缀的 prefill 计算。
- copy-on-write:一旦某个共享序列要往某个块里写新 token,就复制该块再写,互不影响。这让 parallel sampling / beam search(同一 prompt 采多条候选)能廉价地共享前缀。
这买到了什么#
| 收益 | 机制 | 说明 |
|---|---|---|
| 更高并发 | 碎片减少 → 同显存下能容纳更多序列 | 这是最直接的一档 |
| 更高吞吐 | 并发上去 → batch 变大 → decode 每步的权重读取被摊薄 | decode 是带宽受限,这是吞吐提升的主因 |
| 更长上下文可行 | 不再为每个请求预留最大长度 | 长上下文 + 高并发不再二选一 |
| 前缀复用省 prefill | block 共享 + copy-on-write | 对多轮对话、固定长 system prompt 尤其明显 |
| 显存利用率可观测 | block 级统计 | 能比较精确地算出还有多少块可用 |
必须说清的边界:收益大小强依赖负载形态。短请求、低并发、短上下文时,碎片本来就不严重,收益相对小;长上下文、高并发、共享前缀多的场景收益最明显。vLLM 与其 PagedAttention 机制在其公开资料中相对于当时的常见实现报告了显著的吞吐提升,但具体倍数随模型、硬件、负载、版本变化很大,引用前必须核对你所用版本的实测,不要背一个数字。
vLLM 作为推理引擎的其他关键组成#
PagedAttention 只是其中一块。一个可用的 serving 引擎还需要:
- 连续批处理(continuous batching):每步动态进出请求,不等最长序列(→ 见
batching-parallelism.md)。这是吞吐的另一半来源。 - block 级前缀缓存:命中公共前缀时直接复用已算好的 KV。
- 张量并行等分布式支持:单卡放不下大模型时切到多卡。
- 量化与 KV Cache 量化支持:进一步压显存(→ 见
quantization.md)。 - 流式输出与 OpenAI 兼容 API server:应用层几乎零改造接入。
- 可调度的内存策略:
gpu_memory_utilization、max_model_len、max_num_seqs一类参数控制"预留多少显存给权重与 KV、允许多长上下文、允许多少并发"。
⚠️ 版本相关:vLLM 迭代极快,上述组件的名称、默认值、支持矩阵在不同版本间会变化。面试讲机制与取舍是安全的,讲具体参数默认值要么说清版本,要么先查文档。
使用时的常见坑(这部分最像"你真用过")#
gpu_memory_utilization调太高:预留比例过高会让配置阶段通过、运行到长序列时 OOM。宁可留余量。max_model_len设太大:单请求的最坏占用上涨,能容纳的并发数下降;短请求为主的服务没必要开满。- 长请求的尾延迟(tail latency):一个 4k token 输出的请求会长时间占着 block,拉长同批其他请求的完成时间。需要请求级长度配额、超时或优先级调度。
- 权重与 KV 抢显存:模型权重本身占一大块,量化能显著缓解(→ 见
quantization.md)。 - 首次请求慢:模型加载 / 编译 / 首次 kernel warmup 造成的冷启动,需要在评测与 SLO 里单独考虑。
- 别把原理当答案:面试里"你怎么用的、瓶颈在哪、怎么解的"比背机制更值钱。
常见面试问法#
Q:PagedAttention 解决什么问题? A:解决 KV Cache 的显存管理问题。传统实现要求每个请求预分配一整块连续显存,造成内部碎片(按最大长度预留、实际用不满)和外部碎片(释放后的空洞难复用),显存利用率低、并发上不去。PagedAttention 把 KV Cache 切成分页的 block 按需分配,用 block table 映射逻辑块到物理块,把浪费压到不足一个 block。
Q:为什么说它提升了吞吐? A:路径是"碎片减少 → 同显存能容纳更多序列 → batch 变大 → decode 阶段的权重读取被摊薄 → 吞吐上升"。decode 是显存带宽受限(每步算量极小但要读全部权重和 KV),所以提高并发就直接提高吞吐。另外 block 共享让公共前缀的 KV 只存一份、prefill 只算一次,进一步省算力。
Q:block table 是干什么的?和操作系统页表什么关系? A:它维护每个序列「逻辑块号 → 物理块号」的映射。逻辑上序列的 KV 是连续的,物理上散落在不连续的显存块里;attention 按表索引到实际位置。这和 OS 虚拟内存分页同构:逻辑地址连续、物理地址可离散,从而消除外部碎片、支持共享与写时复制。
Q:前缀缓存/前缀共享为什么重要? A:很多真实流量共享同一段前缀(长 system prompt、few-shot 示例、多轮对话的历史),block 共享让这些前缀的 KV 只存一份,同时这段前缀的 prefill 也只算一次。对多轮对话和固定长 system prompt 的服务,这同时降低显存、降低 TTFT 和降低算力成本。copy-on-write 保证共享的序列在分叉后互不影响,所以 parallel sampling 也能廉价共享前缀。
Q:用了 vLLM 之后吞吐还是很低,你会查什么?
A:先定位瓶颈在哪:① 显存是不是被权重挤满导致 KV block 不够、并发上不去(考虑量化);② gpu_memory_utilization / max_model_len / max_num_seqs 是否设得不合理,最坏情况占用过高;③ 请求长度分布是否有极端长尾(少数长请求拖住批次);④ 是否命中前缀缓存(公共前缀多不多);⑤ 是否是 decode 带宽受限(增大并发是否有效)还是 prefill 计算受限(长 prompt 占比高,考虑 chunked prefill / 前缀缓存);⑥ 是否有 CPU 侧瓶颈(tokenize、调度、网络)。能用"瓶颈在显存、带宽还是算力"这套框架回答,比背参数强。
易错点#
- ❌ 说 PagedAttention 是"让 KV Cache 变小了" —— 它不压缩数据,只是更省地管理同样的 KV。压缩 KV 数据的是量化 / GQA / MLA / 滑窗。
- ❌ 说它"加速了 attention 计算" —— 主要收益在显存利用与并发,不在算力本身(虽然间接通过更大 batch 提高 GPU 利用率)。
- ❌ 背一个具体的吞吐提升倍数 —— 收益强依赖负载、模型、硬件、版本,引用必须注明来源与条件。
- ❌ 认为前缀缓存对所有请求都有用 —— 只有共享前缀的请求才受益;前缀各不相同就没有可复用的块。
- ❌ 把 PagedAttention 和连续批处理混为一谈 —— 一个是显存管理(分页),一个是调度策略(迭代级调度),二者互补且各自独立有价值。
- ❌ 忽略版本差异,讲某版本才有的默认行为或参数名。
- ❌ 以为显存够就没问题 ——
gpu_memory_utilization设太高会在运行到长序列时才 OOM,属于典型的事后崩溃。
自测#
1. 传统做法下 KV Cache 的两类碎片分别是什么?PagedAttention 怎么消掉它们?
答案
内部碎片(reservation waste):为每个请求按最大可能长度预分配连续显存,实际只用到一部分,剩余部分一直闲置到请求结束。外部碎片:各请求预分配的块大小不一,请求结束后释放出的空洞大小不匹配,难以被后续请求利用。PagedAttention 把 KV Cache 切成固定大小的 block 按需分配,块间不需要物理连续,用 block table 映射逻辑块到物理块 —— 外部碎片基本消除,浪费只可能发生在最后一个块(≤ 一个 block)。
2. PagedAttention 是通过什么路径提升吞吐的?为什么它"省显存"能直接变成"多吞吐"?
答案
路径:减少碎片 → 同一块显存能容纳更多序列 → 允许更大 batch → decode 阶段每步要读取的全部权重和 KV Cache 被更多 token 摊薄 → 单位时间产出的 token 数上升。关键在于 decode 是显存带宽受限的:每步计算量极小(只算 1 个 token),但必须把全部权重和整个 KV Cache 从显存读一遍,GPU 算力大量闲置。因此提高并发几乎直接转化为吞吐提升。此外 block 共享让公共前缀的 KV 只存一份、prefill 只算一次。
3. 前缀缓存对什么样的服务收益最大?什么样的服务几乎没收益?
答案
收益最大:请求之间共享长前缀 —— 固定的长 system prompt、同一套 few-shot 示例、多轮对话的公共历史、模板化 prompt(如统一的 agent 指令 + 工具描述)。这些前缀的 KV 可只存一份、prefill 只算一次,显存、TTFT、算力三方面都省。几乎没有收益:每个请求的前缀各不相同(如用户各自的长文档问答、随机的 few-shot 组合),没有可复用的块,命中率为零。所以设计服务时把稳定不变的内容放到 prompt 最前面能显著提高命中率。
4. 你在 vLLM 上遇到长请求拖慢整批的情况,会怎么处理?
答案
这是尾延迟问题:一个长输出请求长期占用 block 并留在批次里,拉长同批其他请求的完成时间。可用的手段:① 请求级 max_tokens 上限与超时,限制最坏占用;② 优先级/队列调度,让长请求不与交互式短请求混在同批;③ 检查是否显存不足导致新请求排队(考虑量化权重、调低 max_model_len 或降 gpu_memory_utilization 的预留);④ 长 prompt 与长输出分开处理(chunked prefill 让长 prompt 的 prefill 分片执行,不阻塞 decode);⑤ 监控 P95/P99 延迟而不是平均值。核心思路是把"最坏情况的占用"变成可控量。
5. 有人在面试里说「vLLM 的 PagedAttention 把 KV Cache 压缩了 4 倍」。这句话哪里错了?
答案
错在把"管理"说成"压缩"。PagedAttention 不改变 KV 的表示精度或数量 —— 它管理的是同样的 KV 数据如何占用显存(分页、按需分配、可共享),收益来自减少碎片与提高并发,而不是把数据变小。真正压缩 KV 体积的是:GQA/MQA/MLA 这类结构改动、KV Cache 量化,以及滑窗/驱逐这类信息层面的裁剪。如果说"显存利用率提升了若干倍""同样显存能跑更高并发"是可以的(且需注明负载条件),但说"KV 压缩了"是概念错误。