对外看,LLM 推理引擎只是把 OpenAI 风格的请求转换成一串 Token;对内看,它更像一个专门为自回归模型设计的操作系统:在不断变化的请求之间分配 GPU 计算、显存和执行时间,同时维护每个请求持续增长的状态。

真正困难的并不是完成一次模型 Forward,而是持续回答三个问题:

  1. 下一轮 GPU 应该处理哪些请求?
  2. 有限显存应该保留哪些 KV Cache?
  3. 怎样在首 Token 延迟、生成流畅度和系统吞吐之间取舍?

SGLang、vLLM 等现代推理引擎的大部分优化,都可以放回这三个问题中理解。

推理引擎面对的是一组彼此冲突的工作负载

一个生成请求在执行层面分为两个阶段:

1
2
3
Prompt ── Prefill ──> First Token ── Decode ──> Remaining Tokens
│ │
└── 创建 KV Cache └── 读取并扩展 KV Cache

Prefill 一次处理整段输入,矩阵规模较大,通常更容易利用 GPU 的计算能力;Decode 每轮只为每个请求生成少量新 Token,却需要反复读取模型权重和不断增长的 KV Cache,通常更受显存带宽限制。

两者共享同一块 GPU,却有不同的资源特征:

阶段 主要工作 常见瓶颈 直接影响
Prefill 处理输入并建立 KV Cache 计算量、长 Prompt TTFT
Decode 逐步生成并扩展 KV Cache 显存带宽、批处理效率 TPOT / ITL

这也是推理调度比普通 Web 请求调度更棘手的地方。新到达的长 Prompt 可以高效占满 GPU,却可能阻塞正在流式输出的请求;优先照顾 Decode 可以让输出更平滑,却会延长新请求的等待时间。

Scheduler 是 Runtime 的控制面

现代推理引擎通常不会固定一个 Batch 并等待其中所有请求结束。它们采用 Continuous Batching:每轮 Forward 之后,已完成的请求退出,新请求或等待中的请求进入,Batch 始终处于变化之中。

1
2
3
Step 1:  A(decode)  B(decode)  C(prefill)
Step 2: A(decode) B(done) C(prefill)
Step 3: A(decode) C(decode) D(prefill)

因此,Scheduler 调度的不是一个静态请求列表,而是每个执行步中的工作量。它需要同时考虑:

  • 哪些请求已经可以继续 Decode;
  • 哪些新请求可以进入 Prefill;
  • 本轮计算容量还能容纳多少 Token;
  • KV Cache 是否还有足够的 Block;
  • 是否需要抢占、延后或重新计算某些请求。

Continuous Batching 提高了 GPU 利用率,但也把性能问题变成了策略问题:相同的模型和硬件,仅仅改变请求进入顺序、Prefill 大小或调度优先级,就可能得到完全不同的尾延迟。

Chunked Prefill:把长任务变成可抢占的工作

如果一个很长的 Prefill 独占某次 Forward,正在 Decode 的请求就只能等待,用户看到的结果通常是流式输出突然停顿。Chunked Prefill 将长 Prompt 拆成多个片段,并把它们与 Decode 工作穿插执行:

1
2
3
未切分: Prefill ────────────────────> Decode 恢复

切分后: Prefill ─> Decode ─> Prefill ─> Decode ─> ...

它不是单纯的“加速开关”,而是在重新分配延迟:

  • Chunk 较小,Decode 被阻塞的时间更短,ITL 通常更稳定;
  • Chunk 过小,会增加调度与 Kernel 启动开销,整体吞吐可能下降;
  • Decode 优先级过高,Prefill 排队时间会增加,TTFT 可能恶化。

在单个引擎内,Chunked Prefill 缓解了两类任务的互相干扰;在更大规模的部署中,还可以进一步将 Prefill 与 Decode 放到不同的 Worker 上,即 PD Disaggregation。两者解决的是同一矛盾在不同规模下的表现。

KV Cache 才是并发能力的硬约束

自回归生成之所以不必在每一步重算全部上下文,是因为 Attention 的 Key 和 Value 被保存在 KV Cache 中。它的大小会随上下文长度和并发请求数线性增长。

对于常见 Transformer,单个请求的 KV Cache 可以粗略估算为:

1
2 × 层数 × Token 数 × KV Head 数 × Head Dim × 每元素字节数

其中的 2 对应 Key 和 Value。实际单卡占用还会受到 Tensor Parallel、KV Cache 精度和模型结构影响,但这个近似已经揭示了关键事实:模型权重能够装进显存,不代表服务能够承受目标并发。

推理时的显存大致被以下几部分共同占用:

1
Model Weights + KV Cache + Activations + Runtime Buffers

权重加载后基本固定,KV Cache 却会随流量持续分配和回收。于是 Runtime 不仅要“算 Token”,还要像内存管理器一样处理动态增长、碎片、共享与淘汰。

PagedAttention 与 RadixAttention 解决的不是同一个问题

理解 vLLM 和 SGLang 时,一个常见误区是把 PagedAttention 与 RadixAttention 当成两种互相竞争的 KV Cache 方案。更准确的划分是:

  • PagedAttention 主要回答 KV Cache 如何分配和寻址;
  • RadixAttention 主要回答不同请求的公共前缀如何组织、匹配和复用。

分页:让物理显存不必连续

如果每个请求都需要一段连续显存,KV Cache 随请求增长和结束会产生碎片,也难以提前准确预留空间。PagedAttention 把 KV Cache 切成固定大小的 Block,让逻辑连续的 Token 可以映射到不连续的物理显存:

1
2
Request A: [block 1] -> [block 4] -> [block 7]
Request B: [block 2] -> [block 3]

这套设计将 KV Cache 的分配粒度从“整个请求”缩小到 Block,使扩容、回收和共享都更灵活。它是 vLLM 最具代表性的设计,但分页式 KV 管理如今也已经出现在包括 SGLang 在内的其他推理引擎中。

前缀树:让相同上下文不必重复计算

考虑一组 Agent 请求:

1
2
System Prompt + Tool Definitions + Repo Context + Question A
System Prompt + Tool Definitions + Repo Context + Question B

如果前面的大段 Token 完全相同,对它们重复 Prefill 就是在重复计算。SGLang 的 RadixAttention 使用 Radix Tree 组织已生成的 KV Cache,让新请求匹配最长公共前缀,只计算尚未命中的部分:

1
2
3
4
5
System Prompt
└── Tool Definitions
└── Repo Context
├── Question A
└── Question B

这对多轮对话、Few-shot、RAG 和 Agent 工作负载尤其有效,因为这些请求往往共享稳定且较长的前缀。

不过,“支持 Prefix Cache”也已经不是 SGLang 与 vLLM 的功能分界线:vLLM 同样支持 Automatic Prefix Caching,SGLang 也支持 Paged Attention。两者更值得比较的,是缓存索引、淘汰策略和调度器如何协同,而不是简单检查功能列表。

Prefix Cache 会改变调度目标

缓存命中并不只取决于请求内容,也取决于请求顺序。

假设等待队列里有两个请求:一个先到达但没有缓存命中,另一个稍晚到达却能复用很长的前缀。严格 FCFS 更公平;优先后者则能减少 Prefill 计算、提高吞吐,还可能更快释放队列压力。

这正是 Prefix-aware Scheduling 的价值:Scheduler 不再只看请求到达时间和长度,还把缓存局部性纳入决策。SGLang 的 Radix Cache 采用缓存感知的调度与淘汰机制,使“保存了什么”和“接下来算什么”成为一个联合问题。

代价也同样存在:

  • 为未来复用而保留 KV Cache,会减少当前请求可用的显存;
  • 过度偏向高命中请求,可能导致其他请求饥饿;
  • 缓存容量不足时,频繁淘汰和重算会抵消命中收益。

所以 Prefix Cache Hit Rate 不能脱离吞吐和尾延迟单独评价。更高的命中率是手段,而不是最终目标。

Speculative Decoding:减少 Decode 的串行步数

调度和缓存主要改善“如何组织工作”,Speculative Decoding 则直接攻击自回归生成的串行依赖。

它先用 Draft Model、Medusa/EAGLE 一类 Draft Head,或其他廉价方法提出多个候选 Token,再由目标模型一次验证。连续通过的候选可以一并接受,从而用一次目标模型执行推进多个 Token。

1
2
3
Draft:   A  B  C  D
Verify: ✓ ✓ ✓ ✗
Accept: A B C

但减少目标模型执行次数不等于系统吞吐一定提高。收益取决于候选接受率、Draft 成本、目标模型大小、并发度以及验证开销。低并发时,Decode 没有充分利用 GPU 计算资源,额外的 Draft 工作可能很划算;高并发时,Continuous Batching 本身已经在利用这些空余能力,Speculative Decoding 反而可能与正常请求争夺资源。

因此任何“加速 N 倍”的结果,都至少需要说明模型、硬件、并发度、输入输出长度、Draft 方法、接受率,以及测量的是单请求延迟还是系统吞吐。

从指标反推 Runtime 内部发生了什么

只看 GPU Utilization 很难判断推理服务是否健康。更有用的做法,是把用户侧延迟与 Runtime 内部状态连起来:

现象 应同时观察 可能的机制
TTFT 与 Waiting Requests 一起上升 排队时间、Running/Waiting、Prefill 吞吐 到达率超过处理能力或调度拥塞
TTFT 上升且 Prefix 命中下降 Cache Hit Rate、被缓存 Token 数 公共前缀变化或缓存被淘汰,Prefill 重算增加
流式输出偶发停顿 P99 ITL、Prefill 长度、Chunk 大小 长 Prefill 干扰 Decode
TPOT 持续变差 Decode Batch、KV Cache 使用率、显存带宽 Decode 负载增加或内存系统接近饱和
吞吐提高但尾延迟恶化 P50/P99 TTFT、ITL、并发度 更激进的批处理用等待时间换取利用率

TTFT 不只等于 Prefill 时间,它还包含排队、调度和网络开销;TPOT 是请求级平均值,可能掩盖某几个 Token 的长时间停顿,因此流式服务还应该关注 ITL 分布,尤其是 P99。

应该怎样比较 vLLM 与 SGLang

今天再用“vLLM 有 PagedAttention、SGLang 有 RadixAttention”来做二选一,已经过于简单。两个项目都在快速演进,也在吸收相似的设计。更可靠的比较方式是固定模型、精度、硬件和并行策略,然后使用接近生产的流量进行端到端测试:

  • 输入长度与输出长度的真实分布;
  • 请求到达速率,而不只是固定并发;
  • 公共前缀的长度、比例和重复模式;
  • 冷缓存与热缓存分别测试;
  • 在 TTFT、TPOT 或 ITL 约束下能够达到的最大吞吐;
  • P50 之外的 P95/P99 延迟和稳定性。

对于 Prefix-heavy 的 Agent 或多轮对话流量,缓存组织和缓存感知调度会非常关键;对于共享前缀很少的普通生成流量,模型支持、Kernel 效率、量化、并行策略和运维成熟度可能更重要。

Runtime 的选择最终不是功能表打勾,而是工作负载与实现策略的匹配。

总结

现代 LLM 推理引擎可以归结为三个相互耦合的系统问题:

  1. 执行调度:通过 Continuous Batching 和 Chunked Prefill 安排每轮 GPU 工作;
  2. 状态管理:通过分页、共享和淘汰管理持续增长的 KV Cache;
  3. 性能权衡:在 TTFT、ITL、TPOT、吞吐和显存容量之间寻找平衡。

PagedAttention 改善 KV Cache 的物理管理,RadixAttention 提升公共前缀的复用效率,Speculative Decoding 减少串行生成步数。它们切入的层次不同,但最终都服务于同一个目标:让有限的 GPU 资源完成更多有效工作,同时把用户可感知的等待控制在可接受范围内。

理解一个 Runtime,不妨从四个问题开始:它如何组成 Batch,如何处理长 Prefill,如何管理与复用 KV Cache,以及性能数字是在什么流量模型下测出来的。答案通常比功能列表更接近系统的真实能力。

参考资料