vllm v1 源码精读(三):KV Cache 管理、Chunked Prefill 与异步架构
vllm v1 源码精读(三):KV Cache 管理、Chunked Prefill 与异步架构
本文基于 vllm
ba22152(2026-07-06),源码链接均指向该 commit 的固定行号。 代码浏览入口:github.com/vllm-project/vllm/tree/ba22152
1. 前言
generate() 每调用一步,都在读写 KV Cache——但 KV Cache 到底是什么?存在哪?怎么管的?
先把本篇要拆清楚的几块事情摆出来:
GPU 显存(有限)
└─► KV Cache 分配策略(PagedAttention)
├─► KVCacheBlock vllm/v1/core/kv_cache_utils.py
│ BlockPool(LRU 双链表) vllm/v1/core/block_pool.py
│ BlockTable(寻址映射) vllm/v1/worker/block_table.py
│
├─► 前缀缓存(Prefix Caching)
│ KVCacheManager.get_computed_blocks()
│ vllm/v1/core/kv_cache_manager.py:110
│
├─► Chunked Prefill
│ Scheduler.schedule() vllm/v1/core/sched/scheduler.py:396
│
└─► 异步架构(HTTP serving)
AsyncLLM + ZMQ vllm/v1/engine/async_llm.py
这几块是相互咬合的:PagedAttention 解决了显存碎片,前缀缓存复用了已有的 KV 块,Chunked Prefill 让 prefill 不再阻塞 decode,ZMQ 架构让这一切在 HTTP serving 下也能真正并行。
这里尽量聚焦核心逻辑和设计动机——如果某个地方你觉得”等等,为什么这样设计”,那恰好是我想解释的地方。
2. KV Cache 的根本问题是什么
在看数据结构之前,先把 PagedAttention 要解决的问题搞清楚,不然后面那些代码看起来会很无聊。
LLM 推理的显存大头其实是 KV Cache,不是模型权重。以 LLaMA-3-8B(GQA,8 个 KV head,head_dim=128,fp16)为例:
- 每层每个 token 的 KV Cache:$2 \times 8 \times 128 \times 2 \text{ bytes} = 4096 \text{ bytes}$
- 32 层合计:$32 \times 4096 = 131072$ bytes ≈ 128 KB/token
- 一个 4K 上下文的请求:$4096 \times 128 \text{ KB} \approx 512 \text{ MB}$
如果 batch 里有 10 个这样的请求,仅 KV Cache 就需要 5 GB。更头疼的是,不同请求的实际序列长度差异很大。如果每个请求都预分配”最长可能序列”的显存,内存碎片极其严重——可能 80% 的显存在某个时刻是空闲的,却没法给其他请求用。
这正是 PagedAttention 要解决的问题,思路和操作系统的虚拟内存分页几乎一模一样:把 KV Cache 切成固定大小的”页”(块),物理上不连续,用地址表(block table)维护逻辑序列到物理块的映射。理解了 OS 的虚拟内存,这套设计的动机就一目了然。
搞清楚了为什么要这样设计,下面看具体数据结构就有意思多了。
3. KV Cache 的数据结构:从逻辑块到物理块

3.1 KVCacheBlock:最小单元
# vllm/v1/core/kv_cache_utils.py(精简)
class KVCacheBlock:
block_id: int # 物理块编号(0..num_blocks-1)
ref_cnt: int # 引用计数
_block_hash: BlockHashType | None # 内容 hash(None = 还没填满)
prev_free_block: KVCacheBlock | None # 双链表指针
next_free_block: KVCacheBlock | None
每个 KVCacheBlock 对应 GPU 上 KV Cache tensor 里的一个切片:
# shape: [num_blocks, 2, block_size, num_kv_heads, head_dim]
kv_cache[block_id, 0] # K cache,shape [block_size, nkv, d]
kv_cache[block_id, 1] # V cache,shape [block_size, nkv, d]
逻辑上,一个请求的 KV Cache 是一段连续序列;物理上,它被切成若干个 KVCacheBlock,散布在 GPU 显存的任意位置。连接逻辑和物理的,就是 block table——这个后面讲。
3.2 FreeKVCacheBlockQueue:LRU 双链表
有了单个块,还需要管理”哪些块空闲可用”。这就是 FreeKVCacheBlockQueue——维护所有 ref_cnt == 0 的块,是整个 KV Cache 管理里最关键的数据结构。
head → [无hash块] → [无hash块] → [有hash块] → [有hash块] → tail
↑ 优先被分配 ↑ 尽量保留(前缀缓存)
四种操作都是 O(1):
| 操作 | 场景 | 位置 |
|---|---|---|
popleft() | 分配新块 | 从 head 取 |
appendleft() | 释放无 hash 块 | prepend 到 head(优先被再分配,不当缓存) |
append() | 释放有 hash 块 | append 到 tail(LRU 策略,尽量晚被驱逐) |
remove(block) | 前缀缓存命中 | 从中间移除 |
为什么要用双链表,而不是 Python deque?
前缀缓存命中时,需要把被命中的块从 free list 中间移除——它从空闲状态变为被占用。
deque无法 O(1) 移除任意位置(需要 O(n) 扫描),但双链表可以,因为每个节点保存了前后指针,O(1) 摘下来就行。这和操作系统的 LRU 页面置换算法用双链表是一个道理。如果你做过 LeetCode 146(LRU Cache),这里的设计应该很熟悉。
3.3 块的生命周期
有了数据结构,完整的块生命周期是这样的:
分配(allocate_slots):
get_new_blocks(n) → popleft() × n
→ 如果取到的块有 hash(是前缀缓存),先从 prefix_cache_map 中移除
→ block.ref_cnt = 1
前缀缓存命中(get_computed_blocks → touch):
if block.ref_cnt == 0:
free_queue.remove(block) # 从 free list 中间取出
block.ref_cnt += 1
释放(free_blocks):
block.ref_cnt -= 1
if ref_cnt == 0:
if no hash: free_queue.appendleft(block) # 无缓存价值,头部,优先再用
if has hash: free_queue.append(block) # 有缓存价值,尾部,LRU 保留
哈希注册(cache_blocks):
block._block_hash = compute_hash(token_ids, parent_hash)
prefix_cache_map[block_hash] = block
逻辑块管理清楚了,下一个问题是:attention kernel 怎么知道去 GPU tensor 的哪个位置读写 KV?
4. Block Table 和 slot_mapping:从逻辑地址到 GPU 物理槽
Block Table(vllm/v1/worker/block_table.py)是一个二维数组,连接逻辑序列和物理块:
# CPU 侧:pinned numpy 数组(H2D 速度快)
block_table: np.ndarray # shape [max_num_reqs, max_num_blocks_per_req], dtype int32
# block_table[req_i][j] = 请求 i 第 j 个逻辑块对应的物理 block_id
每步开始前,把 block table 异步拷贝到 GPU:commit_block_table(num_reqs)。
Block table 解决了”哪个逻辑块对应哪个物理块”的问题,但 attention kernel 需要的粒度更细——它需要知道每个 token 具体写入哪个物理槽。这就是 slot_mapping。
slot_mapping 是更精细的映射,公式如下:
\[\text{slot\_id} = \text{block\_table}[\text{req}][\text{pos} \div B] \times B + (\text{pos} \bmod B)\]$B$ 是 block_size,pos 是 token 在请求中的绝对位置。这个映射由 Triton kernel 并行计算,输出一个 [N_tokens] 的 1D tensor。
在 FlashAttention 层里(vllm/v1/attention/backends/flash_attn.py):
# KV 写入:把新计算的 K/V scatter 写入 KV cache(按 slot_mapping)
ops.reshape_and_cache_flash(key, value, key_cache, value_cache, slot_mapping, ...)
# KV 读取:FlashAttention 直接用 block_table 做 paged attention
flash_attn_varlen_func(q=query, k=key_cache, v=value_cache,
block_table=block_table_gpu, ...)
FlashAttention 2/3 原生支持 paged KV——它可以直接从 block_table 找到每个历史 token 的 K/V 位置,不需要先把 KV 拼成连续 buffer,避免了一次 gather 操作。这是 PagedAttention 和 FlashAttention 配合的关键点。
有了 slot_mapping 和 block table,KV Cache 的读写问题解决了。但每次请求都从头 prefill,效率还是低——这就引出了 prefix caching。
5. Prefix Caching:system prompt 复用的秘密
有一个很常见的场景:你有一个很长的 system prompt(比如 1000 token 的角色设定),后续所有用户请求都带着这个前缀。每次都重新 prefill 这 1000 个 token 既浪费算力又慢。
Prefix caching 就是为了解决这个问题:只要两个请求的 token 前缀完全相同,对应的 KV 块可以直接复用,不需要重新计算。
块 hash 是滚动计算的:
# vllm/v1/core/kv_cache_utils.py(伪代码)
# 第 k 块的 hash = f(token_ids_k, parent_hash_{k-1})
两个请求只要共享相同的 token 前缀,对应块的 hash 就完全相同,无论这些块被分配到哪个物理位置。这个滚动 hash 设计保证了只要前缀一致,hash 就一致。
查询流程:
def get_computed_blocks(request):
computed = []
for block_hash in request.block_hashes: # 按顺序查,必须连续
block = prefix_cache_map.get(block_hash)
if block is None:
break # 前缀必须连续,一旦 miss 就停
computed.append(block)
return computed
命中后,request.num_computed_tokens 直接跳到命中块数 × block_size,本次调度跳过这些 token 的 prefill。
前缀缓存在哪里更新:每步 update_from_output() 之后,被完整填满的块(内容确定了)会计算 hash 并写入 prefix_cache_map。
一个容易被忽视的细节:被抢占(preemption)的请求,其 KV 块释放后,如果这些块有 hash(内容完整),它们会进入 LRU 尾部等待驱逐,而不是立即消失。如果这期间显存够用,下次同一个请求重新被调度时,前缀缓存可能还在,只需重新 prefill 新增的那部分。被抢占不一定意味着”从头来”。
Prefix caching 解决了重复前缀的问题,但还有另一个瓶颈没处理:一个很长的 prompt 在 prefill 时会把所有 decode 中的请求全部阻塞住。这就是 chunked prefill 要解决的事。
6. Chunked Prefill:prefill 和 decode 为什么能”同框”
6.1 v0 的痛点
v0 里,prefill 和 decode 是两个独立的阶段,交替执行:一批请求全部 prefill 完,再切换到 decode 阶段。这带来两个问题:
- decode 被 prefill 阻塞:有一个 10000 token 的长 prompt,所有 decode 中的请求都得等它 prefill 完,TTFT(Time to First Token)被严重拖长
- GPU 利用率不稳定:prefill 阶段 compute-bound,decode 阶段 memory-bound,两者交替切换,GPU 无法稳定工作在最优状态
6.2 v1 的解法:num_computed_tokens
v1 删掉了 prefill/decode 的阶段概念,每个请求只有一个字段 num_computed_tokens,表示”已经过 forward pass 的 token 数”:
-
num_computed_tokens == 0→ 还没开始,需要 prefill -
0 < num_computed_tokens < prompt_len→ prefill 进行中 -
num_computed_tokens == prompt_len→ prefill 完成,进入 decode
调度器每步决定每个请求”这步处理多少 token”,用 token_budget 控制总量:
# vllm/v1/core/sched/scheduler.py(精简)
# Phase 2: 等待中的请求准入(chunked prefill 在这里)
for request in self.waiting:
remaining = len(request.prompt_token_ids) - request.num_computed_tokens
# 只取 min(剩余 prompt, budget)——自动 chunk
num_new = min(remaining, remaining_token_budget)
if not enable_chunked_prefill and num_new < remaining:
break # 不支持 chunk:要么整个 prefill 进来,要么等
remaining_token_budget -= num_new
# ... 分配 KV 块,移入 running

图中 Req A 的 500-token prompt 被自动切成 256+244 两步,在这两步里,Req B(decode)从来没有被阻塞,每步都在正常生成 token。
token_budget 怎么设?
max_num_batched_tokens控制每步最多处理多少 token(prefill + decode 加起来)。设太小,长 prompt 的 prefill 被切得很碎,TTFT 变慢;设太大,GPU 单步计算量过大,latency 抖动。官方建议从 2048~8192 开始调,根据目标 latency SLO 和 GPU 显存决定。
6.3 抢占:KV Cache 不够怎么办
调度器发现 KV Cache 不够给 running 队列用时,会抢占优先级最低的请求:
def _preempt(self, request):
self.kv_cache_manager.free(request) # 释放 KV 块
request.num_computed_tokens = 0 # 重置进度
request.status = RequestStatus.PREEMPTED
self.running.remove(request)
self.waiting.add(request) # 回到等待队列
# output_token_ids 保留:下次重新 prefill 时带上已生成的 output
被抢占的请求下次调度时需要重新 prefill(prompt + 已生成的 output),代价较高。所以调度器有个 watermark 机制:只有 free_blocks > watermark 时才允许新请求准入,提前留缓冲。
有了 chunked prefill 和抢占机制,调度层的问题基本解决了。但 HTTP serving 还有一个更深的问题——Python GIL。
7. 异步架构:AsyncLLM + ZMQ 前后端分离
离线推理用 LLM 类,HTTP serving 用 AsyncLLM(vllm/v1/engine/async_llm.py)。两者最大的区别是通信架构:

7.1 为什么要独立进程
Python GIL(Global Interpreter Lock,全局解释器锁)导致同一进程内的多个线程无法真正并行运行 Python 代码。HTTP server 是 asyncio 驱动的(IO 密集型),EngineCore 是调度 + GPU 执行(CPU/GPU 密集型),如果在同一进程里:
- asyncio 事件循环在等网络 IO 时,理论上应该让 EngineCore 跑
- 但 EngineCore 里有大量 Python 逻辑(调度器、KV 块分配),会频繁争 GIL
- 结果是两边都跑得磕磕绊绊
解法:把 EngineCore 放到独立进程,独立进程之间没有 GIL 的问题,两边真正并行。进程间通信用 ZMQ(ZeroMQ,一个高性能异步消息队列库,比 socket 封装更高层,支持 PUB/SUB、PUSH/PULL 等多种通信模式)+ msgpack(轻量序列化协议,比 pickle 快很多)。
多进程 vs 多线程:Python 的经典陷阱
CPython 的 GIL 让多线程在 CPU 密集型任务上几乎没有提速效果(IO 密集型可以,因为等 IO 时会释放 GIL)。真正要并行 CPU 密集型任务,就得用
multiprocessing或者独立进程。vllm 这里的做法是业界标准解法,Celery、Gunicorn 等框架也是类似思路。
7.2 ZMQ 通信协议
ZMQ 的消息格式:单字节类型标识 + msgpack payload
| 类型字节 | 含义 | 方向 |
|---|---|---|
\x00 ADD | 新请求 | 前端 → EngineCore |
\x01 ABORT | 中止请求 | 前端 → EngineCore |
\x05 WAKEUP | 有新请求,唤醒 EngineCore | 前端 → EngineCore |
| (PUB) | EngineCoreOutputs | EngineCore → 前端 |
EngineCore 用的是 ZMQ 的 PUB/SUB 模式:EngineCore 是 publisher,每完成一个 step 就 publish 这一步所有请求的新 token;前端是 subscriber,收到后立即 detokenize 并 yield 给 HTTP 层。
7.3 流式输出怎么实现
# vllm/v1/engine/async_llm.py(精简)
async def generate(self, prompt, sampling_params, request_id):
# 发送请求到 EngineCore
await self.engine_core.add_request(EngineCoreRequest(...))
# 流式接收输出:每步完成就 yield
async for output in self._result_handler.get_output(request_id):
yield output # 每生成一个 token 就推一次给 HTTP client
HTTP server 用 SSE(Server-Sent Events)把每次 yield 的内容推给客户端,这就是 OpenAI API stream=True 时逐 token 输出的实现基础。每完成一个 step,EngineCore 通过 ZMQ PUB 推送输出;前端的 SUB socket 收到后立即 detokenize 并 yield 给 HTTP 层——整个链路没有轮询,完全事件驱动。
8. 阶段性总结:vllm v1 的设计哲学
回顾前几篇,vllm v1 的核心设计思想可以浓缩成几句话:
关注点分离,层次清晰:用户层只做翻译(str ↔ token),调度层只做资源分配,执行层只做 GPU forward,模型层是标准 PyTorch。每层的接口是干净的,可以独立替换。
以复杂性换性能,但要值得:
-
CachedRequestData差量传输 → Worker 侧状态管理变复杂,但序列化开销接近零 - execute/sample 拆成两步 → 代码变绕,但 GPU 和 CPU 真正并行
- 独立进程 + ZMQ → 部署更复杂,但绕开 GIL 拿到真并行
用 OS 经典算法解 LLM 的新问题:PagedAttention 的块管理(LRU 驱逐、地址表)、Chunked Prefill 的 token budget 控制,本质上都是操作系统内存管理和调度算法在 LLM 推理场景下的重新应用。
遇到问题去哪找(完整版):
| 问题场景 | 代码位置 |
|---|---|
| 多 GPU 启动 / NCCL 报错 | vllm/v1/executor/multiproc_executor.py |
| 模型加载失败 | vllm/model_executor/model_loader.py |
| 显存不够 / KV Cache 分配失败 | EngineCore._initialize_kv_caches() |
| 平台不兼容 / worker_cls 找不到 | vllm/platforms/ 对应平台文件 |
| OOM during forward | GPUModelRunner.execute_model(),检查 max_num_batched_tokens |
| Sampling 结果异常 | vllm/v1/sample/sampler.py |
| 输出乱码 | IncrementalDetokenizer,检查 tokenizer 匹配 |
| Attention 报错 | vllm/v1/attention/backends/flash_attn.py |
| HTTP server 延迟高 / 吞吐低 | AsyncLLM 的 ZMQ 通信,检查 EngineCore 进程是否正常 |
| Chunked prefill 相关 | vllm/v1/core/sched/scheduler.py,检查 max_num_batched_tokens 和 enable_chunked_prefill |
| 前缀缓存没有命中 | KVCacheManager.get_computed_blocks(),检查 cache_salt 和 block_hashes 计算 |
如果这篇文章涉及的 KV Cache 管理和 LLM 推理效率优化你想系统深入,可以看看我之前出版的《动手学 AutoML:从 NAS 到大语言模型优化实战》,书里 LLM 推理效率那章和本文讲的系统设计有直接关联,感兴趣可以翻翻。
