揭秘vLLM:大模型推理引擎的高效与可扩展性实践

vLLM深度解析:高效LLM推理秘籍,核心机制到分布式扩展。

原文标题:深度拆解,硬核解构,揭开vLLM推理系统实现高效吞吐的秘籍

原文作者:机器之心

冷月清谈:

vLLM是一个为大语言模型推理设计的高性能框架,旨在通过优化关键环节大幅提升吞吐量和响应速度。文章深入剖析了vLLM的核心架构和工作原理,从基础的LLM引擎及调度机制讲起,详细介绍了其如何通过PagedAttention(分页注意力)和连续批处理技术高效管理显存和请求。
进一步,文章揭示了vLLM如何通过一系列高级功能实现性能飞跃,包括将长提示词处理拆分为小块的“分块预填充”,以及通过复用预计算KV值来加速预填充的“前缀缓存”。此外,它还解释了确保生成内容符合语法的“引导式解码”,以及利用小型草稿模型加速生成过程的“推测解码”。为了优化不同计算特性,vLLM还支持将预填充与解码任务分离的“分离式P/D”。
最后,文章阐述了vLLM如何通过张量并行、流水线并行和数据并行等策略扩展至多GPU、多节点部署,并详细描述了分布式系统中的请求生命周期、API服务与负载均衡。同时,该文也探讨了如何衡量推理系统性能,包括延迟和吞吐量的考量,以及vLLM提供的基准测试和自动调优工具。

怜星夜思:

1、vLLM 里 PagedAttention (分页注意力)是显存管理的核心,但未来硬件发展会不会让它变得没那么关键,甚至有更好的方案出现?大家觉得可能有什么新方向?
2、文章里提到了分块预填充、前缀缓存、推测解码这些高级功能,听起来很美,但实际用起来有没有什么隐藏的坑,或者在某些场景下反而会拖后腿?
3、分离式 Prefill/Decode 的设计对性能提升很大,但在实际部署,尤其是云服务场景下,这种结构会怎么影响资源的分配和成本核算?有什么经验可以分享吗?

原文内容

选自Aleksa Gordic博客

机器之心编译

编辑:冷猫


在大模型应用快速发展的今天,如何让推理变得更快、更高效,已经成为研究和产业界共同关注的焦点。


vLLM 便是在这样的背景下诞生的一套高性能推理框架。它专门针对大语言模型的推理优化,在保持模型准确性的同时,大幅提升了吞吐量与响应速度。凭借对显存管理、并行调度和 KV 缓存等关键环节的创新,vLLM 已经成为业界广泛采用的开源推理引擎。


一篇超长的硬核博客文章:《Inside vLLM: Anatomy of a High-Throughput LLM Inference System》针对 vLLM 的架构、代码和原理进行了深入的分析,这可能是关于 LLM 推理引擎和 vLLM 工作原理的最深入解读。


本文作者是前 Google DeepMind 和 Microsoft 的研究工程师 Aleksa Gordć。



Aleksa 花了好些时间才达到对代码库的这种理解程度,充分低估了这篇文章的工作量,甚至这些内容很容易就能写成一本书。 



  • 博客标题:Inside vLLM: Anatomy of a High-Throughput LLM Inference System

  • 博客链接:https://www.aleksagordic.com/blog/vllm


文中涵盖了:


  • 推理引擎流程基础:包括输入 / 输出请求处理、调度(scheduling)、分页注意力(paged attention)、连续批处理(continuous batching)。

  • 「高级」功能:分块预填充(chunked prefill)、前缀缓存(prefix caching)、引导式解码(guided decoding,基于语法约束的有限状态机 FSM)、推测解码(speculative decoding)、解耦的 P/D(prefill/decoding)。

  • 扩展能力:从可以在单 GPU 上托管的小型模型,到参数量超过万亿的超大模型(通过张量并行 TP、流水线并行 PP、序列并行 SP 实现),最终扩展为多 GPU、多节点的部署方案。

  • Web 端部署与服务:从离线部署,到多个 API 服务器的在线服务;再到负载均衡(load balancing)、数据并行(DP)协调器,以及多引擎(multiple engines)部署架构。

  • 推理系统性能测量:包括延迟(latency,涵盖首 token 时间 TTFT、迭代延迟 ITL、端到端 e2e、吞吐时间 TPOT)、吞吐量(throughput),以及 GPU 性能屋顶线模型(roofline model)。


这篇博客包含了大量的实例以及作者手绘的架构示意图和可视化图像,希望能够对读者们理解推理引擎提供一些有价值的帮助。


以下是博客的详细内容:


在这篇文章中,我会逐步介绍一个现代高吞吐量大语言模型(LLM, Large Language Model)推理系统的核心组件和高级特性。具体来说,我将详细拆解 vLLM 的工作原理。


这篇文章是系列中的第一篇。写作方式采用「倒金字塔结构」:先从宏观层面入手,再逐步深入细节。这样你可以在不被繁琐技术细节淹没的情况下,先建立起对整个系统的清晰整体认知。


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 V1VLLM_ENABLE_V1_MULTIPROCESSING="0" # we're running in a single process


这个配置的特点是:

  • 离线:没有 Web 或分布式系统的支撑。

  • 同步:所有执行都发生在一个单一的阻塞进程中。

  • 单 GPU:没有数据并行、模型并行、流水线并行或专家并行(DP/TP/PP/EP = 1)。

  • 使用标准 Transformer:如果要支持像 Jamba 这样的混合模型,就需要一个更复杂的 混合 KV-Cache 内存分配器。


从这里开始,我们会逐步扩展,构建一个在线、异步、多 GPU、多节点的推理系统 —— 但仍然基于标准 Transformer。


在这个示例中,我们主要做两件事:


1. 实例化一个引擎(instantiate an engine)。

2. 调用它的 generate 方法,从给定的提示词(prompt)中进行采样。


接下来,让我们从 构造函数(constructor) 的分析开始。


LLM 引擎构造函数


引擎的主要组成部分包括:


  • vLLM 配置(vLLM config):包含模型、缓存、并行等所有可调参数。

  • 处理器(processor):将原始输入转化为 EngineCoreRequests,过程包括校验、分词(tokenization)和预处理。

  • 引擎核心客户端(engine core client):在我们的示例中使用的是 InprocClient,它基本上等同于 EngineCore;后面我们会逐步扩展到 DPLBAsyncMPClient,它能支持大规模推理服务。

  • 输出处理器(output processor):将原始 EngineCoreOutputs 转化为用户最终可见的 RequestOutput。


引擎核心(engine core) 本身由多个子组件构成:


  • 模型执行器(Model Executor):负责驱动模型的前向计算。当前我们使用的是 UniProcExecutor,它只在单 GPU 上运行一个工作进程(Worker)。后面我们会逐步扩展到 MultiProcExecutor,以支持多 GPU。

  • 结构化输出管理器(Structured Output Manager):用于 引导式解码(guided decoding),后面章节会详细讲解。

  • 调度器(Scheduler):决定哪些请求进入下一步的引擎执行。它内部进一步包含:


1. 策略设置(policy setting):可以是 FCFS(先来先服务),也可以是 优先级调度(高优先级请求先执行)。

2. 等待队列与运行队列。

3. KV 缓存管理器(KV cache manager) —— 分页注意力(paged attention [3])的核心。


其中,KV 缓存管理器 会维护一个 free_block_queue —— 一个可用 KV 缓存块的池子(通常数量可达几十万甚至更多,具体取决于显存大小和块大小)。在分页注意力中,这些缓存块作为索引结构,负责将 token 映射到其计算得到的 KV 缓存块。


本节核心组件及其关系


标准 Transformer 层(非 MLA)的块大小(block size)计算公式如下:

2 * block_size (default=16) * num_kv_heads * head_size * dtype_num_bytes (2 for bf16)


在 模型执行器(Model Executor) 的构造过程中,会创建一个 Worker 对象,并执行三个关键步骤。(后续使用 MultiProcExecutor 时,这些步骤会在不同 GPU 上的每个工作进程中独立执行。)


1. 初始化设备(Init device)


  • 分配一个 CUDA 设备(例如 "cuda:0")给 Worker,并检查模型的数据类型(dtype)是否受支持(例如 bf16)。

  • 根据设定的 gpu_memory_utilization(例如 0.8 → 使用 80% 的显存),验证是否有足够的显存可用。

  • 设置分布式参数(数据并行 DP、张量并行 TP、流水线并行 PP、专家并行 EP 等)。

  • 实例化一个 model_runner(包含采样器、KV 缓存、以及前向计算所需的 buffer,如 input_ids、positions 等)。

  • 实例化一个 InputBatch 对象(包含 CPU 侧的前向计算 buffer、用于 KV 缓存索引的块表 block tables、采样元数据等)。


2. 加载模型(Load model)


  • 实例化模型结构。

  • 加载模型权重。

  • 调用 model.eval ()(PyTorch 推理模式)。

  • (可选)对模型调用 torch.compile ()。


3. 初始化 KV 缓存(Initialize KV cache)


  • 获取每一层的 KV 缓存规格。历史上通常是 FullAttentionSpec(同质 Transformer),但在混合模型(如滑动窗口、Transformer/SSM 混合模型 Jamba)中会更复杂(参考 Jenga [5])。

  • 运行一次虚拟 / 性能分析前向计算,并截取显存快照,用于计算可容纳的 KV 缓存块数量。

  • 分配、重塑并将 KV 缓存张量绑定到注意力层。

  • 准备注意力元数据(例如指定后端为 FlashAttention),在前向计算时由内核调用。

  • 如果没有传入 --enforce-eager,则会对每个预热(warmup)批大小运行一次虚拟推理,并捕获 CUDA 图(CUDA graphs)。CUDA 图会记录整个 GPU 工作流程形成一个有向无环图(DAG)。在后续的前向计算中,系统会直接复用这些「预编译」的图,避免重复的内核启动开销,从而降低延迟。


这里我省略了一些底层细节,但以上就是需要重点掌握的核心部分。后续章节会多次引用这些概念。


既然引擎已经初始化完成,接下来我们就进入 generate 函数 的解析。


Generate 函数


第一步:验证并将请求输入引擎


对于每个提示词(prompt),处理步骤如下:


1. 创建一个唯一的请求 ID,并记录其到达时间。

2. 调用输入预处理器(input preprocessor),对提示词进行分词(tokenization),并返回一个字典,内容包括:原始提示词、prompt_token_ids、以及输入类型(text、tokens、embeds 等)。

3. 将这些信息打包成一个 EngineCoreRequest,并添加优先级(priority)、采样参数(sampling params)以及其他元数据。

4. 将该请求传递到 引擎核心(engine core),它会把请求封装成一个 Request 对象,并将其状态设为 WAITING。此时请求被加入到调度器的 等待队列:

  • 若为 FCFS(先来先服务) 策略,则采用追加(append);

  • 若为 优先级调度,则采用堆插入(heap-push)。


到这里,引擎已经接收请求,执行流程可以开始。


在同步引擎示例中,这些初始提示是唯一会被处理的请求 —— 中途无法再注入新的请求。


而异步引擎则支持这种操作(即 连续批处理):每一步之后,都会同时考虑新请求与旧请求。


由于前向传播(forward pass)会将批次展平成一个单一序列,并且自定义内核可以高效处理,所以即便是同步引擎,连续批处理 也是在底层得到支持的。


之后,只要仍有请求需要处理,引擎就会反复调用 step () 函数。


每个 step 包含三个阶段:


1. 调度(Schedule):选择本步骤要执行的请求(可能是解码 decode,或 / 和分块预填充 chunked prefill)。

2. 前向传播(Forward pass):运行模型并采样新 token。

3. 后处理(Postprocess):

  • 将采样得到的 token IDs 附加到对应的请求上;

  • 进行去分词(detokenize);

  • 检查停止条件(stop conditions)。

  • 如果请求完成,执行清理操作(例如将其 KV 缓存块归还到 free_block_queue),并提前返回输出。


停止条件(Stop conditions)

- 请求超出了长度限制(max_model_length 或该请求的 max_tokens)。

- 采样到的 token 是 EOS ID(除非启用了 ignore_eos —— 在基准测试时强制生成指定数量的输出 token 时很有用)。

- 采样到的 token 匹配了采样参数中指定的 stop_token_ids。

- 输出文本中出现了 stop string:此时我们会将输出截断到首次出现 stop string 的位置,并在引擎中终止该请求。注意:stop_token_ids 会出现在最终输出中,而 stop string 不会。



接下来,我们将更深入地分析 调度(scheduling) 机制。


调度机制


推理引擎主要需要处理两类工作负载:


1. Prefill 请求 —— 对所有提示(prompt)token 执行一次前向传播。这类请求通常是 计算受限(compute-bound) 的(具体阈值取决于硬件和提示长度)。在完成 prefill 之后,会从最后一个 token 的概率分布中 采样一个 token。

2. Decode 请求 —— 仅对最新生成的一个 token 执行前向传播。之前的 KV 向量已经被缓存好。这类请求则是 内存带宽受限(memory-bandwidth-bound) 的,因为即使只计算一个 token,也仍然需要加载全部 LLM 权重(以及 KV 缓存)。


在 V1 调度器 中,由于更智能的设计,可以在同一个 step 中混合处理 prefill 与 decode 请求。而 V0 引擎 在同一时刻只能处理 prefill 或 decode 二者之一。


调度器会优先处理 decode 请求(即那些已经在运行队列中的请求)。对于每个 decode 请求,它会:


1. 计算需要生成的新 token 数。

2. 调用 KV-cache 管理器的 allocate_slots 函数(下面详述)。

3. 更新 token 配额(token budget),减去第 1 步计算得到的 token 数量。


随后,调度器才会处理 prefill 请求(来自等待队列):


1. 获取计算的块数(如果前缀缓存 prefix caching 被禁用,则返回 0 —— 细节后续介绍)。

2. 调用 KV-cache 管理器的 allocate_slots 函数。

3. 将请求从 waiting 队列弹出,移入 running 队列,并将其状态设置为 RUNNING。

4. 更新 token 配额。


接下来,我们看看 allocate_slots 函数的作用:


1. 计算需要的块数 —— 确定需要分配多少新的 KV-cache 块 (n)。默认每个块可以存储 16 个 token。例如,一个 prefill 请求包含 17 个新 token,则需要分配 ceil (17/16) = 2 个块。

2. 检查可用性 —— 如果管理器的池中没有足够的空闲块,则提前退出。根据请求类型(decode 或 prefill),引擎可能尝试 重计算抢占(recompute preemption)(在 V0 中支持 swap preemption,即通过调用 kv_cache_manager.free 回收低优先级请求的 KV 块),或者跳过调度继续执行。

3. 分配块 —— 通过 KV-cache 管理器的协调器,从块池(即前面提到的 free_block_queue 双向链表)中取出前 n 个块。然后存储到 req_to_blocks 字典中,该字典映射 request_id → KV-cache 块列表。


KV cache 块的列表


前向传播流程


调用 model executor 的 execute_model,它会委托给 Worker,而 Worker 又会调用 model runner。主要步骤如下:


1. 更新状态

  • 从 input_batch 中剪枝已完成的请求。

  • 更新与前向传播相关的元数据(例如:每个请求对应的 KV-cache 块数量,用于索引到分块的 KV-cache 内存)。

2. 准备输入

  • 将输入缓冲区从 CPU 拷贝到 GPU。

  • 计算位置索引。

  • 构建 slot_mapping(后续示例会讲)。

  • 构造注意力元数据(attention metadata)。

3. 执行前向传播

  • 使用自定义 paged attention 内核运行模型。

  • 所有序列会被 展平并拼接成一个长的 「超级序列」 (super sequence)。

  • 通过位置索引和注意力 mask 确保每个序列 只关注自己的 token,这样就能在 不使用右填充 (right-padding) 的情况下实现 连续批处理 (continuous batching)。

4. 收集最后一个 token 的状态

  • 提取每个序列在其 最后位置 的隐藏状态 (hidden states)。

  • 计算 logits。

5. 采样 (sampling)

  • 根据采样配置(greedy、temperature、top-p、top-k 等)从 logits 中采样出下一个 token。


前向传播的两种执行模式


1. Eager 模式:当启用 eager execution 时,直接运行标准的 PyTorch 前向传播。

2. Captured 模式:当不强制 eager 时,执行/重放预先捕获的 CUDA Graph。这些图在引擎构造过程中(初始化 KV-cache 的时候)已经捕获好。


接下来会给一个具体的例子,用于解释 连续批处理 (continuous batching) 与 paged attention 的结合方式。


前向传播:连续批处理与分页注意力


高级功能 —— 扩展核心引擎逻辑


在已经掌握基本的引擎流程之后,我们可以进一步看看一些高级功能。

我们之前已经讨论过 抢占(preemption)、分页注意力(paged attention)和连续批处理(continuous batching)。


接下来要深入介绍的功能包括:

1. 分块预填充(Chunked prefill)

2. 前缀缓存(Prefix caching)

3. 引导式解码(Guided decoding,基于语法约束的有限状态机 FSM)

4. 推测解码(Speculative decoding)

5. 解耦的预填充 / 解码(Disaggregated P/D,即 prefill/decoding 分离)


分块预填充


分块预填充是一种处理长提示词(prompt)的技术。它的核心思想是:把预填充步骤拆分为更小的块来执行。


如果没有这一步,我们可能会遇到一个非常长的请求,它会在一次引擎步骤中独占计算资源,导致其他预填充请求无法执行。这会推迟所有其他请求的执行,从而增加它们的延迟。


举个例子:假设我们让每个分块包含 n = 8 个 token,并用小写字母加 - 来表示分块。一个长提示词 P 可能表现为:x-y-z ,其中 z 是一个不完整的分块(例如只有 2 个 token)。


如果直接执行 P 的完整预填充,那么至少需要 3 个引擎步骤(甚至可能更多,如果它在某一步没有被调度执行)。而且,只有在最后一个分块的预填充步骤结束时,系统才会采样一个新的 token。


下面是这个例子的可视化示意:



实现方式其实很直接:限制每一步中新 token 的数量上限。


如果某个请求的新 token 数量超过了 long_prefill_token_threshold,系统就会把它重置为这个阈值。剩下的处理由之前介绍过的底层索引逻辑(indexing logic)自动完成。


在 vLLM V1 中,可以通过把 long_prefill_token_threshold 设置为一个正整数来启用分块预填充(chunked prefill)。(严格来说,即使没有主动设置,当提示词长度超过 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()


前缀缓存的核心思想是:避免重复计算多个提示词(prompt)在开头部分共享的 token —— 这就是「前缀」的由来。


关键点在于 long_prefix:它被定义为长度超过一个 KV-cache 块的前缀(默认一个块是 16 个 token)。


为了简化例子,我们假设 long_prefix 的长度刚好等于 n × block_size(其中 n ≥ 1)。


也就是说,前缀和块的边界完全对齐。否则,系统就需要重新计算 long_prefix_len % block_size 这些 token,因为我们无法缓存不完整的块。


如果没有前缀缓存,每次处理一个拥有相同 long_prefix 的新请求时,系统都会重复计算全部 n × block_size 个 token。


而启用前缀缓存后,这些 token 只需计算一次(它们的 KV 会被存储在 KV 缓存的分页内存里),之后就能直接复用。这样,系统只需要处理新的提示词 token,从而显著加快预填充请求(prefill request)的速度(不过对解码请求没有帮助)。


在 vLLM 中,它是如何工作的?


在第一次调用 generate 时,在调度阶段(scheduling stage),kv_cache_manager.get_computed_blocks 会调用 hash_request_tokens:


1. 分块:该函数会把 long_prefix + prompts [0] 拆分为若干个 16-token 的块。


2. 计算哈希:

  • 对每个完整的块计算一个哈希值(可使用内置哈希,或者更慢但碰撞率更低的 SHA-256)。

  • 这个哈希由以下部分组合而成:前一个块的哈希值、当前块的 token 以及可选元数据。可选元数据包括:多模态哈希(MM hash)、LoRA ID、缓存盐值(cache salt,注入到第一个块的哈希中,用来确保只有带相同 cache salt 的请求才能复用该块)。


3. 存储结果:每个结果会被封装成一个 BlockHash 对象,包含哈希值和对应的 token IDs。函数最终返回一个 BlockHash 列表。这个列表会被存储在 self.req_to_block_hashes [request_id] 中。


接着,引擎会调用 find_longest_cache_hit 来检查这些哈希是否已存在于 cached_block_hash_to_block 中。


在第一个请求中,通常不会命中任何缓存。



然后,我们调用 allocate_slots,它会进一步调用 coordinator.cache_blocks:

  • 这个函数会把新的 BlockHash 条目 与分配到的 KV 块(KV blocks)关联起来。

  • 同时,它会将这些映射记录在 cached_block_hash_to_block 中。


随后,在前向传播(forward pass)阶段,系统会在分页 KV 缓存(paged KV cache) 中填充对应于刚分配的 KV 块的 K/V 值。


经过多次引擎步骤之后,系统可能会分配更多的 KV 缓存块,但这对我们当前的例子没有影响,因为在 long_prefix 之后,提示词立即发生了分支(diverge),不再复用之前的前缀块。



在第二次调用 generate 时,如果使用相同的前缀(prefix):


  • 步骤 1 到 3 会重复执行。

  • 但是此时,find_longest_cache_hit 会在 n 个块 中找到全部匹配(通过线性搜索)。

  • 引擎可以直接复用这些 KV 块,无需重新计算前缀部分的 token。



如果原始请求仍然存在,这些 KV 块的 引用计数(reference count) 会增加(例如增加到 2)。


在这个例子中,第一个请求已经完成,因此这些块被释放回块池(pool),它们的引用计数也被重置为 0。


由于我们能够从 cached_block_hash_to_block 中检索到它们,这说明这些 KV 块仍然有效(KV-cache 管理器的逻辑就是这样设计的)。


因此,我们只需要再次将它们从 free_block_queue 中移除即可。


高级说明:

KV-cache 块只有在即将 从 free_block_queue 重新分配 时才会被标记为无效(free_block_queue 是从左侧 pop 块)。如果此时发现块仍然有相关的哈希值,并存在于 cached_block_hash_to_block 中,那么我们会:

清除该块的哈希值;从 cached_block_hash_to_block 中移除对应条目。

这样可以确保该块无法通过前缀缓存再次复用(至少不能用于旧前缀)。


总结一下前缀缓存(Prefix Caching) 的核心思想:


不要重复计算已经出现过的前缀 token —— 直接复用它们在 KV 缓存中的值即可!


如果你理解了这个例子,也就理解了分页注意力(paged attention)的工作原理。


引导式解码( FSM)


引导式解码是一种技术:在每一步解码(decoding step)时,logits 会受到基于语法的有限状态机(finite state machine, FSM)约束。


这确保了只有符合语法规则的 token 才能被采样。


这个机制非常强大:

  • 可以强制遵循 正规文法(Chomsky Type-3,例如任意正则表达式模式)。

  • 也可以支持 上下文无关文法(Context-Free Grammar, Type-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)中:在 prefill 阶段,FSM 会对 logits 进行掩码(mask),确保只有 "P" 或 "N" 是可选的。如果采样到了 "P",FSM 就会切换到 「Positive」 分支;在下一步,FSM 只允许 "o" 被采样,以此类推。


FSM 示例


在 vLLM 中的实现方式:


1. 在 LLM 引擎构建阶段,会创建一个 StructuredOutputManager:它可以访问 tokenizer,并维护一个 _grammar_bitmask 张量(tensor)。

2. 当添加一个请求时:请求状态会被设置为 WAITING_FOR_FSM。grammar_init 会选择后端编译器(例如 xgrammar;注意,这些后端是第三方代码)。

3. 该请求的语法会 异步编译。

4. 在调度阶段(scheduling):

  • 如果异步编译完成,状态切换为 WAITING,并将 request_id 添加到 structured_output_request_ids;

  • 如果尚未完成,则将请求放入 skipped_waiting_requests,在下一次引擎步骤重试。

5. 调度循环结束后(仍在调度阶段):如果存在 FSM 请求,StructuredOutputManager 会调用后端准备或更新 _grammar_bitmask。

6. 前向传播(forward pass)生成 logits 后:

  • xgr_torch_compile 的函数会将 _grammar_bitmask 扩展到词表大小(使用 32 位整数时,扩展比例为 32 倍)。

  • 不允许的 token 的 logits 会被设置为 –∞。

7. 采样下一个 token 后:请求的 FSM 会通过 accept_tokens 前进一步。在 FSM 图上,状态会相应向下移动到下一节点。


步骤 6 的进一步说明:

如果 vocab_size = 32,则 _grammar_bitmask 是一个整数。其二进制表示用来编码哪些 token 允许(1),哪些 禁止(0)。例如 "101…001" 会扩展成长度为 32 的数组 [1, 0, 1, …, 0, 0, 1],位置为 0 的 token logits 被设置为 –∞。对于更大的词表,会使用多个 32 位整数,并进行扩展和拼接。后端(例如 xgrammar)负责根据当前 FSM 状态生成这些位模式。


为了直观展示,这里给出一个更简单的示例:vocab_size = 8;使用 8 位整数,适合喜欢可视化表示的读者。



在 vLLM 中,可以通过传入所需的 guided_decoding 配置 来启用引导式解码(Guided Decoding)。


推测解码


在自回归生成(autoregressive generation)中,每生成一个新 token 都需要对大型语言模型(LLM)进行一次前向传播(forward pass)。


这非常耗时 —— 每一步都要重新加载并应用所有模型权重,仅仅为了生成一个 token!(假设 batch size = 1,一般情况是 B)


推测解码通过引入一个较小的草稿模型(draft LM)来加速这一过程:

  • 草稿模型负责快速生成 k 个候选 token。

  • 但我们并不最终从小模型中采样 token —— 它只是用来猜测可能的续写。

  • 大模型仍然决定哪些 token 是有效的。


具体步骤:


1. Draft(草稿):在当前上下文下运行小模型,提出 k 个 token 候选。

2. Verify(验证):使用大模型在 上下文 + k 个草稿 token 上运行一次。这会生成这 k 个位置的概率分布,再加一个额外位置(总共 k+1 个候选)。

3. Accept/Reject(接受 / 拒绝):从左到右检查 k 个草稿 token:

  • 如果大模型给该 token 的概率 ≥ 草稿模型概率,则接受;

  • 否则以概率 p_large (token) /p_draft (token) 接受;

  • 遇到第一个拒绝就停止,或者接受全部 k 个草稿 token。如果全部 k 个 token 都被接受,还可以「免费」采样第 k+1 个 token(因为我们已经计算过大模型分布)。如果有拒绝,则在该位置重新生成一个再平衡的概率分布(p_large - p_draft,最小值限制为 0,归一化到和为 1),并从中采样最后一个 token。


为什么可行?


虽然我们使用小模型来提出候选 token,但接受/拒绝规则保证了序列的期望分布与逐 token 从大模型采样完全一致。


也就是说,推测解码在统计上等价于标准的自回归解码。但它潜在更快,因为一次大模型前向传播可以生成 最多 k+1 个 token。


注意,可以参考 gpt-fast 获取简单实现,原始论文提供了数学细节和等价性证明。vLLM V1 不支持 LLM 草稿模型方法,而是实现了更快但精度略低的候选方案:n-gram、EAGLE、Medusa。


各方法简述:


1. n-gram:

  • 取最后 prompt_lookup_max 个 token,在序列中寻找先前匹配;

  • 若找到,则提出该匹配后面的 k 个 token;

  • 否则缩小窗口并重复,直到 prompt_lookup_min。

  • 当前实现返回第一个匹配后的 k 个 token,可以考虑引入近期偏置(recency bias),反向搜索更自然(即寻找最后一次匹配)。

2. Eagle:

  • 对大模型进行「模型手术」 —— 保留 embeddings 和 LM head,将 transformer 堆栈替换为轻量 MLP;

  • 微调该 MLP 作为廉价草稿模型。

3. Medusa:

  • 在大模型的 embeddings(LM head 前)上训练辅助线性头(linear heads),并行预测接下来的 k 个 token;

  • 使用这些线性头比单独运行小模型更高效地提出 token。


下面是如何在 vLLM 中使用 n-gram 方法启用推测解码的示例:


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 中是怎么实现的呢?


引擎构建阶段(Setup)

1. 初始化设备(Init device):创建 drafter(草稿模型,例如 NgramProposer) 和 rejection_sampler(拒绝采样器)。其中部分组件是用 Triton 实现的。

2. 加载模型(Load model):加载草稿模型的权重(对于 n-gram 方法,这一步是无操作)。


在 generate 函数中处理新请求(假设是全新请求)


1. 使用 大模型 执行常规的 prefill 步骤。

2. 前向传播(forward pass)和标准采样完成后,调用 propose_draft_token_ids (k) 从草稿模型采样 k 个 draft token。

3. 将这些 token 存入 request.spec_token_ids(更新请求元数据)。

4. 在下一次引擎步骤,当请求进入 运行队列(running queue) 时,将 len (request.spec_token_ids) 加入「新 token」计数,以便 allocate_slots 为前向传播保留足够的 KV 缓存块(KV blocks)。

5. 将 spec_token_ids 复制到 input_batch.token_ids_cpu,形成 上下文 + 草稿 token。

6. 通过 _calc_spec_decode_metadata 计算元数据,将 token 从 input_batch.token_ids_cpu 拷贝过来,准备 logits 等信息,然后对草稿 token 执行大模型前向传播。

7. 不是直接从 logits 采样,而是使用 rejection_sampler 从左到右进行接受 / 拒绝操作,生成最终的 output_token_ids。

8. 重复步骤 2–7,直到满足停止条件(stop condition)。


理解这一流程的最佳方式是启动调试器,逐步跟踪代码执行。不过这一节已经让你对 推测解码在 vLLM 中的执行流程有了基本了解。



分离式 Prefill/Decode


我之前已经提到过 分离式 Prefill/Decode 的设计动机。


Prefill 和 Decode 的性能特性非常不同:Prefill 主要受计算能力(compute-bound)限制;Decode 主要受内存带宽(memory-bandwidth-bound)限制。因此,将两者分离执行是一种合理的设计。这种设计可以更精细地控制延迟:TFTT(Time-To-First-Token,首个 token 时间);ITL(Inter-Token Latency,token 间延迟)。


实际上,我们会启动 N 个 vLLM prefill 实例 和 M 个 vLLM decode 实例,并根据实时请求负载自动伸缩。Prefill Worker 会将 KV 写入 专用 KV-cache 服务;Decode Worker 从该服务读取 KV。这样可以将 长且突发的 prefill 请求 与 延迟敏感的 decode 请求 隔离开来,保证系统稳定性和低延迟。


在 vLLM 中的实现方式是怎么样的?


为了说明原理,下列示例使用 SharedStorageConnector,这是一个用于调试的 Connector 实现,用来演示内部机制。


Connector 是 vLLM 用于处理实例间 KV 交换的抽象接口。目前该接口尚未稳定,短期内会有一些改进,其中一些可能会引入破坏性变更。

我们启动 2 个 vLLM 实例(GPU 0:用于 prefill,GPU 1:用于 decode),然后在这两个实例之间传输 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,这是最快、可用于生产环境的 Connector(以 NVIDIA 的 NIXL 作为后端),但它仍处于前沿技术阶段,我在使用中遇到了一些 bug。由于其大部分复杂逻辑存在于外部仓库,SharedStorageConnector 更适合作为讲解示例。


在 vLLM 中的步骤如下:


1. 实例化(Instantiation) — 在引擎构建期间,Connector 会在两个地方创建:在 Worker 的 init device 过程中(位于 init worker distributed environment 函数下),角色为「worker」。在 Scheduler 构造函数中,角色为 「scheduler」。


2. 缓存查询(Cache lookup) — 当 Scheduler 处理来自等待队列的 prefill 请求(经过本地 prefix-cache 检查后),会调用 Connector 的 get_num_new_matched_tokens:该函数检查 KV-cache 服务器中是否有外部缓存的 token。Prefill 在这里始终返回 0;decode 可能会命中缓存。结果会在调用 allocate_slots 之前加入本地计数。


3. 状态更新(State update) — Scheduler 然后调用 connector.update_state_after_alloc,记录已经命中缓存的请求(prefill 为无操作)。


4. 元信息构建(Meta build) — 在调度结束时,Scheduler 调用 meta = connector.build_connector_meta:Prefill 会将 is_store=True 的请求加入,用于上传 KV。Decode 会将 is_store=False 的请求加入,用于获取 KV。


5. 上下文管理器(Context manager) — 在前向传播之前,引擎会进入 KV-Connector 上下文管理器:进入(enter):调用 kv_connector.start_load_kv。对于 decode,这会从外部服务器加载 KV 并注入到分页内存;对于 prefill,则无操作。退出(exit):调用 kv_connector.wait_for_save。对于 prefill,这会阻塞直到 KV 上传至外部服务器;对于 decode,则无操作。


下面是一个可视化示例:



附加说明:对于 SharedStorageConnector 来说,「外部服务器」只是本地文件系统。根据配置,KV 传输也可以按层进行(在每个注意力层前或后)。Decode 仅在请求的第一步加载外部 KV;之后的步骤在本地计算和存储。


拓展系统:从UniProcExecutor到MultiProcExecutor


在掌握核心技术后,我们可以讨论如何扩展系统。


假设你的模型权重已经无法完全放入单个 GPU 的显存中:

  • 第一个选项是使用 张量并行(Tensor Parallelism, TP),将模型分片到同一节点的多张 GPU 上(例如 TP=8)。

  • 如果模型仍然无法容纳,则下一步是在跨节点使用 流水线并行(Pipeline Parallelism, PP)。


说明:

  • 节点内带宽(intranode bandwidth)显著高于节点间带宽(internode bandwidth),这也是为什么通常更偏好 张量并行(TP) 而非流水线并行(PP)。(同时,PP 通信的数据量通常比 TP 少。)

  • 我们不讨论 Expert Parallelism(EP),因为这里关注的是标准 Transformer 而非 MoE 模型,也不讨论序列并行(Sequence Parallelism),TP 和 PP 在实际中是最常用的。


在这个阶段,我们需要多个 GPU 进程(Workers)以及一个协调层来管理它们,而这正是 MultiProcExecutor 提供的功能。


MultiProcExecutor 在 TP=8 配置下(其中 driver worker 的 rank 为 0)


在 vLLM 中的工作原理:


1. 初始化消息队列 — MultiProcExecutor 会初始化一个 rpc_broadcast_mq 消息队列(底层通过共享内存实现)。

2. 生成子进程 — 构造函数循环遍历 world_size(例如 TP=8 ⇒ world_size=8),通过 WorkerProc.make_worker_process 为每个 rank 启动一个守护进程。

3. 创建通信管道 — 对每个 worker,父进程首先创建 reader 和 writer 管道。

4. 运行子进程 — 新进程执行 WorkerProc.worker_main,在其中实例化 worker(经历与 UniprocExecutor 相同的 「init device」、「load model」等步骤)。

5. 确定角色并设置队列 — 每个 worker 判断自己是 driver(TP 组中的 rank 0)还是普通 worker。每个 worker 设置两条队列:

- rpc_broadcast_mq(与父进程共享)用于接收任务。

- worker_response_mq 用于发送结果回父进程。

6. 协调完成 — 初始化期间,每个子进程通过管道将 worker_response_mq 句柄发送给父进程。父进程收到所有句柄后解除阻塞,完成协调。

7. 执行任务循环 — Worker 进入忙等待循环,阻塞在 rpc_broadcast_mq.dequeue。当有任务到来时,执行任务(与 UniprocExecutor 类似,但现在是 TP/PP 特定的分区任务),结果通过 worker_response_mq.enqueue 发送回父进程。

8. 请求处理 — 运行时,当有请求到来,MultiProcExecutor 将其非阻塞地放入 rpc_broadcast_mq 给所有子 worker,然后等待指定输出 rank 的 worker_response_mq.dequeue 收集最终结果。


从引擎视角来看,接口没有变化 —— 所有的多进程复杂性都通过调用 model executor 的 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,我们可以将节点配置如下。


两台 8×H100 节点 配置:节点 1:Headless(无前端 API),节点 2:API 服务器(负责接收外部请求)


在第一台节点上,以 headless 模式(无 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 start 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 中的工作原理:


在 headless 服务器节点上,CoreEngineProcManager 会启动 2 个进程(根据 --data-parallel-size-local),每个进程运行 EngineCoreProc.run_engine_core。每个函数会创建一个 DPEngineCoreProc(引擎核心),然后进入其忙循环。


DPEngineCoreProc 会初始化其父类 EngineCoreProc(EngineCore 的子类),其主要步骤如下:


1. 创建 input_queue 和 output_queue(queue.Queue)。

2. 使用 DEALER ZMQ 套接字(异步消息库)与另一节点的前端进行初始握手,并接收协调地址信息。

3. 初始化 DP(数据并行)组(例如使用 NCCL 后端)。

4. 使用 MultiProcExecutor 初始化 EngineCore(如前所述,TP=4、4 个 GPU)。

5. 创建一个 ready_event(threading.Event)。

6. 启动一个输入守护线程(threading.Thread)运行 process_input_sockets (..., ready_event)。同样启动输出线程。

7. 主线程仍在等待 ready_event,直到跨 4 个进程(覆盖 2 个节点)的所有输入线程完成协调握手,然后执行 ready_event.set ()。

8. 一旦解除阻塞,向前端发送 "ready" 消息,并附带元数据(例如分页 KV 缓存中可用的 GPU 块数量)。

9. 主线程、输入线程和输出线程进入各自的忙循环。


总结:最终我们得到 4 个子进程(每个 DP 副本一个),每个进程运行主线程、输入线程和输出线程。它们完成与 DP 协调器和前端的协调握手,然后每个进程的三个线程进入稳定的忙循环状态。


分布式系统示例:4 个 DP 副本运行 4 个 DPEngineCoreProc


当前稳态(Steady State):


  • 输入线程(Input thread) — 阻塞在输入套接字上,直到 API 服务器路由一个请求过来;收到请求后,它会解码负载,通过 input_queue.put_nowait (...) 将工作项入队,然后回到套接字阻塞状态。

  • 主线程(Main thread) — 当 input_queue.get (...) 被唤醒时,将请求传入引擎;MultiProcExecutor 执行前向推理,并将结果入队到 output_queue。

  • 输出线程(Output thread) — 当 output_queue.get (...) 被唤醒时,将结果返回给 API 服务器,然后恢复阻塞。


附加机制:


  • DP 波计数器(DP wave counter) — 系统跟踪「波次」;当所有引擎空闲时,它们进入静止状态,当新工作到达时计数器递增(有助于协调与指标统计)。

  • 控制消息(Control messages) — API 服务器不仅可以发送推理请求,还可发送中止请求或其他控制 RPC。

  • 锁步虚拟步骤(Dummy steps for lockstep) — 如果某个 DP 副本有工作,所有副本都会执行前向步骤;没有请求的副本执行虚拟步骤以参与必要的同步点(避免阻塞活跃副本)。


锁步说明(Lockstep clarification):事实上,锁步主要用于 MoE 模型,其中专家层(Expert layers)形成 EP 或 TP 组,而注意力层(Attention layers)仍为 DP。当前即使在标准 DP 下也总是执行锁步,这只是因为「内置」非 MoE 的 DP 使用场景有限;在普通场景下,你完全可以运行多个独立的 vLLM,并进行负载均衡。


接下来,我们来看 API 服务器节点会发生什么。


API 服务节点


我们实例化一个 AsyncLLM 对象(一个基于 asyncio 的大语言模型(LLM)引擎封装)。内部会创建一个 DPLBAsyncMPClient(数据并行、负载均衡、异步、多进程客户端)。


在 MPClient 的父类内部,launch_core_engines 函数执行以下操作:

1. 创建启动握手所需的 ZMQ 地址(类似于无头节点中的设置)。

2. 启动一个 DPCoordinator 进程。

3. 创建一个 CoreEngineProcManager(与无头节点相同)。


在 AsyncMPClient(MPClient 的子类)内部:

1. 创建一个 outputs_queue(asyncio.Queue)。

2. 创建一个 asyncio 任务 process_outputs_socket,通过输出套接字与 4 个 DPEngineCoreProc 的输出线程通信,并将结果写入 outputs_queue。

3. 之后,再创建一个 asyncio 任务 output_handler(在 AsyncLLM 中),从 outputs_queue 读取信息,并最终将其发送到 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 协调器保持通信:发送波触发、轮询负载均衡状态、处理动态扩缩请求。


最后,主服务器进程创建一个 FastAPI 应用,并挂载诸如 OpenAIServingCompletion 和 OpenAIServingChat 的端点,暴露 /completion、/chat/completion 等接口。整个堆栈通过 Uvicorn 对外提供服务。


把这些环节串起来,就是完整的请求生命周期:


你从终端发送请求:


curl -X POST http://localhost: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
}'


接下来会发生什么:


1. 请求到达 API 服务器上的 OpenAIServingCompletion 的 create_completion 路由。

2. 该函数异步对提示词进行分词(tokenize),并准备元数据(请求 ID、采样参数、时间戳等)。

3. 然后调用 AsyncLLM.generate,其流程与同步引擎相同,最终会触发 DPAsyncMPClient.add_request_async。

4. 接着调用 get_core_engine_for_request,根据 DP 协调器的状态在各个引擎间做负载均衡(选择分数最低 / 负载最小的引擎:score = len (waiting) * 4 + len (running))。

5. 将 ADD 请求发送到选定引擎的 input_socket。

6. 在该引擎中:

  • Input 线程 — 解除阻塞,从输入套接字解码数据,并将工作项放入主线程的 input_queue。

  • Main 线程 — 在 input_queue 上解除阻塞,将请求添加到引擎中,并反复调用 engine_core.step (),将中间结果加入 output_queue,直到满足停止条件。

提醒:step () 会调用调度器(scheduler)、模型执行器(model executor,可为 MultiProcExecutor)等,我们之前已经讲过了。


  • Output 线程 — 在 output_queue 上解除阻塞,将结果通过输出套接字发送回去。

7. 这些结果触发 AsyncLLM 的输出 asyncio 任务(process_outputs_socket 和 output_handler),将生成的 token 传回 FastAPI 的 create_completion 路由。

8. FastAPI 附加元数据(完成原因、logprobs、使用信息等),通过 Uvicorn 返回 JSONResponse 到你的终端。


就这样,你的文本生成结果返回了 —— 整个复杂的分布式系统都被一个简单的 curl 命令隐藏了起来!😄


附加说明:

  • 添加更多 API 服务器时,负载均衡在 OS / 套接字层处理。应用层感知不到复杂性,仍然是一条请求 - 响应流程。

  • 使用 Ray 作为 DP 后端时,可以暴露 /scale_elastic_ep URL 接口,实现引擎副本数量的自动上下扩缩。


基准测试与自动调优 — 延迟 vs 吞吐量


到目前为止,我们一直在分析「气体粒子」—— 请求在引擎 / 系统内部的流动方式。现在,我们将视角拉远,看看整个系统,并思考:如何衡量推理系统的性能?


在最高层面上,有两个互相制约的指标:

1. 延迟(Latency) — 从请求提交到返回 token 的时间。

2. 吞吐量(Throughput) — 系统每秒能够生成或处理的 token/请求数量。


  • 延迟对交互式应用最为重要,因为用户在等待响应。

  • 吞吐量对离线工作负载更为关键,例如用于训练前 / 训练后的合成数据生成、数据清洗 / 处理,以及任何离线批量推理任务。


在解释为什么延迟和吞吐量存在竞争关系之前,我们先定义一些常见的推理性能指标:




下面是一个简化模型,用来解释延迟(Latency)和吞吐量(Throughput)之间的竞争关系。


假设:主导因素是权重(weight)I/O,而非 KV 缓存 I/O,也就是说我们处理的是短序列。


当观察批大小 B 对单次 decode 步骤的影响时,这种权衡就很明显了:

  • 当 B↓趋近 1 时,每个 token 的间隔延迟(ITL, inter-token latency)下降:每步处理的工作量更少,token 之间不会互相「竞争」。

  • 当 B ↑趋近无穷时,ITL 上升,因为每步要做更多 FLOPs,但吞吐量提高(直到达到峰值性能),原因是权重 I/O 被更多 token 分摊。


这里可以用 roofline 模型来帮助理解:


  • 在饱和批大小 B_sat 以下,步骤时间主要受 HBM 带宽限制(权重逐层流入片上内存),因此步骤延迟几乎保持不变 —— 计算 1 个 token 和 10 个 token 所需时间相近。

  • 超过 B_sat 后,kernel 开始受计算能力限制,步骤时间大约随 B 增长,每增加一个 token 都会增加 ITL。


Roofline 性能模型


注意:为了更严谨的分析,我们必须考虑 kernel 自动调优(kernel auto-tuning):随着批量大小 B 增大,运行时可能针对当前形状切换到更高效的 kernel,从而改变实际达到的性能 P_kernel。每步延迟为 t = FLOPs_step / P_kernel,其中 FLOPs_step 是该步的计算量。你可以看到,当 P_kernel 达到峰值 P_peak 时,每步增加的计算量会直接导致延迟上升。


如何在 vLLM 中做基准测试


vLLM 提供了一个 CLI: vllm bench {serve,latency,throughput} ,它封装了 vllm/benchmarks/{server,latency,throughput}.py 脚本。


这些脚本的作用如下:

  • latency — 使用短输入(默认 32 tokens)并生成 128 个输出 token,使用小批量(默认 8)。运行多次迭代,并报告整个 batch 的端到端延迟。

  • throughput — 一次性提交固定的 prompt 集(默认 1000 个 ShareGPT 样本,等价于 QPS=∞ 模式),并报告输入 / 输出 / 总 token 数以及每秒请求数。

  • serve — 启动 vLLM 服务器,模拟真实工作负载:请求间隔时间从 Poisson 或 Gamma 分布采样。它会在时间窗口内发送请求,测量前面讨论的所有指标,并可选择在服务器端设置最大并发量(例如通过 semaphore 限制为 64 个并发请求)。


下面是如何运行 latency 脚本的示例:


vllm bench latency
  --model <model-name>
  --input-tokens 32
  --output-tokens 128
  --batch-size 8
}'


CI 中使用的基准测试配置存放在 .buildkite/nightly-benchmarks/tests 目录下。


还有一个自动调优脚本,它会驱动 serve 基准测试以寻找满足目标 SLO 的参数设置(例如,「在保持 p99 端到端延迟 < 500 ms 的前提下最大化吞吐量」),并返回推荐配置。


尾声


我们从基础的引擎核心(UniprocExecutor)开始,添加了诸如投机解码(speculative decoding)和前缀缓存(prefix caching)等高级功能,随后扩展到 MultiProcExecutor(TP/PP > 1),最终实现分布式扩展,将所有内容封装在异步引擎和分布式服务栈中 —— 最后介绍了如何衡量系统性能。


vLLM 还包含一些本文未详述的专门处理,例如:

  • 自定义硬件后端:TPU、AWS Neuron(Trainium/Inferentia)等

  • 架构 / 技术:MLA、MoE、编码器 - 解码器(例如 Whisper)、池化 / 嵌入模型、EPLB、m-RoPE、LoRA、ALiBi、无注意力变体、滑动窗口注意力、多模态大语言模型,以及状态空间模型(例如 Mamba/Mamba-2、Jamba)

  • TP/PP/SP

  • 混合 KV-cache 逻辑(Jenga)、更复杂的采样方法如 beam sampling 等

  • 实验性功能:异步调度


好处是,这些大部分与上文描述的主流程是正交的 —— 你几乎可以把它们当作「插件」来使用(当然实际上仍有一些耦合)。


我非常喜欢理解系统。话虽如此,在这个高度上讲解的细节肯定有所损失。接下来的文章里,我会聚焦具体子系统,深入探讨细节。


参考文献:

1. vLLM https://github.com/vllm-project/vllm

2. "Attention Is All You Need", https://arxiv.org/abs/1706.03762

3. "Efficient Memory Management for Large Language Model Serving with PagedAttention", https://arxiv.org/abs/2309.06180

4. "DeepSeek-V2: A Strong, Economical, and Efficient Mixture-of-Experts Language Model", https://arxiv.org/abs/2405.04434

5. "Jenga: Effective Memory Management for Serving LLM with Heterogeneity", https://arxiv.org/abs/2503.18292

6. "Orca: A Distributed Serving System for Transformer-Based Generative Models", https://www.usenix.org/conference/osdi22/presentation/yu

7. "XGrammar: Flexible and Efficient Structured Generation Engine for Large Language Models", https://arxiv.org/abs/2411.15100

8. "Accelerating Large Language Model Decoding with Speculative Sampling", https://arxiv.org/abs/2302.01318

9. "EAGLE: Speculative Sampling Requires Rethinking Feature Uncertainty", https://arxiv.org/abs/2401.15077

10. "Medusa: Simple LLM Inference Acceleration Framework with Multiple Decoding Heads", https://arxiv.org/abs/2401.10774

11. LMCache, https://github.com/LMCache/LMCache



© THE END 

转载请联系本公众号获得授权

投稿或寻求报道:liyazhou@jiqizhixin.com

我觉得推测解码的甜点是那种输出token序列有一定模式或重复性的任务。比如代码生成、结构化数据填充等。但如果遇到开放性极强、创造性要求高的写作任务,或者大模型在处理某个模糊提示时,本身就可能在多个同样“合理”的路径中探索,这时候小模型很难准确预测出大模型最终会选择哪条路径,导致接受率不高。另一个点是**“冷启动”阶段**,也就是最初几个token,或者当prompt非常长时,草稿模型可能需要一些上下文才能开始有效预测,这些时候推测解码的优势就不明显。再者,当批处理的请求中,大部分序列都很短,或者系统本身负载已经很高,GPU已经饱和运行,推测解码带来的额外计算开销(即使是小模型的)也可能成为一种负担,而不是加速。

从我的经验来看,Paged Attention最直接的痛点解决就是显著提升了并发请求数和批量大小。以前跑个长文本任务,很容易就把GPU显存占满,能同时处理的请求数量非常有限。客户一多,系统就卡。Paged Attention出来后,单位显存能承载的对话上下文长度和用户数都肉眼可见地增加了,对ToC业务来说简直是救星。新瓶颈嘛,我觉得主要是调试复杂性提升。毕竟内存不是连续的,如果出了问题,排查KV缓存具体哪个token对应哪个物理块,哪个请求的KV数据被错误覆盖或释放了,会比传统方式更让人头大。再就是,虽然能节省内存,但如果请求的token长度分布非常广,比如有时候长请求,有时候短请求,block_size的选取也需要一番调优,不是一劳而就的。

这个问题问得好!分页注意力(Paged Attention)的核心优势在于它能彻底解决LLM推理中的KV缓存内存碎片化问题。传统的KV缓存分配方式就好比每次都给一个请求分配一块连续的大内存,当请求长度不一、动态增减时,GPU内存很快就会变得支离破碎,大量小块内存无法被有效利用,形同虚设。Paged Attention通过将KV缓存抽象成分页存储,每个物理块可以存储固定数量的token KV值,不要求连续,就像操作系统管理虚拟内存一样。这样一来,无论请求长短、批次大小如何变化,内存分配和回收都只涉及这些小块,大大提高了内存利用率,避免了OOM(显存不足)以及因内存碎片导致的低效。至于新的瓶颈,如果块大小设置不合理,比如太小导致管理开销过大,或者无法与底层硬件的缓存行对齐,也可能会有性能损失。另外,跨层、跨设备的KV缓存同步和管理,在超大规模模型和分布式推理下,会引入控制流和通信开销,需要精细优化。

“天下没有免费的午餐”,这句话在推测解码这里也适用!它能加速是因为小模型帮你提前“蒙”了一堆答案,然后大模型去批量检查。如果小模型蒙得准,那大家皆大欢喜,速度快飞了。但如果小模型老是蒙错,大模型就得频繁地“擦屁股”重新算,一来一回,说不定还没老老实实一步步算来得快呢。举个例子,如果你的任务是生成一些特别专业、特别生僻的文本,小模型可能压根就没见过多少类似的数据,它的“蒙”就是瞎蒙,那推测解码就形同虚设,反而增加了额外的模型推理和验证开销。还有就是,实现推测解码的调度逻辑本身也比普通解码复杂,如果工程实现不好,也可能引入不必要的延迟。

哇,这不就是把传统的“大锅饭”模式,改成了“自助餐+外卖”模式嘛!Prefill相当于后台大厨准备食材(耗时间、拼算力),而Decode就是前台出餐给顾客(要快、要流畅)。分开的好处可太多了:
1. 成本控制更精明: 大厨可能要最贵的炉子(Prefill用算力型GPU),出餐员可能只要够大的餐桌和筷子(Decode用带宽型GPU),根据需求可以动态调整资源,不用全配最贵的。
2. 堵车不影响做饭: 就算外卖小哥摔了一跤(Decode故障),厨房里的大厨还在哗哗切菜(Prefill正常进行),反之亦然。局部小故障不影响全局。
3. 升级改造更容易: 想换个更智能的切菜机(Prefill模型升级),直接换炉子,不影响前台出餐。想换个更快的出餐窗口(Decode服务优化),也不耽误后厨备料。互不干扰,迭代更快!
这比把所有东西都塞一个模块里,牵一发而动全身要强太多了!

推测解码并非完全“免费”,它本质上是一种期望层面的加速,通过预估概率来规避部分大模型的计算。其核心在于“草稿模型”的猜测准确性。当草稿模型(无论是小型LM、n-gram还是Medusa/EAGLE)与大模型在生成分布上差异较大时,草稿token的接受率就会很低,大模型不得不频繁地重新验证和校正,甚至可能每次只接受一两个token。这种情况下,推测解码的收益会大幅下降,甚至由于引入草稿模型本身的推理开销、额外的验证计算和复杂的调度逻辑,可能比直接进行标准的自回归解码更慢,增加端到端延迟。此外,对于高度随机、不可预测的生成任务,或在训练数据中罕见的特定领域文本,草稿模型的泛化能力受限,推测解码的效果也可能不佳。

从我的角度看,分离式Prefill/Decode带来了显著的可伸缩性和弹性。一个直接的好处是能够根据工作负载的实际需求,对Prefill和Decode服务进行独立扩缩容。例如,如果出现大量新请求导致Prefill队列积压,我可以只扩容Prefill服务而不必触碰Decode服务。反之,如果现有请求的解码速度成为瓶颈,我也可以独立扩容Decode部分。这种精细的扩缩容能力,比传统一体化服务更能适应多变的用户请求模式,尤其是在流量峰谷期,可以显著优化云资源成本。另外,它也简化了流量管理和负载均衡。我可以有针对性地对Prefill和Decode的请求进行路由,而不是简单地将所有请求扔给同一个服务池。这对于在复杂分布式环境中保持系统高可用性和QoS(服务质量)至关重要。可以说,这是一种典型的微服务化架构在LLM推理领域的应用,带来了微服务的所有常规优势:模块化、独立部署、独立运维等。

嘿,说到未来“杀手级”的优化,我觉得可能出现“脑电波直读解码”!哈哈哈,开个玩笑。不过认真想,可能未来会有一种“预测性预填充”技术,不是基于前缀,而是基于用户历史行为和场景,直接预填充一大段最可能的“剧本”,等用户真的触发了,直接吐出来,就跟你看电影自带剧本一样。要是猜错了,就快速回滚。还有,会不会有“AI芯片直接思考”的模式?不是运行模型,而是芯片本身就具备某种智能,直接“想”出答案,而不是计算。听起来有点玄幻,但谁知道呢?科技发展速度太快了!

这个问题嘛,就像是请客吃饭。实时聊天机器人就像是米其林餐厅,你得快速上第一个菜(TTFT),然后每个菜(token)之间不能等太久,强调的是体验和速度,所以得请个动作麻利的“厨师”(高带宽GPU)专门伺候。批量文档摘要呢,就像是自助餐,你不在乎第一口啥时候吃,关键是菜品得足够多,管饱!这时候就得多请几个“洗菜切菜”(Prefill)的小工,只要能把食材源源不断地送进“厨房”(计算单元),慢点就慢点,反正最终能吃饱就行,更看重厨房整体的出菜量(吞吐量)。要是把自助餐的大厨拉去做米其林,或反过来,那体验肯定一言难尽,哈哈!

针对“Prefill与Decode性能特性对硬件部署的影响”这个问题,我们可以看到,对于计算密集型的Prefill工作负载,GPU的FLOPS性能是关键考量,这意味着需要更强的计算单元(如Tensor Cores性能更高的显卡)。同时,长序列Prefill还会占用大量显存。应对策略可以是采用分块预填充(如文章所述),或者在设计硬件集群时,将计算资源(高性能GPU)集中配置给Prefill服务。而Decode工作负载,特别是token生成阶段,瓶颈在于HBM带宽。因此,部署Decode服务时,应优先考虑显存带宽更高的GPU以及优化KV缓存访问延迟。更进一步,针对不同workload进行动态调度和资源弹性伸缩至关重要,避免资源单一瓶颈。

哈哈,说到Prefill和Decode对硬件和部署策略的影响,这简直就是我们公司日常的痛点!做实时客服机器人,用户可等不起首词延迟,得把TTFT(Time-To-First-Token)压到最低。这时候,Decode部分的优化就是王道,比如上高带宽的H100,KV缓存管理得做到极致。Prefill因为提示词通常不会太长,可能就没那么敏感了。但如果像批量分析海量文档,Prefill就成了大头,这时候我们更看重整体吞吐量,会考虑用多卡并行处理,甚至在硬件选择上,可能会倾向于性价比更高的计算卡,只要Batch Size够大,能把计算跑满就行,不必追求极致的单token延迟。两种场景,一个侧重“快”,一个侧重“多”,选型和架构简直是两个路子。

杀手级优化?从行业角度看,我个人觉得,除了现有技术的精进,未来可能会出现一些更“智能”的调度和资源管理策略。现在我们还是比较依赖静态分配或者规则。未来AI自己来调度AI,根据实时负载、用户行为预测、甚至不同模型的特点,动态调整Prefill和Decode的比例,分配计算和内存资源,实现极致的利用率和响应。这不仅仅是技术,更是一种服务智能化。另外,多模态LLM的普及,也意味着推理框架需要处理更多非文本数据类型,这会带来图像、音频特征提取和融合的全新优化挑战,比如如何高效地在KV缓存中存储和管理多模态信息,这都会是新的战场。

关于“分离式 Prefill/Decode在生产环境中的管理复杂性”这个问题,确实,分离式Prefill/Decode的挑战主要体现在分布式一致性与性能瓶颈。KV数据传输的延迟确实是系统复杂性的主要来源,尤其是当Prefill Worker和Decode Worker跨节点部署时,网络带宽和延迟会直接影响Decode阶段的TTFT。共享KV-cache服务(如基于Ray或定制RPC)需要确保高可用性、数据一致性,并且有高效的序列化/反序列化机制。故障恢复方面,如果KV-cache服务本身崩溃,或者Prefill Worker/Decode Worker在传输数据过程中出现故障,如何保证数据不丢失、不损坏,以及如何快速进行状态恢复(例如通过定期快照或复制),都是需要精细设计的。此外,跨进程/跨机器的内存管理和同步机制也将变得更复杂。

关于“未来LLM推理系统的杀手级优化技术”,除了推测解码和前缀缓存,我认为未来LLM推理优化方向可能集中在几个方面:首先是硬件与算法协同设计,例如,针对稀疏激活或异构memory设计的ASIC/FPGA,或者更紧密的HBM与计算单元集成。其次,量化(Quantization)技术将持续演进,例如Bit-Slice量化或非均匀量化,以进一步压缩模型同时保持精度。Attention机制的根本性优化,如更高效的稀疏注意力变体,或者完全替代Attention的架构(如Mamba系列的SSM),将是颠覆性方向。此外,动态执行图优化、编译技术(如TVM, Mojo)与MLIR的结合,将提供更深层次的性能挖掘。vLLM作为一个开放框架,很可能会通过接口抽象或插件机制,快速集成这些新兴技术。

分离式Prefill/Decode在生产环境里简直就是从一个坑跳到另一个坑啊!为了提高吞吐量和降低延迟,我们把一个大模型服务拆成两截,然后用一个KV-cache服务做“胶水”。这个“胶水”本身就得是个高可靠、高性能的服务,不然就成了新的瓶颈。数据传输?想都别想,我们现在都尽量在同一个机架内进行,减少跨网络的延迟。监控也是个大问题,现在得监控Prefill服务、Decode服务、KV-cache服务,还有它们之间的通信状况,SLA(服务等级协议)的考量也要更细致。一旦出问题,排查起来简直是地狱模式,到底是Prefill慢了,KV-cache堵了,还是Decode挂了?每次上线都得烧香祷告。

嗯,分离式Prefill/Decode,听起来就像是把一个大厨(LLM)的工作拆成了“预处理配菜”(Prefill)和“翻炒出锅”(Decode)两个岗位,然后把预处理好的菜(KV值)交给一个“菜品中转站”(KV-cache服务)。这个中转站要是效率低、老丢菜、或者经常罢工,那整个厨房还是瘫痪啊!而且两个厨师之间还得不停地电话沟通:“老王,我菜备好了你炒不炒?”“老李,你的菜啥时候好啊我这等着呢!”这沟通成本、管理成本,感觉比请一个全能大厨还头疼。要是“菜品中转站”倒了,那可不是少一个菜,是整个饭店都开不了张了,想想都大!

关于LLM推理优化 “奇技淫巧"的讨论:

@AI_Geek_阿泽:在PagedAttention和Continuous Batching这两大基石之上,LLM推理的显存利用率和调度效率仍然有巨大的"挖潜"空间。以下是一些正在探索或未来可能实现的"奇技淫巧”:

1. KV Cache生命周期管理与压缩:进一步细化KV Cache块的生命周期,例如结合LRU(Least Recently Used)或LDU(Least Development Use)策略进行动态"驱逐",甚至可以周期性地对"冷"KV Cache块进行无损或有损压缩存储,以减少实际内存占用。例如,一些研究尝试对KV Cache进行低秩近似或哈希压缩。
2. 硬件与软件协同调度:不局限于纯软件的调度策略,而是与底层GPU硬件特性(如Stream Multiprocessors (SMs)利用率、HBM带宽瓶颈)深度结合,实现更精细的"感知型调度"(Hardware-aware Scheduling)。例如,将计算量大的Prefill请求分配给相对空闲的SMs,或者将内存密集型Decode请求与HBM带宽利用率较低的Batch进行混合。
3. 异构内存分级存储(Tiered Memory Hierarchies):不仅仅是HBM,而是将KV Cache根据其活跃度和大小,智能地分布在GPU HBM、CPU DRAM乃至NVMe SSD上。通过操作系统和驱动层的统一管理,实现对上层应用透明的"分级"访问,以突破单GPU HBM容量限制。
4. 更复杂的批处理策略:除了简单的连续批处理,可以探索"非连续批处理"或"图批处理",即在GPU上将不同长度、不同性质的序列以图的形式组织起来,实现更极限的计算并行度,减少Padding开销。
5. 注意力机制优化与无注意力变体:例如Mamba这类状态空间模型(SSM)或者其他"无注意力"架构,它们从根本上改变了KV Cache的需求,或者以更内存高效的方式存储上下文信息,这本身就是对现有KV Cache管理模式的颠覆性优化方向。虽然这涉及到模型架构的调整,但其对推理效率的影响是深远的。

关于Prefill/Decode分离(Disaggregated P/D)的讨论:

@LLM-Engineer-小白:作为一名系统架构师,我确实觉得P/D分离在理论上很美,因为它完美地匹配了Prefill和Decode不同的资源瓶颈。但是,实际搞起来,数据一致性是个大挑战。Prefill Worker写KV到共享存储,Decode Worker去读,这里就涉及到强一致性、最终一致性的选择,以及如何处理网络分区、节点故障的情况。通信开销也是个问题,特别是在跨节点部署时,KV缓存的传输量不小,一旦网络延迟高,好处可能就被抵消了。我觉得对于QPS很高、请求Prompt长度差异巨大的场景,比如超长文档摘要或者代码补全这种比较吃Prompt长度的,P/D分离会很划算,因为它能有效削峰填谷,提高系统的整体稳定性。但如果是大部分请求都比较短小的聊天机器人,P/D分离带来的复杂性可能就大于其收益了。

关于推测解码变体的讨论:

@摸鱼研发员小王:推测解码听起来好高大上,其实就是“小抄”技术嘛!N-gram就像你考试时,旁边同学偷偷写的小抄,上面记录了最近几次考点,你瞄一眼觉得能用就直接抄了。优点是不用花时间去研究“小抄”是谁写的,直接拿来用。缺点嘛,万一出的题是新题型,那就傻眼了。EAGLE和Medusa,这俩就像是你有专门找了个学习好的同学,给他重点画了画,让他给你写了个“个性化小抄”。优点是考点覆盖率更高,更精准。缺点就是你得花牛奶钱“贿赂”他,还得等他写完。所以选哪个呢?如果我项目预算少,时间也紧,就直接N-gram“抄现成”的。如果项目特别重要,对效果要求极高,老板又肯投资源,那肯定让EAGLE或Medusa给我搞个“定制小抄”啊!