KV Cache 完全指南:从零理解到工程落地
https://zhuanlan.zhihu.com/p/2020990821923652325
适合人群:完全没接触过 AI 底层机制的新手、产品经理、运营同学、初级工程师。 目标:读完这篇,彻底搞懂 KV Cache 是什么、为什么存在、工程上怎么用。 更新时间:2026-03-26
目录
- 先用一个生活类比搞懂核心
- 大模型到底是怎么”说话”的?
- KV Cache 缓存的到底是什么?
- 没有 KV Cache 会怎样?
- 为什么不能用 Redis / 向量数据库替代?
- 缓存命中规则:什么情况能省钱,什么情况白费?
- 显存爆炸问题:工业级解法全景图
- 开发者最实用的省钱手册
- 可观测性:没有数据就没有优化
- 完整推理流程图
- 常见误区与 FAQ
- 参考资料
1. 先用一个生活类比搞懂核心
想象你是一位翻译官,正在逐字翻译一本厚厚的书。
没有 KV Cache 的情况(极蠢):
- 翻译第 1001 个词时,你把前 1000 个词全部重新读一遍,才能理解上下文,然后才翻译这一个词。
- 翻译第 1002 个词,再把前 1001 个词全部重新读一遍……
- 越往后越慢,慢到崩溃。
有 KV Cache 的情况(正确做法):
- 你在读完前 1000 个词之后,把关键信息记在一张便利贴上(放进记忆)。
- 翻译第 1001 个词时,直接查便利贴,不用重新读书。
- 翻译第 1002 个词,更新便利贴,继续往后。
KV Cache 就是这张”便利贴”。
只不过在大模型里,这张便利贴存的不是文字,而是高维度的数学矩阵(浮点数数组),放在 GPU 的高速显存(VRAM)里。
2. 大模型到底是怎么”说话”的?
2.1 Token 是什么?
大模型不认识”字”,它认识”Token(词元)”。
简单理解:Token 是模型对文字的最小处理单元。
"我爱吃苹果" → ["我", "爱", "吃", "苹", "果"](5个Token,仅示例)
"Hello world" → ["Hello", " world"](2个Token)
通常 1 个汉字 ≈ 1-2 个 Token,1 个英文单词 ≈ 1-2 个 Token。
2.2 自回归生成(为什么逐字输出?)
大模型回答你的方式是一个 Token 一个 Token 地蹦出来,这叫自回归(Auto-regressive)。
你问:"天空是什么颜色?"
模型输出过程:
第1步 → 生成"天"
第2步 → 看完"天",生成"空"
第3步 → 看完"天空",生成"是"
第4步 → 看完"天空是",生成"蓝"
第5步 → 看完"天空是蓝",生成"色"
第6步 → 看完"天空是蓝色",生成"。"
……
每生成一个新 Token,都必须回头看一遍所有历史 Token,这是 Transformer 架构的核心机制——注意力(Attention)。
2.3 注意力机制(Q、K、V 是什么?)
这是理解 KV Cache 的关键,很多文章讲得太抽象。我们用具体类比:
想象你在图书馆查资料:
| 符号 | 全称 | 通俗含义 | 图书馆类比 |
|---|---|---|---|
| Q | Query(查询) | 当前词在”问什么问题” | 你手里的检索关键词 |
| K | Key(键) | 历史词”有什么标签” | 书架上每本书的索引标签 |
| V | Value(值) | 找到后”拿走什么内容” | 书里面的实际内容 |
计算流程(完整5步):
假设模型正在生成第 1001 个 Token:
步骤1:把第1001个Token转成向量,计算它的 Q("我现在要找什么?")
步骤2:取出历史1000个Token各自的 K(特征索引)
→ K₁, K₂, K₃, ..., K₁₀₀₀
步骤3:用 Q 和每个 K 做点积,得到相关分数
→ score₁ = Q · K₁
→ score₂ = Q · K₂
→ ...(1000次点积计算)
步骤4:用相关分数加权汇总历史 V(实际内容)
→ 输出 = score₁×V₁ + score₂×V₂ + ...
步骤5:输出结果决定了下一个Token是什么
KV Cache 缓存的正是步骤2和步骤4用到的 K 和 V 矩阵。
Q 不需要缓存,因为 Q 只描述”当前这一步找什么”,每次都是新的。
3. KV Cache 缓存的到底是什么?
3.1 不是文本,是矩阵
很多人误解 KV Cache 缓存的是”对话记录文字”或者”JSON 格式的上下文”。
错误理解:
{
"history": "你好,我想了解一下..."
}
正确理解:
每一层 Transformer 中,
每一个历史 Token 对应的 K 矩阵和 V 矩阵(float16 精度的高维浮点数数组)
以 GPT-3(175B参数)为例,这些矩阵相当庞大:
3.2 KV Cache 有多大?(内存公式)
单请求 KV Cache 体积 ≈ 2 × L × H_kv × D_head × T × bytes_per_element
字段解释:
| 字段 | 含义 | 典型值示例 |
|---|---|---|
| 2 | K 和 V 两份 | 固定×2 |
| L | 模型层数(Transformer layers) | GPT-4约96层 |
| H_kv | KV 头数 | 32~128(GQA后更少) |
| D_head | 每个头的维度 | 128 |
| T | 已处理的 Token 数量 | 越长越大 |
| bytes_per_element | 精度占用 | FP16=2字节,FP8=1字节 |
举一个具体例子(假设中等规模模型):
- L=32,H_kv=32,D_head=128,T=4096 tokens,FP16
- KV Cache ≈ 2 × 32 × 32 × 128 × 4096 × 2 字节
- ≈ 2.1 GB(仅一个请求!)
核心结论:KV Cache 随 Token 长度线性增长。对话越长、并发越高,显存压力越大。
4. 没有 KV Cache 会怎样?
4.1 计算复杂度爆炸
| 场景 | 计算复杂度 | 实际效果 |
|---|---|---|
| 无缓存 | O(N²)——N 是 Token 总数 | 生成第1000个词时,重算1000个词的注意力 |
| 有缓存 | O(N)——每次仅算新 Token | 只需增量计算新 Token 的部分 |
当上下文达到 10000 个 Token 时:
- 无缓存:每生成一个新词需要做 10000² = 1亿次矩阵运算
- 有缓存:每生成一个新词只需做约 10000 次运算(复用历史 K/V)
4.2 真实瓶颈是”带宽”不是”算力”
这是很多人想不到的地方:
GPU 有时不是”算不动”,而是”喂不饱”——数据从显存传到计算核心的速度跟不上计算速度,这叫带宽瓶颈(Memory Bandwidth Bound)。
FlashAttention 论文的核心发现就是:注意力的实际慢点很多时候来自显存读写(IO),而不只是理论计算量 [5]。
5. 为什么不能用 Redis / 向量数据库替代?
这个问题非常常见,答案需要从三个维度理解。
5.1 访问模式根本不同
Redis 的访问模式:
GET user:1234:profile ← 精准查某一条数据,返回几KB
KV Cache 的访问模式:
每生成一个 Token,把全部历史 K/V 矩阵(可能几个GB)从头扫一遍
→ 这不叫"查数据",叫"高频全量批量读取"
两种访问模式的差异,就好比:
- Redis ≈ 在图书馆用检索系统找某一本书(精准快速)
- KV Cache ≈ 每秒钟把整个图书馆的书扫描一遍(全量高速)
5.2 物理带宽差了三个数量级
GPU HBM 显存带宽: ~3,350 GB/s(3.35 TB/s) ← KV Cache 必须住这里
PCIe 总线(CPU内存): ~128 GB/s ← 慢26倍
Redis(万兆网络): ~1.2 GB/s ← 慢2800倍
数据来源:NVIDIA H100 官方规格页 [14]
具体影响: 假设一个请求的 KV Cache 是 2GB,每生成一个词需要读取一遍:
- 存在 GPU 显存:2000 GB ÷ 3350 GB/s ≈ 0.6ms(几乎无感)
- 存在 Redis:2000 GB ÷ 1.2 GB/s ≈ 1666 秒(27分钟!!)
结论:跨网络存 KV Cache,根本不可行。
5.3 语义层面也不是一类东西
| 对比项 | 向量数据库(RAG用) | KV Cache |
|---|---|---|
| 存储内容 | 文档的语义向量(embedding) | 当前推理过程中的注意力中间状态 |
| 用途 | 知识检索(”找到相关文档”) | 推理加速(”复用已算结果”) |
| 生命周期 | 长期持久化 | 一次推理会话内 |
| 跨模型复用 | 可以 | 不行(绑定特定模型权重) |
二者可以协同(RAG 负责知识召回 + KV Cache 负责推理加速),但不能互相替代。
6. 缓存命中规则:什么情况能省钱,什么情况白费?
6.1 铁律:前缀必须完全精确匹配
这是所有厂商的共同规则,理解这一点能避免大量踩坑。
缓存的是 Token 序列的哈希值(从第一个Token开始)
↓
只要有一个Token变化,从该点往后,所有缓存全部失效
可视化理解:
请求 A:[系统规则 A][工具定义 A][用户问题:你好]
请求 B:[系统规则 A][工具定义 A][用户问题:天气怎样]
↑前缀相同↑ ↑这里不同↑
→ 前缀部分(系统规则+工具定义)命中缓存 ✓
→ 用户问题不同,从此往后重新计算 ✗
请求 C:[系统规则 A(多了一个空格)][工具定义 A][用户问题:你好]
↑这里不同↑
→ 第一个Token就不匹配,完全缓存未命中 ✗
6.2 各厂商的 TTL(缓存存活时间)
| 厂商 | 默认 TTL | 可选 TTL | 备注 |
|---|---|---|---|
| OpenAI | 5-10分钟 | 最长1小时,付费支持24h [6] | 自动缓存,无需手动触发 |
| Anthropic | 5分钟 | 可选1小时(额外计费)[9] | 需要显式标记缓存断点 |
| AWS Bedrock | 5分钟 | 部分模型支持1小时 [11] | 部分模型支持 |
| Google Gemini | 可配置 | 支持显式设置 TTL [10] | 支持隐式+显式两种模式 |
6.3 缓存的隔离范围
重要:不是所有人都共享同一个缓存!
- OpenAI:按组织(Organization)隔离,不同 org 之间不共享 [7]
- Anthropic:同样组织隔离 [8]
- 自建 vLLM:可以做跨请求共享(同一个 API 服务内)
通俗理解:缓存是”同一个公司/团队内复用”,而不是全球用户共享一个池子。
6.4 最容易导致缓存失效的坑
❌ 在前缀里加了时间戳(每次都不同!)
system: "你是一个助手,当前时间是 2026-03-26 14:32:11"
↑每分钟都变,永远miss
❌ JSON 字段顺序不稳定(序列化时key顺序变了)
{"name":"张三","age":18} vs {"age":18,"name":"张三"}
→ 虽然语义相同,Token序列不同,缓存失效
❌ 工具参数定义微调(改了一个默认值)
"temperature": 0.7 → "temperature": 0.8
→ 前缀变了,缓存失效
❌ 图片顺序变化(Anthropic 明确说明会触发失效)
7. 显存爆炸问题:工业级解法全景图
随着对话变长、并发增高,KV Cache 会撑爆 GPU 显存。工业界有一套分层应对方案。
L0 层:先吃平台缓存红利(零改造,最便宜)
直接用厂商提供的 Prompt Caching 功能,不需要改模型,只需要调整 Prompt 结构。
适用场景:调用 OpenAI / Anthropic / Gemini / Bedrock API 的开发者。
L1 层:分页内存管理(PagedAttention)
问题背景:
传统方式为每个请求预分配一大块连续显存来存 KV Cache,导致大量碎片浪费。
传统方式(内存碎片严重):
[请求A占用1000格] [空闲300格碎片] [请求B占用800格] [空闲200碎片]...
→ 虽然总空闲500格,但没有连续空间,无法容纳需要400格的新请求
PagedAttention 的解法:
模仿操作系统的虚拟内存,把 KV Cache 切分为固定大小的”块(Block)”,动态映射,彻底消灭碎片。
PagedAttention(无碎片):
[Block1:A][Block2:B][Block3:A][Block4:空][Block5:B][Block6:空]...
→ 新请求可以随意使用任何空块,不需要连续空间
效果:vLLM 论文报告,采用 PagedAttention 后吞吐可提升 2-4倍 [4]。
还支持LRU(最近最少使用)淘汰策略:显存不够时,优先踢出最久没被访问的缓存块,给新请求腾空间。
L2 层:分级卸载(Offloading)
当 GPU 显存实在不够用时,把冷数据搬到更便宜但更慢的存储层:
┌─────────────────────────────────────────┐
│ L1 GPU 显存(HBM):3350 GB/s │ ← 最快,最贵,最小
├─────────────────────────────────────────┤
│ L2 CPU 内存(DRAM):~50-100 GB/s │ ← 慢约30倍,但容量大很多
├─────────────────────────────────────────┤
│ L3 NVMe SSD:~7 GB/s │ ← 更慢,但容量极大
└─────────────────────────────────────────┘
TensorRT-LLM 官方支持 GPU→CPU Offloading [12][15]:
- 当某个请求暂时不活跃,把它的 KV Cache 从 GPU 挪到 CPU
- 下次命中时再搬回 GPU
- 代价是延迟上升,但比完全重算要快
注意:Offloading 是”省显存,换延迟”的取舍,实时性要求高的场景要慎用。
L3 层:从模型结构上缩小 KV
这是更底层的优化,通常在模型训练阶段就决定了。
三种注意力头设计对比:
MHA(多头注意力,原始设计):
每个 Q 头都有独立的 K/V 头
[Q1 K1 V1][Q2 K2 V2][Q3 K3 V3][Q4 K4 V4]
→ KV最大,质量最好
MQA(多查询注意力):
所有 Q 头共享同一对 K/V
[Q1][Q2][Q3][Q4] → 共用 [K V]
→ KV最小(节省显存最多),但质量有一定损失 [2]
GQA(分组查询注意力,推荐):
Q 头分组,每组共享一对 K/V
[Q1 Q2 → K1 V1][Q3 Q4 → K2 V2]
→ 在速度与质量之间折中,Llama 3 等主流模型都采用此方案 [3]
KV 量化(进一步压缩):
| 精度 | 每个元素占用 | 相对 FP16 体积 |
|---|---|---|
| FP16 | 2 字节 | 100%(基准) |
| FP8 | 1 字节 | 50% |
| INT4 | 0.5 字节 | 25% |
| 2-bit(KIVI论文) | 0.25 字节 | 12.5% [17] |
量化越激进,显存越省,但可能影响生成质量,需要实测验证。
8. 开发者最实用的省钱手册
调用 AI API 时,遵循这些原则可以大幅降低 Token 成本。
8.1 Prompt 结构:千层饼原则
核心思路:把稳定内容放前面,把变化内容放最后。
┌──────────────────────────────────────────────────────┐
│ 第一层(绝对冻结区):永远不变的内容 │
│ - 系统角色定义 │
│ - 业务规则和约束 │
│ - 工具/函数定义 │
│ - 固定知识库(产品文档、API文档、代码规范) │
│ - 示例对话(few-shot examples) │
│ ↑ 这部分 Token 一旦写好就冻结,永远不改一个字符 │
├──────────────────────────────────────────────────────┤
│ 第二层(半稳定区):本次会话的背景 │
│ - 用户基本信息 │
│ - 本次任务的背景说明 │
├──────────────────────────────────────────────────────┤
│ 第三层(动态区):每次都变的内容 │
│ - 用户的实时问题 │
│ - 当前报错日志 │
│ - 实时数据(价格、库存、时间) │
│ ↑ 这部分放最末尾,变化只影响后缀,不影响前缀缓存 │
└──────────────────────────────────────────────────────┘
反例(浪费钱):
# ❌ 错误:时间戳放在前面,每次都 cache miss
prompt = f"""
当前时间:{datetime.now()} ← 每秒都变!
你是一个专业的客服助手...(长长的系统规则)
用户问题:{user_question}
"""
正例(省钱):
# ✅ 正确:稳定内容在前,时间戳放最后
SYSTEM_PREFIX = """
你是一个专业的客服助手。
[长达2000字的公司规则和产品文档...]
""" # ← 这部分做成常量,永远不变
def build_prompt(user_question: str) -> str:
return f"""{SYSTEM_PREFIX}
---
用户问题:{user_question}
当前时间:{datetime.now()}
""" # ← 动态部分放最后
8.2 会话管理:Commit & Clear 策略
问题: 对话进行几十轮后,上下文越来越长,问题越来越多:
- KV Cache 体积膨胀,显存压力大
- 模型注意力稀释,回答质量下降(”幻觉”增加)
- 大量已无用的历史 Token 拖着跑,白白消耗费用
解决方案:总结 + 重开
┌─────────────────────────────────────────────────────────┐
│ 触发条件(满足任一): │
│ • 对话超过 N 轮(如50轮) │
│ • Token 数超过阈值(如30000 tokens) │
│ • 回答质量明显下降 │
│ • 任务阶段切换(从调研阶段切到实现阶段) │
└─────────────────────────────────────────────────────────┘
↓ 触发后执行
┌─────────────────────────────────────────────────────────┐
│ Step 1:让模型输出结构化状态摘要 │
│ Prompt: "请把我们这段对话的关键决策、已完成事项、 │
│ 待办事项、重要约束,整理成 Markdown 格式的摘要"│
└─────────────────────────────────────────────────────────┘
↓
┌─────────────────────────────────────────────────────────┐
│ Step 2:关闭当前会话 │
│ Step 3:开启新会话,以摘要作为第一条系统消息 │
│ │
│ 新会话前缀 = 系统规则 + 上一轮的状态摘要 │
└─────────────────────────────────────────────────────────┘
8.3 前缀稳定性保障(工程实践)
import hashlib
import json
# 把固定前缀模板化,生成后计算哈希
def build_system_prefix(config: dict) -> str:
# 确保key顺序稳定
return json.dumps(config, sort_keys=True, ensure_ascii=False)
def verify_prefix_stable(prefix: str, last_hash: str) -> bool:
current_hash = hashlib.md5(prefix.encode()).hexdigest()
if current_hash != last_hash:
# 前缀变了!警报!可能导致缓存全部失效
alert(f"前缀哈希变化: {last_hash} → {current_hash}")
return False
return True
8.4 Anthropic 手动标记缓存断点(cache_control)
不同于 OpenAI 的全自动缓存,Anthropic 需要开发者显式告知缓存边界,否则不会触发 Prompt Caching。
import anthropic
client = anthropic.Anthropic()
response = client.messages.create(
model="claude-opus-4-6",
max_tokens=1024,
system=[
{
"type": "text",
"text": "你是一个资深Java后端工程师,专注Spring Boot开发。"
},
{
"type": "text",
# 把长文档/知识库放这里,可能几千Token
"text": "[这里是你的API接口文档、DDL表结构、业务规则等固定内容]",
"cache_control": {"type": "ephemeral"} # ← 在此处打标记,告诉 Anthropic 缓存到这里
}
],
messages=[
{
"role": "user",
"content": "帮我看看这段代码有什么问题..." # ← 动态部分,每次不同
}
]
)
# 查看缓存效果
print(f"写入缓存: {response.usage.cache_creation_input_tokens}") # 首次
print(f"命中缓存: {response.usage.cache_read_input_tokens}") # 后续
注意:cache_control 标记最多可以设置 4 个断点。建议在系统规则末尾、长文档末尾各设一个。
8.5 选择合适的 TTL
不是 TTL 越长越好,要根据场景算账:
命中率收益计算:
节省成本 = 命中次数 × 每次节省的 Token × Token 单价
额外成本 = 写入缓存时额外费用(部分厂商有)
当节省成本 > 额外成本时,延长 TTL 才有意义
| 场景 | 推荐 TTL | 原因 |
|---|---|---|
| 高频短轮次对话(客服) | 5分钟够用 | 两次请求间隔短,5分钟内肯定命中 |
| 多步骤 Agent 任务 | 1小时 | 步骤间可能有较长等待 |
| 批量文档分析 | 1小时 | 同批次文档前缀完全相同 |
| 单次问答 | 不需要设置 | 不会重复访问 |
8.6 实战案例:Java 编程助手节省 85% Token 成本
背景: 你做了一个编程助手,固定系统提示包含:
- Java 编码规范:约 2000 Token
- 项目数据库 DDL:约 3000 Token
- 常用 API 接口文档:约 5000 Token
- 固定前缀共计:约 10000 Token
优化前(每次请求都传完整内容,未使用缓存):
每次 API 调用 = 10000(系统提示) + 550(对话历史 + 用户问题)
= 10550 Token 输入 × 1x 单价
1000次/天 → 10,550,000 Token 输入成本
优化后(启用 Anthropic Prompt Caching,前缀命中率 ≈100%):
第1次请求(写入缓存):
10000 Token × 1.25x(写入价)+ 550 Token × 1x = 13050 等效Token
第2~1000次请求(命中缓存):
10000 Token × 0.1x(读取价)+ 550 Token × 1x = 1550 等效Token/次
1000次/天总成本:
13050 + 999 × 1550 = 1,561,550 等效Token
节省比例:(10,550,000 - 1,561,550) / 10,550,000 ≈ 85%
前提:
- 10000 Token 的系统提示保持完全一致(不改动任何字符)
- 用
cache_control正确标记断点(见 8.4 节) - 请求间隔在 TTL 内(5分钟或1小时)
9. 可观测性:没有数据就没有优化
没有监控数据,所有”优化”都是玄学。
9.1 各厂商的可观测字段
OpenAI:
{
"usage": {
"prompt_tokens": 1500,
"completion_tokens": 200,
"prompt_tokens_details": {
"cached_tokens": 1200, ← 命中缓存的 Token 数
"audio_tokens": 0
},
"total_tokens": 1700
}
}
命中率 = cached_tokens / prompt_tokens = 1200⁄1500 = 80% ← 非常好
Anthropic:
{
"usage": {
"input_tokens": 300,
"cache_creation_input_tokens": 1200, ← 写入缓存的 Token(第一次,有额外费用)
"cache_read_input_tokens": 1200, ← 命中缓存读取的 Token(打折计费)
"output_tokens": 200
}
}
Anthropic 三档定价(需要重点理解):
| Token 类型 | 计费倍率 | 说明 |
|---|---|---|
| 普通输入 Token | 1x(基准价) | 未命中缓存的 Token |
| 写入缓存 | 1.25x(略贵) | 首次请求时写入缓存,有存储成本 |
| 读取缓存 | 0.1x(便宜90%!) | 后续命中缓存时,仅需1/10费用 |
结论:写入缓存虽然略贵,但只要后续命中次数 ≥ 2 次,就开始盈利。命中越多,省得越多。
Google Gemini:
{
"usageMetadata": {
"promptTokenCount": 1500,
"cachedContentTokenCount": 1200, ← 缓存命中的 Token
"candidatesTokenCount": 200
}
}
9.2 建议监控的指标
1. 缓存命中率 = cached_tokens / total_input_tokens
目标:核心业务场景 > 70%
2. 首字延迟(TTFT, Time To First Token)
命中缓存时应明显低于 miss 时
3. 每次请求实际计费 Token 数
命中率上升后,此数字应下降
4. 前缀哈希变化频率
频繁变化说明前缀稳定性有问题
10. 完整推理流程图
10.1 单次请求推理流程
flowchart TD
A[收到用户请求] --> B[分词 Tokenize<br/>文本 → Token序列]
B --> C{计算前缀哈希<br/>前缀是否命中缓存?}
C -- "命中 Cache Hit" --> D[直接复用历史 K/V 矩阵<br/>跳过 Prefill 计算]
C -- "未命中 Cache Miss" --> E[Prefill 阶段<br/>从头计算所有 Token 的 K/V<br/>写入缓存池]
D --> F[进入 Decode 循环]
E --> F
F --> G[当前 Q 与所有历史 K/V 做注意力计算]
G --> H[预测并生成下一个 Token]
H --> I[把新 Token 的 K/V 追加进缓存]
I --> J{是否达到停止条件?<br/>遇到结束符 / 超过最大长度}
J -- "否,继续生成" --> G
J -- "是,生成完毕" --> K[返回完整输出 + usage 统计]
10.2 工程降级决策流
flowchart TD
A[显存/成本压力上升] --> B{检查缓存命中率<br/>是否 > 60%?}
B -- "是,命中率高" --> C[优化 TTL 策略<br/>扩大缓存时间窗口]
B -- "否,命中率低" --> D[排查前缀稳定性问题<br/>重构 Prompt 结构]
C --> E{KV Cache 总量<br/>仍超显存预算?}
D --> E
E -- "否,在控制范围内" --> F[保持策略,持续监控]
E -- "是,超出预算" --> G[启用 PagedAttention<br/>分页 + LRU 淘汰]
G --> H{开启后仍超预算?}
H -- "否" --> F
H -- "是" --> I[启用 CPU Offloading<br/>GPU → CPU 内存分级]
I --> J{延迟是否超出 SLA?}
J -- "否,延迟可接受" --> F
J -- "是,延迟不可接受" --> K[模型侧降级<br/>GQA / MQA / KV量化<br/>或缩短最大上下文]
K --> F
11. 常见误区与 FAQ
Q1:KV Cache 会影响模型回答的内容吗?
不会。 KV Cache 只是复用了已经计算好的数学中间状态,不改变模型的权重和推理逻辑。OpenAI 官方文档也明确说明缓存不会影响输出一致性 [6]。
Q2:缓存命中率为什么时好时坏?
最常见原因是前缀不稳定,逐项排查:
□ 系统时间是否混进了前缀?(每秒都变,永远miss)
□ 随机ID(trace_id、session_id)是否在前缀里?
□ 多轮对话中,历史消息格式是否每次完全一致?
□ Anthropic 用户:是否遗漏了 cache_control 标记?
□ Tool 定义是否有动态变化(工具列表随权限不同而变)?
□ 前缀 Token 数是否低于最低阈值?(OpenAI 要求 ≥ 1024 Token 才自动缓存)
□ JSON 序列化时 key 顺序是否稳定?
□ 图片内容或顺序有变化?(多模态场景会触发失效)
排查方法:把每次发出的前缀打印出来,手动 diff,找出哪里在变。也可以用 8.3 节的哈希校验脚本自动检测。
Q3:把上下文拉到最长是不是最好?
不是。 上下文越长:
- KV Cache 体积越大,显存成本越高
- 模型注意力稀释,质量可能下降
- 每次请求计费 Token 数越多
正确做法:用”Commit & Clear”策略,定期总结 + 重开,保持上下文干净精简。
Q4:Prefix Caching 能解决所有性能问题吗?
不能。 Prefix Caching 主要加速 Prefill 阶段(处理输入 Token),对 Decode 阶段(逐字生成输出)帮助有限 [18]。
| 场景 | Prefix Caching 收益 |
|---|---|
| 长输入 + 短输出(如文档摘要、代码审查) | 收益大 |
| 短输入 + 长输出(如写作、故事生成) | 收益有限 |
| 长输入 + 长输出 | 前半段有收益 |
Q5:KV Cache 是”跨用户共享”的吗?
部分共享,有隔离。
- 在 OpenAI / Anthropic 的 API 中,缓存按组织(Org)隔离,不同公司之间不共享
- 在同一个组织内,多个相同前缀的请求可以共享缓存
- 在自建 vLLM 服务中,同一 API 服务内的请求可以跨请求共享前缀
Q6:我是普通开发者,最需要记住什么?
三件事:
- 把不变的内容放 Prompt 最前面,会变的内容放最后
- 绝对不要在前缀部分放时间戳、随机数等动态变量
- 对话太长时,总结 + 开新会话,不要让无用历史越积越多
速查表:一张表记住全部核心要点
| 问题 | 答案(30字以内) |
|---|---|
| KV Cache 缓存什么? | 历史 Token 的 K 和 V 数值矩阵,不是文本 |
| 为什么要缓存? | 自回归生成每步都要看历史,不缓存就要重复计算 |
| 为什么必须放 GPU 显存? | 带宽需求 TB/s 级,Redis/CPU 差几个数量级跟不上 |
| 命中条件是什么? | 从第一个 Token 开始精确匹配,一个字符都不能差 |
| 怎么让命中率高? | 固定前缀永不改变,动态内容全部放最后 |
| Anthropic 如何开启缓存? | 在固定内容末尾加 cache_control: ephemeral 标记 |
| TTL 选多长? | 高频请求选 5 分钟;Agent 长链路选 1 小时 |
| 会话太长怎么办? | 定期”总结提交 + 重开会话”,保持上下文干净 |
| 显存不够怎么办? | 分页管理(PagedAttention)→ Offloading → GQA/量化 |
| 怎么知道有没有命中? | 查 API 返回的 usage 字段(cached_tokens 等) |
| 命中后能省多少钱? | Anthropic 读取缓存仅需 0.1x,最多可省约 85% |
12. 参考资料
- Attention Is All You Need (Transformer 原论文) https://arxiv.org/abs/1706.03762
- Fast Transformer Decoding: One Write-Head is All You Need (MQA) https://arxiv.org/abs/1911.02150
- GQA: Training Generalized Multi-Query Transformer Models from Multi-Head Checkpoints https://arxiv.org/abs/2305.13245
- Efficient Memory Management for LLM Serving with PagedAttention (vLLM) https://arxiv.org/abs/2309.06180
- FlashAttention: Fast and Memory-Efficient Exact Attention with IO-Awareness https://arxiv.org/abs/2205.14135
- OpenAI Prompt Caching 开发者文档 https://platform.openai.com/docs/guides/prompt-caching
- OpenAI Prompt Caching in the API(产品说明) https://openai.com/index/api-prompt-caching/
- Anthropic Prompt Caching 文档 https://docs.anthropic.com/en/docs/build-with-claude/prompt-caching
- Anthropic Pricing(含 cache read/write 计费口径) https://docs.anthropic.com/en/docs/about-claude/pricing
- Google Gemini Context Caching 文档 https://ai.google.dev/gemini-api/docs/caching/
- AWS Bedrock Prompt Caching 文档 https://docs.aws.amazon.com/bedrock/latest/userguide/prompt-caching.html
- NVIDIA TensorRT-LLM KV Cache System https://nvidia.github.io/TensorRT-LLM/features/kvcache.html
- vLLM Automatic Prefix Caching(实现细节) https://docs.vllm.ai/en/stable/design/prefix_caching/
- NVIDIA H100 官方规格页(HBM/PCIe 带宽数据来源) https://www.nvidia.com/en-gb/data-center/h100/
- TensorRT-LLM KV Cache Offloading 示例说明 https://nvidia.github.io/TensorRT-LLM/examples/llm_kv_cache_offloading.html
- TensorRT-LLM Quantization(含 FP8 KV Cache) https://nvidia.github.io/TensorRT-LLM/1.2.0rc5/features/quantization.html
- KIVI: A Tuning-Free Asymmetric 2bit Quantization for KV Cache https://arxiv.org/abs/2402.02750
- vLLM Automatic Prefix Caching(收益边界说明) https://docs.vllm.ai/en/latest/features/automatic_prefix_caching/
一句话总结
KV Cache 是大模型推理的”便利贴”——把历史计算结果存起来,下一个词直接查,不用重算。 工程落地的核心是:前缀冻结稳定 + 动态内容后置 + 命中率持续监控 + 超出预算时分级降级。
摘要
在大语言模型(LLM)推理过程中,KV Cache(键值缓存)是决定吞吐量与延迟的核心机制之一。本文聚焦于 KV Cache 这一具体技术点,从 Transformer 注意力机制的基本原理出发,逐步深入到 KV Cache 的内存占用分析、工程实现细节,以及当前业界最重要的几种优化方案:PagedAttention、Multi-Query Attention(MQA)、Grouped-Query Attention(GQA)、以及 Prefix Caching。文章包含公式推导、内存计算、代码示例和实验数据,帮助读者真正理解”KV Cache 到底缓存了什么、为什么会成为瓶颈、以及工程师们如何系统性地解决它”。
1. 从注意力机制说起:KV Cache 的由来
要理解 KV Cache,必须先回到 Transformer 的自注意力机制。在自回归生成(autoregressive generation)过程中,模型每次只生成一个 token,然后将这个 token 拼接到已有序列后面,再生成下一个 token。这个过程看似简单,但隐藏着巨大的计算冗余。
标准的多头自注意力(Multi-Head Self-Attention)计算如下:
其中 、、 分别是查询(Query)、键(Key)和值(Value)矩阵, 是每个注意力头的维度。
在训练阶段,由于有 causal mask(因果掩码),整个序列的 、、 可以并行计算。但在推理的自回归生成阶段,当我们生成第 个 token 时:
- 新的 Query:只有当前第 个位置的查询向量
- Key 和 Value:需要用到位置 到 所有 token 对应的 和
关键问题在于:位置 到 的 、 向量在生成第 个 token 时就已经计算过了。如果不做任何缓存,每一步都要重新从头计算所有历史 token 的 和 ,这意味着生成第 个 token 的复杂度是 ,整个序列生成的总复杂度是 ,在长序列下极其低效。
KV Cache 的思路非常直观:把已经计算过的 Key 和 Value 向量缓存起来,下一步直接拿来用。这样每步生成只需要:
- 计算当前 token 的 、、
- 把 、 追加到缓存
- 用 与缓存中所有 、 做注意力计算
每步的注意力计算复杂度从 的完整重计算降到了 (对于 的计算部分),极大地提升了推理效率。
2. KV Cache 到底缓存了什么?数学推导
很多工程师对 KV Cache 的理解停留在”缓存了 K 和 V”这个层面,但缓存的具体形状、来源和存储方式值得深入剖析。
2.1 单层、单头的 KV Cache
对于 Transformer 的第 层,第 个注意力头,输入 token 序列 ,对应的 Key 和 Value 通过线性投影得到:
其中 ,( 是注意力头数)。
第 层、第 头的 KV Cache,在处理完前 个 token 后,存储的是:
形状均为 。
2.2 整个模型的 KV Cache
设模型有 层、 个头、头维度为 ,当前序列长度为 ,则完整的 KV Cache 包含:
- 的缓存: 个矩阵,每个形状为
- 的缓存: 个矩阵,每个形状为
通常在实现中,会把 个头合并,所以每层的 KV Cache 形状为:
总参数量:
注意这里有一个关键洞察:KV Cache 的大小与序列长度 成线性关系,与模型宽度()成线性关系,与层数 成线性关系。这三个因素叠加,会让 KV Cache 在大模型、长序列场景下变得非常庞大。
2.3 为什么不缓存 Query?
这是一个值得思考的问题。理论上 Query 也可以缓存,但没有意义:
- 在生成第 步时,我们只需要当前 token 的 来做注意力查询
- 历史位置 的 Query 在当前步骤中不会再被用到(因果注意力只向前看)
- Query 不参与后续步骤的计算,缓存它只会浪费内存
Key 和 Value 则不同:每次生成新 token,当前的 都需要与所有历史 token 的 做注意力,所以缓存 、 是有实际收益的。
3. 内存占用:为什么 KV Cache 会成为瓶颈
3.1 量化计算
我们以 LLaMA-2 70B 为例,做一个具体的内存占用计算:
| 参数 | 值 |
|---|---|
| 层数 L | 80 |
| 注意力头数 H | 64 |
| KV 头数(GQA) | 8 |
| 头维度 d_k | 128 |
| 精度 | FP16(2 bytes) |
对于标准 MHA(Multi-Head Attention),序列长度为 时单条请求的 KV Cache 大小:
当序列长度为 4096 token 时:
而 LLaMA-2 70B 模型权重本身(FP16)约为 140 GB。这意味着:
- 单条长序列请求的 KV Cache 就可能占用几十 GB 显存
- 批量并发时(batch_size=32),KV Cache 可超过模型权重本身
3.2 KV Cache 的动态性问题
与模型权重不同,KV Cache 有几个让它格外难处理的特点:
动态增长:每生成一个 token,KV Cache 就增长一块。这意味着很难在请求开始前就为它分配好内存。
请求间差异巨大:不同请求可能生成 10 个 token,也可能生成 10000 个 token。如果按最大长度预分配,内存浪费严重;如果动态分配,又容易产生碎片。
批处理复杂性:在一个 batch 中,不同请求处于不同的生成阶段,KV Cache 长度各不相同,难以高效地打包成规则张量。
这些问题在 vLLM 论文中有详细分析,实验数据显示,在朴素实现下,KV Cache 内存碎片导致的浪费可以高达 60-80% 的可用显存。
3.3 推理阶段的两个瓶颈
LLM 推理通常分为两个阶段:
- Prefill(预填充)阶段:并行处理输入 prompt,一次性计算所有输入 token 的 KV,填充 KV Cache。这个阶段是计算密集型(compute-bound)。
- Decode(解码)阶段:逐 token 生成,每步只处理一个 token,但需要读取全部 KV Cache。这个阶段是内存带宽密集型(memory-bound)。
在 Decode 阶段,GPU 的算力(FLOPS)大部分是空闲的,瓶颈在于把 KV Cache 从显存搬到计算单元的带宽。这就是为什么优化 KV Cache 的内存布局和访问模式,比单纯提升算力更重要。
4. PagedAttention:像操作系统一样管理显存
4.1 核心思想
vLLM 于 2023 年提出的 PagedAttention 是 KV Cache 管理领域最重要的工程突破之一。它的核心灵感来自操作系统的虚拟内存分页(virtual memory paging)机制。
在传统操作系统中,物理内存被切分成固定大小的”页”(page),进程的虚拟地址空间通过页表(page table)映射到物理内存页。进程不需要占用连续的物理内存,内存分配和回收都以页为单位,极大减少了碎片。
PagedAttention 把同样的思想搬到 GPU 显存管理:
- 把 KV Cache 的显存切分成固定大小的 Block(块),每个块存储固定数量 token 的 KV
- 为每个请求维护一个 Block Table(块表),记录该请求的 KV Cache 分布在哪些物理块上
- 物理块不需要连续,通过块表索引即可找到对应数据
4.2 Block 结构详解
设每个 Block 存储 个 token(Block Size,通常取 16 或 32),那么一个物理块存储:
( 和 各一份,$L层, 个 token 位置, 个头, 维度)
在实际实现中,vLLM 把每层的 KV 分开存储,每个物理块的形状为:
4.3 Block Table 与地址翻译
每个请求维护一个逻辑块到物理块的映射表。假设某请求当前有 17 个 token,Block Size = 8,则需要 3 个逻辑块:
逻辑块 0 → 物理块 #7 (token 0-7)
逻辑块 1 → 物理块 #3 (token 8-15)
逻辑块 2 → 物理块 #12 (token 16, 还有7个空位)
这三个物理块可以散布在显存的任意位置,不需要连续。当做注意力计算时,通过块表把逻辑地址翻译成物理地址,再执行 gather 操作拼接出完整的 KV 序列。
4.4 Copy-on-Write:支持并行采样
PagedAttention 还巧妙地支持了并行采样(parallel sampling)和 Beam Search 的内存共享。
当一个请求需要并行生成 个候选输出时(如 n=5 的采样),这些候选序列共享同一个 prompt 的 KV Cache。传统做法是为每个候选复制一份完整的 KV Cache,内存放大 倍。
PagedAttention 的做法类似 Linux 的 Copy-on-Write(写时复制):
- 所有候选序列的块表指向相同的物理块(prompt 部分)
- 每个物理块维护一个引用计数
- 只有当某个候选序列需要写入新内容时,才为它分配新的物理块
- 引用计数为 1 时可以直接写入,无需复制
4.5 PagedAttention 的实测效果
根据 vLLM 论文的实验数据:
| 指标 | 朴素实现 | vLLM (PagedAttention) | 提升倍数 |
|---|---|---|---|
| 吞吐量(tokens/s) | 基准 | 2-4x | 2-4x |
| 内存浪费率 | 60-80% | < 4% | 15-20x |
| 并发请求数 | 受限 | 显著提升 | 视场景 |
| Beam Search 内存 | N倍 | 共享前缀 | ~N倍节省 |
内存浪费从 60-80% 降到 4% 以下,这是 PagedAttention 最核心的成果——它把显存利用率提升到了接近理论上限的水平。来源:vLLM Blog: Easy, Fast, and Cheap LLM Serving with PagedAttention
5. MQA 与 GQA:从源头减少 KV 头的数量
PagedAttention 解决了”如何高效管理 KV Cache”的工程问题,而 MQA 和 GQA 则从模型架构层面解决”如何从根本上减少需要缓存的数据量”。
5.1 Multi-Head Attention(MHA)的冗余性
标准 MHA 中,每个注意力头都有独立的 、、 投影矩阵。实验观察发现:不同注意力头的 Key 和 Value 矩阵之间存在大量冗余,尤其是 Key 和 Value,而 Query 的多样性则更为重要(不同头的 Query 关注不同的语义模式)。
这一观察催生了两种架构变体:
5.2 Multi-Query Attention(MQA)
MQA 由 Noam Shazeer 于 2019 年提出,思路极其简单粗暴:所有 Query 头共享同一组 Key 和 Value。
数学表达:
MQA:Qh=xWhQ, K=xWK, V=xWV(K, V 只有一组)
KV Cache 的变化:
- MHA:每层 KV Cache 大小 = 2 \times t \times H \times d_k
- MQA:每层 KV Cache 大小 = 2 \times t \times 1 \times d_k(节省 H 倍)
以 LLaMA-2 70B 为例(H=64),MQA 理论上可以将 KV Cache 缩小到 MHA 的 1/64。
但 MQA 的代价是模型质量的下降。多个研究表明,MQA 在需要细粒度注意力的任务(如长文档理解、复杂推理)上表现明显弱于 MHA。
5.3 Grouped-Query Attention(GQA)
GQA 是 MHA 和 MQA 的折中方案,由 Ainslie et al. 于 2023 年提出。它将 H 个 Query 头分成 G 组,每组共享一对 K/V:
\text{GQA}: Q_h = xW_Q^h,\ K_g = xW_K^g,\ V_g = xW_V^g
其中 g = \lceil h / (H/G) \rceil,即第 h 个 Query 头属于第 g 组,使用该组的 K_g, V_g。
MHA (H=8): Q1K1V1 Q2K2V2 Q3K3V3 Q4K4V4 Q5K5V5 Q6K6V6 Q7K7V7 Q8K8V8
GQA (G=2): Q1 Q2 Q3 Q4 | Q5 Q6 Q7 Q8
\ | / / \ | / /
K1,V1 (组1共享) K2,V2 (组2共享)
MQA (G=1): Q1 Q2 Q3 Q4 Q5 Q6 Q7 Q8
\ \ \ \ / / / /
K,V (全部共享)
KV Cache 大小(每层):
\text{GQA}: 2 \times t \times G \times d_k
当 G=H 时退化为 MHA,当 G=1 时退化为 MQA。
关键实验数据:
| 模型架构 | KV Cache 大小(相对MHA) | 推理速度(相对MHA) | 模型质量损失 |
|---|---|---|---|
| MHA | 1x | 1x(基准) | 无 |
| GQA(G=8) | 1/8x | ~1.5-2x | 几乎可忽略 |
| MQA | 1/H x | 最快 | 明显 |
GQA 在 LLaMA-2、Mistral、Gemma 等主流开源模型中被广泛采用,其中 LLaMA-2 70B 使用 H=64, G=8,即 KV 头数只有 8,相比 MHA 节省了 8 倍 KV Cache。
6. KV Cache 量化:用精度换空间
即使使用了 GQA 减少了 KV 头的数量,在超长上下文(如 128K tokens)和大批量推理场景下,KV Cache 依然庞大。量化是进一步压缩 KV Cache 的重要手段。
6.1 为什么 KV Cache 可以量化?
模型权重量化已经被研究得很透彻,但 KV Cache 量化有其特殊性:
- KV Cache 是激活值,不是权重:激活值的分布在每次推理时都不同,且通常比权重更难量化(分布更不规则)
- 量化误差会累积:第 t 步的量化误差会影响第 t+1 步的注意力计算,形成误差链
- Key 和 Value 的量化难度不同:实验发现 Key 的量化通常比 Value 更困难
6.2 KVQuant 与 Per-Channel 量化
朴素的逐 token 量化(per-token quantization)效果较差,原因是 KV Cache 中存在离群值(outliers)——少数通道的激活值远大于其他通道,导致量化范围被这些极端值主导,大多数值的精度损失严重。
更有效的方案是逐通道量化(per-channel quantization):
\hat{K}_{h,t,d} = \text{round}\left(\frac{K_{h,t,d} – z_{h,d}}{s_{h,d}}\right)
其中 s_{h,d} 和 z_{h,d} 是第 h 个头、第 d 个维度的缩放因子和零点,这样每个通道独立确定量化范围,不受其他通道离群值的干扰。
但逐通道量化需要在推理时存储大量的缩放因子,实际上 vLLM 和 SGLang 目前使用的是FP8(8位浮点)量化作为最佳实践.
不同量化精度的内存对比(以 LLaMA-2 70B,GQA G=8,序列长度 4096 为例):
| 精度 | 每个 KV 元素大小 | 总 KV Cache 大小 | 精度损失 |
|---|---|---|---|
| FP16 | 2 bytes | 基准(~8GB) | 无 |
| FP8 | 1 byte | ~4GB(0.5x) | 极小 |
| INT8 | 1 byte | ~4GB(0.5x) | 小 |
| INT4 | 0.5 byte | ~2GB(0.25x) | 中等 |
6.3 量化误差对注意力的影响
量化后的注意力分数计算:
\tilde{A}_{h,t} = \frac{\tilde{Q}_{h,t} \tilde{K}_h^T}{\sqrt{d_k}}
其中 \tilde{K}_h = \hat{K}_h \cdot s_{h}(反量化),误差为:
\Delta A = A – \tilde{A} = \frac{Q_h (\delta K_h)^T}{\sqrt{d_k}}
\delta K_h = K_h – \hat{K}_h \cdot s_h 是量化误差。
这个误差对 softmax 输出的影响大小,取决于注意力分数的尖锐程度:如果注意力分布本身就很集中(某个 token 的权重接近 1),量化误差对最终输出的影响会被放大;反之若分布均匀,误差会被平均掉。
7. 投机解码(Speculative Decoding):并行化自回归生成
前面讨论的技术都是在减少 KV Cache 的大小或提升内存效率。投机解码则从另一个维度切入:用小模型的并行预测来绕过大模型的串行瓶颈。
关于投机解码的详细介绍见文章:
投机采样(Speculative Decoding)深度解析:算法推导、系统实现与性能边界3 赞同 · 0 评论 文章
7.1 核心思想
自回归解码的性能瓶颈在于严格的串行性:必须生成第 t 个 token 后才能生成第 t+1 个。这导致 GPU 在每个解码步骤中只做一次前向传播,而这次传播的计算量(对于单个 token)远远没有填满 GPU 的计算能力——大部分时间 GPU 在等待内存带宽(读取权重矩阵)。
投机解码的想法:
用一个廉价的草稿模型(draft model)先快速生成 K 个候选 token,再用目标模型(target model)并行验证这 K 个 token,接受正确的,拒绝第一个错误的之后的所有 token。
7.2 数学推导:接受率与期望加速比
设草稿模型在位置 t 的输出分布为 q(x_t),目标模型的输出分布为 p(x_t)。
修正拒绝采样(Modified Rejection Sampling)的接受准则:
对于草稿模型采样的 token \hat{x}_t \sim q(\cdot):
\alpha_t = \min\left(1, \frac{p(\hat{x}_t)}{q(\hat{x}_t)}\right)
以概率 \alpha_t 接受该 token,以概率 1 – \alpha_t 拒绝并从修正分布中重新采样:
x_t \sim \text{norm}\left(\max(0, p(\cdot) – q(\cdot))\right)
这一采样方案在数学上保证了:最终生成的 token 序列的分布与目标模型独立解码完全一致,不存在任何质量损失。
期望接受的 token 数量:
设每步的平均接受率为 \alpha,草稿长度为 K,则期望接受的 token 数量为:
\mathbb{E}[\text{accepted}] = \frac{1 – \alpha^{K+1}}{1 – \alpha}
期望加速比(相对于逐个生成):
\text{Speedup} = \frac{\mathbb{E}[\text{accepted}]}{c \cdot K + 1}
其中 c 是草稿模型相对目标模型的相对计算开销(通常 c \ll 1)。
当 \alpha = 0.8, K = 5, c = 0.1 时:
\mathbb{E}[\text{accepted}] = \frac{1 – 0.8^6}{1 – 0.8} \approx 3.93
\text{Speedup} = \frac{3.93}{0.1 \times 5 + 1} \approx 2.6\times
7.4 草稿模型的选择
草稿模型的选择对投机解码的效果至关重要:
| 草稿模型类型 | 示例 | 接受率 | 额外开销 | 适用场景 |
|---|---|---|---|---|
| 小版本同系列模型 | LLaMA-70B + LLaMA-7B | 高(~0.8) | 中 | 通用 |
| N-gram 模型 | 从上下文构建 | 中(~0.6) | 极低 | 重复性文本 |
| 浅层草稿(Medusa) | 目标模型的额外头 | 中高 | 低 | 统一部署 |
| 独立小模型 | SpecTr 等 | 中 | 低 | 延迟敏感 |
Medusa 是一种特别值得关注的变体:它在目标模型的最后一层之上附加多个独立的”头”(Medusa heads),每个头预测未来第 i 步的 token,完全不需要独立的草稿模型。
目标模型最后一层隐藏状态 h_t
├── 原始 LM Head → x_{t+1}(概率分布)
├── Medusa Head 1 → x_{t+2}(候选)
├── Medusa Head 2 → x_{t+3}(候选)
└── Medusa Head K → x_{t+K}(候选)
7.5 投机解码与 KV Cache 的交互
投机解码在 KV Cache 管理上有一个微妙的问题:草稿 token 被拒绝时,需要回滚(rollback) KV Cache 到拒绝点之前的状态。
生成前:KV Cache = [token_0, ..., token_T]
草稿后:KV Cache = [token_0, ..., token_T, draft_0, draft_1, draft_2, draft_3, draft_4]
验证后:假设 draft_2 被拒绝
回滚后:KV Cache = [token_0, ..., token_T, draft_0, draft_1, new_token]
↑ 从修正分布采样
在 PagedAttention 框架下,这个回滚操作只需调整逻辑序列长度指针,不需要实际释放和重新分配物理块(因为物理块的内容已经是正确的前缀),实现非常高效。
8. 连续批处理
前面讨论的技术主要关注单个请求的效率,而连续批处理(也称为 iteration-level scheduling 或 in-flight batching)则关注如何在多请求并发场景下最大化 GPU 利用率。
8.1 静态批处理的问题
传统的静态批处理(Static Batching)方式是:将一批请求打包在一起,等所有请求都完成生成后,再处理下一批。
时间轴 →
Request A: [prompt] [gen1] [gen2] [gen3] [EOS] ← 较短
Request B: [prompt] [gen1] [gen2] [gen3] [gen4] [gen5] [gen6] [EOS] ← 较长
Request C: [prompt] [gen1] [gen2] [gen3] [gen4] [gen5] [EOS] ← 中等
静态批处理:
Batch 1: A+B+C 一起跑,等 B 跑完(最长的)才结束
A 结束后 GPU 为 A 分配的资源空闲,但无法释放给新请求
|AAAA_____|
|BBBBBBBB |
|CCCCCCC__|
↑ 等待最长请求
这导致两个问题:
- GPU 利用率低:短请求完成后,GPU 为其分配的计算资源(batch 中的一个槽位)空置,直到整批完成
- 延迟高:新请求必须等待当前批次全部完成才能进入
8.2 连续批处理的工作原理
连续批处理的核心思想是:在每个解码步骤(iteration)之后,动态地将已完成的请求移出批次,将等待队列中的新请求加入批次。
时间轴(每格 = 一个解码步骤)→
Step: 1 2 3 4 5 6 7 8
A: [gen][gen][gen][EOS]
B: [gen][gen][gen][gen][gen][gen][gen][EOS]
C: [gen][gen][gen][gen][gen][EOS]
D: [加入→][gen][gen][gen][gen][EOS] ← A 结束后立即加入
E: [加入→][gen][gen][EOS] ← 有空位就加入
连续批处理 Batch 状态:
Step 1: {A, B, C}
Step 2: {A, B, C}
Step 3: {A, B, C}
Step 4: {B, C, D} ← A 完成,D 进入
Step 5: {B, C, D}
Step 6: {B, D, E} ← C 完成,E 进入
Step 7: {B, D, E}
Step 8: {D, E} ← B 完成
GPU 始终保持满负荷运行,不存在槽位空置的问题。
8.3 Prefill 与 Decode 的异构性问题
连续批处理在工程实现上面临一个棘手的问题:Prefill 阶段和 Decode 阶段的计算特性完全不同。
| 阶段 | 操作 | 计算量 | 访存量 | 瓶颈 |
|---|---|---|---|---|
| Prefill | 并行处理整个 prompt | 高(矩阵×矩阵) | 低(相对) | 计算密集 |
| Decode | 每步生成一个 token | 低(矩阵×向量) | 高(读取全部权重) | 访存密集 |
当批次中同时存在 Prefill 请求和 Decode 请求时,Prefill 的计算会抢占 GPU 资源,导致 Decode 请求的延迟上升(Time To First Token 变差)。
这催生了 Prefill-Decode 分离(也称 Disaggregated Prefill)的架构:
传统架构(Prefill 和 Decode 在同一 GPU):
GPU 1: [Prefill_A][Decode_B][Decode_C][Prefill_D][Decode_B][Decode_C]...
← Prefill 插入导致 Decode 延迟抖动 →
分离架构:
Prefill GPU(s): [Prefill_A] [Prefill_D] [Prefill_E]
↓ KV Cache 传输 ↓ ↓
Decode GPU(s): [Decode_B][Decode_C][Decode_A][Decode_B][Decode_C][Decode_D]...
← Decode 不被打断,延迟稳定 →
DistServe、Splitwise 等系统实现了这一分离架构,可以独立扩展 Prefill 和 Decode 的资源,根据工作负载动态调整。
8.4 调度策略:何时切换 Prefill 和 Decode
即使在不做物理分离的情况下,调度器也需要决定每个 iteration 是处理 Prefill 请求还是 Decode 请求。
vLLM 使用的调度策略(简化版):
class Scheduler:
def __init__(self, max_batch_size: int, max_tokens_per_iter: int):
self.waiting_queue = [] # 待 Prefill 的请求
self.running_queue = [] # 正在 Decode 的请求
self.max_batch_size = max_batch_size
self.max_tokens_per_iter = max_tokens_per_iter
def schedule(self) -> SchedulerOutput:
scheduled_prefills = []
scheduled_decodes = []
# 优先调度正在运行的 Decode 请求
token_budget = self.max_tokens_per_iter
for req in self.running_queue:
if len(scheduled_decodes) >= self.max_batch_size:
break
scheduled_decodes.append(req)
token_budget -= 1 # Decode 每个请求消耗 1 个 token 预算
# 用剩余预算调度新的 Prefill 请求
for req in self.waiting_queue:
if token_budget <= 0:
break
if req.prompt_len <= token_budget:
scheduled_prefills.append(req)
token_budget -= req.prompt_len
self.waiting_queue.remove(req)
self.running_queue.append(req)
return SchedulerOutput(
prefills=scheduled_prefills,
decodes=scheduled_decodes
)
更先进的调度器(如 SGLang 的 RadixAttention)还会考虑 KV Cache 的复用率,优先调度能够复用已有 KV Cache 的请求,进一步提升吞吐量。
9. 前缀缓存(Prefix Caching):跨请求复用 KV Cache
9.1 动机:重复前缀普遍存在
在实际部署中,大量请求共享相同的前缀:
- System Prompt:所有请求都携带相同的系统提示词(如 “You are a helpful assistant…“)
- Few-shot 示例:相同的示例 prompt 被反复使用
- 长文档问答:同一篇文档被多个用户提问,文档内容的 KV Cache 可以共享
- 多轮对话:用户的历史对话是下一轮的前缀
如果每次请求都重新计算这些前缀的 KV Cache,既浪费计算资源,又增加了首 token 延迟(TTFT)。
9.2 基于哈希的前缀缓存
最直接的实现是:对每个物理块的内容计算哈希值,相同内容的块只存储一份。
9.3 RadixAttention:树形前缀缓存
SGLang 提出了更精妙的 RadixAttention,用一棵基数树(Radix Tree)来管理所有请求的 KV Cache,实现任意粒度的前缀共享。
基数树结构示例:
Root
├── "You are a helpful assistant. " (system prompt,被所有请求共享)
│ ├── "Translate to French: "
│ │ ├── "Hello world" → KV_block_A
│ │ └── "Good morning" → KV_block_B
│ ├── "Summarize: "
│ │ ├── "[Document 1 content...]" → KV_block_C (被多个问题共享)
│ │ │ ├── "What is the main topic?" → KV_block_D
│ │ │ └── "Who is the author?" → KV_block_E
│ │ └── "[Document 2 content...]" → KV_block_F
树中的每个节点对应一段 token 序列及其 KV Cache,当新请求到来时,在树中找到最长公共前缀,只需计算分叉点之后的部分。
SGLang: Efficient Execution of Structured Language Model Programs, Zheng et al., 2024](https://arxiv.org/abs/2312.07104)
9.4 前缀缓存的实测收益
在典型的生产场景下,前缀缓存的收益非常显著:
| 场景 | 前缀共享率 | TTFT 降低 | 吞吐量提升 |
|---|---|---|---|
| 固定 System Prompt(200 tokens) | ~100% | 15-30% | 10-20% |
| 长文档多轮问答(2000 token 文档) | ~80% | 60-80% | 2-3x |
| Few-shot 推理(500 token 示例) | ~90% | 40-60% | 1.5-2x |
| 多轮对话(平均 10 轮) | ~70% | 50-70% | 1.5-2x |
10. FlashAttention:重新设计注意力计算的 IO 模式
以上所有技术都在处理 KV Cache 的存储和管理问题。FlashAttention 则从另一个角度切入:重新设计注意力计算本身的内存访问模式,使其对 GPU 硬件更友好。
10.1 标准注意力的 IO 瓶颈
标准注意力的计算流程:
S = QK^T \in \mathbb{R}^{N \times N}
P = \text{softmax}(S) \in \mathbb{R}^{N \times N}
O = PV \in \mathbb{R}^{N \times d}
用 PyTorch 实现时,每一步都需要将中间结果写回 HBM(High Bandwidth Memory,即显存主存),再从 HBM 读回:
标准注意力的显存读写:
1. 从 HBM 读取 Q, K → SRAM 计算 S = QK^T → 写回 HBM (S: N×N)
2. 从 HBM 读取 S → SRAM 计算 softmax(S) → 写回 HBM (P: N×N)
3. 从 HBM 读取 P, V → SRAM 计算 O = PV → 写回 HBM (O: N×d)
总 IO:O(N²) 读写(主要是 N×N 的注意力矩阵)
对于序列长度 N = 4096,注意力矩阵 S 的大小为 4096 \times 4096 \times 2 bytes = 32 MB(FP16)。这个矩阵必须在每次注意力计算中完整地读写一次,成为严重的 IO 瓶颈。
GPU 的内存层次结构:
A100 GPU 内存层次:
┌─────────────────────────────────────────┐
│ SRAM(L1/共享内存): ~192KB/SM │ 带宽: ~19 TB/s
│ 极快,但容量极小 │
├─────────────────────────────────────────┤
│ HBM(显存主存): 40-80GB │ 带宽: ~2 TB/s
│ 容量大,但相对较慢 │
└─────────────────────────────────────────┘
计算峰值: ~312 TFLOPS (BF16)
HBM 带宽: ~2 TB/s
对于标准注意力,操作强度(FLOPs/Byte):
= 4N²d FLOPs / (4N² + 4Nd Bytes) [读写 Q,K,V,O,S,P]
≈ d / 4 (当 N >> d 时)
= 128/4 = 32 FLOPs/Byte (d=128)
A100 的峰值操作强度 ≈ 312 TFLOPS / 2 TB/s = 156 FLOPs/Byte
说明标准注意力严重 IO 受限,GPU 计算单元大量空闲。
10.2 FlashAttention 的核心思想:Tiling + 在线 Softmax
FlashAttention 的关键洞察是:注意力计算不需要实体化完整的 N \times N 注意力矩阵,可以通过分块(Tiling)计算并在线更新 softmax,直接得到最终输出 O。
在线 Softmax(Online Softmax)推导:
标准 softmax 需要两遍扫描:第一遍找最大值(数值稳定),第二遍计算归一化。
\text{softmax}(x_i) = \frac{e^{x_i – \max_j x_j}}{\sum_j e^{x_j – \max_j x_j}}
FlashAttention 维护一个运行统计量 (m, \ell):
- m:当前见过的最大值
- \ell:当前的归一化因子(分母)
当新块的数据到来时,进行增量更新:
m_{\text{new}} = \max(m_{\text{old}}, m_{\text{block}})
\ell_{\text{new}} = e^{m_{\text{old}} – m_{\text{new}}} \cdot \ell_{\text{old}} + e^{m_{\text{block}} – m_{\text{new}}} \cdot \ell_{\text{block}}
O_{\text{new}} = \frac{e^{m_{\text{old}} – m_{\text{new}}} \cdot \ell_{\text{old}} \cdot O_{\text{old}} + e^{m_{\text{block}} – m_{\text{new}}} \cdot P_{\text{block}} V_{\text{block}}}{\ell_{\text{new}}}
这样只需一遍扫描,且无需将中间的注意力矩阵写回 HBM。
FlashAttention 前向传播算法:
def flash_attention_forward(
Q: Tensor, # [N, d]
K: Tensor, # [N, d]
V: Tensor, # [N, d]
block_size: int = 64, # SRAM 块大小
scale: float = None,
) -> Tensor:
"""
FlashAttention 前向传播(教学版伪代码)
实际实现在 CUDA kernel 中完成
"""
N, d = Q.shape
if scale is None:
scale = d ** -0.5
# 输出和统计量(存在 HBM 中)
O = zeros(N, d) # 最终输出
m = full(N, -inf) # 每行的运行最大值
ell = zeros(N) # 每行的运行归一化因子
# 外层循环:分块遍历 Q(每块加载到 SRAM)
num_q_blocks = ceil(N / block_size)
num_kv_blocks = ceil(N / block_size)
for q_block_idx in range(num_q_blocks):
q_start = q_block_idx * block_size
q_end = min(q_start + block_size, N)
# 从 HBM 加载 Q 块到 SRAM
Q_block = Q[q_start:q_end] # [Br, d],Br = 块大小
# 初始化该 Q 块的局部统计量
m_i = full(q_end - q_start, -inf) # [Br]
ell_i = zeros(q_end - q_start) # [Br]
O_i = zeros(q_end - q_start, d) # [Br, d]
# 内层循环:分块遍历 K, V
for kv_block_idx in range(num_kv_blocks):
kv_start = kv_block_idx * block_size
kv_end = min(kv_start + block_size, N)
# Causal mask:只允许关注过去的 token
if kv_start > q_end:
continue # 完全在未来,跳过
# 从 HBM 加载 K, V 块到 SRAM
K_block = K[kv_start:kv_end] # [Bc, d]
V_block = V[kv_start:kv_end] # [Bc, d]
# 在 SRAM 内计算注意力分数(不写回 HBM!)
S_block = Q_block @ K_block.T * scale # [Br, Bc]
# 应用 causal mask
for i in range(q_end - q_start):
for j in range(kv_end - kv_start):
if kv_start + j > q_start + i:
S_block[i, j] = -inf
# 计算块内的最大值和指数
m_block = S_block.max(dim=-1) # [Br]
P_block = exp(S_block - m_block[:, None]) # [Br, Bc]
ell_block = P_block.sum(dim=-1) # [Br]
# 在线更新统计量
m_new = maximum(m_i, m_block)
ell_new = (exp(m_i - m_new) * ell_i +
exp(m_block - m_new) * ell_block)
# 更新输出(重新缩放旧的 O_i,加上新块的贡献)
O_i = (diag(exp(m_i - m_new)) @ O_i * ell_i[:, None] +
exp(m_block - m_new)[:, None] * P_block @ V_block) / ell_new[:, None]
m_i = m_new
ell_i = ell_new
# 将最终的 O_i 写回 HBM(只写一次!)
O[q_start:q_end] = O_i
m[q_start:q_end] = m_i
ell[q_start:q_end] = ell_i
return O
10.3 FlashAttention 的 IO 复杂度分析
| 方法 | HBM 读写量 | 时间复杂度 |
|---|---|---|
| 标准注意力 | O(N^2) | O(N^2 d) |
| FlashAttention | O(N^2 d / M) | O(N^2 d) |
其中 M 是 SRAM 的大小。由于 M \gg d(SRAM 能装下多个块),FlashAttention 的 HBM 读写量显著更小。
具体地,对于 N=4096, d=128:
- 标准注意力:需要读写 N^2 = 16M 个元素的注意力矩阵
- FlashAttention:HBM IO 约为标准注意力的 5-20\times 更少
实测加速(A100,序列长度 2048,batch=8):
| 方法 | 前向时间 | 前向+反向时间 | 显存占用 |
|---|---|---|---|
| PyTorch 标准注意力 | 基准 | 基准 | O(N^2) |
| FlashAttention v1 | 2.4x 加速 | 6.6x 加速 | O(N) |
| FlashAttention v2 | 3.2x 加速 | 9.0x 加速 | O(N) |
来源:FlashAttention-2: Faster Attention with Better Parallelism and Work Partitioning, Dao 2023
10.4 FlashAttention v2 的改进
FlashAttention v2 在 v1 基础上做了三个关键优化:
1. 减少非矩阵乘法运算
GPU 的矩阵乘法单元(Tensor Core)吞吐量远高于其他运算(标量除法、指数等)。v2 重新设计了算法,将 rescaling 操作从内层循环移到外层,减少了标量运算的次数:
v1:每个 KV 块都做完整的 rescaling
v2:累积 unnormalized 输出,最后统一 rescaling
# v2 的内层循环(简化):
O_i_unnorm += exp(m_block - m_i_new)[:, None] * P_block @ V_block
# 最后只做一次除法:
O_i = O_i_unnorm / ell_i[:, None]
2. 更好的并行策略
v1 在 batch 和 heads 维度并行;v2 额外在序列长度维度并行,更好地利用 GPU 的 SM(流式多处理器),减少空闲。
3. 减少 warp 间通信
v2 优化了前向传播中不同 warp 之间的数据共享,减少了 __syncthreads() 的开销。
11 . 各技术如何协同工作
以一个用户发送长文档问答请求为例,追踪它在系统中的完整旅程:
1. 请求到达
用户请求:[System Prompt (200 tokens)] + [文档 (3000 tokens)] + [问题 (50 tokens)]
总长度:3250 tokens
2. 前缀缓存查询
调度器计算 token 序列的块哈希:
- Block 0-3(System Prompt,共 4 块)→ 命中缓存!直接复用
- Block 4-63(文档内容,共 60 块)→ 部分命中(假设命中 40 块)
- Block 64-65(问题,共 2 块)→ 未命中
实际需要计算的 token 数:
(60-40)*16 + 50 = 370 tokens(节省了 ~90%的 Prefill 计算)
3. 内存分配
BlockManager 为本次请求分配物理块:
- 复用缓存块(引用计数+1):44 块
- 新分配块(需要计算):22 块
- 预分配解码块:若干(按需分配)
4. Prefill 阶段
只对未命中的 370 个 token 做前向传播:
- FlashAttention 计算这 370 个 token 对全部 3250 个历史 token 的注意力
- 生成新的 KV Cache,填入新分配的物理块
- FP8 量化后存储
5. Decode 阶段(循环)
每个解码步骤:
- 投机解码:草稿模型生成 K=5 个候选 token
- 目标模型并行验证(FlashDecoding,序列长度 3250+)
- 平均接受 ~3.5 个 token
- 更新 KV Cache(写入新的物理块)
- 调度器检查:是否有新请求可以加入批次
6. 请求完成
释放非共享的物理块(引用计数-1,归零则真正释放)
将本次生成的 KV Cache 中可复用的前缀块加入前缀缓存
返回生成的 token 序列给用户
12. 总结
本文系统地介绍了现代 LLM 推理中 KV Cache 优化的核心技术体系:
技术图谱回顾
KV Cache 优化技术体系
│
├── 减少 KV Cache 的量
│ ├── MQA(极致压缩,质量有损)
│ ├── GQA(折中方案,主流选择)
│ └── MLA(低秩压缩,DeepSeek 方案)
│
├── 高效管理 KV Cache
│ ├── PagedAttention(虚拟内存范式)
│ ├── Copy-on-Write(并行采样共享)
│ └── 前缀缓存(跨请求复用)
│
├── 压缩 KV Cache 的精度
│ ├── FP8 量化(几乎无损,主流)
│ ├── INT4 量化(有损,极端场景)
│ └── 结构化压缩(H2O,StreamingLLM)
│
├── 加速注意力计算本身
│ ├── FlashAttention(IO 感知重计算)
│ ├── FlashDecoding(解码阶段并行化)
│ └── 稀疏注意力(Quest 等)
│
├── 系统级调度优化
│ ├── 连续批处理(最大化 GPU 利用率)
│ ├── Prefill/Decode 分离(稳定延迟)
│ └── 投机解码(减少串行步骤数)
│
└── 分布式与存储扩展
├── 张量并行 KV 切分
├── CPU 卸载与预取
└── 跨节点 KV 传输
选型指南
实际部署时,根据不同的优化目标选择合适的技术组合:
| 优化目标 | 推荐技术组合 |
|---|---|
| 最大化吞吐量 | PagedAttention + GQA + 连续批处理 + FP8量化 |
| 最小化首token延迟 | 前缀缓存 + Prefill/Decode分离 + FlashAttention |
| 最小化每token延迟 | 投机解码 + FlashDecoding + 张量并行 |
| 超长上下文(>64K) | MLA/GQA + 稀疏注意力 + CPU卸载 + StreamingLLM |
| 资源受限(单卡小显存) | MQA + INT4量化 + KV驱逐 + CPU卸载 |
| 高并发多用户 | PagedAttention + 前缀缓存 + 连续批处理 + 投机解码 |
核心设计原则
纵观所有这些优化技术,可以归纳出几个共同的设计原则:
- IO 感知设计:GPU 的瓶颈往往不是计算,而是内存带宽。所有优化技术都在想方设法减少不必要的数据搬运(FlashAttention、量化、GQA)
- 时间换空间 / 空间换时间的灵活权衡:根据场景灵活选择——前缀缓存用存储换计算,投机解码用额外计算换时间,量化用精度换空间
- 利用自然冗余:KV Cache 存在大量冗余(不同头之间、不同请求之间、不同时间步之间),所有优化技术本质上都在挖掘和消除这些冗余
- 系统与算法协同:最好的优化往往跨越算法和系统的边界——PagedAttention 既是算法创新(虚拟内存思想)也是系统工程,FlashAttention 既是数值算法也是 CUDA 工程
LLM 推理优化仍然是一个高速发展的领域,硬件能力的提升、新模型架构的涌现以及应用场景的多样化都在不断提出新的挑战和机遇。理解这些基础技术的原理,是在这个领域持续创新的基础。
参考文献
- Attention Is All You Need, Vaswani et al., 2017
- Efficient Memory Management for Large Language Model Serving with PagedAttention, Kwon et al., 2023
- Fast Transformer Decoding: One Write-Head is All You Need, Shazeer, 2019
- GQA: Training Generalized Multi-Query Transformer Models from Multi-Head Checkpoints, Ainslie et al., 2023
- FlashAttention: Fast and Memory-Efficient Exact Attention with IO-Awareness, Dao et al., 2022
- FlashAttention-2: Faster Attention with Better Parallelism and Work Partitioning, Dao, 2023
- Fast Inference from Transformers via Speculative Decoding, Leviathan et al., 2023
- Medusa: Simple LLM Inference Acceleration Framework with Multiple Decoding Heads, Cai et al., 2024
- H2O: Heavy-Hitter Oracle for Efficient Generative Inference, Zhang et al., 2023