走进 vLLM:高吞吐量 LLM 推理系统的剖析
注意: 原文发布于 Aleksa Gordic 的个人网站。
从分页注意力 (Paged Attention)、连续批处理、前缀缓存、投机解码等,到多 GPU、多节点的大规模动态服务
在这篇文章中,我将逐步介绍构成现代高吞吐量 LLM 推理系统的所有核心组件和高级功能。特别是,我将详细拆解 vLLM [1] 的工作原理。
本文是一个系列的第一篇。它遵循“倒金字塔”结构,先从宏观视角切入,再层层递进地展开细节,帮助你在不陷入琐碎细节的情况下,形成对整个系统的准确认知。
后续文章将深入探讨特定的子系统。
本文分为五个部分:
- LLM 引擎与引擎核心:vLLM 的基础(调度、分页注意力、连续批处理等)
- 高级功能:分块预填充、前缀缓存、引导式解码与投机解码、预填充/解码分离
- 扩展规模:从单 GPU 到多 GPU 执行
- 服务层:分布式/并发 Web 框架
- 基准测试与自动调优:衡量延迟和吞吐量
注意: * 本文分析基于 commit 42172ad (2025年8月9日)。
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" # 我们正在使用 V1 引擎
- VLLM_ENABLE_V1_MULTIPROCESSING="0" # 我们在单进程中运行
此配置是:
- 离线的(没有 Web/分布式系统框架)
- 同步的(所有执行都在一个阻塞进程中完成)
- 单 GPU 的(没有数据/模型/流水线/专家并行;DP/TP/PP/EP = 1)
- 使用标准 Transformer [2](支持 Jamba 等混合模型需要更复杂的混合 KV 缓存内存分配器)
从这里开始,我们将逐步构建一个在线、异步、多 GPU、多节点的推理系统,但仍用于服务标准 Transformer。
在这个示例中,我们做两件事:
- 实例化一个引擎
- 在引擎上调用
generate,对给定的 prompt 进行采样
让我们从构造函数开始分析。
LLM 引擎构造函数
引擎的主要组件包括:
- vLLM 配置(包含配置模型、缓存、并行度等的所有开关)
- 处理器(通过校验、分词和处理,将原始输入转换为
EngineCoreRequests) - 引擎核心客户端(在我们的运行示例中,使用的是
InprocClient,它基本上等于EngineCore;我们将逐步扩展到DPLBAsyncMPClient,以实现大规模服务) - 输出处理器(将原始的
EngineCoreOutputs转换为用户看到的RequestOutput)
注意: 随着 V0 引擎的弃用,类名和细节可能会发生变化。我将强调核心思想,而不是确切的签名。我会抽象化其中的部分细节。
引擎核心本身由几个子组件组成:
- 模型执行器 (Model Executor):驱动模型的前向传播,我们目前处理的是
UniProcExecutor,它在单个 GPU 上运行单个Worker进程。我们将逐步构建到支持多 GPU 的MultiProcExecutor - 结构化输出管理器 (Structured Output Manager):用于引导式解码(我们稍后会介绍)
- 调度器 (Scheduler):决定哪些请求进入下一个引擎步骤 —— 它进一步包含:
- 策略设置 —— 可以是 FCFS(先来先服务)或 优先级(高优先级请求先服务)
waiting(等待)和running(运行)队列- KV 缓存管理器 —— 分页注意力的核心 [3]
KV 缓存管理器维护一个 free_block_queue —— 一个可用 KV 缓存块池(通常在几十万个数量级,取决于显存大小和块大小)。在分页注意力机制中,块充当索引结构,将 token 映射到它们计算出的 KV 缓存块。

注意: 标准 Transformer 层(非 MLA [4])的块大小计算如下:2(键/值)*
block_size(默认=16) *num_kv_heads*head_size*dtype_num_bytes(例如 bf16 为 2)
在模型执行器构造期间,会创建一个 Worker 对象,并执行三个关键步骤。(稍后,使用 MultiProcExecutor 时,这些步骤会在跨不同 GPU 的每个工作进程上独立运行。)
- 初始化设备
- 为 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()
- 初始化 KV 缓存
- 获取每层的 KV 缓存规格。历史上这始终是
FullAttentionSpec(同构 Transformer),但随着混合模型(滑动窗口、Transformer/SSM 如 Jamba)的出现,它变得更加复杂(参见 Jenga [5]) - 运行一次哑/分析前向传播并获取 GPU 内存快照,以计算可用显存中能容纳多少个 KV 缓存块
- 分配、重塑并将 KV 缓存张量绑定到注意力层
- 准备注意力元数据(例如将后端设置为 FlashAttention),供后续前向传播过程中的内核调用
- 除非提供
--enforce-eager,否则对于每个预热批次大小进行哑运行并捕获 CUDA Graphs。CUDA Graphs 将 GPU 的整个工作序列记录为 DAG。之后在前向传播期间,我们只需启动/重放预先烘焙的图形,从而减少内核启动开销,进而改善延迟。
我在这里省略了许多底层细节 —— 但这些是我现在将介绍的核心部分,因为我将在后面的章节中反复引用它们。
既然引擎已初始化,让我们进入 generate 函数。
Generate 函数
第一步是验证并将请求输入引擎。对于每个 prompt,我们:
- 创建一个唯一的请求 ID 并记录其到达时间
- 调用输入预处理器,对 prompt 进行分词,并返回一个包含
prompt,prompt_token_ids和type(文本、token、嵌入等)的字典 - 将此信息打包成一个
EngineCoreRequest,添加优先级、采样参数和其他元数据 - 将请求传递给引擎核心,它将其封装在
Request对象中并设置状态为WAITING。该请求随后被添加到调度器的waiting队列中(如果 FCFS 则追加,如果优先级则堆插入)
此时引擎已被填充,执行可以开始。在同步引擎示例中,这些初始 prompt 是我们将处理的唯一内容 —— 没有机制在运行中注入新请求。相比之下,异步引擎支持此功能(即 连续批处理 [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,但我们暂时忽略这一点。
接下来,我们将更详细地检查调度。
调度器(Scheduler)
推理引擎处理的工作负载主要有两种:
- 预填充 (Prefill) 请求 —— 对所有 prompt token 进行前向传播。这些通常是 计算密集型 (Compute-bound) 的(阈值取决于硬件和 prompt 长度)。最后,我们在最后一个 token 位置的概率分布中采样一个 token。
- 解码 (Decode) 请求 —— 仅对最近的 token 进行前向传播。所有较早的 KV 向量已缓存。这些是 内存带宽密集型 (Memory-bandwidth-bound) 的,因为我们仍然需要加载所有 LLM 权重(和 KV 缓存)才能计算一个 token。
注意: 在 基准测试部分,我们将分析所谓的 GPU 性能屋脊模型 (roofline model)。那将更深入地说明预填充/解码性能特征。
V1 调度器得益于更聪明的架构设计,可以在同一步骤中混合这两种类型的请求。相比之下,V0 引擎一次只能处理预填充或解码中的一种。
调度器优先考虑解码请求 —— 即已经在 running 队列中的那些。对于每个此类请求,它:
- 计算要生成的新 token 数量(不总是 1,这取决于投机解码和异步调度 —— 稍后会详细介绍)。
- 调用 KV 缓存管理器的
allocate_slots函数(细节如下)。 - 通过减去第 1 步中的 token 数量来更新 token 预算。
之后,它处理来自 waiting 队列的预填充请求,它:
- 检索已计算块的数量(如果禁用了前缀缓存则返回 0 —— 我们稍后介绍)。
- 调用 KV 缓存管理器的
allocate_slots函数。 - 将请求从 waiting 弹出并移至 running,设置其状态为
RUNNING。 - 更新 token 预算。
现在让我们看看 allocate_slots 做了什么,它:
- 计算块数量 —— 确定必须分配多少个新的 KV 缓存块(
n)。每个块默认存储 16 个 token。例如,如果预填充请求有 17 个新 token,我们需要ceil(17/16) = 2个块。 - 检查可用性 —— 如果管理器池中没有足够的块,则提前退出。根据它是解码还是预填充请求,引擎可能会尝试重计算抢占(V0 支持交换抢占),通过驱逐低优先级请求(调用
kv_cache_manager.free将 KV 块返回给块池),或者可能会跳过调度并继续执行。 - 分配块 —— 通过 KV 缓存管理器的协调器,从块池(前面提到的
free_block_queue双向链表)中获取前n个块。存储到req_to_blocks中,这是一个将每个request_id映射到其 KV 缓存块列表的字典。

我们终于准备好进行前向传播了!
运行前向传播
我们调用模型执行器的 execute_model,它委托给 Worker,后者再委托给模型运行器。
主要步骤如下:
- 更新状态 —— 从
input_batch中剔除已完成的请求;更新与前向传播相关的其他元数据(例如,每个请求将用于索引分页 KV 缓存内存的 KV 缓存块)。 - 准备输入 —— 从 CPU 到 GPU 复制缓冲;计算位置;构建
slot_mapping(示例中会有更多介绍);构建注意力元数据。 - 前向传播 —— 使用自定义分页注意力内核运行模型。所有序列被展平并连接成一个长的“超级序列”。位置索引和注意力掩码确保每个序列只关注自己的 token,这使得无需右填充即可实现连续批处理。
- 收集最后一个 token 的状态 —— 提取每个序列最终位置的隐藏状态并计算 Logits。
- 采样 —— 按照采样配置(贪婪、温度、top-p、top-k 等)指示,从计算出的 Logits 中采样 token。
前向传播步骤本身有两种执行模式:
- 即时 (Eager) 模式 —— 在启用即时执行时,运行标准 PyTorch 前向传播。
- “捕获” (Captured) 模式 —— 在不强制执行即时模式时,执行/重放预先捕获的 CUDA Graph(记得我们在初始化 KV 缓存过程中捕获了这些)。
这是一个具体的例子,应该能让连续批处理和分页注意力变得清晰:

高级功能 —— 扩展核心引擎逻辑
基本的引擎流程到位后,我们现在可以看高级功能了。
我们已经讨论了抢占、分页注意力和连续批处理。
接下来,我们将深入探讨:
- 分块预填充 (Chunked Prefill)
- 前缀缓存
- 引导式解码(通过语法约束的有限状态机)
- 投机解码
- 预填充/解码分离
分块预填充 (Chunked Prefill)
分块预填充是一种通过将长 prompt 的预填充步骤拆分为较小块来处理长 prompt 的技术。如果没有它,我们可能最终会有一个非常长的请求垄断一个引擎步骤,不允许其他预填充请求运行。这会推迟所有其他请求并增加它们的延迟。
例如,让每个块包含 n (=8) 个 token,用由“-”分隔的小写字母标记。一个长 prompt P 可能看起来像 x-y-z,其中 z 是一个不完整的块(例如 2 个 token)。对 P 执行完整的预填充将需要 ≥ 3 个引擎步骤(> 可能发生在它未在其中一个步骤中被安排执行时),并且只有在最后一个分块预填充步骤中,我们才会采样一个新 token。
这是同一个例子的直观表示:

实现很简单:限制每一步的新 token 数量。如果请求的数量超过 long_prefill_token_threshold,将其重置为该精确值。底层的索引逻辑(前面描述过)负责处理其余部分。
在 vLLM V1 中,你可以通过将 long_prefill_token_threshold 设置为正整数来启用分块预填充。(从技术上讲,如果不这样做也可能发生,如果 prompt 长度超过 token 预算,我们会将其截断并运行分块预填充。)
前缀缓存 (Prefix Caching)
为了解释前缀缓存的工作原理,让我们对原始代码示例进行一些调整:
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()前缀缓存避免重新计算多个 prompt 开头共享的 token —— 因此称为前缀。
关键点是 long_prefix:它被定义为任何长于一个 KV 缓存块(默认 16 个 token)的前缀。为了简化我们的示例,假设 long_prefix 的长度正好为 n x block_size(其中 n ≥ 1)。
注意: 即它与块边界完美对齐 —— 否则我们将不得不重新计算
long_prefix_len % block_size个 token,因为我们无法缓存不完整的块。
如果没有前缀缓存,每次处理具有相同 long_prefix 的新请求时,我们都会重新计算所有 n x block_size 个 token。
有了前缀缓存,这些 token 只会被计算一次(它们的 KV 存储在 KV 缓存分页内存中)然后被复用,因此只需要处理新的 prompt token。这加快了预填充请求(尽管它对解码没有帮助)。
这在 vLLM 中是如何工作的?
在第一次 generate 调用期间,在调度阶段,在 kv_cache_manager.get_computed_blocks 内部,引擎调用 hash_request_tokens
- 此函数将
long_prefix + prompts[0]拆分为 16 个 token 的块。 - 对于每个完整块,它计算一个哈希(使用内置哈希或 SHA-256,后者较慢但碰撞较少)。哈希结合了前一个块的哈希、当前 token 和可选元数据。
注意: 可选元数据包括:MM 哈希、LoRA ID、缓存盐(注入到第一个块的哈希中可确保只有具有此缓存盐的请求才能复用块)。
- 每个结果都存储为包含哈希及其 token ID 的
BlockHash对象。我们返回一个块哈希列表。
该列表存储在 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。
注意: 在经过许多引擎步骤后,它会分配更多的 KV 缓存块,但这对我们的例子没有影响,因为前缀在
long_prefix之后立即发生偏离。

在第二次使用相同前缀的 generate 调用中,步骤 1-3 重复,但现在 find_longest_cache_hit 找到了所有 n 个块的匹配项(通过线性搜索)。引擎可以直接复用这些 KV 块。

如果原始请求仍然存活,这些块的引用计数将增加(例如增加到 2)。在此示例中,第一个请求已经完成,因此块已被释放回池中,并且它们的引用计数被重置回 0。因为我们能够从 cached_block_hash_to_block 中检索它们,我们知道它们是有效的(KV 缓存管理器的逻辑就是这样设置的),所以我们只是再次将它们从 free_block_queue 中移除。
[!NOTE] 高级说明:KV 缓存块仅在即将从
free_block_queue(从左侧弹出)重新分配时才失效,且我们发现该块仍有关联的哈希并存在于cached_block_hash_to_block中。在那一刻,我们清除块的哈希并从cached_block_hash_to_block中删除其条目,确保它不能通过前缀缓存复用(至少对于该旧前缀而言)。
这就是前缀缓存的要点:不要重新计算你已经见过的相同前缀 —— 只需复用它们的 KV 缓存!
如果你理解了这个例子,你也就理解了分页注意力是如何工作的。
默认启用前缀缓存。禁用它:enable_prefix_caching = False。
引导式解码 (Guided Decoding, FSM)
引导式解码是一种技术,在每个解码步骤中,Logits 都受到基于语法的有限状态机的约束。这确保了只能采样语法允许的 token。
这是一个强大的设置:你可以强制执行从正则语法(Chomsky 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()在我给出的玩具示例中(假设字符级分词):在预填充时,FSM 对 Logits 进行掩码处理,因此只有“P”或“N”是可行的。如果采样到“P”,FSM 移动到“Positive”分支;下一步只允许“o”,依此类推。

这在 vLLM 中是如何工作的:
- 在 LLM 引擎构造时,创建一个
StructuredOutputManager;它有权访问分词器并维护一个_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 状态生成这些位模式。
注意: 这里的绝大多数复杂性都隐藏在 xgrammar 等第三方库中。
这里有一个更简单的例子,vocab_size = 8 且使用 8 位整数(对于那些喜欢我的视觉效果的人):

你可以通过传入所需的 guided_decoding 配置在 vLLM 中启用此功能。
投机解码 (Speculative Decoding)
在自回归生成中,每个新 token 都需要大型 LM 的前向传播。这很昂贵 —— 每一步为了计算一个 token 都要重新加载和应用所有模型权重!(假设批大小 == 1,通常是 B)
投机解码 [8] 通过引入一个较小的草稿 LM 来加速这一过程。草稿模型以廉价方式提出 k 个 token。但我们最终不想从较小的模型中采样 —— 它只是为了猜测候选续写。大型模型仍然决定什么是有效的。
步骤如下:
- 草稿:在当前上下文上运行小模型并提出
k个 token - 验证:在上下文 +
k个草稿 token 上运行一次大模型。这会为那k个位置加上一个额外位置生成概率(所以我们得到k+1个候选) - 接受/拒绝:从左到右遍历
k个草稿 token:
- 如果大模型对草稿 token 的概率 ≥ 草稿模型的概率,则接受它
- 否则,以
p_large(token)/p_draft(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
注意: 当前实现返回第一次匹配后的
k个 token。引入最近度偏差并反转搜索方向(即最后一次匹配)似乎更自然?
-
Eagle:对大型 LM 进行“模型手术”——保留嵌入层和 LM 头部,将 Transformer 堆栈替换为轻量级 MLP;将其微调为廉价草稿
-
Medusa:在大型模型的顶部(LM 头部之前的嵌入层)训练辅助线性头部,以并行预测接下来的
k个 token;使用这些头部比运行单独的小 LM 更有效地提出 token
以下是如何在 vLLM 中使用 ngram 作为草稿方法来调用投机解码:
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)
speculative_config={
"method": "ngram",
"prompt_lookup_max": 5,
"prompt_lookup_min": 3,
"num_speculative_tokens": 3,
}
def main():
llm = LLM(model="TinyLlama/TinyLlama-1.1B-Chat-v1.0", speculative_config=speculative_config)
outputs = llm.generate(prompts, sampling_params)
if __name__ == "__main__":
main()这在 vLLM 中是如何工作的?
设置(在引擎构造期间):
- 初始化设备:创建一个
drafter(草稿模型,例如NgramProposer)和一个rejection_sampler(部分是用 Triton 编写的)。 - 加载模型:加载草稿模型权重(n-gram 为 no-op)。
之后在 generate 函数中(假设我们收到了一个全新的请求):
- 使用大模型运行常规预填充步骤。
- 在前向传播和标准采样之后,调用
propose_draft_token_ids(k)以从草稿模型采样k个草稿 token。 - 将这些存储在
request.spec_token_ids中(更新请求元数据)。 - 在下一个引擎步骤中,当请求在运行队列中时,将
len(request.spec_token_ids)添加到“新 token”计数中,以便allocate_slots为前向传播保留足够的 KV 块。 - 将
spec_token_ids复制到input_batch.token_ids_cpu中以形成(上下文 + 草稿)token。 - 通过
_calc_spec_decode_metadata计算元数据(这会复制input_batch.token_ids_cpu中的 token,准备 Logits 等),然后对草稿 token 运行大模型前向传播。 - 代替对 Logits 进行常规采样,使用
rejection_sampler从左到右接受/拒绝并产生output_token_ids。 - 重复步骤 2-7,直到满足停止条件。
内化这一点的最好方法是启动调试器并逐步浏览代码库,但希望本节让你尝到了一些滋味。


预填充/解码分离 (Disaggregated P/D)
我之前已经暗示了预填充/解码分离背后的动机。
预填充和解码具有非常不同的性能特征(计算密集型 vs 内存带宽密集型),因此将它们的执行分离开来是一个明智的设计。它提供了对延迟的更严格控制 —— 包括 TFTT(首字延迟)和 ITL(token 间延迟)—— 基准测试 章节中会有更多说明。
在实践中,我们运行 N 个 vLLM 预填充实例和 M 个 vLLM 解码实例,并根据实时请求组合自动缩放它们。预填充 worker 将 KV 写入专用的 KV 缓存服务;解码 worker 从中读取。这隔离了长而突发的预填充请求与稳定的、延迟敏感的解码请求。
这在 vLLM 中是如何工作的?
为了清晰起见,下面的示例依赖于 SharedStorageConnector,这是一种用于说明机制的调试连接器实现。
注意: 连接器是 vLLM 用于处理实例间 KV 交换的抽象。连接器接口尚不稳定,计划进行一些近期改进,这将涉及更改,其中一些可能是破坏性的。
我们启动 2 个 vLLM 实例(GPU 0 用于预填充,GPU 1 用于解码),然后在它们之间传输 KV 缓存:
import os
import time
from multiprocessing import Event, Process
import multiprocessing as mp
from vllm import LLM, SamplingParams
from vllm.config import KVTransferConfig
prompts = [
"Hello, my name is",
"The president of the United States is",
]
def run_prefill(prefill_done):
os.environ["CUDA_VISIBLE_DEVICES"] = "0"
sampling_params = SamplingParams(temperature=0, top_p=0.95, max_tokens=1)
ktc=KVTransferConfig(
kv_connector="SharedStorageConnector",
kv_role="kv_both",
kv_connector_extra_config={"shared_storage_path": "local_storage"},
)
llm = LLM(model="TinyLlama/TinyLlama-1.1B-Chat-v1.0", kv_transfer_config=ktc)
llm.generate(prompts, sampling_params)
prefill_done.set() # notify decode instance that KV cache is ready
# To keep the prefill node running in case the decode node is not done;
# otherwise, the script might exit prematurely, causing incomplete decoding.
try:
while True:
time.sleep(1)
except KeyboardInterrupt:
print("Script stopped by user.")
def run_decode(prefill_done):
os.environ["CUDA_VISIBLE_DEVICES"] = "1"
sampling_params = SamplingParams(temperature=0, top_p=0.95)
ktc=KVTransferConfig(
kv_connector="SharedStorageConnector",
kv_role="kv_both",
kv_connector_extra_config={"shared_storage_path": "local_storage"},
)
llm = LLM(model="TinyLlama/TinyLlama-1.1B-Chat-v1.0", kv_transfer_config=ktc)
prefill_done.wait() # block waiting for KV cache from prefill instance
# Internally it'll first fetch KV cache before starting the decoding loop
outputs = llm.generate(prompts, sampling_params)
if __name__ == "__main__":
prefill_done = Event()
prefill_process = Process(target=run_prefill, args=(prefill_done,))
decode_process = Process(target=run_decode, args=(prefill_done,))
prefill_process.start()
decode_process.start()
decode_process.join()
prefill_process.terminate()注意: 我还尝试过
LMCache[11],这是最快的生产级连接器(使用 NVIDIA 的 NIXL 作为后端),但它仍处于最前沿,我遇到了一些错误。由于其大部分复杂性存在于外部存储库中,SharedStorageConnector是解释的更好选择。
这是 vLLM 中的步骤:
- 实例化 —— 在引擎构造期间,连接器在两个地方创建:
- 在 worker 的初始化设备过程中(在初始化 worker 分布式环境函数下),角色为“worker”。
- 在调度器构造函数中,角色为“scheduler”。
- 缓存查询 —— 当调度器处理来自
waiting队列的预填充请求时(在本地前缀缓存检查之后),它调用连接器的get_num_new_matched_tokens。这会在 KV 缓存服务器中检查外部缓存的 token。预填充在此处总是看到 0;解码可能会命中缓存。结果在调用allocate_slots之前添加到本地计数中。 - 状态更新 —— 调度器随后调用
connector.update_state_after_alloc,记录有缓存的请求(预填充为 no-op)。 - 构建元数据对象 —— 在调度结束时,调度器调用
meta = connector.build_connector_meta:
- 预填充添加所有
is_store=True的请求(用于上传 KV)。 - 解码添加
is_store=False的请求(用于获取 KV)。
- 上下文管理器 —— 在前向传播之前,引擎进入 KV 连接器上下文管理器:
- 进入时:调用
kv_connector.start_load_kv。对于解码,这将从外部服务器加载 KV 并注入到分页内存中。对于预填充,它是 no-op。 - 退出时:调用
kv_connector.wait_for_save。对于预填充,这会阻塞直到 KV 上传到外部服务器。对于解码,它是 no-op。
这里是一个直观的例子:

[!NOTE] 附加说明
- 对于
SharedStorageConnector,“外部服务器”只是一个本地文件系统。- 根据配置,KV 传输也可以逐层完成(在每个注意力层之前/之后)。
- 解码仅在请求的第一步加载一次外部 KV;之后它会在本地计算/存储。
从 UniprocExecutor 到 MultiProcExecutor
核心技术到位后,我们现在可以谈谈扩展规模。
假设你的模型权重不再适合单个 GPU 的显存。
第一个选择是使用张量并行(例如 TP=8)将模型分片到同一节点上的多个 GPU 上。如果模型仍然不适合,下一步是跨节点的流水线并行。
[!NOTE] 注意
- 节点内带宽明显高于节点间,这就是为什么张量并行 (TP) 通常优于流水线并行 (PP) 的原因。(流水线并行通信的数据量确实也比 TP 少。)
- 我不涵盖专家并行 (EP),因为我们专注于标准 Transformer 而不是 MoE,也不涵盖序列并行,因为 TP 和 PP 是实践中最常用的。
在此阶段,我们需要多个 GPU 进程(worker)和一个编排层来协调它们。这就是 MultiProcExecutor 提供的功能。

这在 vLLM 中是如何工作的:
MultiProcExecutor初始化一个rpc_broadcast_mq消息队列(在底层通过共享内存实现)。- 构造函数循环遍历
world_size(例如TP=8 ⇒ world_size=8),并通过WorkerProc.make_worker_process为每个 rank 生成一个守护进程。 - 对于每个 worker,父进程首先创建一个读写管道。
- 新进程运行
WorkerProc.worker_main,它实例化一个 worker(经历与UniprocExecutor中相同的“初始化设备”、“加载模型”等过程)。 - 每个 worker 确定它是驱动程序(TP 组中的 rank 0)还是普通 worker。每个 worker 设置两个队列:
rpc_broadcast_mq(与父进程共享)用于接收工作。worker_response_mq用于发送响应。
- 在初始化期间,每个子进程通过管道将其
worker_response_mq句柄发送给父进程。一旦全部收到,父进程就会解除阻塞 —— 这完成了协调。 - 然后 Workers 进入忙碌循环,阻塞在
rpc_broadcast_mq.dequeue上。当工作项到达时,它们执行它(就像在UniprocExecutor中一样,但现在是 TP/PP 特定的分区工作)。结果通过worker_response_mq.enqueue发回。 - 运行时,当请求到达时,
MultiProcExecutor将其排入(非阻塞)所有子 worker 的rpc_broadcast_mq。然后它在指定的输出 rank 的worker_response_mq.dequeue上等待以收集最终结果。
从引擎的角度来看,什么都没有改变 —— 所有这些多进程复杂性都通过调用模型执行器的 execute_model 进行了抽象。
- 在
UniProcExecutor的情况下:execute_model 直接导致调用 worker 上的 execute_model。 - 在
MultiProcExecutor的情况下:execute_model 间接导致通过rpc_broadcast_mq在每个 worker 上调用 execute_model。
此时,我们可以使用相同的引擎接口运行资源允许范围内的任意大小的模型。
下一步是横向扩展:启用数据并行(DP > 1),在节点间复制模型,添加轻量级 DP 协调层,引入副本间的负载均衡,并在前面放置一个或多个 API 服务器来处理传入流量。
服务 vLLM 的分布式系统
设置服务基础设施的方法有很多,但为了保持具体,举个例子:假设我们有两个 H100 节点,并希望跨它们运行四个 vLLM 引擎。
如果模型需要 TP=4,我们可以这样配置节点。

在第一个节点上,以无头模式运行引擎(无 API 服务器),参数如下:
vllm serve <model-name>
--tensor-parallel-size 4
--data-parallel-size 4
--data-parallel-size-local 2
--data-parallel-start-rank 0
--data-parallel-address <master-ip>
--data-parallel-rpc-port 13345
--headless并在另一个节点上运行相同的命令,稍作调整:
- 没有
--headless - 修改 DP 起始 rank
vllm serve <model-name>
--tensor-parallel-size 4
--data-parallel-size 4
--data-parallel-size-local 2
--data-parallel-start-rank 2
--data-parallel-address <master-ip>
--data-parallel-rpc-port 13345注意: 这假设网络已配置,以便所有节点都能到达指定的 IP 和端口。
这在 vLLM 中是如何工作的?
在无头服务器节点上
在无头节点上,CoreEngineProcManager 启动 2 个进程(根据 --data-parallel-size-local),每个运行 EngineCoreProc.run_engine_core。每个函数创建一个 DPEngineCoreProc(引擎核心)并进入忙碌循环。
DPEngineCoreProc 初始化其父类 EngineCoreProc(EngineCore 的子类),它:
- 创建一个
input_queue和output_queue(queue.Queue)。 - 使用
DEALERZMQ 套接字(异步消息库)与另一节点上的前端执行初始握手,并接收协调地址信息。 - 初始化 DP 组(例如使用 NCCL 后端)。
- 用
MultiProcExecutor初始化EngineCore(在 4 个 GPU 上TP=4,如前所述)。 - 创建一个
ready_event(threading.Event)。 - 启动一个输入守护线程 (
threading.Thread) 运行process_input_sockets(…, ready_event)。同样启动一个输出线程。 - 仍在主线程中,等待
ready_event,直到跨 2 个节点的 4 个进程中的所有输入线程都完成了协调握手,最终执行ready_event.set()。 - 解除阻塞后,向前端发送一条“就绪”消息,并附带元数据(例如,分页 KV 缓存内存中可用的
num_gpu_blocks)。 - 主线程、输入线程和输出线程随后进入各自的忙碌循环。
总结:我们最终得到 4 个子进程(每个 DP 副本一个),每个进程运行一个主线程、输入线程和输出线程。它们与 DP 协调器和前端完成协调握手,然后每个进程的三个线程在稳态忙碌循环中运行。

当前稳态::
- 输入线程 —— 阻塞在输入套接字上,直到请求从 API 服务器路由过来;收到后,它解码载荷,通过
input_queue.put_nowait(...)将工作项入队,并返回到套接字阻塞状态。 - 主线程 —— 在
input_queue.get(...)上唤醒,将请求馈送给引擎;MultiProcExecutor运行前向传播并将结果排入output_queue。 - 输出线程 —— 在
output_queue.get(...)上唤醒,将结果发回 API 服务器,然后恢复阻塞。
附加机制::
- DP 波计数器 —— 系统跟踪“波次”;当所有引擎变为空闲时,它们进入静止状态,当新工作到达时计数器递增(对协调/指标很有用)。
- 控制消息 —— API 服务器不仅可以发送推理请求(例如中止和实用/控制 RPC)。
- 锁步哑步骤 —— 如果任何 DP 副本有工作,所有副本都会执行前向步骤;没有请求的副本执行哑步骤以参与所需的同步点(避免阻塞活动副本)。
注意: 锁步澄清:这实际上仅适用于 MoE 模型,其中专家层形成 EP 或 TP 组,而注意力层仍为 DP。目前在 DP 中总是这样做 —— 这只是因为对“内置”非 MoE DP 的使用有限,因为你可以以正常方式运行多个独立的 vLLM 并在它们之间进行负载均衡。
现在对于第二部分,API 服务器节点上发生了什么?
在 API 服务器节点上
我们实例化一个 AsyncLLM 对象(LLM 引擎的 asyncio 包装器)。在内部,这创建了一个 DPLBAsyncMPClient(数据并行、负载均衡、异步、多处理客户端)。
在 MPClient 的父类中,运行 launch_core_engines 函数,该函数:
- 创建用于启动握手的 ZMQ 地址(如无头节点上所见)。
- 生成一个
DPCoordinator进程。 - 创建一个
CoreEngineProcManager(与无头节点上相同)。
在 AsyncMPClient(MPClient 的子类)内部,我们:
- 创建一个
outputs_queue(asyncio.Queue)。 - 创建一个 asyncio 任务
process_outputs_socket,它与所有 4 个DPEngineCoreProc的输出线程通信(通过输出套接字)并写入outputs_queue。 - 随后,来自
AsyncLLM的另一个 asyncio 任务output_handler从此队列中读取,并最终将信息发送到create_completion函数。
在 DPAsyncMPClient 内部,我们创建一个 asyncio 任务 run_engine_stats_update_task,该任务与 DP 协调器通信。
DP 协调器充当前端(API 服务器)和后端(引擎核心)之间的中介。它:
- 定期将负载均衡信息(队列大小、等待/运行请求)发送到前端的
run_engine_stats_update_task。 - 通过动态更改引擎数量来处理前端的
SCALE_ELASTIC_EP命令(仅适用于 Ray 后端)。 - 向后端发送
START_DP_WAVE事件(当由前端触发时)并报告波次状态更新。
回顾一下,前端 (AsyncLLM) 运行几个 asyncio 任务(记住:并发,非并行):
- 一类任务通过
generate路径处理输入请求(每个新客户端请求都会生成一个新的 asyncio 任务)。 - 两个任务(
process_outputs_socket,output_handler)处理来自底层引擎的输出消息。 - 一个任务(
run_engine_stats_update_task)维护与 DP 协调器的通信:发送波次触发器、轮询 LB 状态以及处理动态扩展请求。
最后,主服务器进程创建一个 FastAPI 应用程序并挂载路由,例如 OpenAIServingCompletion 和 OpenAIServingChat,它们公开了 /completion, /chat/completion 等。堆栈随后通过 Uvicorn 提供服务。
所以,汇总在一起,这是完整的请求生命周期!
你从终端发送请求:
curl -X POST https://:8000/v1/completions -H "Content-Type: application/json" -d '{
"model": "TinyLlama/TinyLlama-1.1B-Chat-v1.0",
"prompt": "The capital of France is",
"max_tokens": 50,
"temperature": 0.7
}'接下来发生什么:
- 请求到达 API 服务器上
OpenAIServingCompletion的create_completion路由。 - 该函数异步分词 prompt,并准备元数据(请求 ID、采样参数、时间戳等)。
- 然后它调用
AsyncLLM.generate,它遵循与同步引擎相同的流程,最终调用DPAsyncMPClient.add_request_async。 - 这又调用
get_core_engine_for_request,它根据 DP 协调器的状态在引擎之间进行负载均衡(选择分数最小/负载最低的引擎:score = len(waiting) * 4 + len(running))。 - 请求
ADD被发送到所选引擎的input_socket。 - 在该引擎上:
- 输入线程 —— 解除阻塞,从输入套接字解码数据,并为线程的主线程将工作项放置在
input_queue上。 - 主线程 —— 在
input_queue上解除阻塞,将请求添加到引擎,并重复调用engine_core.step(),将中间结果排入output_queue,直到满足停止条件。
注意: 提醒:
step()调用调度器、模型执行器(它本身可以是MultiProcExecutor!)等。我们已经见过这些了!
- 输出线程 —— 在
output_queue上解除阻塞,并通过输出套接字发回结果。
- 这些结果会触发
AsyncLLM输出的 asyncio 任务(process_outputs_socket和output_handler),进而将 token 返回给 FastAPI 的create_completion路由。 - FastAPI 会附加元数据(完成原因、logprobs、使用信息等),并通过 Uvicorn 将
JSONResponse返回到你的终端!
就这样,你的补全结果就回来了——整个分布式架构隐藏在一个简单的 curl 命令背后!:) 太有趣了!!!
[!NOTE] 附加说明
- 当添加更多 API 服务器时,负载均衡是在操作系统/套接字层面处理的。从应用程序的角度来看,没有发生什么重大变化——复杂性被隐藏起来了。
- 借助 Ray 作为 DP 后端,你可以暴露一个 URL 端点(
/scale_elastic_ep),从而实现引擎副本数量的自动扩缩容。
基准测试与自动调优 - 延迟 vs 吞吐量
到目前为止,我们一直在分析“气体粒子”——即请求如何在引擎/系统中流动的内部细节。现在是时候跳出细节,从整体上审视系统,并提出问题:我们该如何衡量推理系统的性能?
在最高层面上,存在两个相互竞争的指标
- 延迟 (Latency) — 从提交请求到返回 token 所经过的时间
- 吞吐量 (Throughput) — 系统每秒可以生成/处理的 token 或请求数量
延迟对于交互式应用最为重要,因为用户在等待响应。
吞吐量在离线工作负载中非常重要,例如预训练/后训练运行中的合成数据生成、数据清洗/处理,以及一般意义上的任何类型的离线批量推理任务。
在解释为什么延迟和吞吐量会产生竞争之前,我们先定义几个常见的推理指标
| 指标 | 定义 |
|---|---|
TTFT(首字延迟) |
从提交请求到收到第一个输出 token 的时间 |
ITL(token 间延迟) |
两个连续 token 之间的时间(例如,从 token i-1 到 token i) |
TPOT(每个输出 token 的时间) |
请求中所有输出 token 的平均 ITL |
延迟 / E2E(端到端延迟) |
处理请求的总时间,即 TTFT + 所有 ITL 之和,或者等同于从提交请求到收到最后一个输出 token 之间的时间 |
吞吐量 |
每秒处理的 token 总数(输入、输出或两者),或者每秒处理的请求数 |
有效吞吐量 (Goodput) |
满足服务等级目标 (SLO) 的吞吐量,例如最大 TTFT、TPOT 或端到端延迟。例如,只有满足这些 SLO 的请求产生的 token 才会被计算在内 |

这里有一个简化的模型,解释了这两个指标之间的竞争本质。
[!NOTE] 假设:权重 I/O(而非 KV 缓存 I/O)占据主导地位;即我们处理的是短序列。
当我们观察批大小 B 如何影响单个解码步骤时,权衡就显而易见了。当 B ↓ 趋近于 1 时,ITL 会降低:每步的工作量减少,且 token 不会与其他任务“竞争”。当 B ↑ 趋近于无穷大时,ITL 会升高,因为每步需要执行更多的 FLOPs——但吞吐量会提高(直到达到峰值性能),因为权重 I/O 被分摊到了更多的 token 上。
屋顶线模型(Roofline model)在此有助于理解:在饱和批大小 B_sat 以下,步骤时间由 HBM 带宽(将权重逐层流式传输到片上内存)决定,因此步骤延迟几乎是平坦的——计算 1 个与 10 个 token 可能耗时相近。超过 B_sat 后,算子会变为受计算限制(compute-bound),步骤时间大致随 B 增长;每个额外的 token 都会增加 ITL。

[!NOTE] 注意:为了更严谨的论述,我们必须考虑内核自动调优(kernel auto-tuning):随着
B的增加,运行时可能会切换到针对该形状更高效的内核,从而改变实现的性能P_kernel。步骤延迟为t = FLOPs_step / P_kernel,其中FLOPs_step是该步骤中的工作量。你可以看到,当P_kernel达到P_peak时,每步更多的计算将直接导致延迟增加。
如何在 vLLM 中进行基准测试
vLLM 提供了一个 vllm bench {serve,latency,throughput} CLI 工具,它封装了 vllm / benchmarks / {server,latency,throughput}.py。
以下是这些脚本的作用
- latency — 使用短输入(默认 32 个 token)并以小批量(默认 8)采样 128 个输出 token。它运行多次迭代并报告该批次的端到端延迟。
- throughput — 一次性提交固定的提示集(默认:1000 个 ShareGPT 样本,即
QPS=Inf模式),并报告运行期间的输入/输出/总 token 数以及每秒请求数。 - serve — 启动 vLLM 服务器,并通过从泊松分布(或更通用的 Gamma 分布)中采样请求到达间隔来模拟真实世界的工作负载。它在时间窗口内发送请求,测量我们讨论过的所有指标,并可选择强制执行服务器端最大并发限制(通过信号量,例如限制服务器为 64 个并发请求)。
这是一个如何运行延迟脚本的示例
vllm bench latency
--model <model-name>
--input-tokens 32
--output-tokens 128
--batch-size 8注意: CI 中使用的基准测试配置位于
.buildkite/nightly-benchmarks/tests下。
还有一个自动调优脚本,它驱动 serve 基准测试以找到满足目标 SLO 的参数设置(例如,“在保持 p99 端到端延迟 < 500 毫秒的同时最大化吞吐量”),并返回建议的配置。
结语
我们从基础引擎核心(UniprocExecutor)开始,添加了推测解码和前缀缓存等高级功能,扩展到了 MultiProcExecutor(TP/PP > 1),最后横向扩展,将所有内容封装在异步引擎和分布式服务栈中——并以如何衡量系统性能作为结束。
vLLM 还包含了我跳过的专门处理。例如:
- 多样化的硬件后端:TPU,AWS Neuron (Trainium/Inferentia) 等。
- 架构/技术:
MLA,MoE, 编码器-解码器 (例如 Whisper), 池化/嵌入模型,EPLB,m-RoPE,LoRA,ALiBi, 无注意力机制变体,滑动窗口注意力,多模态 LM,以及状态空间模型(例如 Mamba/Mamba-2, Jamba) - TP/PP/SP
- 混合 KV 缓存逻辑 (Jenga),更复杂的采样方法(如束搜索采样)等等
- 实验性功能:异步调度
好的地方在于,其中大多数都与上述主要流程是正交的——你可以几乎把它们当作“插件”来看待(当然,在实践中会有一些耦合)。
我热爱研究系统。话虽如此,在这个高度上,解析度肯定会受损。在接下来的文章中,我将深入探讨特定的子系统,并研究其细节。
[!NOTE] 联系方式:如果您发现文中的任何错误,请通过 X 或 LinkedIn 私信我,或者通过 匿名反馈 发送消息。
致谢
非常感谢 Hyperstack 在过去一年为我的实验提供了 H100 GPU!
感谢 Nick Hill (vLLM 核心贡献者, RedHat)、Kaichao You (vLLM 核心贡献者)、Mark Saroufim (PyTorch)、Kyle Krannen (NVIDIA, Dynamo) 以及 Ashish Vaswani 阅读了本文的预发布版本并提供了反馈!
参考文献
- vLLM https://github.com/vllm-project/vllm
- "Attention Is All You Need" https://arxiv.org/abs/1706.03762
- "Efficient Memory Management for Large Language Model Serving with PagedAttention" https://arxiv.org/abs/2309.06180
- "DeepSeek-V2: A Strong, Economical, and Efficient Mixture-of-Experts Language Model" https://arxiv.org/abs/2405.04434
- "Jenga: Effective Memory Management for Serving LLM with Heterogeneity" https://arxiv.org/abs/2503.18292
- "Orca: A Distributed Serving System for Transformer-Based Generative Models" https://www.usenix.org/conference/osdi22/presentation/yu
- "XGrammar: Flexible and Efficient Structured Generation Engine for Large Language Models" https://arxiv.org/abs/2411.15100
- "Accelerating Large Language Model Decoding with Speculative Sampling" https://arxiv.org/abs/2302.01318
- "EAGLE: Speculative Sampling Requires Rethinking Feature Uncertainty" https://arxiv.org/abs/2401.15077
- "Medusa: Simple LLM Inference Acceleration Framework with Multiple Decoding Heads" https://arxiv.org/abs/2401.10774
- LMCache https://github.com/LMCache/LMCache