状态:草案(2026-10-13),包含部分实际测量。 玩具模型测量(第 3 节)由作者在笔记本电脑 CPU 上直接测量。GPU 服务器测量(第 4、6 节)引用了同系列文章《Toss ML/AI Infra 准备》中单张 8GB 级别 GPU 的测量值。模型结构通过官方配置文件和论文表格进行了确认(2026-10-13 查阅),KV 大小根据这些值计算得出。 编写规则:以实测数据为基础,未能测量的数据标记为“未实测”,无法确认的内容标记为“未确认”。
目录
- 1. 逐个生成标记的模型
- 2. 没有缓存时每一步都从头开始
- 3. 动手尝试:numpy 玩具 Transformer
- 4. 缓存的代价:内存
- 5. 减少大小的方法:MHA → GQA → MQA → MLA
- 6. 预填充与解码:两个特性不同的阶段
- 7. 总结:本系列将探讨的问题
- 参考资料
1. 逐个生成标记的模型
聊天机器人的回答并不是一次性生成的。模型会查看目前的文本(提问 + 至今生成的回答),并选择 下一个标记,将该标记附加到文本末尾,然后再次选择下一个标记。这个过程被称为自回归解码。
在选择单个标记时,Transformer 的每一层都会计算注意力。每个标记会生成三个向量:
- Q(查询,Query):“我现在在找什么”
- K(键,Key):“我是这部分内容”的索引
- V(值,Value):实际要传递的内容
将新标记的 Q 与前面标记的 K 逐个进行对比并打分(关联度有多高),然后用这些分数将 V 进行混合。用公式表示就是 softmax(q·Kᵀ/√d)·V 。
这里有一个重要的性质。语言模型的注意力机制 被遮挡以防看到后面(因果掩码,causal mask)。由于第 3 个标记的 K 和 V 仅根据第 1 到第 3 个标记生成,因此即使后面附加了 100 个标记,它们也 不会改变 。既然如此,就没有理由丢弃已经创建的 K 和 V 并重新创建。只需将它们保存并在下一步中取出使用即可。这就是 KV 缓存。
Q 不会被保存。因为前一个标记的 Q 在生成该标记时使用过一次后就不会再被用到了。新步骤所需的 Q 只有新标记的那一个。
2. 没有缓存时每一步都从头开始
如果没有缓存,在第 t 步时需要重新生成全部 t 个标记的 K 和V。由于每层执行的线性投影(Q、K、V、输出)和 MLP 与标记数量成正比,单步的开销与 t 成正比,积累 n 步后总开销为 1+2+…+n ≈ n²/2 。如果有缓存,每一步只需投影并输入一个新标记到 MLP 中,因此 n 步的总开销为 n 。
注意力分数的计算在两种方式中都存在。即使有缓存,新标记的 Q 也必须与前面的所有 K 进行对比,因此每一步需要消耗与 t 相关的计算量(n 步总计 n²/2)。如果没有缓存,连这一部分在每一步中都是 t²,从而使总和增加到 n³/3。在上下文不是很长的情况下,投影和 MLP 占了计算的大部分,因此实际感受到的差异是“每一步中 t 与 1 的区别”。
3. 动手尝试:numpy 玩具 Transformer
理论上很简单,但很难有直观感受,于是我们用 numpy 构建了一个权重填满随机数的轻量 Transformer,并对这两种方式进行了测量。由于这不是一个训练过的模型,输出的标记没有实际意义,但计算的形态(投影、注意力、MLP、归一化)与真实的模型完全一致。
- 模型:2 层,模型维度 128,8 个注意力头(头维度 16),MLP 512,词表 512,float32
- 环境:笔记本电脑 CPU,Python 3.9,numpy 2.0.2,单进程
- 方法:从 32 个随机提示词标记开始,通过贪婪解码生成到结束。相同条件运行三次取中位数。矩阵乘法的运算量(乘法和加法计为 2)由代码直接统计。
- 代码与原始数据:仓库
docs/experiments/2026-10-13-numpy-kv-cache-toy/(toy_kv_cache.py,result-20261013.txt)
带缓存一侧的核心代码只有几行。将当前标记的 K 和 V 写入预先分配好的数组末尾,注意力机制则查看至今为止累积的全部内容(省略了按头拆分的 reshape)。
k = mm(h, W["k"]) # 새 K
v = mm(h, W["v"]) # 새 V
n = cache["n"]
cache["k"][:, n:n+T] = k # 끝에 붙임
cache["v"][:, n:n+T] = v
K = cache["k"][:, :n+T] # 지난 것 전부
V = cache["v"][:, :n+T]
a = attend(q, K, V)
结果 1. 输出结果相同,但计算量和时间大不相同
运算量(GFLOP,代码统计值):
| 总长度 | 无缓存 | 有缓存 | 倍数 |
|---|---|---|---|
| 128 | 6.71 | 0.121 | 55倍 |
| 256 | 31.0 | 0.264 | 118倍 |
| 512 | 148.2 | 0.599 | 247倍 |
时间(秒,三次运行的中位数):
| 总长度 | 无缓存 | 有缓存 | 倍数 |
|---|---|---|---|
| 128 | 0.205 | 0.084 | 2.5倍 |
| 256 | 1.16 | 0.131 | 8.9倍 |
| 512 | 8.01 | 0.485 | 16.5倍 |
- 在所有三种长度下,两种方式生成的标记直到最后都完全相同,对数几率(logit)的最大差值为 1.5×10⁻⁶,达到了 float32 四舍五入的水平。 缓存不是一种近似,而是避免重复进行相同的计算。
- 每当长度翻倍时,无缓存侧的运算量大约增加到 4.6 至 4.8 倍(n² 中混入了少量注意力的 n³),而有缓存侧大约增加到 2.2 倍。
- 时间倍数小于运算量倍数的原因是,缓存侧的单步操作是一个非常小的矩阵乘法,其中夹杂了大量的 Python 调用开销。长度为 128 时整体耗时在 0.1 秒以内,因此波动也很大(在简短的验证运行中为 4.5 倍)。
结果 2. 如果没有缓存,单步耗时会随上下文成比例变慢。 在运行 512 个标记时,观察每 64 个标记区间的单步耗时中位数,无缓存侧的耗时从 1.25ms 增加到了 35.1ms,而有缓存侧仅从 0.43ms 略微增加到了 0.60ms。即使有缓存也会稍微变慢的原因是第 2 节中提到的注意力分数计算(每一步为 t)。
结果 3. 缓存数组的大小与公式及字节完全一致。 在预填充 1,024 个标记后,将缓存数组的 nbytes 相加。
三种情况下的查询头数均为 8 个,官方计算公式为:2 × 层数(2) × KV 头数 × 头维度(16) × 标记数(1,024) × 4 字节。
| 方式 | KV 头数 | 实际字节数 | 公式计算值 |
|---|---|---|---|
| MHA | 8 | 2,097,152 | 2,097,152 |
| GQA | 2 | 524,288 | 524,288 |
| MQA | 1 | 262,144 | 262,144 |
这三种方式中,使用缓存的最后一个 logit 与没有缓存、从头开始计算的值完全相同(允许误差 10⁻³)。
4. 缓存的代价:内存
缓存是用计算换取内存。单个标记留下的 KV 是每层各有一组 K 和 V,因此公式如下:
KV 字节/标记 = 2 × 层数 × KV 头数 × 头维度 × 数据类型字节数
这个公式是否符合实际服务器的情况已在其他系列中得到验证。Qwen3-1.7B(28层,KV 头数 8,头维度 128,bf16)根据公式计算得出每个标记 114,688 字节,而 vLLM 服务器日志中的 KV 缓存内存(2.36GiB)除以标记数(22,048)约为 114,900 字节,误差在 0.2% 以内(8GB 级别 GPU,vLLM 0.31.0,实测)。如果将 KV 设置为 8 位(fp8),同样的内存可以容纳 41,920 个标记,几乎达到了两倍。
将其代入广为人知的开源模型中,结果如下。层数和头数已从官方资料中确认,大小按 KV 为 bf16(2字节)进行计算。以下模型的头维度均为 128。“32K”表示单个 32,768 标记请求的 KV 大小。
| 模型 | 层数 × KV 头数 | 每标记 KV | 32K |
|---|---|---|---|
| Qwen2.5-1.5B | 28 × 2 | 28KiB | 0.88GiB |
| Qwen2.5-7B | 28 × 4 | 56KiB | 1.75GiB |
| Llama 3.1 8B | 32 × 8 | 128KiB | 4GiB |
| Mistral-7B | 32 × 8 | 128KiB | 4GiB |
| Qwen3-8B | 36 × 8 | 144KiB | 4.5GiB |
| Llama 3.1 70B | 80 × 8 | 320KiB | 10GiB |
| Llama 3.1 405B | 126 × 8 | 504KiB | 15.75GiB |
| 如果 70B 是 MHA(假设) | 80 × 64 | 2.5MiB | 80GiB |
| DeepSeek-V2(MLA) | 60 层,潜在维度 576 | 67.5KiB | 2.11GiB |
查询头数在 Qwen2.5-1.5B 中为 12,Qwen2.5-7B 中为 28,Llama 3.1 8B·Mistral-7B·Qwen3-8B 中为 32,70B 中为 64,405B 中为 128。KV 头数少于查询头数的情况即为第 5 节中的 GQA。
- Llama 3.1 已通过 Llama 3 论文中的模型结构表(8B、70B、405B 的 KV 头数均为 8)以及 Meta 官方仓库的配置值进行了确认。由于 Hugging Face 的 Llama 配置文件需要登录才能打开因此未能查看,头维度 128 是通过模型维度除以头数计算得出的。Qwen 和 Mistral 已在公开的
config.json中进行了确认。 - Llama 3.1 支持 128K 的上下文。如果让 70B 填满 128K 个标记,仅 KV 缓存就需要 40GiB(计算值)。在权重以 bf16 格式占用约 140GB 的模型旁边,几个较长的请求就会迅速占满 GPU 内存。
- DeepSeek-V2 不保存 K 和 V,而是保存压缩后的潜在向量(每层 512 个元素)和用于位置的键(64 个元素)。论文中将每个标记的 KV 元素数量计算为 (512 + 64) × 60 层 = 34,560。67.5KiB 是假设这些元素以 bf16 存储并相乘得到的值(计算值)。如果采用相同的头结构(128 个头,头维度 128)作为 MHA,每个标记将有 1,966,080 个元素,约为其分之五十七(计算值)。
5. 减少大小的方法:MHA → GQA → MQA → MLA
公式中能够减少的部分是 KV 头数(或替代它的某些内容)以及字节数。层数和头维度与模型的能力直接相关,因此不能随意减少。
- MHA(多头注意力,Multi-Head):为每个查询头单独配备 K 和 V。这是最初的 Transformer(2017)采用的方式。
- MQA(多查询注意力,Multi-Query,Shazeer 2019):所有查询头共享同一组 K 和 V。在该论文的翻译模型实验(约 2 亿参数,TPUv2)中,每个输出标记的解码时间从 46µs 缩短至 3.8µs,而 BLEU 分数从 27.7 略微下降至 27.5(贪婪解码)。
- GQA(群查询,Ainslie 等人,2023):介于两者之间。将查询头分成若干组,每组共用一套 K·V。研究表明,只需使用原训练量 5% 的额外训练,就能将原本以 MHA 训练的模型进行转换;在 T5-XXL 实验中,GQA-8 的每个样本耗时 0.28 秒(MHA 1.51 秒,MQA 0.24 秒),平均分数为 47.1(MHA 47.2,MQA 46.6)。Llama 3 系列采用的就是这种方式(KV 头 8 个)。
- MLA(DeepSeek-V2,2024):与其减少头的数量,不如将 K·V 压缩为较小的潜在向量进行存储,使用时再展开。论文报告称,与 DeepSeek 67B 相比,KV 缓存减少了 93.3%,最大生成吞吐量提升了 5.76 倍(在单节点 8 张 H800、部署时包含权重 FP8 及 KV 约 6 比特量化的条件下)。93.3% 的具体计算标准未在正文中找到(未证实)。
- 减少字节数: 如果将 KV 设为 8 比特,体积就会减半。在上述 vLLM 的实际测量中,fp8 KV 使标记(token)数量增加了 1.9 倍。对质量产生的影响需要针对不同的模型和任务分别进行评估。
为什么非要精简到这种程度?第 6 节就是答案。
6. 预填充与解码:性质截然不同的两个阶段
一个请求需要经历两个阶段。
- 预填充(prefill):一次性输入整个提示词,生成所有标记(token)的 K·V 并写入缓存。第一个标记就是在这里生成的。它决定了首字延迟(TTFT)。
- 解码(decode):此后逐个标记(token)地生成。它决定了标记间延迟(ITL·TPOT)。
两者虽然使用相同的模型,但在硬件上表现出的特征却大不相同。
- 预填充是计算瓶颈。 读取一次权重,然后一次性写入数千个提示词标记(token)。由于每读取一个字节所进行的计算较多,GPU 的计算单元非常忙碌。
- 解码是内存带宽瓶颈。 为了生成一个标记,必须从内存中读取全部权重以及该请求的所有 KV,而用这些数据进行的计算仅仅对应一个标记。bf16 权重每个参数占 2 字节,每个标记每个参数大约进行 2 次浮点运算(FLOP),因此如果只解码一个请求,每读取一个字节大约进行 1 次 FLOP(计算得出)。如今的数据中心 GPU 在读取一个字节的同时可以进行数百次 FLOP,因此计算单元大部分时间都在闲置。
在玩具模型中也能看到这种差异(带有缓存,取三次测量中位数)。
| 提示词 | 每个预填充标记耗时 | 单步解码耗时 | 倍率 |
|---|---|---|---|
| 64 | 0.018ms | 0.445ms | 24倍 |
| 256 | 0.034ms | 0.506ms | 15倍 |
| 1,024 | 0.144ms | 0.852ms | 6倍 |
预填充的总时间为:64 个标记 1.16ms,256 个标记 8.76ms,1,024 个标记 146.9ms。
解码单个标记花费的时间是预填充单标记时间的 6 到 24 倍。由于使用的是 CPU 的 numpy,其中混入了 Python 的调用开销,且 1,024 个标记的预填充会导致注意力机制的平方项变大,从而拉高单标记成本。因此,比起倍率本身,更应该关注这样一个方向:「批量一次性处理在每个标记上的成本要便宜得多」.
在 GPU 上,这种差异更为巨大。在其他系列的实际测量中(8GB 级 GPU、Qwen3-1.7B、vLLM),输入处理速度约为 8,200 个标记/秒,而当解码单个请求时,标记间延迟为 15ms(约合 66 个标记/秒)。 单标记速度相差超过 120 倍。
如果解码受限于内存,那么提高吞吐量的方法只有一个。 在读取一次权重的同时解码多个请求,也就是增大批处理大小(batch size)。在同一实际测量中,当并发请求从 1 增加到 8 时,吞吐量从 66 增加到 383 个标记/秒(5.8 倍),从 32 增加到 831 个标记/秒。然而,批处理中的每一个请求都必须将其自身的 KV 放在 GPU 内存中。如果是 512 个输入标记 + 128 个输出标记,那么一个请求就是 640 个标记,该 GPU 的 KV 空间(22,048 个标记)大约可以容纳 34 个。当并发发送 64 个请求时,吞吐量没有增加,而首字延迟飙升至 5.7 秒。 吞吐量的上限是由 KV 内存决定的。
7. 总结:本系列将探讨的问题
| 事实上 | 由此产生的问题 | 涉及篇幅 |
|---|---|---|
| KV 不会改变,因此将其存储起来并重复使用 | — | 第 1 篇 |
| 批处理越大,解码成本越低 | 如何无浪费地使用 KV 内存来增大批处理? | 第 2 篇(PagedAttention、连续批处理) |
| 相同前缀的 KV 是相同的 | 如何复用其他请求的 KV? | 第 2 篇(前缀缓存)、第 3 篇 |
| GPU 内存很小 | 如果将 KV 放在 CPU、磁盘或其他服务器上并按需加载呢? | 第 3 篇(LMCache) |
| 预填充和解码的性质不同 | 如果将两者分配到不同的 GPU 上?如果将请求发送到已经拥有该 KV 的地方呢? | 第 4 篇(Dynamo) |
Titi 的一句话。 在乒乓球比赛中,没有哪个裁判每次都从第一个发球开始重新计算分数,大家都是记在记分牌上然后逐分累加的。KV 缓存正是这样一个记分牌。只不过如果有几百个球场,光是找地方放记分牌就开始不够用了。
参考资料
- Vaswani 等人,Attention Is All You Need: https://arxiv.org/abs/1706.03762
- Shazeer, Fast Transformer Decoding: One Write-Head is All You Need (MQA): https://arxiv.org/abs/1911.02150 (表 1·2)
- Ainslie 等人,GQA: https://arxiv.org/abs/2305.13245 (v3, 第 3 节·表 1)
- DeepSeek-AI, DeepSeek-V2: https://arxiv.org/abs/2405.04434 (v5, 摘要, 2.1 节表 1, 3.2.3 节)
- Llama 3 论文模型架构表: https://arxiv.org/abs/2407.21783 (3.2 节表 3), Meta 架构值: https://github.com/meta-llama/llama-models (
models/sku_list.py) - 公开配置文件: https://huggingface.co/Qwen/Qwen2.5-7B-Instruct/raw/main/config.json, https://huggingface.co/Qwen/Qwen3-8B/raw/main/config.json, https://huggingface.co/mistralai/Mistral-7B-v0.1/raw/main/config.json, https://huggingface.co/Qwen/Qwen2.5-1.5B-Instruct/raw/main/config.json (均于 2026-10-13 访问)
- 玩具模型代码与原始数据:代码库
docs/experiments/2026-10-13-numpy-kv-cache-toy/ - GPU 实测引用:同网站“Toss ML/AI Infra 准备”第 14 篇(vLLM 负载·KV 容量·前缀缓存·分块)