本文基于 vllm
ba22152(2026-07-06),源码链接均指向该 commit 的固定行号。代码浏览入口:github.com/vllm-project/vllm/tree/ba22152
系列文章:
(一)为什么要重写,以及 LLM() 这行代码背后发生了什么 (二)generate() 计算流——model.forward() 在哪里被调用? - (三)KV Cache 管理、Chunked Prefill 与异步架构
(本文) (四)插件系统——用 Python entry_points 实现零侵入扩展 (todo) (五)从 generate() 到 Speculative Decoding 的完整计算流 (todo)
1. 前言
上一篇把 EngineCore.step() 的调用链走了一遍:调度 → GPU forward → 采样 → 状态更新。"调度"这一步内部发生了什么,上一篇故意跳过了——这篇专门把它拆开。
这篇文章围绕三块:KV Cache 怎么管(PagedAttention 的工程实现)、Chunked Prefill 怎么工作(prefill 和 decode 如何混排)、HTTP serving 的异步架构(为什么要独立进程 + ZMQ)。
先给整体,再往细节走。
2. 整体模块关系与调用时序
2.1 三个模块的分工
KV Cache 相关的核心逻辑分布在三个模块:
KVCacheManager:纯 CPU 侧的逻辑块管理器,维护哪些物理块空闲、哪些被占用、前缀缓存里有什么。不接触 GPU,只管块的分配、释放、查找 Scheduler:持有 KVCacheManager,每步决定哪些请求进 running 队列、分配多少 KV 块GPUModelRunner:执行侧,把调度器输出的逻辑块编号转换成 GPU 物理槽位,喂给 FlashAttention
三者的职责边界很清晰:KVCacheManager 管逻辑,GPUModelRunner 管物理,Scheduler 在中间做决策。连接逻辑层和物理层的桥梁是 block_table——一个 CPU numpy 数组,调度后异步 H2D 拷贝到 GPU。
2.2 一个请求从进队到完成的完整时序
先看全貌,后面各节再拆细节。下图展示了一个请求从入队到 KV 块释放的完整生命周期:

几个关键节点:
① 入队时:请求进 waiting 队列,此时没有任何 KV 块。
② 调度时:Scheduler.schedule() 先查前缀缓存——如果 system prompt 之前算过,对应的 KV 块直接复用,num_computed_tokens 跳过这段,只 prefill 新增部分。没有缓存就从 FreeKVCacheBlockQueue 分配新块。分配失败(显存不够)则抢占低优先级请求。
③ 执行时:GPUModelRunner 把 block_table 翻译成 slot_mapping,attention kernel 按 slot_mapping 把新计算的 K/V 写入对应物理槽,同时从 block_table 读历史 KV 做 attention。
④ 完成时:请求结束,KV 块归还 FreeKVCacheBlockQueue。有 hash 的块(内容完整)进队尾(LRU 保留,供后续前缀缓存复用);无 hash 的块进队头(优先被再分配)。
有了这个时序图,后面各节讲的数据结构和逻辑就都有了落脚点。
3. KV Cache 为什么要分块管理
在看数据结构之前,先把 PagedAttention 要解决的问题说清楚。
LLM 推理的显存大头是 KV Cache,不是模型权重。以 LLaMA-3-8B(GQA,8 个 KV head,head_dim=128,fp16)为例:
每层每个 token 的 KV Cache: 32 层合计: bytes ≈ 128 KB/token 一个 4K 上下文的请求:
batch 里有 10 个这样的请求,仅 KV Cache 就需要 5 GB。更头疼的是,不同请求的序列长度差异极大。如果每个请求都预分配"最长可能序列"的显存,内存碎片极其严重——可能 80% 的显存空闲着,却没法给其他请求用。
PagedAttention 的思路和 OS 虚拟内存分页几乎一模一样:把 KV Cache 切成固定大小的"页"(块),物理上不连续,用地址表(block table)维护逻辑序列到物理块的映射。
4. KV Cache 数据结构
vllm 的 KV Cache 数据结构分三层。下图展示了三层之间的关系:

- 左侧(逻辑层)
:请求视角,序列被切成逻辑块,block_table 记录每个逻辑块对应哪个物理 block_id,slot_mapping 进一步细化到每个 token 的物理槽 - 右侧(BlockPool)
:管理器视角, FreeKVCacheBlockQueue维护空闲块,Prefix Cache缓存已计算过的块 - 底部(GPU 物理层)
:实际的 KV Cache tensor
4.1 KVCacheBlock:最小单元
# KVCacheBlock — vllm/v1/core/kv_cache_utils.pyclassKVCacheBlock: 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 cachekv_cache[block_id, 1] # V cache4.2 FreeKVCacheBlockQueue:LRU 双链表
FreeKVCacheBlockQueue 维护所有 ref_cnt == 0 的块:
head → [无hash块] → [无hash块] → [有hash块] → [有hash块] → tail ↑ 优先被分配 ↑ 尽量保留(前缀缓存)四种操作全是 O(1):
popleft() | ||
appendleft() | ||
append() | ||
remove(block) |
为什么用双链表而不是 deque? 前缀缓存命中时,需要把命中的块从 free list 中间移除。deque 中间移除是 O(n),双链表保存了前后指针,O(1) 就能摘出来。做过 LeetCode 146(LRU Cache)对这个模式会很熟悉。
4.3 块的生命周期
对应时序图里的各个阶段:
分配(allocate_slots): popleft() 取块 → 如果取到有 hash 的块,先从 prefix_cache_map 移除 → ref_cnt = 1前缀缓存命中(get_computed_blocks): if ref_cnt == 0: free_queue.remove(block) # 从中间摘出 ref_cnt += 1释放(free_blocks): ref_cnt -= 1 if ref_cnt == 0: no hash → appendleft() # 头部,优先再用 has hash → append() # 尾部,LRU 保留哈希注册(cache_blocks,在 update_from_output() 之后): block._block_hash = compute_hash(token_ids, parent_hash) prefix_cache_map[block_hash] = block5. Block Table 和 slot_mapping:逻辑块到 GPU 物理槽
KVCacheManager 管的是"哪个逻辑块用哪个物理 block_id",但 attention kernel 需要的粒度更细——每个 token 具体写入哪个物理槽。这步转换靠 block_table 和 slot_mapping 完成。

Block Table 是一个 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_idslot_mapping 进一步细化到每个 token 的物理槽,公式如下:
是 block_size,pos 是 token 在请求中的绝对位置,由 Triton kernel 并行计算,输出 [N_tokens] 的 1D tensor。
FlashAttention 2/3 原生支持 paged KV:
# 写:scatter 新 K/V 到物理槽ops.reshape_and_cache_flash(key, value, key_cache, value_cache, slot_mapping, ...)# 读:直接用 block_table 做 paged attention,不需要先 gather 成连续 bufferflash_attn_varlen_func(q=query, k=key_cache, v=value_cache, block_table=block_table_gpu, ...)6. Prefix Caching:system prompt 不用重复计算
有一个很常见的场景:所有请求都带同一个长 system prompt,每次都重新 prefill 这几百 token 既浪费又慢。
Prefix caching 的核心思路:只要两个请求的 token 前缀完全相同,对应的 KV 块直接复用。
判断"前缀相同"靠滚动 hash——第 k 块的 hash 由该块的 token ids 和第 k-1 块的 hash 共同决定,所以只要前缀一致,hash 就一致,跟物理位置无关:
# 第 k 块的 hash = f(token_ids_k, parent_hash_{k-1})查询时要求连续命中,一旦 miss 就停:
# KVCacheManager.get_computed_blocks()defget_computed_blocks(request): computed = []for block_hash in request.block_hashes: block = prefix_cache_map.get(block_hash)if block isNone:break# 前缀必须连续 computed.append(block)return computed命中后,request.num_computed_tokens 直接跳到命中块数 × block_size,跳过这些 token 的 prefill。
被抢占的请求不一定从头来:被抢占后 KV 块释放,但有 hash 的块会进 LRU 尾部而不是立即消失。如果这期间显存够用,下次调度时前缀缓存可能还在,只需重新 prefill 新增部分。
7. Chunked Prefill:prefill 和 decode 同框
7.1 v0 的问题
v0 里 prefill 和 decode 是两个独立阶段,交替执行。一个长 prompt 独占 GPU 几秒,所有 decode 请求全部卡住,TTFT 被严重拖长。
7.2 v1 的解法:num_computed_tokens
v1 删掉了 prefill/decode 的阶段概念,每个请求只有一个字段 num_computed_tokens(已经过 forward pass 的 token 数)。调度器每步用 token_budget 控制总处理量:
# Scheduler.schedule() — vllm/v1/core/sched/scheduler.py:396(精简)for request inself.waiting: remaining = len(request.prompt_token_ids) - request.num_computed_tokens num_new = min(remaining, remaining_token_budget) # 自动 chunk remaining_token_budget -= num_new一个 1000-token 的 prompt 被自动切成 4 步,每步和 decode 请求混在同一个 batch 里执行:
Req B 的 decode 全程没有被阻塞。
token_budget 怎么设?
max_num_batched_tokens控制每步最多处理多少 token(prefill + decode 合计)。设太小 TTFT 变慢,设太大 latency 抖动。官方建议从 2048~8192 开始调。
7.3 抢占:KV Cache 不够怎么办
KV Cache 不够时,调度器抢占优先级最低的请求:
def_preempt(self, request):self.kv_cache_manager.free(request) # 释放 KV 块 request.num_computed_tokens = 0# 重置进度self.running.remove(request)self.waiting.add(request) # 回到等待队列# output_token_ids 保留,下次重新 prefill 时带上调度器有 watermark 机制:只有 free_blocks > watermark 时才允许新请求准入,提前留缓冲。
8. 异步架构:AsyncLLM + ZMQ
HTTP serving 用 AsyncLLM(vllm/v1/engine/async_llm.py),离线推理用 LLM。两者最大的区别是通信架构。
8.1 为什么要独立进程
HTTP server 是 asyncio 驱动(IO 密集型),EngineCore 是调度 + GPU 执行(CPU/GPU 密集型)。同一进程里两边频繁争抢 Python GIL,互相拖慢。
解法是把 EngineCore 放到独立进程,用 ZMQ(ZeroMQ)+ msgpack 通信:

沿着时序图从上往下走:
① HTTP 请求进来:FastAPI handler 调用 AsyncLLM.generate(),把请求序列化成 EngineCoreRequest(msgpack,array_like 格式),通过 ZMQ PUSH socket 发给 EngineCore 进程。
② WAKEUP 唤醒:如果 EngineCore 在等待中,前端同时发一条 \x05 WAKEUP 消息唤醒它,避免 EngineCore 因为没有新消息而一直轮询空转。
③ EngineCore 步进:EngineCore 收到请求后,调用 step() 做调度 + GPU forward + 采样,每步完成后把所有请求的新 token 打包成 EngineCoreOutputs,通过 ZMQ PUB socket 广播出去。
④ 前端 SUB 接收:前端进程的 subscriber socket 收到 EngineCoreOutputs,立即拆包,找到对应 request_id 的新 token,调 IncrementalDetokenizer 解码成文字。
⑤ SSE 推流:解码结果通过 async for output yield 给 HTTP handler,HTTP handler 用 SSE 把每次 yield 的内容立刻推给客户端。这就是 stream=True 时逐 token 输出的底层实现,全链路事件驱动,没有轮询。
8.2 ZMQ 消息协议
\x00 | ||
\x01 | ||
\x05 | ||
EngineCore 用 PUB/SUB 模式:每完成一个 step 就 publish 新 token,前端 subscriber 收到后立即 detokenize 并推给 HTTP client。
8.3 流式输出
asyncdefgenerate(self, prompt, sampling_params, request_id):awaitself.engine_core.add_request(EngineCoreRequest(...))asyncfor output inself._result_handler.get_output(request_id):yield output # 每生成一个 token 就推一次这是 OpenAI API stream=True 逐 token 输出的底层实现。全链路事件驱动,没有轮询。
9. 小结
KV Cache 管理:KVCacheManager 维护逻辑块(CPU 侧),GPUModelRunner 把逻辑块翻译成 GPU 物理槽(slot_mapping)。FreeKVCacheBlockQueue 是 LRU 双链表,O(1) 支持分配/释放/前缀缓存命中。
Chunked Prefill:v1 用 num_computed_tokens 替代 prefill/decode 阶段标记,prefill 和 decode 在同一个 batch 混排,解决了长 prompt 饿死 decode 请求的问题。
异步架构:EngineCore 独立进程 + ZMQ PUB/SUB,绕开 Python GIL,HTTP server 和 GPU 执行真正并行。
遇到问题去哪找:
EngineCore._initialize_kv_caches() | |
GPUModelRunner.execute_model()max_num_batched_tokens | |
vllm/v1/attention/backends/flash_attn.py | |
AsyncLLM | |
vllm/v1/core/sched/scheduler.pymax_num_batched_tokens | |
KVCacheManager.get_computed_blocks() |
另外,我们团队最近出版了《动手学 AutoML:从 NAS 到大语言模型优化实战》,感兴趣的话可以看看。
动手学AutoML书籍封面
夜雨聆风