本文是《大模型推理系统揭秘:从 vLLM 看 LLM Serving Infra 核心技术》系列的第 1 篇(共十五篇)。下一篇:如何衡量一个 LLM Serving 系统?。
NOTE 本文基于 vLLM v0.27.1(tag
6e448d0, 2026-08-11)源码剖析。文中文件路径、类名和函数名均以该版本为准;vLLM 迭代很快,阅读时请以你手上的版本对照。
传统深度学习推理通常围绕一个相对稳定的计算过程展开:输入一批形状相近的数据,执行一次前向传播,然后返回结果。
LLM Serving 则完全不同。一个请求可能携带几十个,也可能携带数万个输入 token;模型还需要逐个生成长度未知的输出 token。在这个过程中,系统必须动态管理请求、显存、KV Cache、批次和 GPU 计算资源。
因此,LLM Serving 的难点并不只是“模型更大”或“参数更多”,而是它改变了推理服务的基本执行模型:
从一次性、静态、可预测的前向计算,转变为持续进行、动态变化、状态不断增长的自回归计算。
本篇要回答的核心问题是:
为什么 LLM Serving 不能沿用传统 DL 推理“一次前向、一次返回”的服务模型?1 它究竟在哪些地方变难了?2
一、总览:三方面的变化与挑战
1. 三方面的变化和挑战
整体来说,有如下三方面的变化和挑战:
- 传统深度学习推理与 LLM Serving 存在巨大的范式差异
- Prefill 与 Decode 是两种完全不同的 GPU Workload
- 吞吐与时延之间难以避免的系统权衡
后文先分别展开这三点,再把它们收拢为三个根本变化,给出后续各篇的阅读地图,最后引入一个贯穿整个系列的具体请求作为算账的例子。
2. 本文的章节安排
| 章 | 主题 | 内容 |
|---|---|---|
| 二 | 范式转移 | 服务对象从“一次计算”变成“持续生成过程” |
| 三 | Prefill 与 Decode | 两种完全不同的 GPU Workload:计算规模、瓶颈与优化手段 |
| 四 | 吞吐与时延 | Batch 增大带来的收益与代价,SLO 约束下的权衡 |
| 五 | 三个根本变化 | 动态执行、带状态推理、混合 Workload |
| 六 | 阅读地图 | 后续内容围绕的四个核心问题与两个横切约束 |
| 七 | 一个贯穿全文的例子 | Llama-3-70B / 8×H100 / 2050+300 token 的基础算账 |
| 八 | 本文小结 | |
| 九 | 自测 | 5 道题 |
二、范式转移:服务对象从“一次计算”变成“持续生成过程”
理解 LLM Serving 的第一步,不是从某个具体优化技术开始,而是先看清它与典型传统 DL Serving 在服务对象上的差异:从一次 Forward 到持续生成。
许多传统深度学习推理任务都可以抽象为“一次请求对应一次前向计算”:
请求进入 → 组 Batch → 执行一次 Forward → 返回结果
即使输入 shape 存在一定变化,系统通常也可以通过 padding、bucketing 或预编译的执行形态,将请求规整为有限几种执行模式。在推理过程中,中间激活一般即可释放。
LLM Serving 则不同。一个请求通常要经历:
- 请求进入
- Prompt Processing / Prefill
- 生成第一个 Token
- Decode Step 1
- Decode Step 2
- Decode Step …
- EOS 或达到长度上限
它的输入和输出都具有动态性:
- 输入长度不固定:可能是几十个 token,也可能是数万甚至更长的上下文;
- 输出长度不确定:模型何时生成 EOS,事先无法准确确定;
- 服务时长不可直接预知:请求可能只生成几个 token,也可能持续数千个 token;
- 中间状态需要跨多个 step 保留:Prefill 产生的 KV Cache 会被后续 Decode 持续访问。
因此,LLM Serving 的核心变化并不只是模型更大,而是服务对象从“一次性计算任务”变成了带有动态生命周期和持久中间状态的生成任务。
| 维度 | 典型传统 DL Serving | LLM Serving |
|---|---|---|
| 服务单位 | 一次或少数几次 Forward | 多轮自回归生成 |
| 输入长度 | 通常可规整为有限几种形态 | 可变,可能非常长 |
| 输出长度 | 通常较容易确定 | 由生成过程动态决定 |
| 中间状态 | 多为临时激活 | KV Cache 跨 Decode Step 持续存在 |
| Batch 语义 | 请求级静态或动态 Batch | 面向持续执行的 Continuous Batching |
| 内存模式 | 相对静态 | KV Cache 动态增长和回收 |
| 服务时长 | 相对容易估计 | 与输出长度、调度和资源竞争有关 |
| 主要挑战 | 高效执行一次计算 | 计算、调度和状态管理协同优化 |
这并不意味着传统 Serving 的技术全部失效,而是意味着系统不能再只依赖以下假设:
- 输入和执行 shape 基本固定;
- 一次 Forward 即可完成请求;
- 请求服务时长容易估计;
- 执行过程中或者执行后无需保存中间状态;
- 内存可以按请求一次性分配。
LLM Serving 需要在显存预算、请求生命周期、生成进度和服务等级约束之间进行动态决策。这是后续调度、缓存和执行优化的共同背景。
三、Prefill 与 Decode:两种完全不同的 GPU Workload
LLM 推理通常分为 Prefill 与 Decode 两个阶段:
──[ Prefill ]──[D][D][D][D][D][D][D]──▶
↑ ↑ ↑ ↑ ↑ ↑ ↑
每步生成 1 个 Token
Prefill 主要决定第一个 Token 何时到达,Decode 则决定后续 Token 的生成速度和稳定性。二者不仅执行顺序不同,计算规模、并行方式和硬件瓶颈也明显不同。
┌──────────────────────────── Prefill 数据流 ─────────────────────────────┐
│ │
│ input_ids: [t₁, t₂, t₃, ..., tₙ] (N 个 Prompt Token 并行输入) │
│ │ │
│ ▼ │
│ ┌─────────┐ Q: [N, H, d] │
│ │ QKV │ K: [N, Hkv, d] ──写入──▶ KV Cache (N 个新位置) │
│ │ Proj │ V: [N, Hkv, d] ──写入──▶ (批量写入 Prompt) │
│ └─────────┘ │
│ │ │
│ Attention: 逻辑上计算 Q × Kᵀ 和 Attention × V │
│ 实际通常由 FlashAttention 等 Kernel 分块完成 │
│ │ │
│ 只取最后 1 个 Position 的 Logits → Sampling → 第 1 个 Output Token │
│ │
│ 特征: Compute-Bound 倾向,高 GPU 利用率,大矩阵 GEMM │
└─────────────────────────────────────────────────────────────────────────┘
┌──────────────────────────── Decode 数据流 ──────────────────────────────┐
│ │
│ input_ids: [tₙ₊ₖ] (1 个新 Token 输入) │
│ │ │
│ ▼ │
│ ┌─────────┐ q: [1, H, d] │
│ │ QKV │ k: [1, Hkv, d] ──追加──▶ KV Cache (1 个新位置) │
│ │ Proj │ v: [1, Hkv, d] ──追加──▶ (增量写入) │
│ └─────────┘ │
│ │ │
│ Attention: q × K_historyᵀ → × V (读取全部历史 KV) │
│ │ │
│ Logits → Sampling → 下一个 Output Token │
│ │
│ 特征: Memory-Bound 倾向,单请求并行度低,大量 HBM 读取 │
└─────────────────────────────────────────────────────────────────────────┘
可以把两者的差异概括为:
Prefill 一次处理多个 Prompt Token,并批量写入 KV Cache;Decode 每个 Step 只处理一个新 Token,却要读取此前累积的历史 KV Cache。
| 维度 | Prefill | Decode |
|---|---|---|
| 单步输入 Token 数 | N 个 Prompt Token | 每个请求 1 个新 Token |
| 单请求序列并行度 | 高 | 低 |
| 跨请求并行度 | 可通过 Batch 提升 | 依赖 Continuous Batching |
| KV Cache 操作 | 批量写入 N 个位置 | 追加 1 个位置,读取历史 KV |
| Attention 序列相关计算 | O(N² · d) | O(L · d),L 为当前序列长度 |
| 总计算量 | O(N·d² + N²·d) | O(d² + L·d) per Step |
| 典型瓶颈 | Compute、长序列 Attention | HBM 带宽、KV Cache、通信 |
| 常见优化 | FlashAttention、Chunked Prefill | PagedAttention、FlashInfer、CUDA Graph |
| CUDA Graph | 需视执行形态而定 | 适合形状和路径相对稳定的场景 |
在多请求服务中,Prefill 和 Decode 会竞争同一组 GPU 资源:
- 长 Prefill 可能占据大量计算资源,导致 Decode 延迟抖动;
- Decode 请求过多,可能挤压 Prefill 的计算机会;
- 新请求不断进入,会同时消耗计算预算和 KV Cache 容量。
这也是以下机制存在的原因:
- Continuous Batching;
- Token Budget;
- Chunked Prefill;
- 抢占与重计算;
- Prefill/Decode 分离。
四、吞吐与时延:无法同时最优的权衡
扩大 Batch 通常可以提高吞吐,因为更多请求能够共享一次权重读取和 GPU 计算。
但 Batch 增大也会带来:
- 更长的计算等待;
- 更激烈的显存带宽竞争;
- 更高的单请求延迟;
- 更严重的尾延迟;
- 更大的 KV Cache 压力。
%% 图:Batch Size 对吞吐和延迟的影响:吞吐随 batch 增大趋于饱和,单请求延迟持续上升
xychart-beta
title "Batch Size 对吞吐和延迟的影响"
x-axis "Batch Size" [1, 2, 4, 8, 16, 32]
y-axis "相对值" 0 --> 100
line "吞吐" [12, 25, 45, 68, 85, 92]
line "单请求延迟" [10, 16, 25, 39, 60, 88]
一般而言:
- 吞吐:先随 Batch 增大而增长,随后进入饱和;
- 单请求延迟:通常随 Batch 增大而上升;
- 尾延迟:在资源接近饱和时可能快速恶化。
因此,系统目标不是简单地追求最大 Batch,而是在 SLO 约束下选择合适的执行规模:
吞吐最大化
▲
│ 可接受区间
│ ┌──────────┐
│ / \
│ / \
└────────────────────▶
延迟 / 尾延迟约束
调度器需要同时考虑:
- 当前活跃请求数;
- Prompt 长度;
- 已生成 token 数;
- KV Cache 占用;
- 请求优先级;
- TTFT、ITL 和 P99 等 SLO。
这也是 Token Budget、Chunked Prefill、抢占和 Admission Control 等机制出现的原因。
五、LLM Serving 的三个根本变化
到这里,可以将 LLM Serving 相对于传统深度学习推理的变化概括为三个方面。
1. 变化一:从静态计算变成动态执行
传统推理通常可以预先确定输入形状、Batch 大小和计算过程。
LLM Serving 中:
- Prompt 长度不确定;
- 输出长度不确定;
- 请求完成时间不确定;
- 每轮活跃请求集合不确定。
因此,系统需要持续调度,而不是只在请求到达时进行一次 Batch 组装。下面用 4 个槽位、12 个 step 的时间线对比两种做法(P = Prefill,D = Decode 一步,E = 生成 EOS 的那一步,. = 槽位空转):
静态 Batch:整批同进同出,最长的请求决定整批何时结束
step 1 2 3 4 5 6 7 8 9 10 11 12
R1 [P][D][D][D][E] . . . . . . . 5 步有效,7 步空转
R2 [P][D][D][D][D][D][D][D][D][D][D][E] 12 步有效
R3 [P][D][E] . . . . . . . . . 3 步有效,9 步空转
R4 [P][D][D][D][D][D][E] . . . . . 7 步有效,5 步空转
R5.. ── 排队,直到 step 12 整批结束才能进入 ──▶
有效槽位 27 / 48
Continuous Batching:以 step 为粒度,完成的请求离开,等待的请求补位
step 1 2 3 4 5 6 7 8 9 10 11 12
slot1 [P][D][D][D][E][P][D][D][D][D][E][P] R1 → R5 → R8
slot2 [P][D][D][D][D][D][D][D][D][D][D][E] R2
slot3 [P][D][E][P][D][D][D][D][E][P][D][D] R3 → R6 → R9
slot4 [P][D][D][D][D][D][E][P][D][D][D][D] R4 → R7
有效槽位 48 / 48
静态 Batch 里,R3 在 step 3 就结束了,但它的槽位要空转到 step 12 才能让给排队的 R5;输出长度越不均匀,浪费越大。Continuous Batching 把调度粒度从“一批请求”降到“一个 step”,任何一步结束都可以有请求离开、有请求进入,槽位始终有事可做。代价是同一个 step 里会混着新请求的 Prefill 和老请求的 Decode(如 step 6 的 slot1 与 slot2),一个长 Prompt 的 Prefill 会拖慢同批所有 Decode 的这一步——这正是第三章说的资源竞争,也是 Chunked Prefill 和 Token Budget 要解决的问题。
2. 变化二:从无状态推理变成带状态推理
传统推理通常只需要处理输入、模型参数和临时激活。
LLM Serving 还必须管理每个请求不断增长的 KV Cache。KV Cache 的位置、容量、复用和回收都会直接影响:
- 最大并发数;
- 可支持的上下文长度;
- 显存利用率;
- 请求是否需要抢占;
- 系统整体吞吐。
3. 变化三:从单一 Workload 变成 Prefill 与 Decode 的混合 Workload
Prefill 更偏向计算密集型,Decode 更偏向访存密集型。
这意味着:
- 适合 Prefill 的优化,不一定适合 Decode;
- 提高 GPU 算力利用率,不一定能降低 Decode 延迟;
- 只优化 Kernel,不一定能解决排队问题;
- 只优化 KV Cache,也不一定能改善长 Prompt 的 TTFT。
六、阅读地图:后续内容如何展开
后续章节将围绕 LLM Serving 的四个核心问题展开。
| # | 核心问题 | 主要机制 | 主要代码落点 | 后续章节 | |
|---|---|---|---|---|---|
| 一 | 请求来了,这一轮谁执行、执行多少? | Continuous Batching、Token Budget、Chunked Prefill、抢占 | Scheduler |
调度 | |
| 二 | 历史状态放在哪里、如何复用? | PagedAttention、Prefix Cache、GQA/MLA、KV 量化 | KVCacheManager / BlockPool |
KV Cache | |
| 三 | 这一轮如何计算得更快? | FlashAttention、CUDA Graph、算子融合、量化、投机解码 | ModelRunner / Attention Backend |
执行优化 | |
| 四 | 一张卡不够,如何扩展? | TP、PP、EP、CP、DP、集合通信 | Executor / distributed |
多卡与集群 |
四问之外还有两个横切约束:模型在变、硬件在变——它们不新增问题,但要求上面四个答案在剧烈变化的外部环境里保持稳定。
七、一个贯穿全文的例子
抽象的讨论容易滑走,所以从这里开始,本文会反复回到同一个具体请求。后面每一章都会带着它算一笔账。
场景设定
- 模型:Llama-3-70B,FP16,80 层,
hidden=8192,64 个 Q head / 8 个 KV head(GQA-8),head_dim=128- 硬件:8 × H100 80GB,TP=8,机内 NVLink
- 请求:2000 token 的 system prompt + 50 token 的用户提问,生成 300 token
- prompt 合计 2050 token,结束时序列长度 2350 token
先把两个最基础的量算出来,后面各章都要用:
| 量 | 计算 | 结果 |
|---|---|---|
| 每 token 每层的 KV | 2(K,V) × 8 kv_head × 128 dim × 2 B |
4 KB |
| 每 token 的 KV(80 层) | 4 KB × 80 |
320 KB |
| ↳ TP=8 时每张卡承担 | 320 KB ÷ 8 |
40 KB |
| 这个请求最终的 KV 总量 | 2350 × 320 KB |
约 734 MB |
| 权重每卡 | 141 GB ÷ 8 |
17.6 GB |
有了这两个量,就可以给第三章“Prefill 偏 Compute-Bound、Decode 偏 Memory-Bound”的判断算一笔账。线性层的 FLOPs 按 2 × 参数量 × token 数 估,每卡参数 70.6B ÷ 8 ≈ 8.8B;HBM 读取按“权重读一遍 + 历史 KV 读一遍”估;H100 SXM 的 FP16 稠密算力约 989 TFLOPS、HBM 带宽约 3.35 TB/s,拐点(ridge point)约 295 FLOP/B——算术强度高于它是 Compute-Bound,低于它是 Memory-Bound:
| 一个 step(每卡) | FLOPs | HBM 读取 | 算术强度 | 相对拐点 295 | 下界耗时 |
|---|---|---|---|---|---|
| Prefill,2050 token | 2 × 8.8G × 2050 ≈ 36 TFLOP |
权重 17.6 GB | ≈ 4100 FLOP/B | 高 14× → Compute-Bound | 算力:36T / 989T ≈ 37 ms |
| Decode,Batch=1,L=2350 | 2 × 8.8G ≈ 17.6 GFLOP |
17.6 GB + KV 94 MB | ≈ 1 FLOP/B | 低 300× → Memory-Bound | 带宽:17.7 GB / 3.35 TB/s ≈ 5.3 ms |
| Decode,Batch=64,L=2350 | 64 × 17.6G ≈ 1.1 TFLOP | 17.6 GB + KV 6.0 GB | ≈ 48 FLOP/B | 低 6× → 仍 Memory-Bound | 带宽:23.6 GB / 3.35 TB/s ≈ 7.0 ms |
| Decode,Batch=256,L=2350 | 256 × 17.6G ≈ 4.5 TFLOP | 17.6 GB + KV 24 GB | ≈ 108 FLOP/B | 低 2.7× → 仍 Memory-Bound | 带宽:41.6 GB / 3.35 TB/s ≈ 12.4 ms |
这张表忽略了 Attention 自身的 FLOPs、TP 通信和 Kernel 效率,只给理想下界,但已经足够说明几件事:
- Prefill 2050 个 token 的算力时间是权重读取时间的 7 倍,GPU 在“算”;Decode 一步做的计算只要 0.02 ms,却要花 5.3 ms 把 17.6 GB 权重从 HBM 搬一遍,GPU 在“等数据”。
- Decode 从 Batch=1 到 Batch=64,一步的耗时只从 5.3 ms 涨到 7.0 ms,工作量却是 64 倍——权重读取被整批分摊,这就是第四章“扩大 Batch 提升吞吐”的来源。
- 但 KV 读取不能分摊:每个请求的历史 KV 都要各读一遍,Batch=256 时 KV 读取(24 GB)已经超过权重本身,一步耗时开始随 Batch 线性上涨。KV Cache 不只是显存容量问题,也是带宽问题,这是后续 KV Cache 各篇和 GQA/MLA、KV 量化存在的原因。
八、本文小结
- LLM Serving 的难点不在“模型更大”,而在服务对象变了:从一次性、静态、可预测的前向计算,变成持续进行、动态变化、状态不断增长的自回归生成过程。
- 输入长度、输出长度、服务时长都不可预知,Prefill 产生的 KV Cache 要跨多个 Decode Step 保留——系统必须在显存预算、请求生命周期、生成进度和 SLO 之间持续做动态决策。
- Prefill 与 Decode 是两种截然不同的 Workload:前者一次处理 N 个 token、偏 Compute-Bound;后者每步只处理 1 个 token、却要读全部历史 KV、偏 Memory-Bound。两者竞争同一组 GPU 资源,这是 Continuous Batching、Token Budget、Chunked Prefill、抢占与 PD 分离存在的原因。
- 扩大 Batch 提升吞吐,但会抬高单请求延迟、尾延迟和 KV Cache 压力;目标不是最大 Batch,而是在 SLO 约束下选择合适的执行规模。
- 归纳为三个根本变化:从静态计算到动态执行、从无状态推理到带状态推理、从单一 Workload 到 Prefill/Decode 混合 Workload。
- 后续各篇围绕四个核心问题展开——这一轮谁执行、状态放哪、如何算得更快、如何扩展——外加模型与硬件两个横切约束;并反复回到同一个 Llama-3-70B / 8×H100 的请求算账(每 token 320 KB KV,全请求约 734 MB)。
九、自测
-
Llama-3-70B(80 层、8 个 KV head、head dim 128、BF16)每 token 的 KV 多大?一个 2000 token prompt + 300 token 输出的请求峰值 KV 多大?
答案
\(2 \times 80 \times 8 \times 128 \times 2\) B = 320 KB / token;2300 × 320 KB ≈ 734 MB。一张 80 GB 的 H100 扣掉权重(TP8 每卡 17.5 GB)后大约能放几十个这样的请求。
-
Prefill 与 Decode 各是什么瓶颈?为什么两者“竞争同一组 GPU”是问题?
答案
Prefill 一次算 N 个 token、算术强度高、compute-bound;Decode 每步 1 个 token 读全部权重与 KV、memory-bound。混在一个 batch 里,一个长 prefill 会拖住同批所有 decode 请求的这一步(TPOT 抖动),分开又浪费资源——Chunked Prefill 与 PD 分离都是对这个矛盾的处理。
-
传统推理服务的“一次前向、一次返回”模型隐含了哪三个假设?LLM 各打破了哪个?
答案
输入形状与计算量已知(LLM 的输出长度未知);请求无状态(LLM 有随步增长的 KV);请求之间独立且同质(LLM 的 prefill 与 decode 是两种 workload 且要动态组 batch)。
-
把 batch 从 8 加到 64,吞吐、单请求延迟、尾延迟、KV 压力各怎么变?为什么“最大 batch”不是目标?
答案
吞吐上升(decode memory-bound,读一次权重服务更多 token);每步时间略增所以 TPOT 变长;长请求拖住短请求、尾延迟恶化;KV 占用线性增长可能触发抢占。目标是在 TTFT / TPOT 的 SLO 下选 batch,超过 SLO 的吞吐不算有效工作(Goodput)。
-
后续各篇围绕的四个核心问题是什么?各对应 vLLM 的哪个模块?
答案
这一轮谁执行、执行多少(Scheduler);状态放哪、怎么复用(KVCacheManager / BlockPool);怎么算得更快(ModelRunner / Attention Backend / Kernel);怎么扩出去(Executor / Worker / 集合通信)。加模型与硬件两个横切约束。
下一篇
-
因为服务对象变了:传统 DL 推理是一次前向、一次返回——输入形状已知、计算量确定、无状态、请求之间互不相干;LLM 推理是一个持续进行的自回归过程——输入长度、输出长度、服务时长在到达时都未知,Prefill 产生的 KV Cache 要跨几百个 Decode step 保留并逐步增长。系统必须每一步重新决定「这一轮谁跑、跑多少」。详见第二章、第三章。 ↩
-
三个变化。从静态计算到动态执行——每一步的 batch 组成都在变;从无状态到带状态——KV Cache 是唯一随时间增长的状态,Llama-3-70B 每 token 320 KB、一个 2K prompt + 300 输出的请求约 734 MB,它决定并发上限、上下文上限与抢占时机;从单一 workload 到 Prefill / Decode 混合——Prefill compute-bound、Decode memory-bound,两者竞争同一组 GPU。目标也变了:不是最大 batch,而是在 SLO 约束下选执行规模。这三个变化直接生出后面每一篇的机制:Continuous Batching、Chunked Prefill、PagedAttention、Prefix Cache、抢占、PD 分离。详见第三至五章。 ↩
系列:大模型推理系统揭秘:从 vLLM 看 LLM Serving Infra 核心技术
- 为什么 LLM Serving 比传统 DL 推理难?
- 如何衡量一个 LLM Serving 系统?
- 鸟瞰 vLLM:一个请求如何穿过整个推理系统?
- Scheduler:GPU 这一轮到底给谁用?
- KV Cache:LLM Serving 的第一号内存问题
- GPU 执行:如何让每个 Token 算得更快?
- 解码的扩展:采样、投机解码与结构化输出
- Multi-GPU:一张卡不够时如何扩展?
- 模型适配:如何跟上变化极快的模型世界?
- 请求形态的扩展:multi-LoRA 与多模态
- 硬件解耦:如何不让芯片差异污染 Serving 核心?
- PD 分离:从资源混部走向计算解耦
- Serving Infra 的下一站:从模型执行器到分布式智能操作系统
- 回到源码:一次请求在 vLLM 内部的真实旅程
- 系列总结与通关自测
本文由 arganzheng 创作,采用 CC BY 4.0 许可协议。在保留原文作者、署名以及完整原文链接(https://arganzheng.life/why-llm-serving-is-hard.html)的前提下,欢迎各种形式的转载、翻译或商业引用。
COMMENTS
评论存放在 GitHub Discussions, 用 GitHub 账号登录即可发表,支持 Markdown。 想针对正文某句话说?选中那段文字,点浮出的「评论」即可划线评论;觉得哪里写错了,发表时勾上「同时提交 Issue」。 有人回复你时 GitHub 会按你的通知设置发邮件,不用守在这里。