vLLM 学习笔记 01:一个请求在 vLLM 里的一生

从零开始理解高吞吐大模型推理引擎

Posted by Liu Mengxuan on September 27, 2026

关于本文:这是我阅读 Aleksa Gordic 的文章 Inside vLLM: Anatomy of a High-Throughput LLM Inference System(vLLM 官方博客,2025-09-05)之后整理的学习笔记,并非原文翻译。文章结构参考了原文的脉络,但文字是我自己的理解和复述,补充了很多原文默认读者已经知道的基础概念;文中所有配图均为我重新绘制,不是原图。想看原汁原味、更深入的讲解,请一定去读原文。

原文基于 vLLM 2025 年 8 月的代码(commit 42172ad),本文的源码位置对照的是我手上 2026-09-24 的 main 分支(commit f5a78f2ad7),文件路径都以这个版本为准。


0. 先补几块地基

原文默认读者懂 Transformer 推理,我当时并不懂,所以先把后面反复出现的几个词讲清楚。

0.1 大模型是怎么”说话”的

大模型每次只做一件事:看着前面所有的字,预测下一个字。预测出来以后,把这个字接到末尾,再预测下一个……一直到输出”结束符”或者达到长度上限。所以生成 100 个字,模型至少要跑 100 次。

这里的”字”准确地说叫 token,是分词器切出来的小片段,一个汉字、半个英文单词都可能是一个 token。

0.2 两个阶段:预填充(prefill)和解码(decode)

一个请求的生命可以分成两段:

  • 预填充:把用户的整段提示词一次性读进去。几百上千个 token 可以并行地一起算,GPU 的算力被吃满,属于算力受限。
  • 解码:之后每一步只产出 1 个新 token。每一步要算的东西很少,但要把整个模型的权重从显存里搬一遍,时间几乎都花在搬数据上,属于显存带宽受限。

记住这个区别,后面几乎所有优化都是围绕它展开的。

0.3 KV 缓存是什么,为什么它是核心

Transformer 的注意力层里,每个 token 都会算出两样东西:K(key)和 V(value)。预测下一个字时,需要用到前面所有 token 的 K 和 V。

如果每次都从头重算,生成第 100 个字时要把前 99 个再算一遍,太浪费。所以做法是把算过的 K、V 存下来,这就是 KV 缓存。代价是它很占显存:序列越长、同时服务的用户越多,KV 缓存就越大。

怎么高效管理这块显存,几乎决定了一个推理引擎能同时服务多少人。vLLM 最出名的 PagedAttention,解决的就是这个问题。


1. 引擎核心:单卡离线推理

先从最简单的场景看起:一张 GPU,一次性给一批提示词,等它全部生成完。用法大概是这样:

1
2
3
4
5
from vllm import LLM, SamplingParams

if __name__ == "__main__":
    llm = LLM("Qwen/Qwen2.5-1.5B-Instruct")
    outputs = llm.generate(["你好", "介绍一下南京"], SamplingParams(max_tokens=64))

1.1 引擎由哪些零件组成

图 1

从外到内:

  • LLMEngine:对外的门面。里面有一个输入处理器(检查参数、把文字切成 token),一个输出处理器(把 token 变回文字),以及真正干活的引擎核心。
  • EngineCore:引擎核心,包含两个大件:
    • 调度器(Scheduler):决定每一步处理哪些请求、各处理多少 token。它维护一个等待队列(还没开始的请求)和一个运行队列(已经在生成中的请求),手里还有一个 KV 缓存管理器。
    • 执行器(Executor):负责真正在 GPU 上跑模型。单卡时就是一个 Worker,Worker 里的 ModelRunner 负责准备输入、跑前向、采样。
概念 源码位置
LLM 入口 vllm/entrypoints/llm.py
LLMEngine vllm/v1/engine/llm_engine.py
输入 / 输出处理器 vllm/v1/engine/input_processor.py、output_processor.py
EngineCore vllm/v1/engine/core.py
调度器 vllm/v1/core/sched/scheduler.py
KV 缓存管理器、块池 vllm/v1/core/kv_cache_manager.py、block_pool.py
单卡执行器 vllm/v1/executor/uniproc_executor.py
Worker、ModelRunner vllm/v1/worker/gpu_worker.py、gpu_model_runner.py

1.2 启动时发生了什么

构造 LLM(...) 的那几十秒里,Worker 主要做三件事:

  1. 初始化设备:选定 GPU,检查显存够不够,建好 ModelRunner。
  2. 加载模型:把权重读进显存。
  3. 初始化 KV 缓存:这一步很关键。vLLM 会先用假数据试跑一次模型(profile_run),看看模型本身和中间计算要占多少显存,在总显存的一个比例之内(由 gpu_memory_utilization 控制,我手上这个版本默认是 0.92),把剩余空间全部切成一个个 KV 块,留着装 KV 缓存。之后通常还会录制 CUDA Graph(capture_model),把一次前向涉及的上百个 GPU 小操作录成一整段,以后直接”回放”,省掉 CPU 一个个发指令的开销。

所以启动日志里经常能看到 “KV cache 可以放下 xxx 个 token” 之类的信息,那就是这一步算出来的。

1.3 引擎的心跳:step

图 2

generate() 把每个提示词包装成一个请求放进等待队列,然后就是一个循环:只要还有请求没完成,就反复调用 step()。每次 step 做三件事:

  1. 调度:挑出这一步要处理的请求,决定每个请求算几个 token,并给它们分配 KV 块。
  2. 前向计算:把选中的请求拼成一批,在 GPU 上跑一遍模型,给每个请求采样出下一个 token。
  3. 收尾:把新 token 追加到各自的请求里,检查是否该停(碰到结束符、到了最大长度、命中停止词等)。结束的请求把 KV 块还回池子。

源码里这段逻辑非常短,就在 vllm/v1/engine/core.py 的 EngineCore.step(),建议直接去读。

连续批处理(continuous batching) 就藏在这个循环里:批不是一开始就定死的,每一步都可以有请求结束离开、有新请求加入。传统做法要等一整批全部生成完才能换下一批,短的请求只能干等着长的;vLLM 以”步”为单位换人,GPU 几乎不会空转。

1.4 调度器:每一步怎么选人

调度器有一个token 预算:每一步最多处理多少个 token(max_num_batched_tokens),同时还限制一批最多多少个请求(max_num_seqs)。

它的大致顺序是:

  1. 先照顾运行队列里的请求。它们正在解码,每个一般只需要 1 个 token 的预算。
  2. 预算还有剩,再从等待队列里拉新请求做预填充。

有意思的是,新版调度器在概念上根本不区分”预填充”和”解码”。每个请求只记两个数:已经算好的 token 数,和总 token 数(提示词 + 已生成的)。调度器每一步做的就是”给每个请求分一些预算,让前者追上后者”。预填充就是一次追很多,解码就是一次追一个。源码 schedule() 开头的一段注释专门讲了这个设计,用这个统一视角,后面的分块预填充、前缀缓存、投机解码都能自然地塞进来。

给请求分配预算时,还得给它分配 KV 块(KVCacheManager.allocate_slots)。如果块不够用了怎么办?调度器会抢占:把运行队列里优先级最低的请求(默认是最晚进来的那个)踢回等待队列,释放它的块。被抢占的请求之后要从头重算,不过有前缀缓存的话,很多块还能直接复用,损失没有想象中大。

1.5 分页式 KV 缓存(PagedAttention)

图 3

最朴素的做法是给每个请求预留一段连续的显存,按最大长度预留。问题是大部分请求根本用不到最大长度,大量显存白白空着,而且长短不一的请求来来去去,显存会被切得七零八落。

vLLM 借用了操作系统分页管理内存的思路:

  • 把 KV 缓存切成固定大小的块,默认每块装 16 个 token(CacheConfig.DEFAULT_BLOCK_SIZE = 16)。
  • 每个请求手里只有一张块表,记录”我的第 1、2、3 块分别是显存里的几号块”。这些块在显存里不需要挨着。
  • 请求变长了就再领一块,结束了就全部还回去。每个请求最多浪费最后那块没填满的部分。

空闲块由一个双向链表管着(kv_cache_utils.py 里的 FreeKVCacheBlockQueue),拿块、还块都是常数时间。

1.6 前向计算:不补齐的批

图 4

有了块表,批处理也可以做得很干净。传统做法把一批请求补齐到一样长,补出来的部分全是无用计算。vLLM 直接把这一步所有请求要算的 token 首尾相接拼成一条长序列,再额外告诉注意力算子两件事:

  • 每段从哪开始、到哪结束,各段之间互相看不到;
  • 每个 token 的 KV 该写进哪个块的哪个位置(叫 slot mapping),以及每段历史 KV 在哪些块里。

于是同一批里可以同时混着”读 6 个新 token 的预填充”和”只读 1 个 token 的解码”,没有一点补齐浪费。拼好的输入进模型跑一遍,取出每段最后一个位置的输出,采样得到每个请求的下一个 token。

这部分在 gpu_model_runner.py 的 _prepare_inputs 和 execute_model 里,代码量很大,第一次读不用追求全懂。


2. 在核心上加的高级功能

有了上面这个”调度 → 前向 → 收尾”的骨架,vLLM 的很多功能都是在某个环节里加一点逻辑。

2.1 分块预填充(chunked prefill)

图 5

如果来了一个几万 token 的超长提示词,一次性预填充会占满好几步的时间,其他正在聊天的用户就会明显卡一下。

解决办法很直接:把长提示词切成几段,分几步喂进去。每一步先保证正在解码的请求拿到预算,剩下的预算再给长提示词切一块。只有最后一段喂完,才会采样出它的第一个输出 token,前面几步虽然跑了模型但不采样。

从调度器的统一视角看,这根本不是什么特殊逻辑:它只是”这一步只让这个请求追上一部分”而已。相关参数有 max_num_batched_tokens 和 long_prefill_token_threshold,可以在 scheduler.py 里搜这两个名字。

2.2 前缀缓存(prefix caching)

图 6

实际使用中,大量请求的开头是一样的:同一段系统提示词、同一份长文档、多轮对话的历史。这些公共前缀的 KV 完全没必要每次重算。

vLLM 的做法:

  1. 对每个填满的块算一个哈希,哈希的输入是”前一块的哈希 + 本块的 token”,必要时还带上 LoRA、多模态图片等附加信息。因为把前一块也算了进去,”第 k 块哈希命中”就意味着”从开头到第 k 块全都一样”。
  2. 维护一张”哈希 → 显存块”的表。新请求进来时,从头逐块查表,找到最长的命中前缀,这部分直接复用,只算剩下的。
  3. 请求结束后,块回到空闲池,但内容和哈希先保留。只有当这个块被别的请求领走、要写入新内容时,才把它的哈希从表里删掉。所以”刚用完的块”还能被后来的请求捡回来用。
  4. 一个块可能同时被好几个请求引用,所以有引用计数,降到 0 才真正算空闲。

没填满的块不参与缓存,因为它的内容还会变。这个功能在新版里默认开启(enable_prefix_caching: bool = True)。

概念 源码位置
链式哈希 kv_cache_utils.py 的 hash_block_tokens
找最长命中 kv_cache_manager.py 的 get_computed_blocks
缓存登记、驱逐 block_pool.py 的 cache_full_blocks、_maybe_evict_cached_block

2.3 约束解码(structured output)

图 7

有时我们要求模型的输出必须符合某种格式:只能从几个选项里选一个、必须是合法 JSON、必须匹配某个正则表达式。

思路是在采样前加一道”筛子”:

  1. 根据格式要求(语法)编译出一个有限状态机。它知道”当前处于什么状态,下一个 token 允许是哪些”。
  2. 每一步把允许的 token 做成一个掩码,不允许的 token 分数直接改成负无穷,softmax 之后概率为 0,采样就不可能选到。
  3. 采样出一个 token 后,状态机前进一步,下一步给出新的掩码。

语法编译可能比较慢,所以是异步做的:请求会先停在一个”等语法编译好”的状态(源码里叫 WAITING_FOR_STRUCTURED_OUTPUT_GRAMMAR),编好了才进入正常调度。编译和生成掩码交给 xgrammar 等专门的库。你在 core.py 的 step() 里能看到一行 get_grammar_bitmask,就是这里。

2.4 投机解码(speculative decoding)

图 8

前面说过,解码阶段 GPU 大部分时间在搬权重,算力其实闲着。那能不能一步多算几个 token?

投机解码的办法是先猜后验:

  1. 起草:用一个很便宜的方法一口气猜出后面 k 个 token。
  2. 验证:把这 k 个猜测一起喂给大模型,一次前向就能得到大模型在每个位置的真实概率。
  3. 接受 / 拒绝:从第一个猜测开始逐个检查,按一套概率规则决定接受还是拒绝。一旦某个位置被拒绝,就用大模型的概率在这里重新采样一个,后面的猜测全部作废。

猜得准,一步就能前进好几个 token;全猜错,至少也能得到 1 个,和普通解码一样。更重要的是,这套接受规则在数学上保证最终输出的分布和只用大模型时完全一致,不是近似。

vLLM 里的起草方法有好几种:n-gram(在已有文本里找重复片段来猜,完全不需要额外模型)、EAGLE、Medusa(在大模型上加几个小的预测头),新版本里还多了 draft model、suffix decoding 等,都在 vllm/v1/spec_decode/ 下面。验证用的采样器在 vllm/v1/sample/rejection_sampler.py。

2.5 预填充 / 解码分离(PD 分离)

图 9

预填充吃算力,解码吃带宽,两者混在同一批机器上会互相干扰。PD 分离干脆把它们拆开:

  • 一拨实例只做预填充:读完提示词,产出完整的 KV 缓存。
  • 另一拨实例只做解码:拿到 KV 缓存,接着往下生成。
  • 中间用一个 KV 连接器(connector)把 KV 从前者搬到后者,可以走共享存储,也可以走高速网络。

这样做的好处是两类指标可以分开优化:首字延迟主要看预填充那边,后续每个字的间隔主要看解码那边,各自加机器、各自调参数。

连接器的接口定义在 vllm/distributed/kv_transfer/kv_connector/v1/base.py。同目录下有一个用于学习和调试的 example_connector.py(原文里对应的叫 SharedStorageConnector,新版改名了),还有 LMCache、NIXL、Mooncake 等生产用实现。


3. 一台机器多张卡

模型太大、一张卡放不下时,就要多卡。

图 10

最常用的是张量并行(TP):把每一层的权重矩阵切成几份,每张卡只存一份、只算一份,每层算完卡之间交换一次结果(all-reduce)拼成完整答案。因为每层都要通信,TP 对卡间带宽要求很高,所以一般只在同一台机器内部用(机内有 NVLink 这类高速互联)。跨机器更常用流水线并行(PP):按层切,前几层放一台机器,后几层放另一台,通信次数少得多。

这时执行器从单进程的 UniProcExecutor 换成 MultiprocExecutor(vllm/v1/executor/multiproc_executor.py):

  • 每张卡起一个独立的 Worker 进程。
  • 引擎把”这一步的计划”放进一个共享内存广播队列,所有 Worker 同时读到。
  • 每个 Worker 算完,把结果放回自己的应答队列。

对调度器和引擎核心来说,什么都没变。它仍然只是调用”执行这一步”,底下变成了几张卡一起干。这种分层让单卡和多卡共用同一套调度逻辑,读源码时可以先把执行器当黑盒。


4. 变成在线服务

真实部署时,请求是源源不断从网络进来的,不是一次性给一批。

图 11

典型的部署长这样:

  • API 服务器:一个 FastAPI 应用,提供和 OpenAI 格式兼容的接口(vllm/entrypoints/openai/api_server.py)。它负责接收请求、分词、把结果流式返回给用户。内部用的是异步版本的引擎 AsyncLLM(vllm/v1/engine/async_llm.py),一边收新请求一边吐结果,互不阻塞。
  • 多个引擎副本:这叫数据并行(DP)。每个副本有自己完整的调度器和 KV 缓存,内部可以再做 TP。副本可以分布在多台机器上,没有 API 服务器的机器叫 headless 节点,只跑引擎。
  • 负载均衡:API 服务器要给每个新请求挑一个副本。它会综合每个副本正在排队和运行的请求数给一个负载分,挑最低的;新版还会考虑 KV 缓存的紧张程度,KV 快满的副本排队消化得慢,会被额外”扣分”(core_client.py 里 DPLBAsyncMPClient 的选择逻辑)。
  • DP 协调器:一个单独的进程(vllm/v1/engine/coordinator.py),定期汇总各副本的负载告诉 API 服务器,还负责让副本们在需要时保持同步(主要是 MoE 模型需要)。

进程之间用 ZMQ 收发消息。一个 curl 请求从进来到返回,大致是:API 服务器分词 → 挑副本 → 发给那个副本的引擎 → 引擎在一次次 step 里生成 → token 一个个流回 API 服务器 → 反分词后返回给用户。


5. 怎么衡量快不快

图 12

“快”有好几种意思,先把指标分清:

  • 首字延迟(TTFT):从发出请求到收到第一个字。聊天时用户最直接感受到的”反应速度”,主要由排队和预填充决定。
  • 字间隔(ITL):相邻两个字之间隔多久,决定输出”流不流畅”。另有一个相近的 TPOT,是平均每个输出 token 的时间。
  • 端到端延迟(E2E):从发出到全部生成完。
  • 吞吐量:整个系统单位时间吐出多少 token,决定能服务多少人、每个 token 多少钱。
  • 有效吞吐(goodput):只统计满足延迟目标的那部分吞吐。吞吐再高,用户都等得不耐烦也没意义。

延迟和吞吐是跷跷板。看图 12 右边:解码阶段,批里请求少时,每一步的耗时主要花在搬权重上,多塞几个请求几乎不增加时间,等于白送吞吐;过了某个拐点,算力开始成为瓶颈,请求越多每一步越慢,每个人的字间隔都变长。调参数(最大批大小、token 预算等)本质上就是在这条曲线上找一个合适的点。

vLLM 自带压测工具:

1
2
3
vllm bench latency     # 小批量,看单次延迟
vllm bench throughput  # 一次性把所有请求塞进去,看极限吞吐
vllm bench serve       # 模拟真实的请求到达节奏,压测在线服务

源码在 vllm/benchmarks/ 下。


6. 小结与下一步

把全文压缩成一句话:vLLM 的核心是一个”调度 → 前向 → 收尾”的循环,调度器用分页的 KV 块精打细算地管理显存,其余功能都是在这个循环的某个环节上加一点逻辑,再通过换执行器、加副本扩展到多卡和多机。

原文还提到了一些这篇笔记没有展开的话题:不同硬件后端、MLA 和 MoE 这类新结构、LoRA、状态空间模型、混合 KV 缓存、beam search、异步调度等。作者的观点是,这些大多是挂在核心骨架上的”插件”,理解了骨架再去看会轻松很多。

我接下来打算按这个顺序读源码,后续笔记也会按这个顺序写:

  1. vllm/v1/engine/core.py:EngineCore.step(),全局入口。
  2. vllm/v1/core/sched/scheduler.py:schedule() 和 update_from_output()。
  3. vllm/v1/core/kv_cache_manager.py 和 block_pool.py:分页和前缀缓存。
  4. vllm/v1/worker/gpu_model_runner.py:输入是怎么拼出来的。

如果觉得 vLLM 代码量太大,强烈推荐先读 nano-vllm。它用一千多行 Python 实现了上面讲的调度器、分页 KV 块、前缀缓存、张量并行等主干,一个下午就能通读,读完再回来看 vLLM 会亲切很多。


参考