LLM 推理引擎架构:vLLM / SGLang 的核心设计

副标题: PagedAttention 借用 OS 虚拟内存思想管理 KV Cache,Continuous Batching 让 GPU 不再等人,RadixAttention 进一步用前缀树实现"相同前缀不重算"------三层递进,拆解推理引擎的底层逻辑


一、引子 🎯

先看一个对比。用 HuggingFace Transformers 默认方式跑 Llama 2 7B 推理,和用 vLLM 跑同一个模型:

方案 吞吐量 显存利用率
HuggingFace Transformers(默认) ~1x ~20-30%
vLLM 10-24× ~96%

同样的 GPU,同样的模型。差距不在硬件------在 KV Cache 的管理方式

这篇文章专注拆解三层递进的核心机制:

  1. PagedAttention --- 为什么把 KV Cache 当"虚拟内存"管,显存利用率就能从 20% 跳到 96%?
  2. Continuous Batching --- 为什么 PagedAttention 是动态组批的物理前提?Chunked Prefill 又是什么?
  3. 前缀缓存演进 --- vLLM 的哈希表方案 vs SGLang 的 Radix Tree 方案,为什么数据结构的选择在多轮对话中差了 5×?

每层机制都不是独立存在的------下一层依赖上一层提供的物理基础。


二、KV Cache:推理引擎要解决的根本问题

2.1 自回归解码与 KV 缓存

LLM 推理是自回归的:每生成一个新 token,都要用它之前所有 token(输入 prompt + 已生成输出)的 Key 和 Value 来计算注意力。

复制代码
生成第 1 个 token:计算 prompt token 1..N 的 attention
生成第 2 个 token:计算 prompt token 1..N + 第1个输出 token 的 attention  
生成第 3 个 token:计算 prompt token 1..N + 第1,2个输出 token 的 attention
...

不缓存 KV 的代价:每生成一个新 token 都要重算所有历史 token 的 K 和 V------计算量随序列长度呈 O(n²) 增长。所以 KV 必须缓存。

2.2 显存到底被吃了多少?

KV Cache 的显存占用有一个精确的计算公式:

复制代码
KV Cache Size = 2 × batch_size × seq_len × num_layers × num_kv_heads × head_dim × dtype_bytes

其中:
  2 = K + V(两份缓存)
  batch_size = 同时处理的请求数
  seq_len = 每个请求的 token 总数(prompt + 已生成)

以 Llama 2 7B(32 层,32 KV heads,head_dim=128,FP16)为例:

场景 模型权重 KV Cache 总显存
1 个请求,2K 上下文 14 GB 67 MB ~14 GB
32 个请求,2K 上下文 14 GB 2.1 GB ~16 GB
32 个请求,128K 上下文 14 GB ~70 GB ❌ 远超任何单卡

长上下文下,KV Cache 可以是模型本身的 5 倍。 这不是边缘场景------Agent、RAG、多轮对话都在往长上下文方向走。

2.3 传统分配方案的三个死穴

死穴 1:预留分配(Internal Fragmentation)

传统做法:请求进来时,按照 max_tokens(最大生成长度)一次性分配一块连续内存。

复制代码
请求设定 max_tokens=2048
  实际只生成了 50 个 token
  → 1998 个 token 的 KV 空间全浪费
  → 显存利用率 50/2048 = 2.4%

死穴 2:外部碎片(External Fragmentation)

不同请求的 KV Cache 长度不同,释放后留下的空洞无法被新请求利用:

复制代码
显存布局: [请求A: 300 tokens] [空洞: 200] [请求B: 500 tokens] [空洞: 100]
新请求需要 350 tokens → 放不进任何空洞 → 尽管总空闲 300 tokens,无法分配

死穴 3:预填充(Prefill)与解码(Decode)的节奏不匹配

自回归推理分为两个物理特征完全不同的阶段:

阶段 做什么 瓶颈在哪
Prefill 一次性处理整个 prompt,计算所有 token 的 K/V,生成第一个输出 token Compute-bound(GPU 算力是瓶颈)
Decode 每步生成一个 token,只需计算最新 token 的 Q,其余 K/V 从缓存读 Memory-bound(显存带宽是瓶颈)

Prefill 是 compute-bound 的密集计算,Decode 是 memory-bound 的带宽受限。静态 batching 把这两个阶段混在一起------一个长 prompt 的 prefill 会卡住所有其他请求的 decode,导致"慢的拖死快的"。


三、PagedAttention:把 KV Cache 当虚拟内存管

3.1 核心洞见

vLLM 团队的 insight 来自操作系统教科书:OS 不会给每个进程分配一整块连续物理内存,而是用分页机制------物理内存切成固定大小的 Page(4KB),进程看到的是连续的虚拟地址,实际物理内存分散在不同 Page 里。

KV Cache 为什么不能这样做?

PagedAttention 把每个请求的 KV Cache 切成固定大小的 Block (默认 16 tokens/Block),用一张 Block Table(映射表)管理 "逻辑 Block 序号 → 物理 Block 地址" 的映射。

复制代码
传统分配(连续数组):
  KV Cache for Req A: [████████████████████████████████████████]
  一整块连续内存,预分配 2048 tokens,实际用了 50
  浪费: 1998 tokens × KV_cache_per_token ≈ 几十 MB 白费

PagedAttention(分页 + Block Table):
  逻辑 Block 0 ──→ 物理 Block 7   
  逻辑 Block 1 ──→ 物理 Block 3    ← 只分配实际用到的 Block
  逻辑 Block 2 ──→ 物理 Block 12
  逻辑 Block 3 ──→ 物理 Block 5
  
  浪费: 最后一个 Block 内不满 16 token 的部分
        (< 16 tokens × KV_cache_per_token ≈ < 1 MB)

物理 Block 不需要连续。 Block 7 和 Block 3 在显存里可能相距甚远------但 Block Table 维护了逻辑顺序,attention 计算时按表查物理地址即可。

3.2 内存分配与回收机制

分配:按需增长

一个请求最初只分配少量 Block(比如足够存 prompt 的长度)。每生成 16 个新 token,才向全局空闲 Block 池申请一个新 Block。如果池子空了,触发回收。

回收:引用计数 + LRU

每个物理 Block 有一个引用计数器。vLLM 的淘汰策略:

复制代码
1. 优先淘汰 ref_count = 0 的 Block(没有任何请求在使用)
2. 在 ref_count = 0 的 Block 中按 LRU 淘汰(最近最少使用)
3. 如果仍然不够 → 抢占(preemption):驱逐某个活跃请求的所有 Block,
   后续需要时重新计算(recompute)或从 CPU 内存换回(swap)

共享:Copy-on-Write

如果两个请求共享同一段前缀(比如同样的 system prompt),它们的 Block Table 可以指向相同的物理 Block,ref_count 设为 2。只有当某个请求的生成内容超出了共享前缀范围时,才为它分配新的物理 Block------此时共享 Block 保持不变(ref_count 减为 1)。

复制代码
两个请求共享 system prompt(1000 tokens ≈ 63 Blocks):
  
  Req A Block Table: [共享Block_1][共享Block_2]...[共享Block_63][独有Block_A64]...
  Req B Block Table: [共享Block_1][共享Block_2]...[共享Block_63][独有Block_B64]...
                             ↑ 前 63 个 Block 是同一块物理内存,ref_count = 2

3.3 Attention 计算如何适配分页?

有了分页后,attention 的 K/V 不再连续。GPU kernel 在计算时需要查 Block Table 来定位物理地址:

复制代码
传统 attention kernel(连续):
  K_ptr = kv_cache + seq_start        // 直接偏移访问

PagedAttention kernel(分页):
  for block_idx in 0..num_blocks:
      physical_idx = block_table[block_idx]    // ← 查映射表
      K_ptr = kv_cache + physical_idx * block_size
      对当前 block 内的 16 个 token 计算 attention

增加的寻址开销(查一次 Block Table + 多一次指针跳转)远小于省下的显存带来的吞吐提升------显存利用率从 20% 到 96%,batch size 可以大 5 倍。

3.4 Block 大小为什么是 16?

Block 大小 优点 缺点
大(如 128) 映射表小,attention 寻址少 退回连续分配老路,浪费多
小(如 1) 零浪费,精确匹配 映射表巨大,attention 需要查太多次表
16(vLLM 默认) 平衡浪费和开销 ---

vLLM 选择了 16 作为平衡点。但 SGLang 后来把它改成了 1------后面会解释为什么以及代价是什么。

3.5 vLLM 的自动前缀缓存(APC)

PagedAttention 的 Block 机制天然支持前缀共享------但需要一个查找机制来判断"新请求的前缀是不是已经有人算过了"。

vLLM 的 APC 使用哈希表方案:

复制代码
1. 为新请求的每个 Block 计算一个哈希值(基于 token 内容)
2. 在全局哈希表中查找:hash → 已有物理 Block?
3. 如果命中 → 复用该 Block(ref_count++)
4. 如果未命中 → 分配新 Block,计算 KV,写入哈希表
5. 一旦某个 Block 哈希不匹配 → 后续 Block 的哈希也不同(链式依赖)→ 复用截止

哈希表的优点: 实现简单,O(1) 查找。缺点: 只能匹配完整的 16-token Block------如果两个请求的前缀在 Block 中间分叉(比如第 20 个 token 不同),第 2 个 Block 的哈希全变了,只能复用前 1 个 Block。这就是 Block 对齐损失。


四、Continuous Batching:GPU 不再等人

4.1 Prefill 和 Decode 为什么必须分开调度?

回到第二章提到的两个阶段:

复制代码
Prefill(一次处理整个 prompt):
  计算量: O(n²) --- 每个 token 都要和所有 token 做 attention
  GPU 状态: 算力打满,Compute-bound
  耗时: 取决于 prompt 长度(几百 ms 到几秒)

Decode(每步生成一个 token):
  计算量: O(n) --- 只有最新 token 需要和 KV Cache 做 attention
  GPU 状态: 带宽打满,Memory-bound  
  耗时: 每步约 10-50ms(取决于模型大小和显存带宽)

传统静态 batching 的问题:一个长 prompt 的 prefill(Compute-bound 的几秒)卡住了所有其他请求的 decode(每步只需要几十 ms)。而且一旦有人做完,它的空位不能被新请求填补------因为 KV Cache 是连续分配的,释放后留下碎片。

4.2 Continuous Batching 的核心机制

vLLM 的做法:每生成一步 token,重新组一次批。

复制代码
调度循环(每步一次):

1. SCHEDULE:
   a. 所有正在 decode 的请求优先调度(保证延迟稳定)
   b. 用剩余的 token 预算调度 chunked prefill(把长 prompt 切成小块)
   c. 有请求完成 → 释放其 Block → 从等待队列调度新请求
  
2. FORWARD PASS:
   模型跑一次前向传播(prefill chunk + decode token 混在一起算)
  
3. POSTPROCESS:
   检查 stop 条件(EOS、stop string、max_tokens)
   完成的请求 → 释放 KV Cache Block,通知客户端
   未完成的 → 回到步骤 1

关键:步骤 1-3 在每生成一个 token 后重复。 一个请求完成了,下一步就换新请求进来。GPU 不会因为等待"最慢的那个人"而空闲。

4.3 Chunked Prefill:不让长 prompt 卡死所有人

如果不用 chunked prefill,一个 8192 token 的 prompt 会在一次 prefill 中垄断 GPU 几秒钟------期间所有 decode 请求都要等。

Chunked prefill 把这个长 prompt 切成小块(受 max_num_batched_tokens 控制,默认 2048),然后把 prefill 的小块和 decode 混在一起调度

复制代码
Step 1: Prefill Req_A tokens 1-2048    + Decode Req_B/C/D
Step 2: Prefill Req_A tokens 2049-4096  + Decode Req_B/C/D  
Step 3: Prefill Req_A tokens 4097-6144  + Decode Req_B/C/D/E (E 刚到达)
Step 4: Prefill Req_A tokens 6145-8192  + Decode Req_B/C/D/E
Step 5: All decode (包含 Req_A 的第一个生成 token)

收益: 长 prompt 的 TTFT(首个 token 延迟)虽然略增,但不会影响其他请求的 decode 延迟------prefill 的密集计算被"切碎"分散到了多个 step 中。

4.4 抢占:显存不够怎么办?

当 Block 池耗尽时,vLLM 有两种方式回收资源:

策略 做法 代价
Recompute 丢弃某个请求的所有 KV Cache Block,需要时重新跑 prefill 重新计算的开销(但 prefill 本来就快)
Swap 把 Block 换出到 CPU 内存,需要时换回 CPU↔GPU 传输带宽开销

vLLM 默认选择 recompute------因为 PagedAttention 下 prefill 已经很快,重新算一遍比在 CPU 和 GPU 倒腾数据更省时间。


五、前缀缓存演进:哈希表 vs 基数树

5.1 两种数据结构解决同一个问题

PagedAttention 解决了"怎么存不浪费",Continuous Batching 解决了"怎么排不等"---但还有一个问题没碰:

如果新请求的前缀和某个已完成请求的前缀一模一样,为什么还要重新算一遍 KV?

两个请求共享 system prompt(2000 tokens)是很常见的场景。vLLM 的 APC(3.5 节)用哈希表做 Block 级复用,SGLang 的 RadixAttention 用 Radix Tree(基数树)做 Token 级复用。两种方案都能复用前缀------但精度和适用场景差别很大。

vLLM APC(哈希表):

复制代码
哈希链: 
  Block 0: hash(token[0:16])  ──→  缓存命中?✅
  Block 1: hash(Block[0].hash + token[16:32]) ──→ 命中?✅  
  Block 2: hash(Block[1].hash + token[32:48]) ──→ 命中?❌
  → 从 Block 2 开始必须重新计算 KV
  → Block 0-1 被复用(32 tokens)✓,Block 2-... 全重算

一旦某个 Block 不匹配,其哈希变了,后续所有 Block 的哈希链都断掉------只能复用分歧点之前的 Block。而且 Block 是 16 token 的固定单位------如果分歧发生在 token 20,token 17-19 也不能被复用(它们和 token 0-15 在同一个 Block 1 中)。

SGLang RadixAttention(Radix Tree):

复制代码
基数树结构(边 = token 序列,节点 = KV Cache):
  
  根
  └── token[0:1999] (system prompt, 2000 tokens)
        ├── token[2000:2099] (用户问题1: "什么是PagedAttention?")
        │     └── token[2100:...](助手回复1...)
        └── token[2000:2099] (用户问题2: "什么是RadixAttention?")  
              └── token[2100:...](助手回复2...)
  
  新请求 = system prompt + "什么是PagedAttention?" + 新一轮对话
  → 沿树匹配到最长前缀 (system prompt + 整个历史对话)
  → 只计算新增 token 的 KV

5.2 Radix Tree 的节点分裂机制

RadixAttention 的核心操作是节点分裂(node split)。当一个新请求的前缀和树中某个节点"部分匹配"时:

复制代码
当前树: 
  节点 A: "你好,请问什么是PagedAttention"

新请求: "你好,请问什么是RadixAttention"

匹配过程:
  1. 从根出发,找到节点 A
  2. 逐 token 比对: "你好,请问什么是" ← 10 tokens 完全匹配
  3. 第 11 个 token: "P" vs "R" ← 不匹配

节点分裂:
  分裂前: [节点 A: "你好,请问什么是PagedAttention"]
  分裂后: [公共节点: "你好,请问什么是"]
            ├── [子节点: "PagedAttention" → 原 KV Cache 的尾部]
            └── [子节点: "RadixAttention" → 新分配的 KV Cache]
  
  新请求复用公共节点的 10 个 token 的 KV Cache ✓
  只计算后续 14 个 token ✓

这种 token 级精确匹配 + 节点动态分裂,是 Radix Tree 对比哈希表的核心优势。

5.3 为什么 page_size 从 16 降到 1

vLLM 的 Block 对齐损失(Block 内 16 个 token 必须全匹配)在 Radix Tree 中不存在------因为树是 token 级的。SGLang 把 Block 大小设为 1(page_size=1),每个物理 Block 只存 1 个 token 的 KV 对。

vLLM APC (page_size=16) SGLang RadixAttention (page_size=1)
前缀匹配精度 16-token 对齐边界 逐 token 精确匹配
分歧发生在 token 20 只能复用前 16 tokens 可以复用前 20 tokens
元数据开销 Block Table 小 Block Table 大(×16)
主要适用场景 模板化批量推理(前缀完全相同) 多轮对话、Agent(前缀动态变化)

代价: 映射表大小 ×16------从"每 16 token 一条映射"变成"每 token 一条映射"。但在实际部署中,省下的 KV Cache 重算开销(尤其在多轮对话中可能重算几千个 token)远大于增加的元数据开销。

5.4 淘汰策略:树感知 vs 平坦 LRU

两种方案在"显存不够了该删谁"这件事上也不同:

vLLM APC(平坦 LRU):

每个 Block 是独立的,按最后访问时间排序,最久没用的先删。不考虑 Block 之间的树形依赖。

SGLang RadixAttention(树感知 LRU):

叶子节点开始删除,向父节点方向传播:

复制代码
树结构:   [公共前缀: 1000 tokens, ref_count=3]
            ├── [叶子A: 200 tokens, ref_count=1]
            ├── [叶子B: 500 tokens, ref_count=1, LRU 最旧]
            └── [叶子C: 300 tokens, ref_count=1]

淘汰叶子B: 
  1. 删除叶子B的 500 tokens KV Cache
  2. 检查父节点: ref_count 从 3 降为 2 > 0 → 保留
  3. 如果某个父节点的 ref_count 降为 0 且没有其他子节点 → 继续向上删除

为什么这个设计好? 公共前缀(ref_count 最高的节点)是缓存经济性最高的部分------多个请求都在用。树感知淘汰优先删叶子(只有一个请求在用),保护主干(多个请求共享),最大化缓存的"复用价值"而不是只看"最近用了没有"。

5.5 数字说话:一个 RAG 场景的对比

100 并发用户,每个请求 = 400-token system prompt + 3 篇检索文档(600 token/篇),文档之间有 60% 重叠:

方案 每批次 Prefill Token 数 减少幅度
无缓存 225,000 baseline
vLLM APC(哈希表) 125,000 -44%
SGLang RadixAttention(基数树) 65,000 -71%

Prefill 减少 71% 意味着:

  • 吞吐提升约 3.5×(prefill 是 compute-bound 瓶颈)
  • TTFT 降低约 60%(不需要重算已被其他请求算过的前缀)
  • KV Cache 显存节省约 40%(共享前缀只存一份)

六、三层递进的架构总结

把三个机制放在同一张图里,它们之间的依赖关系清晰可见:

复制代码
Layer 1: PagedAttention(物理基础)
  · 把 KV Cache 切成 Block,用 Block Table 管理映射
  · 消除了连续分配的内外部碎片
  · 显存利用率: 20% → 96%
  · 提供了 Copy-on-Write 共享和按需分配的能力
  
  ↓ 没有 Layer 1,Layer 2 不可能实现 ↓

Layer 2: Continuous Batching(调度层)
  · 每步 token 后重新组批,有人做完就换人
  · Chunked Prefill: 把长 prompt 切碎混入 decode 调度
  · Prefill/decode 分离调度: decode 优先,prefill 填空
  · GPU 不再因为等最慢的请求而空闲
  
  ↓ Layer 2 让 KV Cache 的利用率问题解决了
  ↓ 但它没解决同一个前辍算两次的浪费 ↓

Layer 3: 前缀缓存(复用层)
  vLLM APC (哈希表)          SGLang RadixAttention (基数树)
  · Block 级复用                · Token 级复用  
  · 16-token 对齐损失            · 节点分裂,任意位置匹配
  · 平坦 LRU 淘汰               · 树感知淘汰,保护共享主干
  · 批量推理胜出                 · 多轮对话/Agent 胜出 (3-5×)

每一层都依赖前一层提供的物理基础。 PagedAttention 的分页使 Continuous Batching 成为可能(消除了碎片约束),PagedAttention 的 Block 共享语义使前缀缓存成为可能(Block Table 可以指向相同的物理 Block)。


七、vLLM vs SGLang:选型指南

维度 vLLM SGLang
核心 KV 管理 PagedAttention(16 token/Block) PagedAttention + page_size=1
前缀缓存 哈希表 Block 级复用 Radix Tree Token 级复用
淘汰策略 平坦 LRU 树感知 LRU(从叶子到根)
Continuous Batching
Chunked Prefill
结构化输出 基础支持 ✅ 原生正则/EBNF 约束解码
单轮简单生成 接近 接近
多轮对话/Agent/RAG baseline 3-5× 吞吐提升
稳定性 生产验证成熟 快速增长,偶有 bug
社区 54K+ stars 快速增长中
学习曲线 1-2 天上手 3-5 天

一句话: 单轮生成、批量推理 → vLLM;多轮对话、Agent、RAG → SGLang。两者 API 兼容 OpenAI 格式,换框架基本不换代码。混用(vLLM 负责简单场景,SGLang 负责复杂 Agent 场景)也是成熟实践。


八、总结

回到开篇的问题:同样的 GPU、同样的模型,为什么 HuggingFace 默认实现和 vLLM 差了 10-24×?

答案不在更好的硬件,而在 KV Cache 的三层递进式管理

复制代码
HuggingFace Transformers(默认):
  KV Cache: 连续数组预分配         显存利用率: ~20%
  Batching: 静态(等所有人做完)     GPU 利用率: ~50%
  前缀复用: 无

vLLM:
  KV Cache: 分页虚拟内存           显存利用率: ~96%
  Batching: 动态(有人做完就换人)   GPU 利用率: ~95%+
  前缀复用: 哈希表 Block 级

SGLang (vLLM + RadixAttention):
  KV Cache: 前缀树 Token 级共享    多轮显存: 再省 30-50%
  Batching: 同 vLLM               多轮吞吐: 3-5× vLLM
  前缀复用: 基数树 Token 级 + 树感知淘汰

这三个机制不是平行的功能,而是依赖的阶梯。 不理解 PagedAttention 的分页原理,就无法理解为什么 Continuous Batching 可行;不理解哈希表和基数树的差异,就无法理解为什么同样的场景、同样的模型,两个引擎的吞吐可以差 5×。


附录:进一步阅读

相关推荐
禅思院2 小时前
AI对话前端从入门到崩溃:一个长对话引发的五层优化战争【五】
前端·架构·前端框架
herosunly11 小时前
60ms 不是启动快,而是不用重新启动:CubeSandbox 极速冷启动架构拆解
架构·cube sandbox
zlinear数据采集卡12 小时前
从气动比例阀到任意波形:硬核拆解ZLinear采集卡的DAC输出架构与工业闭环控制实战
arm开发·架构
2601_9619460814 小时前
AI API 网关实战:从单 Key 管理到企业级多租户架构
大数据·人工智能·金融·架构·api·个人开发
XUHUOJUN16 小时前
Windows 2025架构深度分析之Stretch Cluster 延迟工程现实
windows·microsoft·架构·azure local
Web极客码17 小时前
突破并发瓶颈:云端高性能架构如何赋能海外 AI Agent 矩阵的高效产出
服务器·人工智能·架构
laoli_coding18 小时前
系统部署之框架选型方案
微服务·云原生·架构
掘金_答案18 小时前
上线那天,一个 ConcurrentHashMap 差点送走我的 AI 客服——3 天排查 JVM 血泪史
java·后端·架构
秦任之19 小时前
Gliding Horse 整体架构拼图:当 AI Agent 有了自己的操作系统
人工智能·架构