AI / 大模型
KV Cache:为什么长上下文又慢又贵
从注意力公式出发,算一笔显存账:KV Cache 是什么、为什么它决定了推理成本,以及写 prompt 时怎么蹭上缓存。
用 API 的时候注意到两件事:输入越长,第一个字出来得越慢;很多厂商对“缓存命中的输入 token”打一到五折。这两件事背后是同一个东西 —— KV Cache。
先看注意力在算什么
上一篇 说过,Transformer 每层都有自注意力。对每个 token,模型用三个矩阵把它的向量投影成三个新向量:
- Q(Query):我在找什么信息
- K(Key):我这里有什么信息
- V(Value):如果你要,我给你的内容
然后:
Attention(Q, K, V) = softmax(Q · Kᵀ / √d) · V
翻译成人话:拿当前 token 的 Q,和前面每个 token 的 K 做点积算“相关度”,softmax 归一化成权重,再按权重把大家的 V 加起来。生成式模型还有一个因果遮罩:每个 token 只能看自己和前面的,不能偷看后面的。
不缓存会怎样
生成是一个 token 接一个 token 进行的。生成第 n 个 token 时,需要前面 n−1 个 token 在每一层的 K 和 V。
关键观察:因为有因果遮罩,前面 token 的 K、V 不会因为后面来了新 token 而改变。那每生成一个新 token 都把前面的重算一遍,就是纯浪费 —— 总计算量会随长度平方增长。
所以推理引擎把每层算过的 K、V 存起来,这就是 KV Cache。新 token 只需要算自己的 Q、K、V,把 K、V 追加进缓存,再用 Q 去和缓存里所有的 K 做注意力。
算一笔显存账
缓存省了计算,代价是显存。每个 token 要存的大小:
每 token 字节数 = 2(K 和 V) × 层数 × KV 头数 × 每头维度 × 每个数的字节数
以 Llama 2 7B 为例(32 层,32 个头,每头 128 维,fp16 占 2 字节):
2 × 32 × 32 × 128 × 2 = 524,288 字节 ≈ 0.5 MB / token
一条 4096 token 的请求,光 KV Cache 就要 2 GB。模型权重本身才 14 GB 左右,同时服务 8 个这样的请求,缓存就比模型还大了。
这就是为什么长上下文贵:它占的不是算力,是显存,而显存直接决定一张卡能同时服务多少个请求。
怎么省:GQA
后来的模型普遍用 GQA(Grouped-Query Attention):Q 还是 32 个头,但多个 Q 头共享一组 K、V。Llama 3 8B 只有 8 组 KV 头:
2 × 32 × 8 × 128 × 2 = 131,072 字节 = 128 KB / token
直接降到四分之一,质量损失很小。除此之外还有几条常见路线:
- 量化 KV Cache:fp16 换成 int8 甚至更低,再省一半
- PagedAttention(vLLM):像操作系统分页一样按块分配缓存,消灭碎片,同一批卡能塞下更多请求
- 滑动窗口 / 稀疏注意力:只看最近的一段或部分 token,从根上限制缓存大小
两个阶段:Prefill 和 Decode
一次请求的推理分两段,性能特征完全不同:
| 阶段 | 做什么 | 瓶颈 | 体感指标 |
|---|---|---|---|
| Prefill | 一次性处理整个输入,填满 KV Cache | 算力(大矩阵乘,能并行) | 首字延迟 TTFT |
| Decode | 逐个生成输出 token | 显存带宽(每步都要读一遍权重和整个缓存) | 每 token 耗时 |
输入越长,Prefill 越久,所以第一个字出来得慢。而 Decode 每步只算一个 token,GPU 算力大部分在闲着等数据搬运,所以输出 token 通常比输入 token 贵好几倍。
回到开头:Prompt Caching
如果两次请求的开头完全一样,那开头那部分的 KV Cache 也完全一样,没必要再 Prefill 一次。厂商把这部分缓存保留几分钟,下次命中就直接复用 —— 省了他们的算力,所以给你打折,首字也更快。
这对写 prompt 有直接的指导意义:
- 不变的放前面,变化的放后面。 系统提示词、工具定义、长文档放开头,用户问题放最后。
- 前缀要逐字一致。 在系统提示词开头塞一个当前时间戳,每次都不一样,缓存就永远命中不了。
- 多轮对话只追加,不修改。 改了历史中间某条消息,从那里往后的缓存全部作废。
Agent 场景特别吃这一点:一次任务几十轮调用,每轮都带着同样的系统提示词和越来越长的历史,缓存命中率高低能让成本差出好几倍。
一句话:KV Cache 用显存换计算,长上下文的成本本质是显存成本;让 prompt 前缀保持稳定,就能白嫖缓存。