跳转到内容
Yuuki学习手记
GitHub

从因果 Attention 到 KV Cache

专题入口 · 上一篇 前端增量计算 · 下一篇 前缀缓存与 Yuuki

上一节把“哪些派生结果可以继续使用”归结为依赖关系。现在把这个问题放进模型:在固定模型、相同位置和因果可见范围下,为什么追加一个 token 后,历史位置的 K/V 仍然有效?

这一站分三小步:先把向量看成数组,再看 Q/K/V 分工,最后看为什么旧 K/V 可以保留。 公式和完整实验数据都在选读区。

先把分词后的 token 看成整数 ID,模型把 ID 转成数值向量,再逐层处理。本节用 A B C D 表示给定的 token;实验直接提供这些符号的向量,方便用 JavaScript 数组观察。

名称可以怎样理解它的数据形态作用
token ID一个整数指向词表中的一项
隐藏向量 h一个数值数组当前层对这个位置的表示
模型权重 W固定的数值矩阵把输入向量变换到不同表示空间
K/V 缓存按层、位置保存的向量集合复用已经处理过的位置状态

权重是计算规则,KV 是这次输入产生的中间结果。换一段输入,沿用相同权重仍会得到不同的状态。

可以先把「层」想成一个处理阶段:上一阶段交出一组数字,下一阶段继续变换。真实模型的每层远比下面的例子复杂,但数据仍然是数字在流动。

先看图,暂时只记分工:Q 是这次怎么读,K 参与决定读取权重,V 是最后被组合的数字内容。

当前隐藏向量分别产生 Q/K/V;Q 与可见 K 计算分数,再把分数变成权重,对 V 加权组合

图中的「权重」可以先理解成配比:例如 25% 取第一组 V,75% 取第二组 V。这个配比由当前 Q 和可见的 K 计算得到,会随当前位置变化。

这里有两个容易撞名的词:模型权重是训练得到的参数,推理时通常固定;注意力权重是这次算出的配比,会随输入变化。就像一段函数的规则固定,每次调用得到的结果仍然可以不同。

这里的 K/V 不能按 JavaScript Map.get(key) 来理解。 Attention 会计算与多个可见 K 的分数,再组合对应的 V;它不是找一个完全相同的业务键。

选读:用几个数字和 JavaScript 算一次分数

「点积」就是对应项相乘,再加起来:

const q = [1, 2];
const k = [2, 1];
const score = q.reduce((sum, value, i) => sum + value * k[i], 0);
// 1 * 2 + 2 * 1 = 4

每个可见 K 都会得到一个这样的分数。真实 Attention 还会缩放分数,并用 softmax 把它们变成非负、总和为 1 的权重。例如权重为 [0.25, 0.75],V 为 [10, 0] 和 [0, 20],加权结果就是 [2.5, 15]。

这是运算示意,不代表这几个数字拥有固定的语言含义。

选读:投影、伪代码和单头公式

在一个 Attention 头里,当前隐藏向量经过三组线性投影,得到 Query、Key 和 Value。新位置用 Q 与可见位置的 K 做点积并缩放,再经 softmax 得到权重,对 V 加权求和。Attention Is All You Need 第 3.2 节

可以先按下面这段伪代码读公式:

query = project(hidden, queryWeights);
scores = visibleKeys.map(key => dot(query, key) / Math.sqrt(headDimension));
weights = softmax(scores);
attentionOutput = weightedSum(weights, visibleValues);

Q 表达当前如何读取上下文,K 参与决定读取权重,V 提供被加权组合的数值内容。它们都是由模型计算出的向量;“读取”是理解运算角色的说法。

对应的单头公式是:

Attention(q, K, V) = softmax(q Kᵀ / √d) V

这里 d 是 Q/K 的向量维度。因果约束通过只保留允许读取的 K/V,或在 softmax 前屏蔽不可见位置来实现。

因果在这里表示:计算一个位置时,只能读取自己和前面的位置。mask 就是把不允许读取的位置挡住。

因果可见矩阵:A 只看 A,B 看 A/B,C 看 A/B/C,D 看 A/B/C/D;未来位置被遮挡

先只看 A 那一行:末尾加了 D,A 仍然看不见 D。所以 A 不需要因为新增的未来内容改变旧计算。这个关系在每一层成立,历史 K/V 才能被复用。

选读:把图中的可见范围列成表
正在计算的位置可以读取的位置
AA
BA B
CA B C
DA B C D

追加 D 后,A/B/C 在每一层仍然只能读取原来的前缀。因此在模型、位置和计算配置保持一致的推理中,它们的表示与 K/V 可以继续使用。这个性质逐层成立,是缓存的依据。Hugging Face 缓存原理

若把 B 改成 X,C 的 token 和位置虽然相同,但它能读到的前缀改变了,深层表示也可能改变。这与上一节累计和的失效方向一致;模型里的每层状态依赖其可见上下文。

Prefill 处理已有输入并填充缓存,Decode 消费新产生的 token 继续计算。实际推理实现会为这两个阶段采用不同的计算路径。TensorRT-LLM 的上下文与生成阶段

先把两个新词翻成动作:Prefill 是先处理已有输入;Decode 是之后一步步继续生成。 一段输入中已有的位置可以并行处理,后续生成的 token 则逐步送回模型。

Prefill 处理 ABC 并选出 D;下一步送入 D 才把 D 的 K/V 加入缓存,再选出 E;随后同样处理 E

选读:用一张表核对输入长度与缓存长度

下面假设没有命中的跨请求前缀,使用普通的一次预测一个 token 的生成循环:

阶段这次送入模型的 token计算完成后的 KV根据输出选出下一个 token
PrefillA B CA B C 的 K/VD
第一次 DecodeDA B C D 的 K/VE
第二次 DecodeEA B C D E 的 K/VF

选出了 D,和已经计算了 D 的 K/V,是两个时刻。 D 在下一次作为模型输入被处理时,才加入缓存。理解这个顺序,有助于阅读生成循环中的输入长度和缓存长度。

新位置 D 需要自己的 Q/K/V。历史 K/V 被复用,D 的 Q 仍需与当前可见的全部 K 计算注意力,再组合 V。历史 Q 属于此前位置的查询,下一位置读取上下文时使用的是它自己的 Q,因此常规 KV 缓存保留的是 K/V。Hugging Face 缓存更新过程

第一停点:旧 K/V 可以留下,新位置仍要用新的 Q 读它们。 能指着上面的图说出这句话,就已经完成本篇的核心部分。

运行两层因果 Attention 实验:

终端窗口
node docs/kv-cache/labs/attention-cache.mjs

先只看「处理新 token D」一行:新算的 K/V 从 8 组变成 2 组;QK 点积仍有 8 次。这里省下了历史位置的重复计算,读取历史状态的工作仍然存在。

选读:实验范围、函数入口和完整计数

实验采用固定的二维向量、两层单头 Attention、残差和简单位置输入。它对给定的 token 流计算隐藏向量;完整模型中的分词、训练、归一化、MLP 和词表采样不在这个小模型里。

读代码时沿这三个函数走:

  1. fullForward 在每层重新处理整个序列,作为数值基线。
  2. appendToken 只处理新 token,并在每层追加 K/V。
  3. checkResult 对照最后位置输出,以及每一层、每个位置的 K/V。

脚本中的“一组 K/V”指一个位置在一层中的 K 与 V;QK 点积按查询与键的配对次数计数。

场景从头算 K/V 组缓存路径新算 K/V 组从头算 QK 点积缓存路径 QK 点积
首次输入 A B C661212
追加 D82208
将 B 改为 X 后重算后缀862018

脚本逐步断言两条路径误差小于 1e-12。首次输入为了便于观察而按 token 执行,真实 Prefill 可以利用并行计算;这里的计数用于说明工作量,不能换算成真实模型的加速倍数。

第二行值得停下来推一次:两层各产生一组新的 K/V,所以是 2 组;D 的 Q 在每层各读取 4 个 K,所以仍有 8 次点积。缓存省去了历史位置的重复处理,读取已有上下文仍然需要工作。

第三行先把每层缓存截到共同前缀 A,再处理 X/C/D。实验还确认,同一位置的 C 在前缀改变后,第二层 K 确实改变了。只按当前 token ID 保存和查找深层 K/V,会丢失这种上下文依赖。

第二停点:知道缓存路径和从头计算的结果一致,同时能指出省了什么。 下一次想看应用,直接去DeepSeek 请求;对内存好奇,再读容量图解。

选读:常规 KV 的内存公式,读容量图解后再看

对于每层都有完整 K/V、K/V 头维度相同的常规 MHA/GQA,可从张量元素数量估算有效数据大小:

字节数 = 2 × B × L × T × H_kv × D × s
2 K 和 V 两份
B 相同长度的序列数量
L 层数
T 每条序列已缓存的 token 数
H_kv 每层 K/V 头数
D 每个头的向量维度
s 每个元素的字节数

这是对常规按层 K/V 张量形状的元素计数;张量通常可按 [B, H_kv, T, D] 理解。Hugging Face 缓存存储结构

取一个假设配置:B=1、L=32、T=4096、H_kv=8、D=128,每个元素 2 字节,得到 536,870,912 字节,即 512 MiB。相同结构下,长度翻倍或序列数量翻倍,有效 KV 数据也翻倍。

这个数值只统计 KV 元素。模型权重、临时张量、内存分配和预留容量需要另外计算;JavaScript 实验的对象与 Number 也不采用这里的内存布局。

GQA 让一组 Query 头共享 K/V 头,因此估算时使用 H_kv,不能直接拿 Query 头数代入。GQA 原始论文

采用滑动窗口的层可以限制历史缓存长度;静态预分配和量化缓存也有各自的容量与速度取舍。遇到这些结构时,应按实际缓存布局重算。Hugging Face 缓存策略

  1. 追加 D 后为什么能复用 C 的 K/V,而修改 B 后却可能需要重新计算 C?关键是 C 的可见前缀是否改变。
  2. 为什么保留了历史 K/V,D 仍要做 QK 点积?权重由当前 Q 与可见 K 共同决定。
  3. 更长的历史为什么仍然昂贵?缓存数据变多,当前查询需要读取和组合的历史状态也更多。

接下来把一次生成内部的复用扩展到不同请求:前缀缓存与 Yuuki。