6.2 自回归推理的 KV Cache 困境:LLM 推理为什么慢 一个 7B 模型生成 1000 个 token,要跑 1000 次前向传播;每一次前向都要重新算一遍所有历史 token 的注意力——除非把这些中间结果缓存下来。这就是 KV Cache,但缓存本身又成了新的瓶颈。 6.2.1 自回归:LLM 推理的天然低效 理解 LLM 推理为什么慢,首先要理解它的自回归(Autoregressive) 本质。LLM 生成文本是「逐 token 生成」:每生成一个 token,都要把它作为下一步的输入,再生成下一个。一个 1000 token 的回复要跑 1000 次前向传播。 对比传统 ML(如分类模型):一次前向就出结果。LLM 的「自回归」让它的单次请求成本是传统模型的几百到几千倍。
一个 7B 模型生成 1000 个 token,要跑 1000 次前向传播;每一次前向都要重新算一遍所有历史 token 的注意力——除非把这些中间结果缓存下来。这就是 KV Cache,但缓存本身又成了新的瓶颈。
理解 LLM 推理为什么慢,首先要理解它的自回归(Autoregressive) 本质。LLM 生成文本是「逐 token 生成」:每生成一个 token,都要把它作为下一步的输入,再生成下一个。一个 1000 token 的回复要跑 1000 次前向传播。
生成序列 "Hello world how are you": Step 1: 输入 [Hello] → 预测 "world" Step 2: 输入 [Hello, world] → 预测 "how" Step 3: 输入 [Hello, world, how] → 预测 "are" ...每次都要重新计算所有历史 token
对比传统 ML(如分类模型):一次前向就出结果。LLM 的「自回归」让它的单次请求成本是传统模型的几百到几千倍。
更糟糕的是,朴素的自回归实现有一个明显浪费——每次前向都要重新计算所有历史 token 的 Key/Value。如果生成第 1000 个 token 时还要重算前 999 个 token 的 K/V,这是巨大的算力浪费。
KV Cache(Key-Value Cache) 是解决「重算历史」的标准方案。回顾 Transformer 的注意力机制:
其中 Q、K、V 分别是查询、键、值矩阵。在自回归生成中,已经计算过的历史 token 的 K 和 V 不会变——无论后面生成什么,前面 token 的 K/V 都一样。所以可以把它们缓存下来,下一步只算新 token 的 K/V,追加到缓存。
无 KV Cache:每步重算所有 token 的 K/V → O(n²) 重复计算 有 KV Cache:缓存历史 K/V,每步只算新 token → 节省大量算力
| 维度 | 无 KV Cache | 有 KV Cache |
|---|---|---|
| 每步计算量 | 全部 token 的 K/V | 仅新 token 的 K/V |
| 内存 | 不存历史 | 存历史 K/V |
| 速度 | 极慢 | 快 |
💡 判读:KV Cache 是 LLM 推理的「时间换空间」——用显存存历史 K/V,换不用重算的算力。它是 LLM 推理优化最基础也最重要的机制,几乎所有推理引擎(vLLM、TGI、TensorRT-LLM)都默认启用。
但 KV Cache 带来一个新问题:它随序列长度线性增长,而且是大头。
一个 Transformer 层、一个 token 的 KV Cache 大小:
对于一个 70B 模型(80 层、hidden_dim 8192、FP16):
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 880 280" font-family="sans-serif" font-size="12"> <text x="440" y="22" text-anchor="middle" font-size="15" font-weight="bold">KV Cache 大小 vs 序列长度(70B 模型示意)</text> <!-- 坐标轴 --> <line x1="80" y1="240" x2="820" y2="240" stroke="#475569" stroke-width="1.5"/> <line x1="80" y1="240" x2="80" y2="50" stroke="#475569" stroke-width="1.5"/> <text x="40" y="145" text-anchor="middle" transform="rotate(-90, 40, 145)">KV Cache 大小 (GB)</text> <text x="450" y="270" text-anchor="middle">序列长度 (token)</text> <!-- 网格刻度 --> <g font-size="10" fill="#64748b"> <text x="80" y="258" text-anchor="middle">0</text> <text x="240" y="258" text-anchor="middle">1K</text> <text x="400" y="258" text-anchor="middle">2K</text> <text x="560" y="258" text-anchor="middle">4K</text> <text x="720" y="258" text-anchor="middle">8K</text> <text x="74" y="244" text-anchor="end">0</text> <text x="74" y="180" text-anchor="end">10</text> <text x="74" y="120" text-anchor="end">20</text> <text x="74" y="60" text-anchor="end">40</text> </g> <!-- 柱状图 --> <rect x="180" y="200" width="120" height="40" fill="#dbeafe" stroke="#2563eb"/> <text x="240" y="195" text-anchor="middle">2.5GB</text> <rect x="340" y="160" width="120" height="80" fill="#fef9c3" stroke="#ca8a04"/> <text x="400" y="155" text-anchor="middle">5GB</text> <rect x="500" y="120" width="120" height="120" fill="#fed7aa" stroke="#ea580c"/> <text x="560" y="115" text-anchor="middle">10GB</text> <rect x="660" y="60" width="120" height="180" fill="#fecaca" stroke="#dc2626"/> <text x="720" y="55" text-anchor="middle">20GB</text> <text x="440" y="225" text-anchor="middle" font-size="11" fill="#dc2626" font-style="italic">单次请求 KV Cache 可达 10-20GB,远超模型权重分摊</text> </svg>
KV Cache 的几个关键特点:
KV Cache 不仅总量大,它的增长模式还导致严重的显存碎片。
传统推理引擎(如 HuggingFace Transformers 的早期实现)为每个请求预分配最大序列长度的连续显存。例如,设定最大 2048 token,那么每个请求一开始就分配 2048 token 的 KV Cache 空间,无论实际生成多长。
这种预分配导致两类浪费:
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 880 240" font-family="sans-serif" font-size="12"> <text x="440" y="22" text-anchor="middle" font-size="15" font-weight="bold">传统 KV Cache 管理:预分配导致碎片</text> <!-- 显存块 --> <rect x="40" y="60" width="800" height="50" fill="#fee2e2" stroke="#dc2626"/> <text x="50" y="80" font-size="11" fill="#7f1d1d">请求 A:预分配 2048 token,实际用 100(深色),浪费 1948(浅色)</text> <rect x="40" y="65" width="40" height="40" fill="#dc2626"/> <rect x="80" y="65" width="760" height="40" fill="#fecaca"/> <rect x="40" y="120" width="800" height="50" fill="#fee2e2" stroke="#dc2626"/> <text x="50" y="140" font-size="11" fill="#7f1d1d">请求 B:预分配 2048,实际用 500(深色),浪费 1548</text> <rect x="40" y="125" width="200" height="40" fill="#dc2626"/> <rect x="240" y="125" width="600" height="40" fill="#fecaca"/> <rect x="40" y="180" width="800" height="50" fill="#fee2e2" stroke="#dc2626"/> <text x="50" y="200" font-size="11" fill="#7f1d1d">请求 C:预分配 2048,实际用 2000(深色),浪费 48</text> <rect x="40" y="185" width="780" height="40" fill="#dc2626"/> <rect x="820" y="185" width="20" height="40" fill="#fecaca"/> <text x="440" y="225" text-anchor="middle" font-size="11" fill="#dc2626" font-style="italic">实际利用率 60%-80%,浪费 20%-40% 显存</text> </svg>
研究表明,传统 KV Cache 管理下,显存实际利用率只有 60%-80%——20%-40% 被浪费在碎片上。这等于「花钱买了一张 A100,只用到 0.7 张」。
KV Cache 的总占用决定了「一个 batch 能装多少请求」。一张 A100 80GB,扣除模型权重(如 7B 模型占 14GB)后,留给 KV Cache 的空间是 66GB。如果每个请求平均 KV Cache 是 1GB,那最多只能 batch 66 个请求。
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 720 280" font-family="sans-serif" font-size="12"> <text x="360" y="22" text-anchor="middle" font-size="15" font-weight="bold">显存预算与最大 batch 的权衡</text> <!-- A100 80GB 显存分配 --> <rect x="40" y="60" width="640" height="60" rx="4" fill="#1e293b"/> <rect x="40" y="65" width="112" height="50" fill="#fce7f3"/> <text x="96" y="92" text-anchor="middle" font-size="10">模型权重 14GB</text> <rect x="152" y="65" width="32" height="50" fill="#fef9c3"/> <text x="168" y="92" text-anchor="middle" font-size="10">其他 4GB</text> <rect x="184" y="65" width="496" height="50" fill="#dcfce7"/> <text x="432" y="92" text-anchor="middle" font-size="11">KV Cache 预算 62GB</text> <text x="360" y="150" text-anchor="middle" font-size="11">↓ 每请求 KV Cache 大小决定最大 batch</text> <g font-size="11"> <rect x="40" y="170" width="600" height="30" rx="3" fill="#dbeafe" stroke="#2563eb"/> <text x="50" y="190">每请求 0.5GB → 最大 batch ≈ 124(高吞吐)</text> <rect x="40" y="205" width="300" height="30" rx="3" fill="#fed7aa" stroke="#ea580c"/> <text x="50" y="225">每请求 1GB → 最大 batch ≈ 62(中)</text> <rect x="40" y="240" width="60" height="30" rx="3" fill="#fecaca" stroke="#dc2626"/> <text x="100" y="260">每请求 5GB → 最大 batch ≈ 12(低吞吐,长序列场景)</text> </g> </svg>
这就是 LLM 推理吞吐的核心约束:最大 batch 受显存约束,而显存大头是 KV Cache。任何能压缩 KV Cache 或提升其利用率的优化,都能直接放大 batch,提升吞吐。
LLM 推理分为两个阶段,二者特性截然不同:
处理输入 prompt 的阶段。一次性处理整个 prompt(可能数千 token),计算所有 token 的 K/V 并填充 Cache。
逐个生成输出 token 的阶段。每步用上一步的输出,计算新 token 的 K/V 追加到 Cache。
| 阶段 | 特性 | 瓶颈 | 频次 |
|---|---|---|---|
| Prefill | 计算密集 | GPU 计算 | 一次 |
| Decode | 内存密集 | 显存带宽 | 多次(每 token 一次) |
💡 判读:Prefill 与 Decode 的不对称性是后续优化的关键线索。Continuous Batching 让多个请求的 Prefill 与 Decode 交错,提升整体 GPU 利用率(6.3 节);Chunked Prefill 把长 Prefill 切分,避免打断 Decode(7.1 节);PD 分离把 Prefill 与 Decode 放在不同节点(7.4 节)——这些都是基于「两阶段不对称」的洞察。
理解了 KV Cache 与两阶段,再看传统批处理为什么低效。
Static Batching(静态批处理) 是最朴素的批处理方式:把一批请求凑齐,一起送入模型,等所有请求都生成完毕才返回。
问题在于:一个 batch 内各请求的生成长度差异极大——请求 A 生成 50 token 就完了,请求 B 要生成 1000 token。Static Batching 必须等最慢的 B 完成才返回,期间 A 已经完成却要陪着 B 占着显存,GPU 在 A 完成后利用率也下降。
Static Batching: 时间步: 0 1 2 ... 50 ... 1000 请求 A: 生成 生成 生成 ... 完成 空等 空等 请求 B: 生成 生成 生成 ... 生成 ... 完成 ↑ ↑ 开始 全部返回
这种「等最慢的」让 GPU 在后半程大量空闲,吞吐低下。研究表明 Static Batching 的 GPU 利用率在 batch 后半程可能只有 10%-30%。
最后讲清一个核心工程权衡:吞吐(throughput)与延迟(latency)的博弈。
| 指标 | 含义 | 影响因素 |
|---|---|---|
| TTFT | 首 token 延迟 | Prefill 速度 |
| TPOT | 每输出 token 延迟 | Decode 速度 |
| 吞吐 | 每秒处理 token/请求数 | batch 大小、利用率 |
batch 越大,吞吐越高(GPU 满载),但单请求延迟也越高(资源被瓜分)。batch 越小,延迟越低,但吞吐也低。这是 LLM 推理的核心权衡,不同的业务场景有不同的偏好:
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 720 280" font-family="sans-serif" font-size="12"> <text x="360" y="22" text-anchor="middle" font-size="15" font-weight="bold">吞吐 vs 延迟的权衡</text> <line x1="80" y1="240" x2="680" y2="240" stroke="#475569" stroke-width="1.5"/> <line x1="80" y1="240" x2="80" y2="50" stroke="#475569" stroke-width="1.5"/> <!-- 吞吐曲线 --> <path d="M 80 220 Q 200 200 400 100 T 680 60" fill="none" stroke="#16a34a" stroke-width="3"/> <text x="650" y="80" font-size="11" fill="#16a34a">吞吐</text> <!-- 延迟曲线 --> <path d="M 80 220 Q 200 215 400 180 T 680 100" fill="none" stroke="#dc2626" stroke-width="3"/> <text x="650" y="120" font-size="11" fill="#dc2626">延迟</text> <text x="80" y="260" font-size="11" fill="#475569">小 batch</text> <text x="600" y="260" font-size="11" fill="#475569">大 batch</text> <text x="40" y="60" font-size="11" fill="#475569" transform="rotate(-90, 40, 60)">指标值</text> <text x="360" y="270" text-anchor="middle" font-size="11" fill="#475569">→ batch size</text> </svg>
💡 判读:连续批处理(Continuous Batching,6.3 节)与 PagedAttention 的关键价值,就是「在不损失延迟的前提下,把吞吐推到接近理论上限」——通过让请求动态进出 batch、KV Cache 高效复用,让 GPU 始终满载又不让单请求等太久。这是 vLLM 革命性的根本。
把本节诊断的 LLM 推理瓶颈汇总成一张表,这是 6.3-6.4 节 vLLM 与 PagedAttention 要逐个击破的目标:
| 瓶颈 | 表现 | 根因 |
|---|---|---|
| KV Cache 总占用大 | 单请求 GB 级,限制 batch | 序列长度 × 模型规模 |
| 显存碎片 | 利用率仅 60%-80% | 预分配最大长度 |
| Static Batching 低效 | GPU 后半程空闲 | 必须等最慢请求 |
| 两阶段不对称 | Prefill 阻塞 Decode | 计算密集 vs 内存密集 |
这四大瓶颈正是 LLM 推理「慢且贵」的根因。下一节的 PagedAttention 与 Continuous Batching,将分别击破「显存碎片」与「Static Batching 低效」两大瓶颈。
下一节《6.3 PagedAttention 分页显存原理》将讲清 vLLM 如何用 OS 虚拟内存思想革命性地解决这些瓶颈。