学习 / 实践
KV Cache 拆解:多轮对话的历史,为什么还要再传一遍?
llm · 机制拆解
从因果注意力推导 K/V 复用,用一个可计算的内存例子区分推理缓存、前缀缓存与聊天记录。
浏览统计暂不可用
本文目录 6
在 Java 多轮对话 Demo 中,程序每次都把以前的问答放进 messages。看到接口返回 cached_tokens 后,很容易产生一个疑问:既然模型已经有缓存,下一次能不能只发新问题?
对于需要客户端携带历史的 Chat Completions 请求,答案是不能据此省略历史。接口需要哪些输入,由会话协议决定;服务器怎样加速这些输入,由推理实现决定。 KV Cache 讨论的是后一个问题。
阅读范围
这是一篇基于一手资料的 AI 辅助机制拆解,不是 GPU 实测。以下算例由明确假设推导,不代表 Agnes 或其他具体服务的配置、价格和延迟。无需下载模型即可核对算术。
1. 先把三种“记忆”拆开
| 层次 | 保存什么 | 谁负责 | 丢失后会怎样 |
|---|---|---|---|
| 应用对话历史 | 用户与助手消息 | Java 程序或会话服务 | 请求可能缺少完成任务需要的事实 |
| 一次生成的 KV Cache | 每层过去位置的 K/V 张量 | 推理引擎 | 可以重新计算,代价是计算时间 |
| 跨请求前缀缓存 | 可复用前缀对应的缓存块 | 支持此能力的服务 | 缓存未命中,仍应按完整请求计算 |
例如 vLLM 的前缀缓存设计用前缀上下文和 Token 等信息区分缓存块,目标是复用计算,不是替用户补写未提供的消息。cached_tokens 的具体语义还要看服务商协议,单凭这个数字不能推出“已记住上次对话”。参见 vLLM 前缀缓存设计。
可以做一个不需要模型的检查:把要发出的 JSON 打印出来。如果关于“我正在学习 Java”的信息既不在请求中,也没有通过会话标识交给有状态服务,那么客户端并未提供这条事实。模型偶然答对也不能证明它持久保存了历史。
2. 为什么保存 K/V,而不是把旧 Q 也存下来?
在一层注意力中,可以把 Q 看作当前位置用来查询的向量,把 K/V 看作可被读取的位置表示。核心运算是:
scores = Q × transpose(K) / sqrt(head_dim) + causal_mask
weights = softmax(scores)
output = weights × V因果遮罩让一个位置无法读取未来位置。于是,在模型参数、输入前缀和位置处理保持一致的推理中,追加新 Token 不要求重算旧位置的表示。新位置仍要计算自己的 Q/K/V;旧位置的 K/V 可以复用。新查询不需要再使用旧查询向量,所以标准 KV Cache 不保存旧 Q。机制依据:Transformer 原论文 与 Hugging Face 缓存说明。
假设提示词处理后有 3 个位置:
已缓存:K[0..2]、V[0..2]
处理新位置 3:计算 q3、k3、v3
读取:q3 与 K[0..3] 计算权重,再对 V[0..3] 加权
保存:追加 k3、v3
输出:用该位置的隐藏状态预测下一个 Token这里缓存的是每一层的张量,不是把输入文本复制进一个字典。修改前缀中的一个 Token 后,其后位置的表示可能改变,不能把原来的整段缓存不加判断地继续接上。
3. Prefill 与 Decode 的工作不同
Prefill 处理提示词,建立缓存,并可从最后位置得到首个输出 Token 的分布。Decode 随后逐步处理新生成的位置,继续扩展缓存。一次生成并不是每次都重新跑完整提示词。
但“有缓存”不意味着后续每步都是固定成本。对标准全注意力,当前查询仍需访问越来越长的历史 K/V。长上下文节省了重复投影和旧位置计算,却增加了缓存占用与读取量。不能把“单步只新增一个位置”理解成“只看最后一个 Token”。
这也解释了两个延迟指标为何应分开观察:首个 Token 的等待与整个回答生成耗时。网络、排队、提示长度、输出长度都可能参与;仅凭一次 API 超时,无法判断是不是 KV Cache 配置导致。
4. 一张纸算出缓存大小
对每层都使用全注意力、统一 KV 头数与精度的简化模型,单个 K 或 V 的元素数是 B × Hkv × T × D。因此:
KV 字节数 = 2 × L × B × Hkv × T × D × S
2 K 与 V 两份
L 层数
B 同时保留的序列数
Hkv KV 头数(不是查询头数)
T 每条序列缓存的位置数
D 每个头的维度
S 每个元素的字节数GQA 允许多组查询共享更少的 KV 头,所以计算时不能直接把查询头数代入。参见 GQA 原论文。
教学假设:32 层、8 个 KV 头、头维度 128、每元素 2 字节;每条序列长度相同,不做前缀共享,不计额外开销。
| 序列数 B | 长度 T | 理论 KV 占用 |
|---|---|---|
| 1 | 4,096 | 512 MiB |
| 1 | 8,192 | 1 GiB |
| 4 | 8,192 | 4 GiB |
| 4 | 32,768 | 16 GiB |
可用 Node.js 或浏览器控制台核对:
function kvGiB({ layers, sequences, kvHeads, tokens, headDim, bytes }) {
return (2 * layers * sequences * kvHeads * tokens * headDim * bytes) / 2 ** 30;
}
const config = { layers: 32, sequences: 1, kvHeads: 8,
tokens: 8192, headDim: 128, bytes: 2 };
console.log(kvGiB(config)); // 1 GiB
console.log(kvGiB({ ...config, sequences: 4, tokens: 32768 })); // 16 GiB如果把 KV 头数从 8 改成 32,其他假设不变,表内各值变为四倍。这是公式敏感性分析,不是更换真实模型后的测量结果。
总显存还包含模型权重、激活、运行时工作空间和分配开销。滑动窗口、混合层、不同序列长度、缓存量化与共享会改变估算方式。不能用“权重能放进显卡”直接推导“任意长对话都能跑”。
5. 三种优化,各自付出什么?
Hugging Face 提供动态、静态、卸载和量化等缓存策略。动态缓存随长度增长;静态缓存预留容量,便于某些编译优化;卸载降低 GPU 驻留需求但引入传输;量化降低存储精度,也可能增加处理成本。选择与模型和执行方式有关,不存在统一最快选项。参见 官方缓存策略文档。
将这些机制映射回应用问题:
- 长文问答经常显存不足:先确认长度与并发对应的 KV 容量,再决定是否裁剪、共享或更换缓存策略。
- 短请求用了量化反而更慢:节省的空间未必能抵消额外处理;需要测量,而不是只比较位宽。
- 同一系统提示重复很多次:有前缀复用机会,但命中还受 Token 前缀、位置、模型与服务策略约束。
对于调用托管 API 的 Java 客户端,上述服务端策略未必可配置。客户端更直接的职责是明确上下文预算、保留必要历史、区分输入与输出用量,并记录失败。不要把底层名词当成不存在的接口开关。
6. 用三个反例检查理解
“有 KV Cache,所以可以不传历史。” 混淆了请求语义与计算复用。先确认协议如何提供上下文。
“缓存减少显存。” 相对不保留旧张量的实现,KV Cache 用内存换计算。量化或卸载是在这个缓存之上继续做权衡。
“上下文翻倍,延迟一定翻倍。” 本文公式只推导统一假设下的缓存字节数,不推导端到端延迟。硬件、批处理、注意力内核和排队都会影响结果。
回到 Java 多轮对话,现在可以分别检查两件事:程序有没有提供正确历史,服务有没有高效处理这段历史。二者相关,但要分别验证。
写给未来的自己合上文章前,留下一点自己的理解。
你的私人学习便签,只存在当前浏览器,不会上传或公开。清理浏览器数据会丢失,请导出留存。三种语言共用这篇文章的便签。
继续探索
继续探索
接下来,走哪条路?
- Java 8 调用大语言模型:从第一次请求到多轮对话 →
用三个真实运行的 Java Demo,理解模型请求、JSON 解析、读取超时与对话历史。
- OpenVLA 代码导读:图像和一句指令,怎样变成机器人动作? →
沿着 processor、视觉投影、动作 Token 与反归一化追踪一次推理,重点检查动作单位、边界索引和部署接口。
依据文章关联与同主题已发布内容整理,不使用随机推荐。