走进 vLLM:高吞吐量 LLM 推理系统的剖析
注: 本文最初发布于 Aleksa Gordic 的个人网站。
从 PagedAttention、连续批处理、前缀缓存、投机采样等,到大规模多 GPU、多节点动态服务
在这篇文章中,我将逐步介绍现代高吞吐量 LLM 推理系统的核心组件和高级功能。特别是,我将详细解析 vLLM [1] 的工作原理。
本文是该系列的第一篇。它从宏观角度切入,随后层层深入(采用倒金字塔结构),帮助你建立起对整个系统的准确认知,而不会陷入琐碎细节中。
后续文章将深入探讨特定的子系统。
本文分为五个部分:
- LLM 引擎与核心引擎:vLLM 的基础(调度、PagedAttention、连续批处理等)
- 高级功能:分块预填充、前缀缓存、引导式解码与投机采样、存算分离
- 扩展规模:从单 GPU 到多 GPU 执行
- 服务层:分布式/并发 Web 框架
- 基准测试与自动调优:衡量延迟与吞吐量
注: * 本分析基于 提交记录 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函数,从给定提示词进行采样
让我们开始分析构造函数。
LLM 引擎构造函数
引擎的主要组件有:
- vLLM 配置(包含所有用于配置模型、缓存、并行度等的开关)
- 处理器(通过验证、分词和处理,将原始输入转换为
EngineCoreRequests) - 引擎核心客户端(在我们的运行示例中,我们使用
InprocClient,它基本上等同于EngineCore;我们将逐步构建到支持大规模服务的DPLBAsyncMPClient) - 输出处理器(将原始
EngineCoreOutputs转换为用户看到的RequestOutput)
注: 随着 V0 引擎的弃用,类名和细节可能会发生变化。我将强调核心思想而非精确的签名。我会抽象化处理部分但非全部细节。
核心引擎本身由几个子组件组成:
- 模型执行器 (Model Executor)(驱动模型的前向传播,我们目前处理的是单 GPU 上只有一个
Worker进程的UniProcExecutor。我们将逐步构建支持多个 GPU 的MultiProcExecutor) - 结构化输出管理器 (Structured Output Manager)(用于引导式解码——稍后讨论)
- 调度器 (Scheduler)(决定哪些请求进入下一个引擎步骤)——它进一步包含:
- 策略设置——可以是 FCFS(先来先服务)或 优先级(高优先级请求优先)
等待 (waiting)和运行 (running)队列- KV 缓存管理器——PagedAttention [3] 的核心
KV 缓存管理器维护一个 free_block_queue——可用 KV 缓存块的池(通常在几十万个量级,取决于显存大小和块大小)。在 PagedAttention 中,块作为索引结构,将 Token 映射到它们计算出的 KV 缓存块。

图 1:本节所述的核心组件及其关系
注: 标准 Transformer 层(非 MLA [4])的块大小计算如下:2 (key/value) *
block_size(默认为 16) *num_kv_heads*head_size*dtype_num_bytes(例如 bf16 为 2)
在模型执行器构造过程中,会创建一个 Worker 对象,并执行三个关键步骤。(稍后使用 MultiProcExecutor 时,这些相同的步骤会在不同 GPU 上的每个 Worker 进程中独立运行。)
- 初始化设备
- 为 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]) - 运行一次模拟/性能分析前向传播,并进行显存快照,以计算可用显存中能容纳多少个 KV 缓存块
- 分配、重塑 KV 缓存张量并将其绑定到注意力层
- 准备注意力元数据(例如将后端设置为 FlashAttention),供后续前向传播中的内核使用
- 除非提供了
--enforce-eager,否则对于每种预热批大小,都会进行模拟运行并捕获 CUDA 图。CUDA 图将 GPU 的整个工作序列记录到 DAG 中。随后在前向传播期间,我们启动/重放预先构建的图,从而减少内核启动开销并改善延迟。
我在这里抽象掉了许多底层细节——但这些是我现在介绍的核心部分,因为我在接下来的章节中会反复引用它们。
现在我们已经初始化了引擎,让我们进入 generate 函数。
生成 (Generate) 函数
第一步是验证并将请求馈送到引擎。对于每个提示词,我们:
- 创建一个唯一的请求 ID 并记录其到达时间
- 调用输入预处理器,对提示词进行分词并返回一个字典,包含
prompt、prompt_token_ids和type(文本、token、嵌入等) - 将此信息打包进
EngineCoreRequest,添加优先级、采样参数和其他元数据 - 将请求传递给引擎核心,它将其包装在
Request对象中,并将状态设置为WAITING。该请求随后被添加到调度器的waiting队列中(如果 FCFS 则追加,如果优先级则推入堆中)
此时,引擎已准备就绪,可以开始执行。在同步引擎示例中,这些初始提示词是我们唯一要处理的请求——没有在运行中途注入新请求的机制。相反,异步引擎支持这一点(即 连续批处理 [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会出现在输出中,但停止字符串不会)。

图 2:引擎循环
注: 在流式模式下,我们会发送生成的中间 Token,但我们暂时忽略这一点。
接下来,我们将更详细地检查调度。
调度器(Scheduler)
推理引擎处理的两种主要工作负载:
- 预填充 (Prefill) 请求——对所有提示词 Token 进行前向传播。这些通常是 计算密集型(阈值取决于硬件和提示词长度)。最后,我们在最后一个 Token 位置的概率分布中采样一个 Token。
- 解码 (Decode) 请求——仅对最近的一个 Token 进行前向传播。所有较早的 KV 向量已缓存。这些是 内存带宽受限型,因为我们仍需加载所有 LLM 权重(和 KV 缓存)才能计算出一个 Token。
注: 在 基准测试部分,我们将分析所谓的 GPU 性能 Roofline 模型。这将更深入地探讨预填充/解码的性能配置。
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 缓存块列表的字典。

图 3:KV 缓存块列表
我们终于准备好进行前向传播了!
运行前向传播
我们调用模型执行器的 execute_model,它委托给 Worker,后者依次委托给模型运行器。
主要步骤如下:
- 更新状态——从
input_batch中剔除已完成的请求;更新与前向传播相关的杂项元数据(例如,将用于索引分页 KV 缓存内存的每个请求的 KV 缓存块)。 - 准备输入——将缓冲区从 CPU 复制到 GPU;计算位置;构建
slot_mapping;构建注意力元数据。 - 前向传播——使用自定义分页注意力内核运行模型。所有序列被展平并拼接成一个长的“超级序列”。位置索引和注意力掩码确保每个序列仅关注其自身的 Token,这使得无需右侧填充即可实现连续批处理。
- 收集最后 Token 状态——提取每个序列最终位置的隐藏状态并计算 Logit。
- 采样——根据采样配置(贪婪、温度、Top-P、Top-K 等)从计算出的 Logit 中采样 Token。
前向传播步骤本身有两种执行模式:
- Eager 模式——启用 Eager 执行时,运行标准 PyTorch 前向传播。
- "捕获" (Captured) 模式——未强制 Eager 时,执行/重放预先捕获的 CUDA 图(记住我们在初始化 KV 缓存步骤中捕获了这些)。
这是一个具体示例,应该能清楚地说明连续批处理和 PagedAttention:

图 4:前向传播:连续批处理和 PagedAttention
高级功能 —— 扩展核心引擎逻辑
在掌握了基本引擎流程后,我们现在可以看一下高级功能。
我们已经讨论了抢占、PagedAttention 和连续批处理。
接下来,我们将深入探讨:
- 分块预填充 (Chunked prefill)
- 前缀缓存
- 引导式解码(通过语法约束的有限状态机)
- 投机采样
- 存算分离 (P/D)
分块预填充 (Chunked prefill)
分块预填充是一种处理长提示词的技术,通过将它们的预填充步骤拆分为更小的块。没有它,我们可能最终会导致一个非常长的请求垄断一个引擎步骤,从而不允许其他预填充请求运行。这会推迟所有其他请求并增加它们的延迟。
例如,让每个块包含 n (=8) 个 Token,用连字符连接的小写字母标记。一个长提示词 P 可能看起来像 x-y-z,其中 z 是一个不完整的块(例如 2 个 Token)。对 P 执行完整的预填充将需要 ≥ 3 个引擎步骤(如果它未在其中一个步骤中被调度执行,可能会发生 > 情况),并且只有在最后一个分块预填充步骤中我们才会采样一个新的 Token。
这是同一个示例的可视化:

图 5:分块预填充
实现很简单:限制每一步的新 Token 数量。如果请求的数量超过 long_prefill_token_threshold,则将其重置为该值。底层的索引逻辑(前面已描述)会负责处理其余部分。
在 vLLM V1 中,通过将 long_prefill_token_threshold 设置为一个正整数来启用分块预填充。(从技术上讲,如果不设置也可能发生这种情况,如果提示词长度超过 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()前缀缓存避免重新计算多个提示词开头相同部分的 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 缓存分页内存中)然后被重用,因此只有新的提示词 Token 需要处理。这加快了预填充请求(尽管对解码没有帮助)。
vLLM 中是如何工作的?
在第一次 generate 调用期间,在调度阶段,在 kv_cache_manager.get_computed_blocks 内部,引擎调用 hash_request_tokens
- 此函数将
long_prefix + prompts[0]拆分为 16 个 Token 的块。 - 对于每个完整的块,它计算一个哈希(使用内置哈希或 SHA-256,后者较慢但碰撞更少)。哈希组合了上一个块的哈希、当前 Token 和可选元数据。
注: 可选元数据包括:MM 哈希、LoRA ID、缓存盐(注入第一个块的哈希确保只有带有此缓存盐的请求才能重用块)。
- 每个结果都存储为一个
BlockHash对象,包含哈希及其 Token ID。我们返回一个块哈希列表。
该列表存储在 self.req_to_block_hashes[request_id] 中。
接下来,引擎调用 find_longest_cache_hit 来检查这些哈希是否已存在于 cached_block_hash_to_block 中。在第一个请求时,没有发现命中。

图 6:前缀缓存 - 哈希函数
然后我们调用 allocate_slots,它调用 coordinator.cache_blocks,将新的 BlockHash 条目与已分配的 KV 块关联,并记录在 cached_block_hash_to_block 中。
之后,前向传播将填充与我们上面分配的 KV 缓存块对应的分页 KV 缓存内存中的 KV。
注: 在多个引擎步骤之后,它会分配更多的 KV 缓存块,但这对于我们的示例无关紧要,因为前缀在
long_prefix之后立即分叉了。

图 7:前缀缓存 - 在分页内存中填充 KV
在第二次使用相同前缀调用 generate 时,步骤 1-3 重复,但现在 find_longest_cache_hit 为所有 n 个块找到了匹配项(通过线性搜索)。引擎可以直接重用那些 KV 块。

图 8:前缀缓存 - 重用 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 缓存!
如果你理解了这个例子,也就理解了 PagedAttention 的工作原理。
前缀缓存默认启用。要禁用它:enable_prefix_caching = False。
引导式解码 (Guided Decoding,基于 FSM)
引导式解码是一种技术,在每个解码步骤中,Logit 受基于语法的有限状态机约束。这确保了只能采样语法允许的 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 对 Logit 进行掩码处理,使得只有 "P" 或 "N" 是可行的。如果采样了 "P",FSM 移动到 "Positive" 分支;下一步只允许 "o",依此类推。

图 9:玩具示例 FSM
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。 - 在前向传播产生 Logit 之后,xgr_torch_compile 的函数将位掩码扩展到词汇表大小(32 倍扩展比,因为我们使用 32 位整数),并将禁止的 Logit 掩盖为 –∞。
- 采样下一个 Token 后,通过
accept_tokens推进请求的 FSM。在视觉上,我们移动到 FSM 图上的下一个状态。
步骤 6 值得进一步澄清。
如果 vocab_size = 32,_grammar_bitmask 是一个整数;其二进制表示编码了哪些 Token 是允许的 ("1") 与禁止的 ("0")。例如,"101…001" 扩展为一个长度为 32 的数组 [1, 0, 1, ..., 0, 0, 1];位置为 0 的 Logit 设置为 –∞。对于更大的词汇表,使用多个 32 位字并相应地扩展/拼接。后端(如 xgrammar)负责使用当前的 FSM 状态生成这些位模式。
注: 这里的大多数复杂性隐藏在像 xgrammar 这样的第三方库中。
这是一个更简单的例子,词汇表大小为 8,8 位整数(对于喜欢我视觉效果的你):

图 10:玩具示例
你可以通过传入所需的 guided_decoding 配置在 vLLM 中启用此功能。
投机采样 (Speculative Decoding)
在自回归生成中,每个新 Token 都需要对大语言模型进行前向传播。这很昂贵——每一步都重新加载并应用所有模型权重仅仅是为了计算一个 Token!(假设批大小 == 1,通常是 B)
投机采样 [8] 通过引入一个较小的草稿 LM 来加速这一点。草稿以低廉的成本提出 k 个 Token。但我们最终不想从较小的模型中采样——它只是为了猜测候选续写。大模型仍然决定什么是有效的。
步骤如下:
- 草稿 (Draft):在当前上下文上运行小模型并提出
k个 Token - 验证 (Verify):在大模型上运行一次上下文 +
k个草稿 Token。这会为这k个位置加上一个额外的 Token 产生概率(因此我们得到k+1个候选) - 接受/拒绝 (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 采样一样。这意味着投机采样在统计上等同于标准的自回归解码——但潜在速度快得多,因为单次大模型的前向传播最多可以产生 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 头,用轻量级 MLP 替换 Transformer 堆栈;将其微调为廉价的草稿模型
-
Medusa:在大模型之上(LM 头之前的嵌入)训练辅助线性头,以并行预测接下来的
k个 Token;使用这些头比运行单独的小 LM 更有效地提出 Token
这是使用 ngram 作为草稿方法在 vLLM 中调用投机采样的代码:
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,准备 Logit 等),然后在大模型上对草稿 Token 运行前向传播。 - 不使用 Logit 进行常规采样,而是使用
rejection_sampler从左到右进行接受/拒绝,并生成output_token_ids。 - 重复步骤 2-7,直到满足停止条件。
内化这一点最好的方法是启动调试器并逐步分析代码库,但这一节希望能让你有所体会。还有这一点:


图 11:投机采样
存算分离 (Disaggregated P/D)
我之前已经暗示过存算分离(预填充/解码)背后的动机。
预填充和解码具有完全不同的性能特征(计算密集 vs 内存带宽密集),因此将它们的执行分离是明智的设计。它使你能够更紧密地控制延迟——包括 TFTT(首个 Token 时间)和 ITL(Token 间延迟)——更多内容将在 基准测试 部分详述。
在实践中,我们运行 N 个 vLLM 预填充实例和 M 个 vLLM 解码实例,基于实时请求组合进行自动缩放。预填充 Worker 将 KV 写入专门的 KV 缓存服务;解码 Worker 从中读取。这使得长时间、突发性的预填充与稳定、延迟敏感的解码相互隔离。
vLLM 中是如何工作的?
为清晰起见,下面的示例依赖于 SharedStorageConnector,这是一个用于演示机制的调试用连接器实现。
注: Connector 是 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 作为后端),但它仍然处于最前沿,我遇到了一些 bug。由于其大部分复杂性存在于外部存储库中,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。
这是一个可视示例:

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

图 13:TP=8 设置中的 MultiProcExecutor(驱动 Worker 为 rank 0)
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句柄发送给父进程。一旦全部收到,父进程解除阻塞——这就完成了协调。 - 然后 Worker 进入繁忙循环,在
rpc_broadcast_mq.dequeue上阻塞。当工作项到达时,它们执行它(就像在UniprocExecutor中一样,但现在有了 TP/PP 特定的分区工作)。结果通过worker_response_mq.enqueue送回。 - 运行时,当请求到达时,
MultiProcExecutor将其入队到rpc_broadcast_mq(非阻塞)供所有子 Worker 执行。然后它在指定输出 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 节点,想要在它们之间运行 4 个 vLLM 引擎。
如果模型需要 TP=4,我们可以这样配置节点。

图 14:具有 2 个 8xH100 节点的服务器配置(1 个无头,1 个 API 服务器)
在第一个节点上,以无头模式(无 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 中如何工作?
在无头 (headless) 服务节点上
在无头节点上,CoreEngineProcManager 启动 2 个进程(每 --data-parallel-size-local),每个运行 EngineCoreProc.run_engine_core。这些函数中的每一个都创建一个 DPEngineCoreProc(引擎核心),然后进入繁忙循环。
DPEngineCoreProc 初始化其父进程 EngineCoreProc(EngineCore 的子进程),它:
- 创建一个
input_queue和output_queue(queue.Queue)。 - 使用
DEALERZMQ 套接字(异步消息传递库)与另一节点的前端执行初始握手,并接收协调地址信息。 - 初始化 DP 组(例如使用 NCCL 后端)。
- 使用
MultiProcExecutor(如前所述在 4 个 GPU 上TP=4)初始化EngineCore。 - 创建一个
ready_event(threading.Event)。 - 启动一个输入守护线程(
threading.Thread),运行process_input_sockets(…, ready_event)。同样启动一个输出线程。 - 仍在主线程中,等待
ready_event,直到所有 4 个进程(跨越 2 个节点)的所有输入线程都完成了协调握手,最终执行ready_event.set()。 - 一旦解除阻塞,向前端发送一条带有元数据的“就绪”消息(例如,分页 KV 缓存内存中可用的
num_gpu_blocks)。 - 主线程、输入线程和输出线程随后进入各自的繁忙循环。
TL;DR:我们最终得到 4 个子进程(每个 DP 副本一个),每个运行一个主线程、一个输入线程和一个输出线程。它们与 DP 协调器和前端完成协调握手,然后每个进程的三个线程都在稳态的繁忙循环中运行。

图 15:运行 4 个 DPEngineCoreProc 的 4 个 DP 副本的分布式系统
当前稳态::
- 输入线程——在输入套接字上阻塞,直到从 API 服务器路由来请求;收到后,它解码有效载荷,通过
input_queue.put_nowait(...)将工作项入队,并返回套接字阻塞。 - 主线程——在
input_queue.get(...)上唤醒,将请求馈送到引擎;MultiProcExecutor运行前向传播并将结果入队到output_queue。 - 输出线程——在
output_queue.get(...)上唤醒,将结果送回 API 服务器,然后恢复阻塞。
其他机制::
- DP 波计数器 (DP wave counter)——系统跟踪“波”;当所有引擎都变为空闲时它们静止,当新工作到达时计数器增加(用于协调/指标)。
- 控制消息——API 服务器可以发送不仅仅是推理请求的消息(例如,中止和实用工具/控制 RPC)。
- 步进锁定 (Dummy steps for lockstep)——如果任何 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。- 在该引擎端
- 输入线程 — 解除阻塞,从输入 socket 解码数据,并将工作项放入主线程的
input_queue中。 - 主线程 — 在
input_queue上解除阻塞,将请求添加到引擎中,并重复调用engine_core.step(),将中间结果存入output_queue,直到满足停止条件。
注:提醒一下:
step()会调用调度器、模型执行器(它本身可能是MultiProcExecutor!)等。我们已经见过这些了!
- 输出线程 — 在
output_queue上解除阻塞,并将结果通过输出 socket 发送回去。
- 这些结果触发了
AsyncLLM的输出 asyncio 任务(process_outputs_socket和output_handler),将 Token 传播回 FastAPI 的create_completion路由。 - FastAPI 附加元数据(结束原因、logprobs、使用情况信息等),并通过 Uvicorn 将
JSONResponse返回到你的终端!
就这样,你的补全结果回来了——整个分布式机器都被隐藏在一个简单的 curl 命令后面!:) 真有趣!!!
[!NOTE] 其他说明
- 当添加更多的 API 服务器时,负载均衡是在操作系统/socket 层面处理的。从应用程序的角度来看,没有什么显著变化——复杂性被隐藏了。
- 以 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 才会被计入 |

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

图 17:屋顶线性能模型
[!NOTE] 注意:为了更严谨的讨论,我们必须考虑内核自动调优:随着
B的增长,运行时可能会切换到针对该形状更高效的内核,从而改变实现的性能P_kernel。步骤延迟为t = FLOPs_step / P_kernel,其中FLOPs_step是该步骤的工作量。可以看出,当P_kernel达到P_peak时,每步更多的计算将直接导致延迟增加。
如何在 vLLM 中进行基准测试
vLLM 提供了一个 vllm bench {serve,latency,throughput} 命令行工具,它封装了 vllm / benchmarks / {server,latency,throughput}.py 脚本。
以下是这些脚本的作用
- latency — 使用短输入(默认 32 个 Token)并以小批量(默认 8)采样 128 个输出 Token。它运行多次迭代并报告该批次的端到端延迟。
- throughput — 一次性提交一组固定的提示词(默认:1000 个 ShareGPT 样本)(即
QPS=Inf模式),并报告运行期间的输入/输出/总 Token 数以及每秒请求数。 - serve — 启动 vLLM 服务器并通过从泊松(或更通用的 Gamma)分布中采样请求到达间隔来模拟真实世界的工作负载。它在一段时间内发送请求,测量我们讨论过的所有指标,并可选择强制执行服务器端最大并发限制(通过信号量,例如限制服务器同时处理 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 端到端延迟 < 500ms 的同时最大化吞吐量”),并返回建议的配置。
结语
我们从基础引擎核心(UniprocExecutor)开始,添加了推测解码和前缀缓存等高级功能,扩展到 MultiProcExecutor(具有 TP/PP > 1),最终进行了横向扩展,将所有内容封装在异步引擎和分布式服务栈中——最后讨论了如何衡量系统性能。
vLLM 还包含了我跳过的专业处理方案。例如:
- 多样化的硬件后端:TPU、AWS Neuron (Trainium/Inferentia) 等。
- 架构/技术:
MLA,MoE, 编码器-解码器 (如 Whisper), 池化/嵌入模型,EPLB,m-RoPE,LoRA,ALiBi, 无注意力机制变体, 滑动窗口注意力, 多模态 LLM, 以及状态空间模型 (如 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