读一个真实 serving 系统 · vLLM 架构巡礼
不教新概念,把 Ch4-6 的抽象在开源 vLLM 里对号入座:PagedAttention(把 KV cache 当虚拟内存分页管理)、scheduler、KV cache manager 的真实实现,以及怎么读一个 serving 系统的源码。前置:Ch4、Ch5、Ch6。
这是系统设计路书的第 7 章,约 2 小时,纯 Mode A,是第三站「推理服务」的收官章。
前四章你建立了一整套 serving 的概念:prefill 与 decode、KV cache、连续批处理、调度、PD 分离。这一章不添任何新概念,而是带你走进开源推理引擎 vLLM 的真实源码,把这些抽象一个个在代码里对号入座 —— 看 KV cache 在代码里到底是个什么对象、连续批处理那个「每步重新组批」在调度器里长什么样、PagedAttention 怎么像操作系统管内存那样管 KV cache。更重要的是,你会学到一套读任何 serving 系统源码的通用方法,把「懂这些概念」真正变成「能读懂跑在生产里的那套系统」。我们把代码 pin 在一个真实的版本上:vLLM v0.22.0(2026 年 5 月底发布),所有文件路径都能照着在仓库里翻到。
本章三节:
- 怎么读一个 serving 系统的源码(三段走读法 · 25 min)
- PagedAttention 在代码里长什么样(45 min)
- 调度器:连续批处理在代码里长什么样(40 min)
0. 换挡:从「我讲给你听」到「你自己去代码里看」
前面六章,概念都是我讲给你听的。这一章要做一件不一样的事:带你去真实的生产代码里,亲眼确认这些概念不是我编出来的,而是字面意义上写在工程师每天改的源码里。这件事的价值不只是「验证」,它是一种能力的跃迁:一个真正进入这个领域的人,迟早要能打开一个陌生的大型系统、自己读懂它在干什么,而不是永远等别人讲解。这一章就用 vLLM 当例子,练这个能力。
为什么选 vLLM?第一章就说过,它是开源推理引擎的事实标准,概念最干净、文档最好、被引用最广,直到 2026 年依然是通用生产 serving 的默认选择(prefix 极重的多轮场景上 SGLang 是有力对手,纯性能极限上 TensorRT-LLM 领先,但通用基准点仍是 vLLM)。要读源码就得 pin 一个确定的版本,否则代码一直在变、说不清。我们 pin 在 v0.22.0(commit 0b3ba88,2026 年 5 月 29 日发布)。还有一件你必须先知道的事:vLLM 在 2025 年初做了一次大的架构重写,代号 V1,现在的核心引擎代码都在 vllm/v1/ 这个目录下,本章走读的就是这套当前的 V1 代码,而不是网上很多旧教程里那套已经过时的结构。
1. 怎么读一个 serving 系统的源码
在钻进 vLLM 之前,先给你一套方法,因为读源码最怕的不是看不懂某一行,而是一头扎进几十万行代码里迷路。面对任何一个陌生的大型系统,有一套屡试不爽的三段走读法,你这一章就会用它读 vLLM,以后读别的系统也照样能用。
第一段,定位入口与主循环。任何一个服务系统,核心都是一个「不停转」的主循环,找到它,你就抓住了系统的心跳。vLLM 的主循环在 vllm/v1/engine/core.py 的 EngineCore 里,每转一圈做三件事:调度(决定这一步算哪些请求)、执行(把模型跑一遍)、收尾(处理输出、释放资源)。先把这个循环看清楚,后面所有细节都是挂在这三件事上的。
第二段,找核心数据结构。一个系统的灵魂往往藏在它的数据结构里,而不是函数里;看懂了它用什么对象表示「一个请求」「一块缓存」,你就看懂了它的世界观。对一个 serving 系统,最该先找的就是它怎么表示 KV cache,这正是下一节我们要找的那个 KVCacheBlock。读源码时,与其顺着函数调用一层层跳,不如先把几个核心数据结构的字段读明白,事半功倍。
第三段,把你已知的抽象对号入座。这是最关键、也最省力的一段:你已经懂了 prefill/decode、连续批处理、KV cache 这些概念,读源码时不要从零理解每一行,而是带着这些概念去「认领」代码:看到一个 waiting 队列和一个 running 队列,你立刻认出「这是连续批处理」;看到块表和引用计数,你立刻认出「这是 PagedAttention 和前缀共享」。概念是你的地图,代码是地形,有地图的人不会迷路。接下来两节,我们就用这套方法,分别认领 vLLM 里的 PagedAttention 和调度器。
2. PagedAttention 在代码里长什么样
先认领第一章就埋下、第四章反复用到的那个名字:PagedAttention。它的核心想法你其实已经懂了 —— 把一个序列的 KV cache 切成固定大小的小块,再用一张表把这些逻辑块映射到 GPU 显存里分散的物理块,就像操作系统用分页管理虚拟内存那样。现在我们去代码里看它到底怎么实现。块的大小是多少?在 vllm/config/cache.py 里写得明明白白,默认值是 16 个 token 一块。
那「一块 KV cache」在代码里是个什么对象?答案在 vllm/v1/core/kv_cache_utils.py 里,一个叫 KVCacheBlock 的小小数据类。它值得你逐字读一遍,因为整个 PagedAttention 的故事,几乎都浓缩在这几个字段里:
@dataclass(slots=True)
class KVCacheBlock:
"""KV-cache block metadata."""
block_id: int # 物理块编号,0 .. num_gpu_blocks-1
ref_cnt: int = 0 # 引用计数
_block_hash: BlockHashWithGroupId | None = None # 前缀缓存的哈希键
prev_free_block: "KVCacheBlock | None" = None # 空闲块双向链表
next_free_block: "KVCacheBlock | None" = None
is_null: bool = False这一个数据类,把前面几章的好几个概念全串了起来。block_id 是它在 GPU 显存里的物理位置,而一个序列「逻辑上连续」的那串块,物理上就是一组分散的物理编号,这就是分页。ref_cnt 是引用计数,也是前缀共享的关键:当两个请求共享同一段前缀,它们会指向同一个块,这个块的引用计数就大于一,谁都不能随便改它,要改就得先复制(copy-on-write)。_block_hash 是这块内容的哈希,用来做前缀缓存的快速查找。最后那两个 free 指针把所有空闲的块串成一个双向链表,方便 O(1) 地分配和回收。读懂这六个字段,你就读懂了 PagedAttention 的数据结构核心。
这些块由谁来管?物理块的池子是 vllm/v1/core/block_pool.py 里的 BlockPool,它持有所有物理块、维护那个空闲链表。而面向调度器的门面,是 vllm/v1/core/kv_cache_manager.py 里的 KVCacheManager。这里有个值得一提的当前细节:很多旧教程会说 KVCacheManager 直接管 BlockPool,但在 v0.22.0 里,它们之间还隔了一层叫 KVCacheCoordinator 的协调器(用来支持滑动窗口、混合模型这些更复杂的布局)。你不必记住这层,但它提醒你一件事:读源码一定要 pin 版本看当前代码,别信网上可能已经过时的结构图。
下面这张图把分页这件事画出来 —— 一个序列的逻辑块,经过块表,落到物理显存里分散的位置上。
为什么要这么麻烦地分页?回到第四章的那笔账:KV cache 主宰显存,而最朴素的做法是给每个请求预留一整段连续显存、装它「可能用到的最长」长度,结果绝大多数请求用不了那么长,预留的显存白白浪费。PagedAttention 用分页消灭了这种浪费:按块、按需分配,不必连续。SOSP 2023 那篇论文报告,朴素分配下有效显存可能低到只有约两成,而分页几乎把浪费降到零。这就是它当年能把吞吐做到前代两到四倍的根本原因。
分页还顺手解决了一件第五章惦记的事:前缀共享。如果两个请求开头那段 prompt 一样(比如同一个 system prompt),它们前缀那几块的内容完全相同,何必各存一份?vLLM 让它们的块表指向同一批物理块,用前面说的 ref_cnt 引用计数管理,谁要改写谁才复制。这套机制在代码里就是 BlockPool 的 touch() 方法(命中共享前缀时把块的引用计数加一),而自动前缀缓存在 vLLM 里默认是开着的。下面这张图画的就是两个请求合用同一批前缀物理块。
这里要破除一个常见的误解,免得你日后去翻代码扑空:在今天的 V1 里,PagedAttention 不是「一个 kernel 文件」。分页这件事(块表、按块分配)在 vllm/v1/core/ 里,而真正算注意力的,是 vllm/v1/attention/backends/ 下一组可插拔的后端(默认走 FlashAttention 或 FlashInfer,它们读取分好页的 KV)。那个最早成名的、独立的 PagedAttention CUDA kernel(csrc/attention/paged_attention_v1.cu)至今还在,但已经退成了一条特定的备用路径。所以「PagedAttention」今天更准确的理解是:核心的分页机制 + 一组读取分页 KV 的注意力后端,而不是某一个文件。
3. 调度器:连续批处理在代码里长什么样
认领完 KV cache,我们去认领连续批处理。它的家在 vllm/v1/core/sched/scheduler.py 的 Scheduler 类里。按第一段走读法,先找它的核心数据结构 —— 你会看到两个队列:self.waiting(排队等着上车的请求)和 self.running(正在生成的请求)。光这两个队列,你就该立刻认出第五章讲的连续批处理:每一步都从这两个队列重新组批,而不是锁死一整批等它跑完。
真正干活的是 schedule() 方法,它在每一步被调用一次,决定这一步把算力分给哪些请求。而这个方法开头有一段注释,我建议你原样读一遍,因为它一句话点破了前面三章你可能还觉得是「三个独立概念」的东西其实是同一个机制:
# There's no "decoding phase" nor "prefill phase" in the scheduler.
# Each request just has the num_computed_tokens and
# num_tokens_with_spec. At each step, the scheduler tries to assign
# tokens to the requests so that each request's num_computed_tokens
# can catch up its num_tokens_with_spec. This is general enough to
# cover chunked prefills, prefix caching, speculative decoding ...读懂这段注释,你对 serving 的理解会上一个台阶。它说:调度器眼里根本没有「prefill 阶段」和「decode 阶段」之分,每个请求只有两个数字,已经算了多少 token、总共需要算到多少 token,调度器每一步要做的,就是给各个请求分配 token、让前者去追上后者。一个还没 prefill 的新请求,就是「已算 0、要算 1000」;一个正在 decode 的老请求,就是「已算 1500、要算 1501」。在这个统一的视角下,prefill 和 decode 不再是两种特殊状态,而只是同一把尺子上的不同位置,而第五章讲的 chunked prefill(把大 prefill 切块)、前缀缓存,全都自然地收进了这同一个机制里。这就是优秀系统设计的样子:用一个足够一般的抽象,把好几个看似独立的特性统一掉。
schedule() 内部分两步走,正好印证第五章讲的调度逻辑:它先照顾 running 队列里正在生成的请求(显存不够时甚至会抢占),再用每一步固定的 token 预算去接纳 waiting 队列里的新请求。这个 token 预算,就是第五章说的、用来平衡 TTFT 和 TBT 的那个旋钮,在代码里默认值是 2048。而 chunked prefill 在 V1 里默认开启,一个长 prompt 会被这个预算自动切成几块、分到几步里算。
调度器选好这一步算哪些请求后,要给它们分配 KV cache 的块,这就回到了上一节的 KVCacheManager。有一个细节特别能呼应那段注释:分配块的方法叫 allocate_slots(),而它既用于新请求的 prefill(一次要好几块),也用于老请求每步的 decode(追加一块)—— 同一个方法,不分阶段,正是「没有 prefill/decode 之分」那个设计的落地。请求结束时,free() 方法把它的块还回去,引用计数减到零的块才真正回到空闲链表。下面这张图把这一整圈 EngineCore 的迭代画出来,你会看到这就是连续批处理在代码层面的样子。
综合 · 你现在能读懂一个真实的 serving 系统了
把这一章收拢。你没有学任何新概念,而是拿着前六章的地图,走进了 vLLM v0.22.0 的真实源码,把抽象一个个对号入座。下面这张表是你这一章的成果,也是你以后回头查的索引:
| 你学过的抽象 | 它在 vLLM 代码里 |
|---|---|
| KV cache(分页) | KVCacheBlock + BlockPool(vllm/v1/core/),物理块由块表索引 |
| 连续批处理 | Scheduler.schedule() 每步重组 waiting / running 队列 |
| prefill / decode | 不分阶段:统一成「已算 token 追总需 token」,allocate_slots() 两用 |
| chunked prefill | 默认开启,由每步 token 预算(默认 2048)自动切块 |
| 前缀缓存 / 共享 | ref_cnt 引用计数 + touch() + copy-on-write,默认开启 |
| PD 分离 | 原生支持,走 vllm/distributed/kv_transfer/ 的连接器(如 NIXL) |
更值钱的是你带走的那套三段走读法:定位主循环、读懂核心数据结构、用已知抽象对号入座。这套方法不绑定 vLLM —— 哪天你要读 SGLang、读某个公司内部的推理引擎、读一个完全陌生的分布式系统,同样能用。能打开一个陌生的大型系统、自己读懂它,而不是永远等讲解,这正是从「懂概念」迈向「能上手前沿系统」的那一步,也是这条路书使命里「为前沿岗位做准备」最实在的一块。
到这里,第三站「推理服务」这条主脊就走完了:你能解剖一次请求(Ch4)、把吞吐做上去(Ch5)、把它扩到集群并做 PD 分离(Ch6)、再到真实源码里验证这一切(Ch7)。你已经能从头到尾推理一个生产 LLM serving 系统是怎么运转的。接下来第四站换一个视角:不再问「系统怎么组织」,而是问「性能到底卡在哪、怎么压榨每一个 FLOP」。下一章 Ch8 会给你一套 roofline 性能心智 —— 怎么判断一个 workload 是被算力卡住还是被显存带宽卡住(你在第四章已经见过这个区分的雏形),以及怎么 profile 找到真正的瓶颈。
本章关键术语
本章引入的 canonical 术语,点进术语表看更完整的解释:
- PagedAttention vLLM 的签名机制:把 KV cache 切成固定大小的块(默认 16 token),用块表把逻辑块映射到 GPU 显存里分散的物理块,像操作系统的虚拟内存分页;几乎消灭碎片 + 支持前缀共享。今天的 V1 里,分页在 core、算注意力在可插拔后端
- 块表 block table 每个序列一张的「逻辑块 → 物理块」映射表;让一个序列逻辑上连续、物理上分散,是分页管理 KV cache 的核心数据结构(代码里即
KVCacheBlock的block_id+ 引用计数 + 哈希)
参考文献
Mode A · 引用出处(正文 inline,集中列在此)
- vLLM 源码 · v0.22.0(vLLM, 2026-05-29 · commit
0b3ba88)· 本章走读 pin 的版本;核心在vllm/v1/(core/sched/scheduler.py·core/kv_cache_manager.py·core/block_pool.py·core/kv_cache_utils.py·engine/core.py) - Efficient Memory Management for LLM Serving with PagedAttention(Kwon et al., UC Berkeley, SOSP 2023)· PagedAttention 原始论文:near-zero waste + 2–4× 吞吐
- vLLM PagedAttention 设计文档(vLLM)· 「块如页、token 如字节、请求如进程」的操作系统类比出处(注:页面已标注不再完全对应 V1 当前代码)
- vLLM V1: A Major Upgrade(vLLM, 2025-01)· V1 重写、
vllm/v1/核心、chunked prefill 默认开等当前架构说明
深 dive 资源(可选 · 想往下走再看)
- vLLM KV-transfer / disaggregated serving(vLLM)· PD 分离在 vLLM 里的原生实现(NIXL 等连接器)—— 把 Ch6 的「机器之间」那层落到代码
- SGLang / RadixAttention(LMSYS)· 用三段走读法换一个引擎练手:它的前缀缓存用基数树,和 vLLM 的块哈希是不同实现、同一目的
说明:本章是「读真实系统」式收官,真实素材就是 vLLM 的开源源码本身(pin 在 v0.22.0,所有路径可查);没有 Mode B 视频站 —— 系统源码走读缺少合格的人讲解长视频,由 Mode A 带读。
下一章
下一章 Ch8 进入第四站「性能 · 硬件」,换一个视角看 serving:不再问系统怎么组织,而是问性能到底卡在哪。你会拿到一套叫 roofline 的性能心智 —— 把一个 workload 放到「算力」和「内存带宽」两条线下面,一眼判断它是被哪一条卡住的(第四章说 decode 是 memory-bound,Ch8 会把这句话背后的模型讲透),还会学 MFU 是什么、为什么「理论与实际的差距」是性能工程师每天在追的东西,以及怎么 profile 一个系统找到真正的瓶颈。这是你从「会搭系统」走向「会调系统」的起点。