学习 / 实践

KV Cache 拆解:多轮对话的历史,为什么还要再传一遍?

llm · 机制拆解

从因果注意力推导 K/V 复用,用一个可计算的内存例子区分推理缓存、前缀缓存与聊天记录。

进阶 · 10 分钟 · 更新于 2026-09-22

浏览统计暂不可用

本文目录 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 多轮对话,现在可以分别检查两件事:程序有没有提供正确历史,服务有没有高效处理这段历史。二者相关,但要分别验证。

写给未来的自己合上文章前,留下一点自己的理解。

你的私人学习便签,只存在当前浏览器,不会上传或公开。清理浏览器数据会丢失,请导出留存。三种语言共用这篇文章的便签。

继续探索

继续探索

  1. 实战教程

    Java 8 调用大语言模型:从第一次请求到多轮对话

    用三个真实运行的 Java Demo,理解模型请求、JSON 解析、读取超时与对话历史。

    15 分钟已发布
  2. 机制拆解

    OpenVLA 代码导读:图像和一句指令,怎样变成机器人动作?

    沿着 processor、视觉投影、动作 Token 与反归一化追踪一次推理,重点检查动作单位、边界索引和部署接口。

    12 分钟已发布

接下来,走哪条路?

依据文章关联与同主题已发布内容整理,不使用随机推荐。

全部文章