AI Infra 技术总结(二):vLLM 内核------PagedAttention 与调度器原理
系列第二篇。上一篇(第一部分)介绍了 vLLM 的定位、特点、用法与常见类;本篇深入引擎内部,拆解让 vLLM 快起来的两个核心机制:
PagedAttention(KV Cache 的分页内存管理) 与 调度器(Scheduler,连续批处理的决策中枢) 。
读完你会理解:显存是怎么被"榨干"的,以及"GPU 每一步算谁"这件事是怎么被决定的。
0. 从一个请求的生命周期说起
一次 LLM 推理请求在引擎里经历两个阶段:
- Prefill(预填充) :一次性前向计算整个 prompt。prompt 有 2000 个 token,就并行算 2000 个位置,计算密集;
- Decode(解码) :逐 token 生成输出。每步只算最新 1 个 token,但要把模型全部权重和此前的 KV Cache 重新读一遍,显存带宽密集。

1. Prefill 对应图里:Prompt phase → LLM iteration 1
- Prompt phase(提示词阶段)= Prefill(预填充阶段)
输入:完整 prompt 的全部 tokenIs tomato a fruit? - iteration 1:执行 Prefill 前向计算
- 一次性把 prompt 所有 token 跑一遍 Transformer
- 生成 prompt 部分完整的 KV Cache,存下来,给后面复用
- 输出第一个候选 token
Yes
✅Prefill 的本质:处理全部输入 prompt,构建 KV 缓存,只发生 1 次,在请求最开始。
2. Token generation phase = Decode 解码阶段(迭代 2、3、4)
Prefill 做完之后,就进入循环生成 token,每一个 iteration 就是一次 Decode step:
- iteration2:拿已经建好的 KV Cache +
上一步输出 tokenYes,算出下一个 tokenit,更新 KV Cache - iteration3:KV Cache +
it→输出is,更新 KV Cache - iteration4:KV Cache +
is→输出EOS结束符,请求结束
每一轮 Decode iteration,只输入 1 个新 token,复用历史全部 KV,不断追加新 KV。
Decode 阶段每算一个 token,每个 Transformer 层都会产生一对 K、V 向量并缓存 下来(这就是 KV Cache),供后续所有 token 的注意力计算使用。上下文越长、并发越高,KV Cache 占的显存越大------它通常比模型权重本身还大。
于是 vLLM 要回答两个问题:
- KV Cache 放哪、怎么放 → PagedAttention(第 1 节);
- 每步把哪些请求放进 GPU 批次 → 调度器(第 2 节)。
1. PagedAttention:把 KV Cache 当成"虚拟内存"
1.1 传统做法的浪费在哪
传统推理引擎为每条请求的 KV Cache 预分配一整块连续显存 ,大小按请求的最大可能长度预留。这造成两类浪费:
- 内部碎片:请求实际只生成了 100 个 token,却预留了 2048 个 token 的容量,剩余空间闲置且无法挪作他用;
- 外部碎片:长请求结束后中间空出一大块,却往往放不下一个新请求,显存白白浪费。
业界与论文实验普遍观测到,传统方案下 KV Cache 显存利用率只有 20%~40%,是推理成本居高不下的主因。
1.2 分页设计:物理块池 + Block Table
PagedAttention(论文:Efficient Memory Management for Large Language Model Serving with PagedAttention,SOSP 2023)完整复刻了操作系统虚拟内存的分页思想:
| 操作系统概念 | PagedAttention 对应物 |
|---|---|
| 物理页(Page,如 4KB) | KV Block(默认存 16 个 token 的 K+V) |
| 虚拟地址空间(进程视角连续) | 请求视角的"逻辑 KV Cache" |
| 页表(Page Table) | Block Table(每个请求一张) |
| 物理内存池 | GPU 显存中预分配的物理块池 |
| 缺页中断 | 无空闲块可分配 → 触发抢占(preemption) |
| 共享页 / 写时复制(CoW) | 前缀缓存、beam search、并行采样共享块 |
| 页置换(LRU 换出) | 前缀缓存块的 LRU 驱逐 |
具体实现上,物理 KV Cache 是一块预分配的大显存张量(以 FlashAttention 后端为例):

图中的 Block 是 KV Cache 的最小物理存储单元(页)
- 每个 Block 可以存放固定数量 token 的 K、V 向量(图里每个 block 存 4 个 token,block size=4)。
- Block 是 GPU 显存上一块连续内存;但是一个请求完整的 KV 缓存,不需要占用连续显存,可以分散在多个不连续的 Block 里。
- Block 有物理编号:图中
Block0、Block1、Block2,就是物理块 ID。
⚠️注意:Block 是物理显存块,不是请求;多个不同请求不能共用同一个物理 Block(除了 prefix sharing 前缀共享场景)。
Query 向量如何访问分散的 KV Block(图的箭头含义)?
图左侧:forth 这个 token 生成对应的 Query 向量 。
正常标准 Attention:KV 是一块连续内存,Query 直接对完整连续 KV 矩阵做注意力计算。
PagedAttention:KV 被切散到 Block0、Block1、Block2 三块不连续显存块 。
箭头含义:Query 向量,间接寻址,分别读取 Block0、Block1、Block2 里面的 Key/Value 向量,再完成注意力分数计算。
不需要把分散的 Block 拷贝拼接成一块连续大内存;直接对分散块做 attention,这就是 PagedAttention 的核心。
逻辑视角 vs 物理视角(非常关键)
- 逻辑序列(请求视角) :对用户 Request 来说,它看到的是完整连续 token 序列:
Four score and seven years ago our fathers brought forth,逻辑上是连续。 - 物理 Block(GPU 显存视角) :逻辑序列被切到 Block0、Block1、Block2,物理显存地址不连续 。
vLLM 内部每个请求维护一张块表 (block table):记录该请求逻辑上的位置,对应到哪些物理 Block ID。
块表 = 类比 OS 的页表,逻辑页号 → 物理块号。

python
key_cache: [num_blocks, block_size, num_kv_heads, head_size]
value_cache: [num_blocks, block_size, num_kv_heads, head_size]
num_blocks是显存能装下的最大物理块数;- 每个请求持有一张 Block Table (
block_id列表),把逻辑位置映射到物理块; - 物理块无需相邻------请求 A 的第 0、1、2 个逻辑块可以落在物理块 42、17、99 上。
由此得到三个关键性质:
- 外部碎片 = 0:任意空闲块都可被任意请求使用;
- 增长是 O(1):多生成一个 token,只需在 Block Table 末尾追加一个 entry,无需搬移数据;
- 可跨请求共享:同一物理块可以同时挂在多个请求的 Block Table 下(靠引用计数 refcount 管理)。
1.3 Block 的生命周期(vLLM 运行时)
- 请求 Prefill 阶段:分配若干物理 Block,写入 prompt 全部 token 的 KV,填充 block table。
- Decode 循环:新 token 不断填入 Block,填满就申请新 Block。
- 请求结束:所有属于该 Request 的物理 Block,全部标记为空闲,可以分配给别的新请求复用。
- 还有 Swap:显存不足时,可以把冷 Block 换出到 CPU 内存;需要时 swap 回 GPU 显存。
1.4 为什么 block_size 默认是 16
- 太小:每个块一次间接寻址,Block Table 变长,访存开销线性放大;
- 太大 :内部碎片回归(每个请求最多浪费
block_size - 1个槽位)。
16 是工程经验值:对 FlashAttention 的 tile 大小友好、与 RoPE/GQA 的 head 维度配合好,内部碎片影响约 2% 以内 ,可用 --block-size 调整。
1.5 共享与写时复制(Copy-on-Write)
共享是 PagedAttention 相对"单纯分页"更进一步的能力,三种典型场景:
| 场景 | 行为 |
|---|---|
| 前缀缓存(Prefix Caching) | 两个请求共享相同 system prompt 的前缀块,ref_cnt 累加,最后一个引用消失才回收 |
| Beam Search(beam=4) | 4 条候选共享前缀,到分歧点 fork:共享时 ref_cnt++,分歧时写时复制 |
| 并行采样(n=4) | prompt 段 4 个候选共享同一批块,generation 段各自独立 |
每个块用引用计数 跟踪使用者数量;只有 ref_cnt 归零才归还物理块池。共享块被某个请求"改写"时(分歧点),先复制再写,避免互相污染------这就是 COW。
1.6 内核实现:从自家 Kernel 到 FlashAttention
- 2023 论文版 :vLLM 自研
paged_attention_v1/v2.cuCUDA 内核,论文的 24× 吞吐主要来自这套内核; - 2024 之后 :FlashAttention v2/v3 与 FlashInfer 原生支持 paged + varlen 模式(直接接受
block_table参数),性能更优,成为默认执行后端;自家 paged kernel 退化为 fallback。
一句话:"PagedAttention"是内存管理思想,而实际的注意力计算已交给 FlashAttention/FlashInfer 的 paged 版本执行。
1.7 代价与收益
- 代价:每个 KV 访问多一次 Block Table 间接寻址,attention 单步慢约 20%;
- 收益 :论文实验(OPT-13B 等)中 KV 缓存利用率从约 20% 提升到 96%,加上连续批处理带来的 GPU 利用率提升,端到端吞吐达到约 24×(论文摘要口径:对比 FasterTransformer、Orca、TGI 等当时 SOTA)。
注:官方博客中"连续批处理带来 23×"是单独针对批处理机制与静态批处理对比的口径;论文的 24× 是整体系统对比口径,两者针对的基准不同,勿混淆。
2. 调度器(Scheduler):决定 GPU 每一步算谁
2.1 为什么要使用调度器
如果没有调度器,最简单推理模式就是:串行执行,一个请求完整跑完 prefill + 全部 decode,再处理下一个请求 。这种模式资源利用率极低,延迟高、并发差。调度器就是为了解决这个痛点,充分利用 GPU 算力 + 显存,实现连续批处理 (Continuous Batching)+PagedAttention。
- 实现连续批处理 Continuous Batching(最核心目的)
普通推理框架 (HuggingFace transformers):
提交请求 A → A 完整跑完 prefill+decode 全部 token 结束 → 再处理请求 B。
请求 A 在 decode 循环生成几百个 token 的漫长时间里,GPU 大部分时间闲置,算力浪费。
调度器带来的改变:
请求 A 还在 decode 生成 token 的时候,新请求 B 可以到来。调度器可以把 B 选进 batch,A 做 decode,B 做 prefill,两者同一个 GPU forward 并行执行。
- 不需要等旧请求全部结束,新请求随时插入 batch。
- 每一步 GPU 迭代,调度器重新挑选 batch;batch 成员动态变化。
- 管理 PagedAttention 的 Block(KV Cache 显存管理)
PagedAttention 把 KV‑cache 拆成很多 Block(显存页),Block 的申请、分配、复用、回收、swap、引用计数,全部由调度器驱动。
- Prefill:调度器为请求分配若干物理 Block,构建 block_table。
- Decode:每生成新 token,Block 写满时,调度器申请新 Block 追加。
- 请求结束:调度器释放 Block 还给空闲池。
- 开启前缀缓存:调度器匹配前缀 Block,做 KV 复用,更新引用计数。
- 显存不够:调度器决策 swap‑out,把部分请求 KV 块挪到 CPU 内存,释放 GPU 显存。显存空闲后 swap‑in 回来。
如果没有调度器:你就要自己管理每个请求连续的 KV 显存;传统连续 KV 会造成严重显存碎片,并发上不去。PagedAttention 这套分页机制完全依赖调度器。
3. 平衡首 token 延迟 (TTFT) 与吞吐
GPU 算力特性:
- Prefill:计算密集,算力利用率高;
- Decode:访存密集,算力容易打不满。
调度器可以做权衡:
- 当 waiting 队列堆积大量新请求,调度器可以把部分正在 decode 的长请求 swap‑out,腾出 GPU 显存,优先执行新请求的 prefill,降低新用户首 token 等待时间。
- 支持不同调度策略:FCFS、优先级调度,控制哪些请求优先被服务。
如果没有调度器,长请求一旦跑起来,新请求只能排队等待,TTFT 会急剧恶化。
2.2 从静态批处理到连续批处理
- 静态批处理 :请求打包成固定批次,必须等整批全部生成完才释放位置,长请求拖死整批,GPU 大量空转;
- 连续批处理:每次前向计算(step)后动态调度------完成的请求移出,新请求随时补位。这是 vLLM 吞吐量量级提升的另一半来源。
2.2 V1 引擎:统一 Token 调度模型
vLLM V1 引擎对调度做了一次"革命性"简化:调度器不再区分 Prefill 阶段和 Decode 阶段。每个请求只有两个计数器:
num_computed_tokens:已经算过的 token 数;num_tokens_with_spec:目标 token 数(prompt 长度 + 已生成 token 数,含投机解码的 draft token)。
调度器只做一件事:让前者追上后者。差距是多少,这一步就分配多少 token 的计算预算(受 token budget 限制):
| 场景 | 差距 | 本步分配 |
|---|---|---|
| 新请求 Prefill(2000 token prompt) | 2000 | 最多 2000,受 budget 限制 |
| Decode 中 | 1 | 1 |
| Chunked Prefill(剩余 1500,budget 剩 512) | 1500 | 512,剩余下一步继续 |
| 投机解码(1 正常 + 4 draft) | 5 | 5 |
"Prefill"和"Decode"不再是调度器需要判断的阶段,而是差距大小不同时自然产生的结果 。V1 也因此能在同一个 forward pass 里混合执行 prefill 和 decode(V0 只能要么全 prefill、要么全 decode)。

2.3 队列结构与优先级
V1 调度器维护两个队列:
waiting队列:等待被调度(尚未开始 / 被抢占待恢复)的请求;running队列:正在运行、处于 decode 的请求。
调度策略两种:FCFS (先到先得,默认)与 priority(按优先级,低值先处理,适合 SLA 场景)。
每步调度的优先级明确:
- 先服务
running队列(decode):保证已开始生成的请求不"断火",解码连续性优先; - 再用剩余 token budget 接纳
waiting队列(prefill); - 如果本步发生了抢占,则不再接纳新请求:避免新请求挤占被抢占请求的恢复资源。
2.4 一次 step() 的完整旅程
引擎反复调用 step(),每步三个阶段:
- Schedule(调度):选出本步要执行的请求(decode 和/或分块 prefill);
- Forward(前向):把选中请求拼成一个大 batch 跑模型,采样 token;
- Postprocess(后处理):把采样 token 追加进请求,检查停止条件;完成的请求清理 KV 块(归还物理块池)并返回输出。
调度时对每个请求调用 KV 管理器 allocate_slots:
- 计算需要几块 :
ceil(新增token数 / block_size); - 检查空闲块池:空闲不足 → 触发抢占(见 2.5)或跳过本步调度;
- 分配块 :从空闲队列头部取出物理块,记入
request → blocks映射。
停止条件包括:达到 max_tokens / 模型最大长度、采样到 EOS、命中 stop_token_ids、出现 stop 字符串。
2.5 抢占(Preemption):V1 只留 RECOMPUTE
触发条件:running 请求需要新的 KV 块,但物理块池空闲不足时,调度器必须"踢掉"一个请求腾空间。
踢谁:
- FCFS 策略:抢占最后加入的请求;
- priority 策略:抢占优先级最低的请求。
踢掉之后怎么办------V0 与 V1 选择了不同的路线:
| V0 | V1 | |
|---|---|---|
| SWAP(换出) | 支持:把 KV 块搬到 CPU 内存,恢复时搬回 | 不支持 |
| RECOMPUTE(重算) | 支持 | 唯一方式 |
V1 的 RECOMPUTE 流程:
- 释放被抢占请求的全部 KV 块;
num_computed_tokens归零(之前算的全部作废);- 放回
waiting队列头部(sticky-front,下一步优先恢复)。
V1 弃用 SWAP 的原因很工程化:前向计算比 CPU↔GPU 搬运便宜;被抢占的请求往往能命中前缀缓存,重算成本没那么高;SWAP 还需要额外 CPU 内存和带宽,复杂度高。
2.6 Watermark:显存"安全水位"
如果调度器把显存用到最后一块才停,新请求一进来就挤掉别人,会引发频繁抢占 。因此 vLLM 设计了 Watermark 机制:预留一定比例的空闲块作为缓冲,只有空闲块超过安全水位线时才接纳新的 waiting 请求------就像水库不会把水用到见底。
2.7 前缀缓存(Prefix Caching):让 Prefill 接近零成本
RAG、Agent 等场景里,大量请求共享相同的 system prompt 或知识库前缀。前缀缓存让这些前缀的 KV 只算一次:
- 每个 block 计算一个链式哈希(包含前序所有 token,保证只有完全相同的前缀才命中);
- block 算满时把哈希注册进全局哈希表;
- 新请求逐个 block 查哈希,命中的块直接复用(refcount +1),完全跳过前向计算;
- 缓存块用 LRU 管理,显存不足才驱逐。
与统一 Token 调度配合:命中后 num_computed_tokens 直接跳到命中位置------比如 1000 token 命中,差距从 1020 缩到 20,请求几乎瞬间从"等 prefill"变成"开始 decode"。
2.8 Chunked Prefill:长 Prompt 不阻塞
假设 GPU 每步 token budget 是 2048,来了一个 8000 token 的 prompt。若一次性算完,本步 budget 被占光,其他 decode 请求全部停摆。Chunked Prefill 把长 prompt 切成小块,每步只算 budget 允许的部分,与 decode 请求混在一个批次里:
Step 1: 算 token 0~2047 ← 和别人的 decode 混在一起
Step 2: 算 token 2048~4095 ← 混在一起
Step 3: 算 token 4096~6143 ← 混在一起
Step 4: 算 token 6144~7999 ← prefill 完成,下一步开始生成
在统一 Token 调度模型下,Chunked Prefill 不是单独实现的特性,而是"差距被 budget 截断"的自然结果------不需要任何阶段判断代码。
3. 两套机制如何协同
调度器(每步)
┌──────────────────────────────┐
│ running: decode 优先 │
│ waiting: prefill / chunked │
│ token budget / max_num_seqs │
└───────┬──────────────────────┘
│ 选出本步请求 + 需要的 token 数
▼
KV Cache Manager(PagedAttention)
┌──────────────────────────────┐
│ 按需分配物理块 / 前缀缓存命中 │
│ 空闲不足 → 抢占 RECOMPUTE │
│ refcount / COW / LRU │
└───────┬──────────────────────┘
│ Block Table(逻辑→物理映射)
▼
Attention Kernel(FlashAttention/FlashInfer paged 版)
┌──────────────────────────────┐
│ 按 block_table 间接访问 KV 计算 │
└──────────────────────────────┘
一句话:调度器决定"算谁、算多少",KV 管理器决定"KV 放哪、够不够",注意力内核负责"按块表高效地算"。 三者通过 Block Table 与 token 预算解耦又协同。
4. 生产实践:参数 ↔ 机制对照
理解了机制,生产参数就不再有黑盒感:
| 参数(vllm serve / EngineArgs) | 对应机制 | 调优提示 |
|---|---|---|
--gpu-memory-utilization |
决定 KV 物理块池总大小(含 Watermark 空间) | 0.85--0.95;过高易 OOM,过低导致频繁抢占 |
--max-num-batched-tokens |
每步 token budget(Chunked Prefill 的"截断线") | 过小吞吐降,过大长 prompt 挤占 decode |
--max-num-seqs |
每步最大序列数(running 上限) | 默认 256;与显存、延迟平衡 |
--enable-prefix-caching |
前缀缓存(链式哈希 + LRU) | RAG/长 system prompt 场景强烈建议开启 |
--block-size |
KV 分块粒度(默认 16) | 一般保持默认 |
--scheduling-policy |
fcfs / priority(抢占选谁) | 有 SLA 分级需求时用 priority |
--num-scheduler-steps |
每次调度运行多个前向步(多步调度) | V1 特性,提升吞吐,需关注延迟 |
值得监控的指标 :preemption_count(抢占次数,过高说明 KV 池偏小或 Watermark 过低)、前缀缓存命中率、KV Cache 利用率、每步 token 利用率。
5. 小结
- PagedAttention = 把 KV Cache 当 OS 物理页管:定长块 + 每请求一张 Block Table + 物理块池 + refcount/COW/LRU。外部碎片归零、增长 O(1)、支持跨请求共享;代价只是 attention 单步约 20% 的间接寻址开销,换来 KV 利用率与吞吐的量级提升。
- V1 调度器 = 统一 Token 调度模型:没有 prefill/decode 阶段之分,只有"已算 vs 目标"两个计数器;decode 优先、waiting 用剩余 budget 补位;KV 不足时用 RECOMPUTE 抢占(V1 弃用 SWAP);Watermark 防抖、前缀缓存省钱、Chunked Prefill 防阻塞。
- 两者协同:调度器选请求 → KV 管理器分配块 → 内核按块表计算,构成 vLLM 高性能推理的完整闭环。
下期预告
从单机引擎到多卡与集群:张量并行、流水线并行、数据并行如何划分模型与数据,PD 分离(Prefill-Decode Disaggregation)为何成为大规模生产服务的"最后一公里",以及 vLLM 在 Kubernetes 上的部署与扩缩容实践。
参考来源:vLLM 官方博客 Inside vLLM: Anatomy of a High-Throughput LLM Inference System(2025-09)、PagedAttention 论文(arXiv:2309.06180)、vLLM 官方文档与社区源码解读文章。