文章从系统内部结构解析 vLLM 如何实现大语言模型的高吞吐推理。内容面向需要理解或优化模型服务性能的工程人员。
在本文中,我将逐步介绍构成现代高吞吐量 LLM 推理系统的所有核心系统组件和高级特性。具体来说,我会深入拆解 vLLM [1] 的工作原理。
本文是系列文章的第一篇。它会先从宏观层面入手,然后逐层深入细节(采用倒金字塔式结构),让你不至于淹没在琐碎细节中,也能建立起对完整系统准确的高层次认知模型。
后续文章将深入探讨具体的子系统。
本文分为五个部分:
LLM 引擎与引擎核心:vLLM 的基础原理(调度、分页注意力、连续批处理等)
高级特性:分块预填充、前缀缓存、引导式解码与推测解码、解耦式 P/D
横向扩展:从单 GPU 执行扩展到多 GPU 执行
服务层:分布式/并发 Web 脚手架
基准测试与自动调优:测量延迟和吞吐量
本文分析基于 commit 42172ad(2025 年 8 月 9 日)。
目标读者:任何对当前最先进 LLM 引擎工作原理感兴趣的人,以及有意参与 vLLM、SGLang 等项目贡献的人。
我将重点关注 V1 引擎。我也研究过目前已经弃用的 V0,这对于理解项目的演进过程很有帮助,而且其中许多概念在 V1 中依然适用。
关于 LLM 引擎/引擎核心的第一节可能会有些信息过载或枯燥,但博客后面的部分包含大量示例和图示。:)
LLM 引擎是 vLLM 最基础的构建模块。单独使用它,就已经可以实现高吞吐量推理——但仅限于离线场景。此时还无法通过 Web 向客户提供服务。
我们将使用下面这段离线推理代码作为贯穿全文的示例(改编自 basic.py)。
from vllm import LLM, SamplingParams
prompts = [
"Hello, my name is",
"The president of the United States is",
]
sampling_params = SamplingParams(temperature=0.8, top_p=0.95)
def main():
llm = LLM(model="TinyLlama/TinyLlama-1.1B-Chat-v1.0")
outputs = llm.generate(prompts, sampling_params)
if __name__ == "__main__":
main()
VLLM_USE_V1="1" # we're using engine V1
VLLM_ENABLE_V1_MULTIPROCESSING="0" # we're running in a single process
该配置具有以下特点:
离线(没有 Web/分布式系统脚手架)
同步(所有执行都发生在单个阻塞进程中)
单 GPU(没有数据/模型/流水线/专家并行;DP/TP/PP/EP = 1)
使用标准 Transformer [2](若要支持 Jamba 等混合模型,则需要更复杂的混合 KV 缓存内存分配器)
接下来,我们将逐步构建一个在线、异步、多 GPU、多节点的推理系统——但仍然使用标准 Transformer。
在这个示例中,我们做了两件事:
实例化一个引擎
调用它的 generate,根据给定的提示词进行采样
让我们从分析构造函数开始。
引擎的主要组件包括:
vLLM 配置(包含配置模型、缓存、并行机制等内容的所有参数)
处理器(通过校验、分词和处理,将原始输入转换为 EngineCoreRequest)
引擎核心客户端(在贯穿全文的示例中,我们使用的是 InprocClient,它基本等同于 EngineCore;之后会逐步扩展到 DPLBAsyncMPClient,后者能够支持大规模服务)
输出处理器(将原始 EngineCoreOutput 转换成用户看到的 RequestOutput)
引擎核心本身由多个子组件构成:
Model Executor(驱动模型执行前向传播;目前我们使用的是 UniProcExecutor,它在单个 GPU 上只有一个 Worker 进程)。之后会逐步扩展到支持多个 GPU 的 MultiProcExecutor
Structured Output Manager(用于引导式解码——稍后会介绍)
Scheduler(决定哪些请求进入引擎的下一执行步骤),其中又包含:
策略设置——可以是 FCFS(先到先服务),也可以是 priority(优先处理优先级更高的请求)
waiting 和 running 队列
KV 缓存管理器——分页注意力 [3] 的核心
KV 缓存管理器维护着一个 free_block_queue,即由可用 KV 缓存块组成的池(根据显存大小和块大小的不同,其数量通常可达数十万个)。在分页注意力执行期间,这些块充当索引结构,将 token 映射到已经计算好的 KV 缓存块。
在构造 Model Executor 时,会创建一个 Worker 对象,并执行三个关键过程。(之后使用 MultiProcExecutor 时,同样的过程会在不同 GPU 上的每个 Worker 进程中独立运行。)
初始化设备:
"cuda:0"),并检查是否支持模型的数据类型(例如 bf16)gpu_memory_utilization(例如 0.8 → 总显存的 80%),验证是否有足够的显存model_runner(包含采样器、KV 缓存以及 input_ids、positions 等前向传播缓冲区)InputBatch 对象(包含 CPU 侧的前向传播缓冲区、用于 KV 缓存索引的块表、采样元数据等)为 Worker 分配一个 CUDA 设备(例如 "cuda:0"),并检查是否支持模型的数据类型(例如 bf16)
根据请求的 gpu_memory_utilization(例如 0.8 → 总显存的 80%),验证是否有足够的显存
设置分布式配置(DP / TP / PP / EP 等)
实例化 model_runner(包含采样器、KV 缓存以及 input_ids、positions 等前向传播缓冲区)
实例化 InputBatch 对象(包含 CPU 侧的前向传播缓冲区、用于 KV 缓存索引的块表、采样元数据等)
加载模型:
model.eval()(PyTorch 的推理模式)torch.compile()实例化模型架构
加载模型权重
调用 model.eval()(PyTorch 的推理模式)
可选:对模型调用 torch.compile()
初始化 KV 缓存:
FullAttentionSpec(同构 Transformer),但随着混合模型的出现(滑动窗口、Jamba 这类 Transformer/SSM 模型),其复杂度也随之提高(参见 Jenga [5])--enforce-eager,否则会针对每个预热批次大小执行一次虚拟运行并捕获 CUDA graph。CUDA graph 会将完整的 GPU 工作序列记录为一个 DAG。之后执行前向传播时,我们会启动/重放这些预先生成的 graph,从而减少内核启动开销并降低延迟。获取每一层的 KV 缓存规格。过去它始终是 FullAttentionSpec(同构 Transformer),但随着混合模型的出现(滑动窗口、Jamba 这类 Transformer/SSM 模型),其复杂度也随之提高(参见 Jenga [5])
执行一次虚拟/性能分析前向传播并获取 GPU 内存快照,以计算可用显存能够容纳多少个 KV 缓存块
分配、重塑 KV 缓存张量,并将其绑定到注意力层
准备注意力元数据(例如将后端设置为 FlashAttention),供之后的前向传播过程中内核使用
除非提供了 --enforce-eager,否则会针对每个预热批次大小执行一次虚拟运行并捕获 CUDA graph。CUDA graph 会将完整的 GPU 工作序列记录为一个 DAG。之后执行前向传播时,我们会启动/重放这些预先生成的 graph,从而减少内核启动开销并降低延迟。
这里我省略了许多底层细节——但这些都是我现在需要介绍的核心组成部分,因为在接下来的章节中,我会反复提到它们。
第一步是校验请求并将其送入引擎。对于每个提示词,我们会:
创建唯一的请求 ID,并记录其到达时间
调用输入预处理器,对提示词进行分词,并返回一个包含 prompt、prompt_token_ids 和类型(文本、token、嵌入等)的字典
将这些信息打包到一个 EngineCoreRequest 中,同时添加优先级、采样参数及其他元数据
将请求传递给引擎核心;引擎核心会用一个 Request 对象封装它,并将其状态设置为 WAITING。随后,该请求会被添加到调度器的 waiting 队列中(如果采用 FCFS,则追加到队列;如果采用 priority,则压入堆中)
此时,引擎已经接收到输入,可以开始执行。在同步引擎示例中,最初的这些提示词就是我们将要处理的全部内容——没有机制能够在运行过程中插入新请求。相比之下,异步引擎支持这种机制(也就是连续批处理 [6]):每一步执行结束后,新请求和旧请求都会被纳入考量。
接下来,只要还有请求需要处理,引擎就会重复调用其 step() 函数。每一步包含三个阶段:
调度:选择在此步骤中运行哪些请求(解码和/或分块预填充)
前向传播:运行模型并采样 token
后处理:将采样得到的 token ID 追加到每个 Request,执行反分词,并检查停止条件。如果请求已经完成,则进行清理(例如,将其 KV 缓存块归还给 free_block_queue),并提前返回输出
请求超过了其长度限制(max_model_length 或其自身的 max_tokens)
采样得到的 token 是 EOS ID(除非启用了 ignore_eos;当我们希望强制生成指定数量的输出 token 时,这对基准测试很有用)
采样得到的 token 与采样参数中指定的任意 stop_token_ids 匹配
输出中出现了停止字符串——我们会在停止字符串首次出现的位置截断输出,并在引擎中终止该请求(注意,stop_token_ids 会保留在输出中,但停止字符串不会)。
接下来,我们将更详细地研究调度。
推理引擎主要处理两类工作负载:
预填充请求——对所有提示词 token 执行一次前向传播。这类请求通常受计算能力限制(具体阈值取决于硬件和提示词长度)。最后,我们从最终 token 所在位置的概率分布中采样一个 token。
解码请求——仅对最新的一个 token 执行前向传播。此前所有 KV 向量都已经缓存。此类请求受内存带宽限制,因为仅仅为了计算一个 token,我们仍然需要加载全部 LLM 权重(以及 KV 缓存)。
得益于更智能的设计,V1 调度器可以在同一个步骤中混合处理这两类请求。相比之下,V0 引擎一次只能处理预填充或解码中的一种。
计算需要生成的新 token 数量(由于推测解码和异步调度,这个数量并不总是 1——稍后会详细介绍)。
调用 KV 缓存管理器的 allocate_slots 函数(详见下文)。
从 token 预算中减去步骤 1 得出的 token 数量,以更新预算。
获取已计算块的数量(如果禁用了前缀缓存,则返回 0——稍后会介绍)。
调用 KV 缓存管理器的 allocate_slots 函数。
从 waiting 中弹出请求,将其移入 running,并将状态设置为 RUNNING。
更新 token 预算。
计算块数量——确定必须分配多少个新的 KV 缓存块(n)。默认情况下,每个块存储 16 个 token。例如,如果一个预填充请求有 17 个新 token,则需要 ceil(17/16) = 2 个块。
检查可用性——如果管理器的池中没有足够的块,则提前退出。根据请求属于解码还是预填充,引擎可能会尝试通过重新计算抢占来处理(V0 支持交换抢占),具体做法是驱逐低优先级请求(调用 kv_cache_manager.free,将 KV 块归还到块池);也可能跳过调度并继续执行。
分配块——通过 KV 缓存管理器的协调器,从块池中获取前 n 个块(也就是前面提到的 free_block_queue 双向链表)。将它们存入 req_to_blocks,这是一个把每个 request_id 映射到其 KV 缓存块列表的字典。
我们调用模型执行器的 execute_model,它会将任务委托给 Worker,Worker 再将其委托给模型运行器。
主要步骤如下:
更新状态——从 input_batch 中移除已完成的请求;更新与前向传播相关的其他元数据(例如,每个请求的 KV 缓存块,这些块将用于索引分页 KV 缓存内存)。
准备输入——将缓冲区从 CPU 复制到 GPU;计算位置;构建 slot_mapping(示例中会进一步说明);构造注意力元数据。
前向传播——使用自定义分页注意力内核运行模型。所有序列都会被展平并拼接成一个很长的“超级序列”。位置索引和注意力掩码可确保每个序列只关注自身的 token,从而无需右侧填充即可实现连续批处理。
收集末尾 token 状态——提取每个序列最终位置的隐藏状态,并计算 logits。
采样——根据采样配置的要求,从计算得到的 logits 中采样 token(贪心、temperature、top-p、top-k 等)。
前向传播步骤本身有两种执行模式:
Eager 模式——启用 eager execution 时,运行标准的 PyTorch 前向传播。
“捕获”模式——未强制启用 eager 时,执行或重放预先捕获的 CUDA Graph(还记得吗?在引擎构造期间,我们已在初始化 KV 缓存的流程中捕获了这些图)。
下面这个具体示例应该可以清楚说明连续批处理和分页注意力:
高级功能——扩展核心引擎逻辑
了解基本引擎流程后,我们现在可以看看高级功能。
前面已经讨论过抢占、分页注意力和连续批处理。
接下来,我们将深入介绍:
引导式解码(通过受语法约束的有限状态机实现)
分离式 P/D(预填充/解码)
分块预填充是一种处理长提示词的技术,它会把预填充步骤拆分成更小的块。如果不这样做,单个非常长的请求可能会独占某个引擎步骤,导致其他预填充请求无法运行。这会推迟所有其他请求并增加其延迟。
例如,假设每个块包含 n(=8)个 token,用小写字母表示,并以“-”分隔。长提示词 P 可能形如 x-y-z,其中 z 是一个不完整的块(例如包含 2 个 token)。这样一来,完成 P 的全部预填充至少需要 3 个引擎步骤(如果它在某个步骤中未被调度执行,则可能多于 3 个步骤),并且只有在最后一个分块预填充步骤中才会采样一个新 token。
下面以可视化方式呈现同一个示例:
实现方式很直接:限制每个步骤中的新 token 数量。如果请求的数量超过 long_prefill_token_threshold,就将其重置为该阈值。底层索引逻辑(前文已介绍)会处理剩余工作。
在 vLLM V1 中,可以通过将 long_prefill_token_threshold 设置为正整数来启用分块预填充。(严格来说,即使不这样设置,也可能发生分块预填充:如果提示词长度超过 token 预算,我们会截断它并执行分块预填充。)
为了解释前缀缓存的工作原理,我们对最初的代码示例稍作修改:
from vllm import LLM, SamplingParams
long_prefix = "<a piece of text that is encoded into more than block_size tokens>"
prompts = [
"Hello, my name is",
"The president of the United States is",
]
sampling_params = SamplingParams(temperature=0.8, top_p=0.95)
def main():
llm = LLM(model="TinyLlama/TinyLlama-1.1B-Chat-v1.0")
outputs = llm.generate(long_prefix + prompts[0], sampling_params)
outputs = llm.generate(long_prefix + prompts[1], sampling_params)
if __name__ == "__main__":
main()
前缀缓存可以避免重复计算多个提示词开头共享的 token——因此称为“前缀”。
关键部分是 long_prefix:它被定义为任何长度超过一个 KV 缓存块的前缀(默认是 16 个 token)。为了简化示例,假设 long_prefix 的长度恰好是 n x block_size(其中 n ≥ 1)。
如果没有前缀缓存,每次处理具有相同 long_prefix 的新请求时,都要重新计算全部 n x block_size 个 token。
使用前缀缓存后,这些 token 只需计算一次(其 KV 存储在 KV 缓存的分页内存中),之后即可复用,因此只需处理新的提示词 token。这会加速预填充请求(但对解码没有帮助)。
这在 vLLM 中是如何工作的?
在第一次调用 generate 时,引擎会在调度阶段的 kv_cache_manager.get_computed_blocks 内调用 hash_request_tokens:
该函数将 long_prefix + prompts[0] 拆分成由 16 个 token 组成的块。
对于每个完整块,它都会计算一个哈希值(使用内置哈希或 SHA-256;后者速度较慢,但碰撞更少)。该哈希由前一个块的哈希值、当前 token 和可选元数据共同计算得出。
每个结果都存储为一个 BlockHash 对象,其中包含哈希值及其 token ID。最后返回一个块哈希列表。
该列表存储在 self.req_to_block_hashes[request_id] 中。
接下来,引擎调用 find_longest_cache_hit,检查这些哈希是否已存在于 cached_block_hash_to_block 中。处理第一个请求时,不会发现任何命中。
随后,我们调用 allocate_slots;该函数会调用 coordinator.cache_blocks,将新的 BlockHash 条目与已分配的 KV 块关联起来,并将它们记录到 cached_block_hash_to_block 中。
之后,前向传播会将 KV 填充到与上面分配的 KV 缓存块相对应的分页 KV 缓存内存中。
第二次使用相同前缀调用 generate 时,会重复步骤 1~3,但这次 find_longest_cache_hit 会找到全部 n 个块的匹配项(通过线性搜索)。引擎可以直接复用这些 KV 块。
如果原始请求仍然存活,这些块的引用计数就会递增(例如增加到 2)。在这个例子中,第一个请求已经完成,因此这些块已被释放回池中,其引用计数也被重置为 0。由于我们能够从 cached_block_hash_to_block 中取回它们,因此可以确定它们是有效的(KV 缓存管理器的逻辑就是这样设计的),所以我们只需再次将它们从 free_block_queue 中移除。
这就是前缀缓存的核心:不要重新计算已经见过的前缀——直接复用它们的 KV 缓存即可!
前缀缓存默认启用。要禁用它:enable_prefix_caching = False。
引导式解码是一种在每个解码步骤中,通过基于语法的有限状态机约束 logits 的技术。这样可以确保只有语法允许的 token 才能被采样。
这是一种非常强大的机制:你既可以强制执行正则文法(乔姆斯基层级中的 3 型文法,例如任意正则表达式模式),也可以一直扩展到上下文无关文法(2 型文法,涵盖了大多数编程语言)。
为了让这个概念不那么抽象,我们从最简单的示例开始,在前面的代码基础上继续:
from vllm import LLM, SamplingParams
from vllm.sampling_params import GuidedDecodingParams
prompts = [
"This sucks",
"The weather is beautiful",
]
guided_decoding_params = GuidedDecodingParams(choice=["Positive", "Negative"])
sampling_params = SamplingParams(guided_decoding=guided_decoding_params)
def main():
llm = LLM(model="TinyLlama/TinyLlama-1.1B-Chat-v1.0")
outputs = llm.generate(prompts, sampling_params)
if __name__ == "__main__":
main()
在我给出的这个玩具示例中(假设采用字符级 tokenization):在预填充阶段,FSM 会屏蔽 logits,使得只有 "P" 或 "N" 可选。如果采样到了 "P",FSM 就会转移到 "Positive" 分支;下一步只允许 "o",以此类推。
它在 vLLM 中的工作方式如下:
在构造 LLM 引擎时,会创建一个 StructuredOutputManager;它可以访问 tokenizer,并维护一个 _grammar_bitmask 张量。
添加请求时,请求状态会被设为 WAITING_FOR_FSM,而 grammar_init 会选择后端编译器(例如 xgrammar [7];请注意,这些后端是第三方代码)。
该请求的语法会被异步编译。
在调度期间,如果异步编译已经完成,状态就会切换为 WAITING,并将 request_id 添加到 structured_output_request_ids;否则,请求会被放入 skipped_waiting_requests,以便在下一次引擎步骤中重试。
调度循环结束后(仍处于调度过程内部),如果存在 FSM 请求,StructuredOutputManager 会要求后端准备或更新 _grammar_bitmask。
前向传播生成 logits 后,xgr_torch_compile 的函数会将位掩码扩展到词表大小(扩展比例为 32 倍,因为我们使用的是 32 位整数),并将不允许的 logits 屏蔽为 –∞。
采样出下一个 token 后,请求的 FSM 会通过 accept_tokens 向前推进。从视觉上看,就是在 FSM 图中转移到下一个状态。
第 6 步值得进一步说明。
如果 vocab_size = 32,那么 _grammar_bitmask 就是一个整数;它的二进制表示编码了哪些 token 被允许("1"),哪些不被允许("0")。例如,"101…001" 会被扩展为一个长度为 32 的数组 [1, 0, 1, …, 0, 0, 1];值为 0 的位置会将对应的 logits 设为 –∞。对于更大的词表,则会使用多个 32 位字,并相应地进行扩展和拼接。后端(例如 xgrammar)负责根据当前 FSM 状态生成这些位模式。
下面是一个更简单的示例,其中 vocab_size = 8,并使用 8 位整数(献给那些喜欢我这些可视化示意图的读者):
你可以通过传入所需的 guided_decoding 配置,在 vLLM 中启用这一功能。
在自回归生成中,每生成一个新 token,都需要对大型 LM 执行一次前向传播。这个过程开销很大——每一步都要重新加载并应用所有模型权重,却只为了计算一个 token!(假设 batch size == 1,更一般地说是 B 个)
推测式解码 [8] 通过引入一个更小的草稿 LM 来加速这一过程。草稿模型可以低成本地提出 k 个 token。但我们最终并不希望从这个较小的模型中采样——它的作用只是猜测候选的后续内容。哪些候选有效,仍然由大模型决定。
草拟:在当前上下文上运行小模型,并提出 k 个 token
验证:在“上下文 + k 个草稿 token”上运行一次大模型。这会生成这 k 个位置以及额外一个位置的概率(因此我们会得到 k+1 个候选)
接受/拒绝:从左到右依次处理这 k 个草稿 token:如果大模型赋予该草稿 token 的概率 ≥ 草稿模型赋予它的概率,则接受它;否则,以 p_large(token)/p_draft(token) 的概率接受它;遇到第一个被拒绝的 token 时停止,或者接受全部 k 个草稿 token。如果全部 k 个草稿 token 都被接受,则还可以从大模型中“免费”采样额外的第 k+1 个 token(因为我们已经计算出了该位置的分布)。如果发生拒绝,则在该位置创建一个新的再平衡分布(p_large - p_draft,将最小值截断为 0,再归一化使总和为 1),并从中采样最后一个 token。
如果大模型赋予该草稿 token 的概率 ≥ 草稿模型赋予它的概率,则接受它
否则,以 p_large(token)/p_draft(token) 的概率接受它
遇到第一个被拒绝的 token 时停止,或者接受全部 k 个草稿 token。
如果全部 k 个草稿 token 都被接受,则还可以从大模型中“免费”采样额外的第 k+1 个 token(因为我们已经计算出了该位置的分布)。
如果发生拒绝,则在该位置创建一个新的再平衡分布(p_large - p_draft,将最小值截断为 0,再归一化使总和为 1),并从中采样最后一个 token。
它之所以有效,是因为:尽管我们使用小模型来提出候选,但接受/拒绝规则能够保证,从期望上看,生成序列的分布与逐 token 从大模型采样时完全相同。这意味着推测式解码在统计意义上等价于标准的自回归解码——但速度可能快得多,因为大模型的一次前向传播最多可以生成 k+1 个 token。
vLLM V1 不支持使用 LLM 草稿模型的方法,而是实现了速度更快但准确度较低的提议方案:n-gram、EAGLE [9] 和 Medusa [10]。
n-gram:取最后 prompt_lookup_max 个 token;在序列中寻找之前出现过的匹配项;如果找到,则提出该匹配项之后的 k 个 token;否则缩小窗口,并不断重试,直至 prompt_lookup_min
Eagle: