QKV:
Transformer
计算图谱
借助 KV 缓存,沿着 Q、K、V,看清 Transformer 从处理输入到生成回答的计算与数据流向:哪些结果已经算过,怎样保存和复用,哪些步骤可以并行,哪些必须等待。结合图解、实例与推理题,理解缓存与推理优化背后的计算机制、收益和代价。
KV 缓存的实际收益
影响因素与评价指标
七类影响因素
- Agent 架构
- 输入与模型配置
- 模型架构
- 数据表示与压缩
- 推理框架
- 缓存保存与传输
- 硬件和实际负载
八项实际结果
- 保存体积与运行峰值
- 重复输入的缓存命中
- 恢复时间
- 首字等待
- 生成速度
- 多人同时使用时的处理量
- 总成本
- 答案与任务正确性
阅读目录 · 9 个章节 · 36 个问答
哪些层次共同决定 KV 缓存的实际收益?
KV 缓存保存模型已经算出的中间状态,供后续计算复用。这里的实际收益,包括少占存储、少做重复计算、缩短等待和降低整个任务的费用,也可能让同样的设备支持更长输入或更多并发任务。这些收益都要以任务仍能正确完成为前提。
先分清:模型的“层”、层里的“头”和 token 的位置
先以常见的 Transformer 语言模型为例。模型由一层接一层的计算模块组成,每个模块叫一个模型层,也叫解码器层(decoder block)。第一层接收 token 转成的初始数值,后面的层接收前一层的结果。一个层的内部包含注意力计算和前馈网络等部分;多个注意力头,位于这一层的注意力部分里面。
用户输入了 5 个 token,这里只跟踪第 5 个 token。先画前两层,每层用 4 个查询头作示意。
从这里开始,紫红色虚线框表示可并行的一组工作。所需输入准备好后,本图中相同编号的框之间可以并行;每个框内部仍按箭头执行。
编号只在当前图内配对,不是 token 位置、模型层号或头号,也不代表先后顺序;先后依赖看箭头。这里只标出重点,未标框的计算不代表不能并行。
第 1 个模型层
注意力部分:4 个查询头
再做数值变换
第 2 个模型层
注意力部分:本层自己的 4 个查询头
再做数值变换
后文还会比较两种共享:同一层内的多个头共享 K/V,这是 GQA/MQA 讨论的范围;不同模型层共享 K/V,这是 YOCO 的做法。共享历史记录后,各头、各层仍可产生自己的查询和计算结果。查看 MHA、GQA、MQA 与 YOCO 的对照表。[7][11]
前图里,注意力之后还有一段“前馈网络”:继续加工当前 token 的内部数值。听说 MoE 会把一个 token 交给少数几个专家处理。
这些专家和注意力头是什么关系?选中两个专家,会多出两套历史 K/V 吗?
先看它发生在哪里。这里介绍的是把前馈部分换成专家网络的常见 MoE 结构。注意力先读取前文信息;随后,前馈部分用训练得到的参数,继续加工当前 token 的内部数值。
混合专家(Mixture of Experts,MoE)为这一步准备多套参数不同的前馈网络,每套叫一个专家。一个负责打分和选择的小模块——路由器(router)——根据当前 token 的内部数值,挑出少数专家执行,再合并它们的输出。这样可以增加模型可用的参数总量,同时让一个 token 只执行少数专家的计算。专家与注意力头位于本层的不同部分。[62]
已给定 5 个输入 token,只跟踪第 5 个 token 在第 1 层的计算。示例设 4 个查询头,各自保存 K/V;前馈部分有 8 个专家,每个 token 选 2 个。
沿用 FIG 01 的紫红色虚线框:同号框表示可并行的一组工作。①的结果备好后,才能进行②;未标框不表示不能并行。
本层的注意力部分
窄屏可左右滑动图;对应说明紧接在下方。
本层的 MoE 前馈部分
亮色为本次选中的专家,灰色为本次未选中的专家。
实际模型也把这两种数量分开设置。Qwen3-30B-A3B 有 128 个专家,每个 token 激活 8 个;注意力却是 32 个查询头、4 个 KV 头。因此它每层按 4 个 KV 头保存历史,不按 8 个或 128 个专家来算。[63]
小王专家的参数从哪里来?路由器又是谁教会的?训练数据会告诉它“这次应该选专家 2、7”吗?
大王:先看各部分一起训练的常见做法:专家、路由器、注意力和输出层都有初始参数,共同学习预测训练文本的下一个 token。数据给出真实的下一个 token,通常不提供“正确专家编号”。
沿用前五个位置的例子。训练文本中,第六个 token 已知是“甲”;模型预测它时只能读取前五个位置。假设简化词表只有甲、乙、丙,模型当前给出的概率是 20%、50%、30%。训练程序直接检查真实“甲”得到的概率;概率越低,惩罚越大。这个惩罚叫预测损失(loss),不必先抽出一个 token 再判对错。
只展开第 5 个位置的一处 MoE。绿色实线:计算预测红色虚线:训练反馈与参数更新真实的第 6 个 token 用于评分,不提前送入这次预测的前文。
窄屏可左右滑动图;解释紧接在下方。
小王没有“正确专家”标签,路由器怎么知道该怎样改?“选中谁”不是一个离散选择吗?
大王:要分开看选中的编号和参与混合的权重。编号 2、7 决定这次执行哪两条分支;0.7、0.3 则参与了实际数值计算。假如沿着后续计算,稍微增加专家 2 的混合权重能降低预测损失,反向传播就能给出相应反馈;方向相反时,反馈也相反。
0.7、0.3 是路由器根据本次输入算出来的数。训练更新的是产生这些分数和权重的路由器参数,同时也训练被采用的专家怎样加工输入。在这里采用的 Top-k 路由中,“挑出得分最高的几个编号”本身不直接求导;训练反馈沿被采用的连续权重等可求导计算传回,后续分数变化时,选中的专家也可能改变。[65]
没有被选中的专家,不会在这个位置被逐个试算。程序也没有直接获得“换成另一个专家会怎样”的比较结果。图中这个位置的专家执行分支,只让 2、7 得到这条分支的任务梯度;其他专家仍可能在别的位置或样本被选中并得到训练。
小王那会不会一直选中同几个专家,其他专家始终学不到东西?
大王:会出现这种风险。假设一批有 100 个位置,每个选 2 个专家,共 200 次分配;如果全选 2、7,这两位各接到 100 次,其他六位一次也没有。被选中的继续学习,闲置的缺少机会;专家分布在不同设备时,还会造成有的设备忙、有的空闲。这就是需要处理的专家负载不均衡。
- 给过于集中的分配增加惩罚。例如 Qwen3-MoE 的公开训练路径可以在预测损失之外加入均衡辅助损失,推动路由不要长期集中于少数专家。它鼓励合理分配,不给每条输入指定一个学科专家。[65]
- 根据忙闲,调整选专家时的分数。DeepSeek-V3 训练时会降低过忙专家的路由偏置、提高过闲专家的偏置;这些偏置影响谁被选中,输出混合仍使用原来的相关性分数。该模型还保留很轻的序列级均衡损失。[66]
随着不同数据被分配给不同专家,各自的参数逐渐形成处理倾向,但“专家 2 专管数学、专家 7 专管代码”并不是通用的预设分工。分工可能重叠,也可能与词法、语法或上下文特征有关。[67]
MoE 会怎样影响缓存的实际收益?
- 命中旧前缀,也能少做旧 token 的专家计算。后续层需要的历史 K/V 已准备好,通常不必再走一遍产生这些状态的计算;新 token 仍需完成自己的注意力和专家计算。
- 只选少数专家,不代表只存少数专家的参数。专家参数与请求产生的 KV 是两种数据。若更多专家权重占用显存,留给 KV 的空间就可能减少。
- 少算的收益,要扣除分发和汇合的代价。专家若分布在不同 GPU 上,还要传递当前 token 的内部数值、汇合结果。具体加速多少,取决于计算、存储和通信的共同开销。[64]
先看哪些因素会改变开销,再看任务最终得到了什么收益。
七类因素,共同影响缓存的使用
Agent 架构
哪些信息送给模型,保留多久?
工具结果、工具加载、摘要与分工
输入与模型配置
旧状态与这次计算是否兼容?
输入前缀、模型权重、位置与图片处理
模型架构
需要保存哪些状态?每一步读取哪些历史?
按头共享、跨层共享、局部窗口与稀疏读取
数据表示与压缩
每份状态占多少字节?还原它要付出什么代价?
量化、缩放系数、还原后的临时数据
推理框架
怎样找到数据、分批计算,并安排多个请求?
分页寻址、分块计算、请求调度
缓存保存与传输
状态存在哪里?怎样共享、取回和回收?
显存、主存、SSD 与跨机器交接
硬件和实际负载
设备能存多少、读多快?请求有多长、重复多少、间隔多久?
容量与带宽 · 输入长度 · 并发量 · 重复程度
八项结果,分别观察
保存体积与运行峰值
各处保存多少数据?运行时最多占用多少内存?
重复输入的缓存命中
本次输入有多少复用了已有前缀状态?
恢复时间
取回并准备好可继续使用的状态,要多久?
首字等待
发出请求后,多久开始看到回答?
生成速度
单个回答开始后,每秒输出多少 token?
多人同时使用时的处理量
多个请求每秒合计输出多少 token?这叫吞吐量。
总成本
完成整个任务花多少钱,包括失败、重试和额外请求。
答案与任务正确性
优化之后,任务是否仍然正确完成?
评价时要同时看:保存体积与运行峰值、重复输入的缓存命中、恢复时间、首字等待、单个回答的生成速度、多人同时使用时的处理量、总成本和任务正确性。例如,把缓存移到更便宜的存储中,可能降低费用,却增加取回时间;额外发请求保活,可能增加眼前开销,却避免下一轮更贵的重算。后文会分别说明这些收益与代价从哪里来。
先弄清:KV 到底是什么?
缓存的价值来自少做重复计算。理解这件事,需要先区分“读入已有输入”和“生成新的内容”。
我评价一个新模型时,通常先看它答得准不准、生成得快不快。但如果让它连续读代码、运行测试、修复错误,同一份项目资料和越来越长的对话会被反复带回。
这些重复输入为什么值得单独关注?缓存省下的计算,会怎样影响整个任务的费用和耗时?
设想一个会读代码、调用终端并修复错误的助手。第一次给它项目资料,第二次带回测试结果,第三次继续修改。同一份工具说明、项目约定和历史对话,会反复出现在请求中。
输入一次 → 得到一段回答
人容易只关注回答是否聪明、每秒输出多少字。
反复带回历史 → 多次计算 → 验收
同一份上下文的重复处理成本,也会影响任务能否做得起。
评价模型完成多轮任务的效率,应在相同验收标准下,比较整个任务的总费用、总耗时和失败重试次数。这类任务会反复提交相同的项目资料和历史对话,因此,缓存能否复用、能省去多少重复计算,也会影响任务的整体开销。
说“把模型已经读过的内容缓存起来”,我会想到保存原文,或者把上次的回答存下来,下次直接取用。但这里保存的却叫 K 和 V。
K 和 V 实际是什么,分别用来做什么?保存它们以后,模型下一步具体能少算哪一部分?
模型处理一个已知 token 时,会在注意力层里生成供计算使用的数字向量。Q 用来查询,K 用来计算匹配程度,V 提供按权重读取的信息。当前 token 也会产生自己的 K/V,与已有 token 的 K/V 一起参与本层计算。[1]
模型处理一个 token 时使用的内部数值,称为模型中间表示(hidden state);它还不是输出层给候选 token 打出的分数。
下面固定为同一次回答:假设用户输入只有 3 个 token,占序列位置 1~3;模型已经选出回答的第 1 个 token,占位置 4。现在把它送回模型计算,准备选出回答的第 2 个 token,也就是位置 5。
先计算当前查询与各个 Key 的匹配程度,再按权重汇总 Value。
一次调用通常先读入已给定的一整段输入(Prefill),再逐步生成新 token(Decode)。读入输入时可以批量、并行处理许多 token;生成时,处理当前已知 token 的结果用于预测下一个 token。缓存省去的是旧 K/V 的重复计算,后续读取和新内容的计算仍然存在。[1]
图中只展开一个注意力层。普通 Transformer 的不同层会分别产生自己的 K/V,这里省略了注意力头等维度。选出 token 5,不代表已经计算了它自己的 K/V。如果还要继续生成,token 5 会在下一轮被送回模型处理,再产生对应的 K/V。
下一次请求能否重新使用这些历史,还要检查前缀是否匹配、缓存是否可用。
只有文字输入时,模型从文字位置的内部数值产生 Q、K、V。如果输入里再加一张商品照片,让它在同一段回答中描述衣服颜色、读出标签文字:图片应该接在这条计算流程的哪里?
图片混入输入序列,和另外准备图像 K/V,有什么区别?文字的 Q 要读取的记录会变多吗?
图片先由处理像素的网络——视觉编码器——变成多组数值向量,再由连接模块接入语言模型,不必先变成一段文字描述。接下来有两种接法:让图像向量占据主序列的位置;或保留文字序列,安排专门的注意力模块读取图像。[70]
先分清两个“长度”:一份 Q 包含多少个数由模型配置决定;这份 Q 可以匹配多少条 K/V取决于供它读取的记录。下面比较的是后者,从纯文字的 5 条记录开始。
统一比较:5 个文字 token,图片转换为 4 个图像向量。只跟踪最后的文5,在相关注意力模块的一次查询;它完成全部层的计算后,用来预测回答的第一个 token“答1”。图中各次查询读取允许访问的全部记录;省略特殊标记、填充和头的分组,假设全部图像记录可见。
①没有图片:文字序列有 5 个位置
窄屏可左右滑动;旁注在图下方。
②图片混入序列:4 个图像位置+5 个文字位置=9
图像在前、文字在后;方格仍与上图同样大。
③图片单独准备:文字序列仍是 5,另做一次读图计算
两种注意力分别展开;中间其余计算省略。
两种图片接法是不同的模型结构。后者保留了较短的文字序列,也增加了专门读取图像的模块;读图的计算和缓存并没有消失。
小王回答继续生成时,文字和图片的缓存分别怎样增长?
大王:文5的末层输出经过输出层,得到词表分数,选出“答1”。此时通常还没有计算答1自己的 K/V。下一步把已经选出的答1送回模型,才追加它的文字 K/V,并用它的计算结果预测“答2”。
小屏可左右滑动查看表格。
| 连接方式 | 用文5预测答1时 | 送回答1,预测答2时 |
|---|---|---|
| 只有文字 | 自注意力:5 条 | 自注意力:6 条 |
| 图片混入序列 | 自注意力:9 条 图像 4+文字 5 | 自注意力:10 条 图像仍为 4,文字变为 6 |
| 图片通过交叉注意力读取 | 文字自注意力:5 条 图像交叉注意力:4 条 | 文字自注意力:6 条 图像交叉注意力:仍为 4 条 |
小王图像 K/V 没变,为什么描述颜色时和读标签时,还能读出不同的信息?
大王:在图像特征不变、已有图像 K/V 仍可用的前提下,可以复用这批记录。后续文字位置产生新的 Q,对同一批 K 的匹配分数、对 V 的读取权重可以不同。省去重复准备图像记录,不等于省去每一步的查询、匹配和读取。[70]
“文字 K/V”指它们由文字位置产生,不表示其中只能有文字信息:前面的交叉注意力已经可以把图片信息合入这些位置的内部数值。混入序列的接法则要注意,如果图像前面的文字改变,深层图像位置的 K/V 也可能改变;不能只凭图片文件相同,就认定整份语言模型缓存可以照用。
假设我提交 1,000 个已经确定的输入 token,再让模型生成 1,000 个新 token。前面的图说明,两者在被处理时都会计算 Q/K/V,可 API 通常给它们不同的单价。
输入和输出都要计算 Q/K/V,为什么前者可以成批处理,后者通常更贵?
先看没有命中旧缓存的普通新输入。这些输入仍要计算 Q/K/V、读取上下文,并经过模型各层。要理解它为什么能高效处理,先解决一个疑问:后面的位置依赖前文,为什么多个位置仍能一起算?
把 1,000 个输入暂时缩成 A、B、C 三个已知 token;本问用 D、E、F 表示随后生成的回答 token。下面区分进入一层时的数值、该层生成的 Q/K/V,以及这一层算完后的数值;不要把它们当作同一个时刻的东西。
输入已经给出 A、B、C 三个 token,按第 1、2、3 位排列。只展开一个注意力头和前两层;色格表示一组内部数值。
并行① 同号虚线框之间可并行,先后依赖仍看箭头;编号只在本图内配对。
① 输入已知:三个位置都有自己的起点
② 本层读取:限制能看哪里,不要求按行排队
③ 进入下一层:前文影响已经进入各自的 hidden state
生成回答时,为什么不能照样提前算后面的 token?
下面把两种处理放在一起:输入 A、B、C 的内容一开始就已给定;生成时则要先选出 D,才能查 D 的 embedding,把它送回模型。两边都先生成本层 Q/K/V,再做注意力。
同一个普通因果 Transformer、同一个注意力头,只展开第 1 层。两部分是同一条对话的先后两步:先处理输入 A、B、C;选出 D 后,把 D 送回模型,继续预测 E。
并行① 同号虚线框之间可并行,先后依赖仍看箭头;编号只在本图内配对。
先处理输入:三个 token 都已给定
再继续生成:这一步的新输入是 D
大王:是,但这里指多个位置一起做同一个步骤。本层先准备 Q/K/V,再计算注意力、完成后续处理;这些步骤之间仍有依赖。各步骤取得所需数据后,可以成批处理多个位置。下一层仍要接着上一层的结果计算。
大王:不是。图中的生成侧正在处理第 4 位 D,准备选出第 5 位 E。同一层里的多个查询头仍可以一起处理 D,各自使用自己的 Q。在一个头内,Q₄ 与不同 K 的匹配也可以并行;前馈等运算中的大量数字同样可以并行处理。
普通生成需要等待的是E、F 的内容依次确定:E 还没有选出来,就不知道该查哪个 embedding,不能提前处理 E。相比已知输入,少了同一条回答中多个新位置一起处理的机会,并没有失去当前 token 内部的并行能力。[40]
大王:要区分“这一步直接读取什么”和“它的输入已经包含什么”。以输入 A、B、C 中的 C 为例:
第一层:C 用自己的 embedding 生成 Q/K/V,此时还没有通过注意力读取 A、B。随后,C 的第一层注意力读取 A、B、C 的 K/V;经过本层后续计算,C 的第一层输出已经可能受到 A、B 的影响。
第二层:C 使用自己的第一层输出生成新的 Q/K/V,因此这些数值已经间接受到 A、B 的影响。只是生成它们时,不需要先读取 A、B 的第二层 K/V,也不用等 A、B 做完第二层注意力。
不直接读取本层的历史 K/V,不等于不受历史信息影响。历史信息可以先进入上一层的输出,再影响这一层生成的 Q/K/V。[40]
模型先处理整段输入。为了生成回答的第一个 token,许多实现只取最后一个输入位置经过各层处理后的内部数值,再由输出层给词表中的候选 token 打分。前面的输入位置仍参与模型计算,只是不必分别计算它们各自的下一 token 预测分数。这里的词表分数(logits),是输出层对候选 token 的原始分数,尚未转成概率。[11]
下面回到原题的 1,000 个输入:D、E、F 仍表示回答 token,但它们在整条序列中的位置依次是 1,001、1,002、1,003;不再是三个输入简例中的第 4、5、6 位。
同一段 1,000 个已知输入,接着生成 D、E、F。两部分都按模型层从上向下画;红色箭头标出先后依赖。
并行① 同号虚线框之间可并行,先后依赖仍看箭头;编号只在本图内配对。
1输入:多个已知位置一起处理
同一层内,可以让多个位置一起做同一个步骤。先生成各自的 Q/K/V,再做注意力和后续处理;不是把这些步骤同时执行。下一层接着上一层的输出计算。
得到全部输入位置的最终表示后,为了选出第一个回答 token D,通常只需给第 1,000 位计算词表分数。
2生成:当前 token 内部仍可并行
分叉的三条线,是同一位置的不同查询头。它们可以并行;一个头里的 Q 与多个 K 的匹配、前馈中的大量运算,也有可并行的部分。
要等的是下一 token 的内容。D 的各层计算完成并选出 E 后,才知道要取 E 的哪个 embedding;E、F 不能像已知输入那样提前成批送入。
3Q/K/V 不直接查历史,为什么仍会受前文影响?
先看蓝色这段。第一层从本 token 的 embedding 生成 Q/K/V,不需要先读取前面 token 的 K/V。
再沿绿色箭头往下看。注意力读取历史,影响本位置的第一层输出;第二层用这个结果生成 Q/K/V,它们就能间接受到前文影响。
不直接读取本层历史 K/V,不等于不受历史影响。深层 Q/K/V 的输入,已经经过前一层的注意力计算。
例如,GPU 把一块模型权重读入计算所用的高速存储后,批量处理时可以用它计算多个 token。单条回答逐步生成时,每一步新处理的位置较少,却仍要访问模型权重和历史 K/V,更容易花时间等数据。这里的权重读取主要发生在 GPU 的显存与计算单元之间,模型不会因此每步都从硬盘重新加载。[28]
批量处理没有把 1,000 个 token 的计算量变成一个 token 的计算量。它改变的是这些工作怎样安排,以及单位 token 的开销如何分摊。输出过程本来就在使用 KV 缓存;缓存省掉了旧位置的重复计算,却无法提前确定未来 token,也不能免除新一步的计算与历史读取。
输出还会让请求持续占用资源:越晚结束,相关状态就需要保留越久;继续生成也会增加新状态。服务端可以把多个用户的生成合成一批,但仍要兼顾显存容量和每个人的等待时间。
整个前缀,究竟要不要“整个存下来”?
这里的核心区别,是前缀的身份与前缀的计算状态。把它们分开,hash、单 token 缓存和块缓存就能放进同一张图。
比较“我 喜欢 苹果”和“我 讨厌 苹果”。假设这里的“苹果”是同一个 token,也处在相同的位置;变化的只有它前面的词。
既然当前 token 和位置都一样,为什么还不能直接拿上一次“苹果”的 K/V 来用?前文是怎样影响它的?
比较两句中的第 3 个 token“苹果”。在标准模型的第一层、尚未读取前文时,相同 token 和相同序号可以得到相同 K/V。但第一层注意力分别读取“喜欢”与“讨厌”所在的前文,计算出的结果可能不同;第二层再用这些结果生成 K/V,就可能出现差异。
下标 i 表示 token 的序列位置,上标 (ℓ) 表示第 ℓ 个模型层;xi 是第 i 个 token。Fℓ 表示对应层的计算关系,它与用来查目录的 hash 函数是两件事。
因此,仅用 token_id 或 (token_id, position) 作为通用的高层 KV 缓存键,会把不同的上下文混为一谈。需要把此前的条件也区分开。
但这里还没有推出“缓存键必须把整段前缀明文存进去”。那正是下一个问题。
既然同一个 token 的 K/V 会受前文影响,那我能不能给不同的前文生成不同的缓存编号?这里用 A、B、C 分别代表一个 token。
比如,先给 A 生成一个编号;接着处理 B 时,把“A 的编号”和 B 一起生成新编号,用它代表 A→B;再处理 C 时,用“A→B 的编号”和 C 生成下一个编号。
然后用一张按编号查找的表,也就是 hash map,找到对应的缓存。这样,每个编号虽然很短,却已经考虑了此前的整段内容,不必在每个缓存条目里重复写一遍前文。
这种办法能不能正确区分不同上下文?如果可以,“必须包含整个前缀”指的是保存前文原文,还是保存能够区分前文的编号?
可以。服务端先根据输入前缀和模型等条件生成缓存编号,再查找已经保存的 K/V,不需要把 K/V 重新算一遍,才判断能否复用。[2]
先沿紫色线区分前文,再沿绿色线找到已经算过的数据。
同一个“苹果”,前缀编号可以不同
vLLM 的 hash_block_tokens 就把父块 hash、当前块 token 与附加身份信息一起交给 hash 函数。这与“parent_hash + current_token”的思想一致,只是它通常以块为单位。[3]
既然能够为每个 token 位置建立缓存入口,为什么不这样做,让之前算过的相同前缀全部得到复用?
比如,上次处理了 1,000 个 token,这次前 999 个完全相同,只有最后一个变了。假设之前的缓存都还在,也能直接继续使用,那么为每个位置建立入口,就可以复用前 999 个,只计算最后一个。按每 16 个一组复用,却只能复用到第 992 个,还得把已经算过的 7 个重新算一遍。
这里想达到的 100%,只指那 999 个相同 token 全部复用;新内容仍需计算,本次全部 1,000 个输入的命中率仍是 99.9%。
为什么不把入口做得足够细,让已经算过的相同前缀达到 100% 复用?按组管理缓存究竟省下了什么,值得付出这些额外计算?
可以这样做。入口更细能减少重复计算,也可能增加编号、查找和管理工作;是否划算,要比较两边的实际成本。
大王:先算建立入口要花多少成本,再看这些入口能替后续请求省下多少计算。
比较两种直接做法:每个 token 建一个入口,或每 16 个 token 建一个入口。按组的方案只复用完整分组;两种方案的旧缓存都可用。
建立成本:首次处理同一篇 100 万 token 的资料
复用收益:换用 1,000 token 的请求,看两种分歧位置
左侧把前 992 个 token 折叠,右侧逐个展开最后 8 个;这里不按整段长度比例绘制。
而且,在“前一个编号+当前内容”的链式 hash 方案里,后一个编号依赖前一个编号。第一次建立整条链时,不能简单地让所有编号同时生成。vLLM 的相关代码就是逐组生成,并把本组编号交给下一组使用。[13]
大王:系统还要知道缓存在哪里、谁正在使用它,以及什么时候可以回收。
例如,两个请求同时使用同一份缓存。第一个请求结束时,系统不能把第二个请求还在读取的数据回收。因此,每次开始使用、结束使用,都需要更新相应记录。
vLLM 的缓存管理代码会逐个处理缓存块:命中后增加使用计数,请求释放时减少计数,并调整哪些块可以被回收。[13]
如果连实际管理单位也从 16 个 token 拆成 1 个,这类管理操作就会变多。高并发时,CPU 花在管理缓存上的时间,可能抵消 GPU 少算几个 token 带来的收益。
但要分清:只提高匹配精度,不一定需要把底层存储和管理单位也拆细。这几件事可以分别设计。
大王:可以。实际系统会改进数据结构,避免提前为每个位置建立独立条目。
例如,先把连续的 1,000 个 token 作为一段记录。下次请求与它共享前 999 个 token,就在第 999 个 token 后面把这段记录拆开。这样,仍然能准确复用前 999 个,却不必一开始就建立 1,000 个独立的树节点。
SGLang 的缓存树就有这种做法:节点可以保存一段连续内容,匹配结束在段内时再拆分;其普通 token 匹配路径支持单 token 精度。这里拆分的是组织缓存的记录,已有 K/V 数据可以继续使用。[5]
因此,工程上真正需要比较的是:更细的复用多省了多少计算,为此多付出了多少查找、管理和存储成本。最终要用同一批请求测量总耗时、内存占用和服务能力,才能判断哪种方案更合适。
有相同前缀,还差哪些条件才能命中?
缓存目录得找到它,数据得还在,而且留下来的状态必须足以从那个位置继续。API 的缓存点就处在这一层。
我在 Claude 请求的一段资料末尾加上 cache_control,后面还会继续放本次的问题。这个位置被称为 breakpoint,有时又被译成“截断点”,听起来像是后面的内容会被截掉。
这个标记实际控制什么?它缓存的是被标记的这一段,还是从请求开头到这里的全部内容?后面的新问题还会正常处理吗?
更合适的中文是缓存边界或恢复点。“截断点”容易让人误会后面的内容不再输入给模型。
把 cache_control 放在某个内容块上,表达的是:从整个有效提示的开头,到这个块结束的前缀,成为候选缓存范围。Claude 文档规定相关顺序为 tools → system → messages。[4]
紫色括号圈出从开头到标记处的前缀;后面的内容仍然输入模型。
在标记处区分:模型计算与服务层缓存
多个缓存点可以对应不同稳定程度:工具和系统指令几乎不变,项目资料偶尔改变,对话不断追加。较长的入口失效时,较短的入口仍可能有效。但它们都表示“从开头到某处”,不会把后面相同的文本自动变成独立于前文的缓存片段。
我连续三次提交同一份长资料,但分别问三个不同的问题。DeepSeek 不要求我手动标出资料在哪里结束,服务端需要自己决定保存哪些部分。
它怎样发现并保存这段共同的开头?相同资料从第二次就能复用,还是可能要等到后面的请求?
先固定场景:A 是整份资料,B、C、D 是三个不同的问题。每次都从 A 开始,随后的问题被替换。
先分清:全局 K/V 与局部 K/V
有些模型会同时维护两种 K/V 缓存,这里讨论的 DeepSeek V4/V4.1 就属于这种情况。“全局”和“局部”说的是它们覆盖的上下文范围。
全局 K/V:让较早的内容仍可被读取
保留覆盖已处理长上下文的记录,供模型回看报告前面乃至开头的信息。这些记录可以压缩保存,也可以由多个模型层共用;“全局”不表示每一步都必须把所有记录读一遍。
局部 K/V:各层保留最近一小段状态
每个模型层保留自己为近期 token 算出的 K/V,用来读取当前位置附近的内容。最近这段 token 的范围叫作局部窗口。随着新 token 被处理,新记录加入,最早的记录从当前窗口移出。
在近期窗口内读取信息的计算,叫局部窗口注意力(Sliding Window Attention,SWA)。在同时使用两路 K/V 的层里,模型既读取局部窗口,也可以从全局记录中选取较早的信息。这样能兼顾长距离信息和各层近期的状态。[12][14]
已经算过 A,为什么还可能接不上 C?
API 文档直接说明,前缀保存规则受到 SWA 影响。关键在于:能识别相同前缀,不代表从那里继续计算所需的全部状态仍在。[6]
下面用一个局部窗口宽度为 4 的教学模型:资料 A 占第 1~4 个 token,旧问题 B 占第 5~8 个。看完 A+B 后,现在要从 A 的末尾改接新问题 C。
同一时刻的两份记录:上次请求已经处理到 token 8;本次想从 token 4 后面换一个问题。
沿编号看:保留、移出、恢复
V4.1 报告 §3.2.1 回顾旧版 V4 的部署:全局 K/V 整段保留,局部窗口快照主要保存在输入结束和输出结束处。中间某个前缀的末尾,不一定有局部快照。[14]
结合报告与 API 示例,可以这样解释:先前缺少 A 末尾的局部恢复状态,需要补算;公共前缀被识别并保存后,后续请求才有对应的接续条件。这是机制解释,API 没有逐项公开线上内部过程。
V4.1 怎样改变这个约束?
报告 §3.2.2 的有界重放方案,在全局 K/V 可用、局部状态缺失时,只重新计算资料末尾的一小段来补回局部状态,降低了对长期局部快照的依赖。但这些 token 原先可能用过更早的计算结果;缺少那些结果,补出的状态未必与原来相同。[14]
KV 怎样保存、搬运并参与计算?
从缓存的存放位置出发,看 GPU 怎样按页找到 K/V,再看注意力计算怎样减少显存与芯片内部之间的数据搬运。
模型读完一份长资料,已经算好了对应的 K/V。用户暂时不再提问时,这些结果必须一直占着 GPU 显存吗?
等用户回来继续问,如果缓存搬到了别处,或分散在显存的不同位置,GPU 怎样找到并正确读取它们?
缓存不必一直占着 GPU 显存。下图把同一份 K/V 的保存、取回、读取和分别续写放在一起看。
从用户暂时离开,到回来继续提问,沿着这份已算好的 K/V 看三件事。
1暂时不用,可以移出显存
或其他机器
2放回显存后,按表找到记录
这里每页装 4 个 token 的 K/V。
3两个回答要分别续写,先分开可写的尾页
StaticCache 的“静态”,是指内容不再更新吗?
不是。StaticCache 先预留固定容量,新的 K/V 仍会写入,有效长度仍会推进;DynamicCache 随输入增长而扩展存储。前者更易配合固定形状编译,但可能预留用不完的空间。它们与“只保留最近一段历史”的滑动窗口,是不同维度的选择。[41]
模型正在读入一份很长的资料。除了保存 K/V,注意力计算还要生成匹配分数,把分数变成读取权重,再算出从各条 V 取回的内容。
这些临时结果为什么也会占用大量显存?FlashAttention 怎样解决这个困难?
先看困难:K/V 之外,还会产生两张很大的临时表
假设模型一次读入 10,000 个 token,先只看其中一层的一组 Q、K、V。把计算拆成几个独立步骤的做法是:
- 算匹配分数。每个输入 token 的 Q 与各条 K 按模型规则算出分数。把所有查询的结果排在一起,就得到一张分数表。
- 把分数变成读取权重。对同一个 Q 的分数取指数,再除以这些指数值的总和,让这一排权重加起来等于 1。这一步叫 softmax;不允许读取的未来位置会被排除。
这是普通 Transformer 已有的步骤。后面的索引向量用于先筛选记录;入选后仍需重新打分,再用 softmax 算读取权重。
- 用权重取出 V 的贡献。把每条 V 中的数字乘以对应权重;再把各条 V 的第一个数字相加、第二个数字相加,依次得到输出中的每个数字。
如果实现先把完整方表存进显存,10,000 行、10,000 列就是 1 亿个数。按每个数 2 字节计算,一张表占 200 MB;若分数表和权重表各留一份,就是 400 MB。这是一个采用完整方表的存储算例:未来位置虽然不能参与读取,方表仍为它们留了位置;这里只计一层的一组 Q、K、V,MB 按十进制计算。
困难还不只是放不放得下,还包括数据在两处之间搬运。显存用来保存大量数据;计算时,要把需要的部分读入 GPU 芯片内部的高速存储,例如寄存器、片上 SRAM,供计算单元处理。
如果打分、softmax 和组合 V 分开执行,中间结果就可能先写到显存,下一步再读入芯片内部。下图用箭头把这条路径画出来。
FlashAttention:把一小块数据的计算连着做完
FlashAttention 是一种注意力计算方法。它把一小块 Q、K、V 读入芯片内部,让临时分数在那里继续用于后续计算,避免把完整分数表、权重表写到显存。对照下面两条路径,就能看出省在哪里。
两幅图都把显存放在左边、GPU 芯片内部放在右边。沿箭头看数据去向;橙色标出普通分步实现需要保存的完整临时表。数据都分批搬运,不会把整张大表一次塞进片上存储。
普通分步实现:算完一张表,先写到显存,下一步再读入
算出匹配分数
变成读取权重
再逐项相加
FlashAttention:读入小块后,在芯片内部连续算下去
按小块取用
继续以上计算 ↻
选读:分块计算,softmax 为什么仍能得到原来的结果?
把规模缩成 3 个输入 token,只跟踪第 3 个 token 的 Q。假设三个匹配分数取指数后为 1、1、3,对应 V 为 0、0、10;本例每条 V 只用一个数字说明。
先不急着除以总和,保留“指数值总和”和“指数值乘 V 后的总和”:
- 第一块有第 1、2 条记录:指数值累计为
1 + 1 = 2,V 贡献累计为1 × 0 + 1 × 0 = 0。 - 第二块有第 3 条记录:两个累计量分别更新为
2 + 3 = 5和0 + 3 × 10 = 30。 - 所有块处理完:统一相除,得到
30 ÷ 5 = 6。这与权重1/5、1/5、3/5分别乘 V 后相加的结果相同。
不能把每块独立计算的答案直接平均:第一块是 0,第二块是 10,平均得到 5。那样会丢掉两块应占的权重。
实际代码还要处理一件事:指数太大,怎么办?
分数很大时,取指数可能超过计算机能表示的范围。做法是先减去目前见过的最大分数,再取指数。这相当于把指数值同时缩小相同倍数;V 的贡献也缩小同样倍数,最后相除的结果不变。
如果后一个块出现了更大的分数,旧累计结果也要一起调整。上例第二块到来后,把指数值统一缩小 3 倍:第一块的累计 2 变成 2/3,第三条的 3 变成 1,V 贡献总和变成 10。最后 10 ÷ (2/3 + 1) = 6,结果仍然一样。
所以,实际实现为每个查询保留最大分数、指数值总和、V 贡献累计,每来一块就更新它们,最后完成除法。数学结果等价;有限精度下,计算顺序改变仍可能带来舍入差异。[49]
选读:把上述步骤写成更新公式
m 是已处理记录的最大匹配分数,ℓ 是减去这个最大值后得到的指数值总和,z 是用这些指数值乘 V 后的累计结果。新块的分数为 sᵢ,对应的 V 为 vᵢ:
α = exp(m − m′)
ℓ′ = αℓ + Σ exp(sᵢ − m′)
z′ = αz + Σ exp(sᵢ − m′)vᵢ
α 把旧累计结果换到新最大值对应的尺度;所有块处理完后输出 z/ℓ。开始时 m = −∞、ℓ = 0、z = 0。只累计允许读取的位置;被因果遮罩排除的未来 token 不参与。
它省掉了什么,仍然要做什么?
省掉的是两张完整临时表的存储,以及围绕它们的显存读写。减少这些搬运可以提高速度,但这不意味着删掉历史 K/V,或跳过原本应参与的 Q、K 匹配计算。
| 方法 | 主要省在哪里 | 仍然需要什么 |
|---|---|---|
| 前缀缓存 | 相同输入的 K/V 不必重新生成 | 新查询仍需读取历史并计算 |
| PagedAttention | 无需预留一整块连续显存,减少空间浪费 | 每条 K/V 本身仍要占用空间 |
| FlashAttention | 不把完整分数表和权重表存进显存 | 仍读取参与本次注意力计算的 K/V |
这些技术可以配合使用。FlashAttention 的公开缓存接口可接受页表;具体实现是否同时支持分页、量化和窗口注意力,仍需分别核对。[48]
模型结构与数值格式,怎样改变缓存开销?
先算实际保存多少组数字,再看量化、跨层共享和短记录怎样减少空间与计算。
一个模型有 32 个注意力头。我把缓存按 32 份去算,却发现框架的实际数值小了几倍。到底是框架漏存了,还是我把“头”数错了?
缓存容量应该怎样算?GQA、MQA 究竟少存了什么?
查询头的数量,不一定等于实际保存的 K/V 组数。先固定一个模型层,按单个 token 计算缓存大小:这个例子有 8 个查询头,每 4 个头共用一组 K/V,共 2 组。
假设每个 K 和 V 都各有 4 个数,每个数占 2 字节:一组 K/V 占 (4+4)×2 = 16 字节,两组共 32 字节。再按保留的 token 数、各层的实际形状累加,就得到整份缓存的基本大小。
多个查询头各留一组 K/V,是多头注意力(MHA);所有查询头共用一组 K/V,是多查询注意力(MQA);在两者中间,几组查询分别共用 K/V,是分组查询注意力(GQA)。这是头之间的共享,不是请求之间或层之间的共享。[39]
大王:这里指同一层中,不同查询头之间共享 K/V。固定同一个 token 来看,它在不同头里产生各自的 Q,这些 Q 可以共同读取一套历史 K/V。
例如已经有 5 个输入 token,下面只看第 5 个 token 在这一层的计算:它产生 8 份 Q,各查询头一份。头 1~4 共同读取 A 组历史 K/V,头 5~8 共同读取 B 组;每组都仍保存第 1~5 个 token 各自的 K/V。
如果忘记了层与头的关系,可以回看 FIG 01「一个层里有多个头;层与层依次传递结果」。
固定同一层,已有 5 个输入 token。图中 8 个 Q 都属于第 5 个 token,分别由头 1~8 产生。A、B 两组缓存都保留第 1~5 个 token 各自的 K/V。
并行① 同号虚线框之间可并行,先后依赖仍看箭头;编号只在本图内配对。
查询头 1~4
token 1~5,各有自己的 K、V
查询头 5~8
token 1~5,各有自己的 K、V
八个头都标“并行①”。本层的 Q/K/V 备好后,这八项注意力可以并行;A、B 是缓存的分组,不是必须先后执行的两批计算。
共用 K/V,不要求 Q 相同。各头用自己的 Q 读取所属组的记录,匹配分数、softmax 权重和输出仍可不同。
“一组 K/V”是一套按 token 位置保存的历史记录。A 组中仍有第 1 个 token 的 K/V、第 2 个 token 的 K/V,一直到第 5 个 token 的 K/V。它们共同供头 1~4 使用,并没有把这 5 个 token 合成一对 K、V。
大王:这也会发生。例如头 1 中,第 4 个 token 的 Q 和第 5 个 token 的 Q,都能读取第 2 个 token 已有的 K/V。这种沿 token 序列的复用,在普通 MHA 中本来就有;GQA、MQA 还让不同查询头共用同一套历史。[39][40]
沿用图里的 5 个 token、8 个查询头:MHA 为每个 token 保存 8 对 K/V,共 40 对;分为两组的 GQA 共 10 对;MQA 共用一组,共 5 对。减少的是 K/V 的头数,每个 token 的记录仍然保留。
| 教学配置:32 层、32,768 token、每头 128 维、每数 2 字节 | KV 头数 | K/V 基本载荷 |
|---|---|---|
| MHA:32 个查询头 | 32 | 16 GiB |
| GQA:仍是 32 个查询头 | 8 | 4 GiB |
| MQA:仍是 32 个查询头 | 1 | 0.5 GiB |
表中只计算 K/V 数字本身,未计页表、对齐、临时工作区等;1 GiB = 2³⁰ 字节。三行是不同的架构选择,不能直接删掉已训练模型的 KV 头来获得同样输出。[39]
我看到某个缓存标成“4 bit”,以为内存一定只剩原来的四分之一。但程序里还有 scale、解包和临时张量;有时换了格式反而更慢。
KV 量化怎样真正省空间?为什么存得更小仍可能跑得更慢?
把每个数字用更少的位保存,叫量化。直觉上像把精细刻度换成较少档位:存的是档位编号,还要知道这一组数字的量级。恢复后通常只是原数的近似值。
例如,这一组每档代表 0.1:原数 0.32 可以记成档位 3,读取时算 3 × 0.1 = 0.3。少存了位数,也丢掉了一点精度;“每档 0.1”这个缩放系数(scale)也必须保存。
先算真正保存的字节,再看压缩后的数据怎样参与注意力计算。
同样保存 16 个数
缩放系数也占空间。这里每 16 个数共用一个 4 字节系数,说明每档有多大。例如档位 3、每档 0.1,还原值就是 0.3。
读取时怎样还原,也会影响速度
先还原整段
读低位数据 → 还原成较高精度 → 写回显存 → 注意力再读取
要额外保存、搬运还原后的数据。边读边还原
读一小块 → 在计算单元附近还原并计算 → 继续下一块
减少整段数据往返,但仍要做解包和缩放。大王:不能。只有原本受缓存读取带宽限制、压缩路径被内核有效利用,而且新增开销较小,少搬数据才可能转成延迟收益。上下文较短、显存已足够时,量化反而可能更慢;应把恢复工作区也算进峰值内存。[41]
大王:K 的误差先改变匹配分数,再通过 softmax 改变读取比例;V 的误差直接进入汇总结果。因此,两者可以采用不同的量化方式,是否合适要看模型和任务。[42]
如果程序把低位数据全部还原成较高精度张量后再保存,就不能只按压缩文件的大小估算内存。附录 C 会分别记录打包数据与缩放系数的实际字节数。
YOCO 的全称是 You Only Cache Once,直译是“只缓存一次”。但一个 token 仍然要经过模型的多层计算,这个“一次”到底在数什么,很容易混淆。
它是让一个 token 只计算一次,还是让多个层共用一份历史 K/V?哪些层共享,前面的层还需要保存自己的状态吗?
YOCO 是一种大语言模型架构。它让后半多个层共读前半生成的全局 K/V,减少重复保存,并让部分输入位置省去后半计算。代价是后半各层不再独立生成各自的全局历史;模型需要按这种结构训练。[7][8]
原论文把模型分成前半的 Self-decoder 和后半的 Cross-decoder:前半先得到上下文表示,由它生成共享的全局 K/V;后半各层通过各自的查询读取这份记忆。[7]
大王:对于原始 YOCO,后半层没有另外保存各层私有的局部 K/V。它们用自己的 Q,读取前半统一提供的全局 K/V。
前半则仍需要保留自己的状态:采用滑动窗口时,各层保存最近一段 token 的 K/V;采用递归变体时,保留的是不断更新的状态,不能直接称为“最近若干 token 的局部 K/V”。[7][8]
没有单独的局部缓存,不意味着看不到最近的 token。例如,共享全局缓存包含第 1~1,001 个 token 的记录,那么最近的第 990~1,001 个也在其中,后半层照样可以读取。
大王:有自己的 Q 和后续计算,不等于生成了自己的 K/V。普通 Transformer 通常每层都生成自己的 Q、K、V;YOCO 改变了这个安排:后半各层生成自己的 Q,K 和 V 由前半统一提供。注意力需要使用 Q、K、V,但不要求三者都由当前层生成。
沿用 4 个模型层的例子,先看首次处理 1,000 个已知输入 token,再看怎样逐个生成回答。两个阶段的区别,主要在于哪些位置还需要经过后半层。
① 首次读入 1,000 个已知 token(Prefill)
共享缓存尚未建立。为了开始回答,本次只需要最后一个输入位置的预测,用它选出回答的第 1 个 token。
② 把选出的第 1,001 个 token 送回模型,预测第 1,002 个(Decode)
这次送回模型的 token 1,001,仍要经过全部四层。它在前半生成自己的 K/V;后半各层用自己的 Q 读取更新后的共享缓存,继续计算。
蓝线:第 1,001 个 token 的中间计算绿线:K/V 的写入与读取
参考代码中,后半注意力模块有生成 Q 的 q_proj 和处理结果的 out_proj,K、V 则从外部传入。[8]
后文的 DeepSeek V4.1 Flash 采用了另一种组合:共享全局 K/V,同时维护各层自己的局部窗口 K/V。它的参考代码另设 window_kv_cache,为各层生成并保存局部记录。它有局部缓存,是因为架构安排了这条计算与保存路径,不能仅凭“后半层还在做注意力和前馈计算”就推断出来。[12]
上下文越长,固定大小的局部窗口所占比例越小。
两部分仍按因果顺序运行:当前位置只能使用允许看到的历史,后续新 token 还会再经过前半和后半。这里的“前半像编码器”不意味着它可以双向读到未来。[7]
一段输入有许多 token,模型又有许多计算层。我能理解多个层共用缓存会少占内存,但还不明白:哪些 token 可以少经过几层,哪些仍然要经过全部层?
YOCO 省掉的是哪些 token、在哪些层里的计算?
场景:处理已给定的 1,000 个输入 token,准备生成回答的第一个 token。模型有 4 个计算层;横向是 token 顺序,纵向是模型层。
格中“计算”:在这一层读取信息,再加工得到的结果(前馈处理)。
格中“生成 K/V”:用上一层结果生成本层缓存,不是取回旧缓存。
每层都是完整计算块;输入转换与最终输出层另计。四层仅为教学示例。
普通 Transformer
YOCO
哪些模型已经采用?
下面是截至 2026 年 9 月 30 日已核对的代表案例:DeepSeek、Google 的相关模型采用 YOCO 改进方案或同类跨层 K/V 共享;同时列出 GLM、Kimi、Qwen 的不同做法,便于比较。
| 厂商 | 核对的模型 | 采用情况与区别 |
|---|---|---|
| DeepSeek | DeepSeek-V4.1-Flash | 明确借鉴 YOCO 并改造。官方报告写明其结构受 YOCO 启发,并结合分组共享等设计;公开代码确实复用其他层产生的 K/V。[14][12] |
| GLM/智谱 | GLM-5.3 GLM-5.3-Flash | 所查实现不是 YOCO。5.3 的 IndexShare 共享的是“选中了哪些历史位置”,K/V 仍由各层分别生成;Flash 则混合线性与稀疏注意力,各层维护自己的状态。[15] |
| Kimi/月之暗面 | Kimi-K3 | 所查实现不是 YOCO。采用混合注意力,不同层分别维护自己的状态,保存历史 K/V 的层也各自生成缓存。[16] |
| Qwen/阿里 | Qwen3.8-2.4T-A95B Qwen3.8-Flash-Next | 所查实现不是 YOCO。采用混合注意力;需要历史 K/V 的层分别生成自己的 K/V,没有采用后半多层共读前半一套缓存的结构。[17] |
| Gemma 3n | 已采用同类跨层共享设计。中间层产生的局部和全局 K/V,交给后面的多个层共同使用。[18] |
对比限定于表中已核对的公开型号;“同类跨层共享”不等于完整照搬 YOCO。
已有评估结果:DeepSeek 报告称,其受 YOCO 启发的 CED 结构将输入处理计算量减少近一半,同时保持与基线相近的效果。这是厂家的报告结果。[14]
进阶选读:少存几个数字,怎样继续计算注意力?
有些模型每条历史只保存一串较短的数字,真正的 K、V 可以由它生成。可如果读取时还要逐条展开,省下的空间会不会换来更多计算?
少存几个数字,怎样继续计算注意力?
有些模型从设计时就规定:每条历史先生成一串较短的数字,再用固定的线性变换得到 K、V。这串较短的数字称为潜在表示(latent representation)。
如果展开规则允许,一部分工作可以移到查询这一侧:当前查询先变换一次,再直接匹配很多条短记录。下面用两个数生成三个数的 K 来看。
本例只演示线性关系下、缩放前的匹配分数;不含位置旋转。
模型规定:用两个数生成三个数的 K
同一个查询 Q = [1, 0, 2],两种算法
把每条历史的 K 展开,再匹配
1×2 + 0×1 + 2×3
匹配分数:8先改写一次查询,再匹配短记录
查询变成 [1+2, 0+2],即 [3, 2]。
3×2 + 2×1
匹配分数:仍是 8查询变换一次,就能对很多条短记录重复使用。这样不必为了当前查询,把每条历史都先展开成完整 K。
V 如果也由短记录作线性变换得到,还可以先按读取权重汇总短记录,再把汇总结果展开。成立条件是模型具有这样的计算关系;位置旋转或非线性操作不能随意交换顺序。
这里仍为每个历史位置保存记录,只是每条更短。把历史贡献合并成固定大小的状态,是另一种结构;下一问用一个小矩阵演示怎样写入与读取,恢复条件另见附录 A 题三。
把每条历史记录变短,仍然没有改变记录数量:每多读入一个 token,就要多保存一条。文本越来越长,历史记录还是会不断增加。
能不能把新信息合并进一份大小不变的状态?不再逐条保存历史后,新 Q 靠什么读出所需信息?
可以按另一种注意力规则设计模型。下面介绍一种经典的线性注意力:先按约定函数变换 Q、K,再用它们计算相似程度。这个计算形式允许把历史贡献累加起来,让新查询直接读取累计状态。这里的“线性”指在维度固定时,处理整段输入的主要注意力计算量随长度线性增长。[68]
固定一个模型层的一个头。假设变换后的 K 有两个数,V 也有两个数,只需维护一个 2×2 的矩阵 S,以及一个含两个数的向量 z。S 累计“变换后的 K 与 V 的各项乘积”,让当前 Q 算出按匹配分数加权的 V 总和;z 累计变换后的 K,让同一个 Q 算出匹配分数的总和。最后用前者除以后者,把原始分数变成总和为 1 的读取比例。下面直接给出变换后的数字,先看状态怎样写入、怎样读取。
已给定 2 个输入 token;每个位置先把自己的 K/V 计入状态,再用自己的 Q 读取。初始 S 和 z 全为 0。矩阵和向量里的每个方格是一项数值;矩阵的行、列是数值分量,不是 token 的位置。下方是同一状态在两个时刻的样子。
第 1 个位置
K 的每个数,分别乘 V 的每个数
这四个数加进 S;变换后的 K 加进 z
第 2 个位置
K 的每个数,分别乘 V 的每个数
这四个数加进 S;变换后的 K 加进 z
第 2 个位置查询:变换后的 Q=[1, 2]
用已保存的 S 算出按分数加权的 V 总和,用 z 算出分数总和。两边使用同一个 Q。
从 S 读出加权和
第一个数:1×2+2×4=10
第二个数:1×6+2×3=12
从 z 算出分数总和
1×3+2×3=9
z 是 [3, 3];9 是当前 Q 与 z 的点积。
为什么最后要除以 9?
把两条记录展开,看看这个 9 代表什么。下面只是解释同一计算;实际递推读取不必重新保存或扫描这两条 K。
把总权重分成 9 份:第 1 条占 5 份,第 2 条占 4 份
按比例读取:5/9 × [2, 0] + 4/9 × [0, 3]
这里直接归一化变换后的 Q、K 算出的非负匹配分数,没有再做一次 softmax。普通 softmax 注意力也让读取权重之和为 1,但它先对匹配分数取指数,再归一化。
小王不再逐条保存历史,具体省下了什么?这些信息合并之后,又会失去什么?
大王:下面比较本问这套线性注意力与保留逐条历史 K/V 的普通全注意力;固定模型层数、头数与向量维度,关注推理时的注意力状态和计算。
小屏可左右滑动查看表格。
| 比较的方面 | 优势 | 代价与条件 |
|---|---|---|
| 历史越长,要保存多少? | 递推只保留当前 S、z,大小不随 token 数增加。长会话不必为这一头不断追加历史记录。 | 各条记录的贡献已经混合,不能像完整 K/V 缓存那样按原位置单独取出一条。固定大小、有限精度的状态,不能保证无损保留任意长历史的所有细节。 |
| 每生成一步,要读取和计算多少? | 新 Q 读取固定大小的 S、z,无需逐条扫描全部历史。维度固定时,整段注意力计算量随长度线性增长。 | 矩阵更新、特征变换仍有开销;短输入时,固定矩阵未必比少量 K/V 更小、更快。实际速度还取决于计算实现与硬件,不能只看“线性”二字。 |
| 能直接替换现有模型吗? | 同一种线性注意力规则既可逐条求和,也可按 S、z 递推;无需为了当前查询重算所有旧记录。 | 它改变了普通 softmax 注意力的计算规则,需要相应的模型设计与训练。不能只换缓存容器,就保证原模型的答案与能力不变。 |
状态大小、更新和读取方式可直接从作者实现核对;计算量的比较见论文 §3.2–3.4。[68][69]
回到旧位置,需要旧状态或撤销更新所需的信息。当前 S、z 不会自动留下每一步的旧版本。可以保存快照、从较早位置重算;图中的加法累计若保留了待撤回的贡献,也可按相应规则撤销更新。推测解码试算了四步却只接受两步时,不能直接“删掉最后两条 K/V”;具体办法见状态回退,不同保存方式的比较见附录 A 题三。
图中逐个位置演示,是为了看清更新。已知整段输入时,仍可利用批量计算和前缀累积组织计算;不必把每个位置的整个模型都串行跑完。如果模型混用了保留完整历史的注意力层,整份模型状态也未必固定大小。
DeepSeek-V4.1-Flash 如何减少缓存和计算开销?
长输入带来三类开销:保存历史占内存,读入长文本需要计算,生成每个新 token 时还要查找历史。DeepSeek-V4.1-Flash 分别从这些地方减少开销,也作出了相应取舍。
前面说,K 用来判断该关注哪些位置,V 提供实际读出的信息。这是两种不同用途,我会以为必须分别保存两组数字。
同一组数字怎样同时充当 K 和 V?这是把现有模型的两份缓存直接合并,还是必须让模型从设计和训练时就适应这种用法?
先跟踪第 3 个历史 token 的记录 m₃:只存一份,却参与两种不同用途的计算。
同一个存储对象,接出两条使用路径
我的理解是:RoPE 只对 Q 和 K 进行位置编码。所以,在采用 RoPE 的模型里,K/V 共享时,缓存中保存的应该是经过位置编码的 K,以及没有位置信息的 V。
读取缓存后,再针对当前 Q 的位置,对 K 进行逆 RoPE。
这个理解对吗?缓存、读取和逆旋转,到底怎样衔接?
“旋转后的 K,加上没有做这次旋转的 V”是常见的缓存形式;但读取后并不先对 K 做逆 RoPE。当前 Q 按自己的位置旋转,直接与缓存 K 比较。文中那道逆旋转发生得更晚:先把读出的信息按权重相加,再处理得到的新结果。
先澄清“共享”:多个头或层共用一组 K/V,不等于 K 和 V 本身是同一个向量。这问讨论上一问的后一种设计。旋转位置编码(RoPE)让位置参与 Q、K 的匹配;这种特殊设计又把已旋转的同一份向量拿来当 V。
RoPE 的基本公式与相对位置
固定一个注意力头。把 Q 或 K 中任意一对数字记为 (a, b),该 token 的序列位置记为 p。基本 RoPE 对这对数字作如下旋转:
θ 是这一对数字每前进一个位置所增加的角度,单位为弧度;不同数字对可以使用不同的 θ。p 是序列位置,不是词表 ID,也不是缓存的显存地址。
把整个向量的旋转记为 R(p)。查询位置为 m,被读取位置为 n;qm、kn 是旋转前的向量,T 表示转置。固定旋转频率时:
两边分别旋转后再匹配,角度关系合并为相对位置 n−m。移动 K/V 的存储地址不会改变这些位置;更换前文则还可能改变旋转前的向量,不能只调整旋转角度就认定旧缓存可用。[71]
先分清两种缓存形式,再沿着“同一份向量兼任 K、V”的路径,算出一次读取的新结果。
1先看缓存:两份分别存,还是一份兼任?
本节的设计:同一份向量兼任 K、V
当前 Q 仍与缓存 K 直接比较。因为当作 V 读出的数字也带着旋转,后面还要处理读出的新结果。[12]
这里不先对 K 做逆 RoPE。Q、K 的相对旋转已经在匹配计算中体现。
2再算读出:每条乘权重,然后同一位置的数字相加
下面只跟踪共享向量的方案。当前处理第 10 个 token,简化为只读取第 8、9 条记录,每条只画两个数。表中的数字已经是缓存里旋转后的值。
Q、K 先通过对应数字相乘、相加算出匹配分数,这是点积打分;分数经必要的缩放、屏蔽后,再由 softmax 转成各条记录的读取权重。softmax 是注意力内部的一步,不是额外的模型层。本例给定权重 0.75、0.25,下面看它们怎样作用于 V 的数字。
0 × 0.75 = 0
4 × 0.25 = 1
这就叫“加权汇总”。它把两条记录各自的一部分信息合成一份新向量,结果仍然是两个数。没有把两条缓存拼接成四个数,也没有改写它们。
3最后才逆旋转:处理刚得到的新向量
仍用教学设定:每个位置多转 10°,当前查询位置是 10。现在对刚汇总出的这两个数反向旋转 100°,而不是回头改缓存里的 K。
这里 −100° 指“再转多少”,不是结果的最终朝向。只旋转新结果一次,等价于把各条加权贡献都这样旋转后再相加;它让这些贡献按相对于当前查询的位置来表达。
“V 没有位置信息”也要收窄为“V 没有施加这一层的 RoPE 旋转”。V 来自模型的中间计算,可能已经包含前面层形成的位置相关信息;未做这次旋转,不等于完全没有位置信息。
对照代码:缓存写入、乘权重相加、输出逆旋转
Llama 的常见实现先对 Q、K 调用 apply_rotary_pos_emb,再将 K、V 交给缓存;所以存入的是已经旋转的 K,以及没有做这次旋转的 V。[11]
文中共享向量的实现先旋转并保存 kv。随后 sparse_attn 用它与 Q 算分数,又用同一份数据完成“各乘系数,再逐项相加”。最后才对这份输出调用 apply_rotary_emb(..., inverse=True)。[12]
匹配分数 = 当前旋转后的 Q,与缓存 K 作点积
读取权重 = 把匹配分数转换成每条记录的系数
读出结果 = 每条缓存向量乘自己的系数,再把同一维度的数相加
后续输入 = 对读出结果的 RoPE 部分,按当前查询位置逆旋转
这是按相应方式设计和训练的模型,不是给任意模型合并 K、V 就能直接得到同样的效果。num_key_value_heads = 1 也只说明 KV 头少,不能单独证明 K 与 V 是同一个向量。
我们通常在旧对话后追加新内容,把完整历史一起发给模型。
既然模型已经收到这些信息,为什么生成下一个 token 时,还要筛选历史?
因为“历史都可以使用”和“这一步让哪些历史参加计算”是两回事。
假设对话没有被截断或替换成摘要,你提交的完整历史都属于本次上下文。模型会为这些输入生成 K/V,或者复用已经保存的 K/V。
但是,缓存只省掉了历史 K/V 的重复生成。每生成一个新 token,模型仍要根据当前计算产生 Q,用它寻找需要的历史信息,再把读出的信息组合起来。
如果采用完整注意力,就让全部允许访问的历史 K/V 参与这个过程。历史很长时,每一步都要读取大量数据、进行大量计算,即使显存放得下,也不意味着读得快。
DeepSeek V4.1 Flash 的一种做法是:先用较短的索引向量给历史记录评分,选择一部分全局 K/V,再让主注意力读取这些记录,同时读取近期局部窗口的 K/V。[14]
和普通 Transformer 相比,索引筛选多做了哪一步?
下面只看某一层里,一个查询怎样使用已经准备好的 K/V。可以对照前面介绍的 softmax来看:先确定哪些记录参与,再计算各条 V 的贡献。
| 比较什么 | 普通 Transformer 完整注意力 | DeepSeek V4.1 Flash 索引筛选+主注意力 |
|---|---|---|
| 哪些记录参与主注意力? | 本次允许访问的全部 K/V 记录。 | 索引选出的一部分全局记录,加上本层近期局部窗口的记录。 |
| 怎样打分? | 主 Q 与这些记录的主 K 直接计算匹配分数。 | 先用较短的索引 Q、索引 K 打分并选记录;主 Q、主 K 再对实际参与的记录重新打分。 |
| 怎样决定 V 的贡献? | 主注意力分数经过 softmax,得到读取权重,再用这些权重组合 V。 | 同样由主注意力分数经过 softmax得到读取权重。索引分数只用于筛选,不能直接拿来乘 V。 |
| 哪些计算仍要做? | 即使某条记录最终权重很小,完整注意力仍会计算它的分数,并让它按权重贡献 V。 | 未入选且不在局部窗口中的记录,本次不进入主注意力计算;它的 K/V 可以继续留在缓存中,供以后使用。 |
DeepSeek 有些层会沿用前面已经选好的名单;但这些层仍用自己的主 Q 计算读取权重。普通注意力对照原始 Transformer 论文 ↗,索引筛选对照固定版本报告与代码。[12][14]
因此,完整发送 100 轮对话,不代表生成回答的每一步,都要对这 100 轮对应的全部记录做同样完整的注意力计算。它们可以留在缓存中,供不同步骤选择。
筛选省的是每一步使用历史的工作,但也可能漏掉有用信息。索引本身还要占空间、花时间,所以历史较短时,全部计算也可能更划算。
你说索引向量是“用训练得到的系数变换”算出来的。可是模型训练时,还没有见过我今天提交的资料。
这些系数是什么?没见过我的资料,怎么提前学好?
提前学好的是怎样处理输入的计算规则。你提交资料后,模型才根据这份资料计算新的数字,再把它们代入已经学好的规则。
例如,把当前记录简化为 [2, 1],计算某个索引数字的规则是:
第一个数乘 0.6,第二个数乘 −0.2,再相加。
2 × 0.6 + 1 × (−0.2) = 1.0
2、1 来自这次读到的内容;0.6、−0.2 是训练后保存在模型里的参数。换一条记录,如果输入变成 [5, 3],同一规则就算出 5 × 0.6 + 3 × (−0.2) = 2.4。内容和计算结果变了,普通推理时的参数没有变。
这里的 1.0 或 2.4 只是索引向量里的一个数字,还不是最终相关性分数。用更多组参数生成索引向量后,才把历史记录的索引 K 与本次查询的索引 Q 拿来打分。上面的数值用于讲解,不是模型实际参数。[12]
还要分清两种容易都被叫作“权重”的数字:
- 模型参数:例如
0.6、−0.2,训练后保存下来,对不同输入应用同一套变换。 - 本次读取权重:例如第 20 问的
0.75、0.25,由当前查询与具体历史的匹配结果算出,决定这次各条 V 贡献多少。
参数为什么会学成这些值,还需要解释训练时用什么纠正它的判断。下一问把这个过程展开。
我知道有一种 mask 任务,会遮住文字,再让模型补出来。但筛选历史时,谁告诉索引器哪些记录重要、哪些可以少看?
模型怎样学会判断相关性?DeepSeek V4.1 Flash 为此增加了新的训练任务吗?
可以给索引器单独设置一个训练目标,让它学习应该怎样分配分数。先分清两个都可能被叫作 mask 的操作:
- 遮住文字,再预测原文:例如把“小王住在北京”改成“小王住在
[MASK]”,要求补出“北京”。这是掩码语言建模,BERT 使用过这种任务。[38] - 计算当前位置时,不许偷看后文:模型根据前文预测下一个 token,训练程序用正确的后续文字检查预测。禁止读取未来位置的规则叫因果注意力遮罩(causal attention mask)。它规定哪些位置可以看,没有直接告诉模型哪些历史最值得看。
V4.1 Flash 的语言主体采用逐步预测后续 token 的因果模型。筛选器怎样学会选历史,是另一个需要说明的训练问题。[14]
一个公开得很具体的实例来自 DeepSeek V3.2:让主注意力先完整处理历史,再让小索引器模仿它的读取分配。主注意力负责实际读取 K/V、汇总信息;索引器负责用较小的计算预选记录。下面画的是 V3.2 训练索引器的起步阶段,称为密集预热(dense warm-up)。[37]
先有已经训练过的 V3.1-Terminus 基础模型,再加入索引器;主模型只在索引器预热期间暂停更新,后面还会继续训练。[37]
V3.2 的密集预热阶段:主模型参数暂时保持不变,只调整索引器。三条记录及百分比均为教学设定。
每条横条的全长均表示 100%;同类分配在 A、B、C 上合计 100%。
把这种差异,变成修改参数的依据
KL 散度:比较参照与索引器的分配
固定图中这个查询位置,令 pi 为主注意力给历史记录 i 的参照权重,ri 为索引器经 softmax 得到的权重;两组权重各自加起来等于 1。
∑i 表示对这些历史记录求和,ln 是自然对数。KL 散度不小于 0;两种分配完全一致时为 0。这里比较的是参照 p 对索引器 r,交换两者通常会得到不同结果。
代入上图:p=(0.45, 0.45, 0.10),r=(0.10, 0.20, 0.70),得到约 0.847。[37]
这与信息熵、交叉熵分别用在哪些训练任务?见图后的对照。
用 KL 散度衡量分布差距,得到训练误差。
沿计算过程往回求误差怎样随参数变化,这叫反向传播。
小幅更新生成索引和打分所用的系数,使分配接近参照。
在大量样本上反复计算、比较、调整。训练完成后保存这些参数,用它们给新输入打分。
小王这里让索引器模仿主注意力,用的是 KL 散度。训练模型预测下一个 token 时,又常说交叉熵;还有信息熵。它们分别适合什么训练任务?
先看训练要达到什么目的,再选衡量方法
大王:有时要让模型按正确答案学习,有时要模仿另一套概率分配,有时只是希望它多尝试几种选择。三个量关注的事情不同,但并不对应三类互不相干的训练任务,同一次训练也可以组合使用。
下面用 ri 表示待训练模型给候选 i 的概率,pi 表示已有的目标概率。候选可以是词表 token、类别或历史记录;同一次比较必须对应同一批候选。公式使用自然对数 ln,∑i 表示把各候选的项相加。
小屏可左右滑动查看表格。
| 量与含义 | 适合什么训练目标? | 训练时怎样使用? |
|---|---|---|
| 信息熵只看 r 自己:选择有多分散? | 鼓励探索。例如强化学习中,除了追求奖励,还希望策略不要过早只选一种动作,保留尝试其他动作的机会。 | 可以给较高的熵增加奖励,等价于从要最小化的总损失中减去一项加权熵。熵小只说明更确定,不说明更正确:几乎全押错的答案,熵也很小。[73] |
| 交叉熵按目标 p 检查:模型给应当出现的结果多少概率? | 按已知答案学习。常见于多类别分类、下一 token 预测的预训练、掩码补词,以及用示范回答做监督微调。目标也可以是一整组概率,不限于唯一答案。 | 最小化交叉熵,让模型提高目标要求的概率。若正确答案是“甲”,目标给甲 100%,单次损失就是 −ln r甲;不是先抽一个 token 再判对错。[72] |
| KL 散度DKL(p ∥ r)两套分配相差多少?公式见上图。 | 学习一个参照分布。例如让学生模型模仿教师给各候选的概率,称为知识蒸馏;本问则让索引器模仿主注意力对历史的分配。 | 在这些例子中固定参照 p,调整 r,使 DKL(p ∥ r) 变小。它比较整套分配,不只是最高分选项是否相同;交换方向,训练侧重点可能不同。[37][74] |
交叉熵和 KL,为什么有时可以互换?
固定目标 p、只更新模型预测 r 时,H(p) 不随训练改变。所以在相同概率定义与加权方式下,最小化交叉熵,和最小化这个方向的 KL,会给 r 相同的更新方向。这里的 H(p) 是目标自身的熵,不是上表用来鼓励探索的 H(r)。[72]
沿用前文 MoE 训练的例子:正确 token 是甲,模型只给它 20%,交叉熵为 −ln 0.2 ≈ 1.609。这个样本的目标是“甲 100%、其他 0%”,自身的熵为 0,因此对应的 KL 也等于 1.609。若教师给的是多个候选的概率,交叉熵同样可以用来学习它。
MoE 的“专家使用均衡”不能直接等同于信息熵越大越好。让每个 token 对所有专家都同样犹豫,与让整批 token 的工作合理分散,是两件事。前文 Qwen3-MoE 的实现使用专门的均衡辅助损失,并把它加在预测损失之外。[65]
主注意力的参照又是怎么来的?主模型此前已经接受文本预测训练:根据前文预测后续 token,用预测与正确文本的差距调整参数,其中包括注意力里的参数。索引器利用这套已经学到的读取方式作为参照,不需要人工给每条历史标注“相关/不相关”。
V3.2 后续进入真正的稀疏训练时,主模型继续按文本预测误差调整,索引器则按选中记录上的分配差异调整,两套训练信号分开。训练完成后,实际使用时先让索引器选记录,再做主注意力;不需要每次回答都先完整计算一遍主注意力来当参照。[37]
这里的“相关”,是索引器在近似主注意力对历史的选择。权重高不等于已经证明该记录对正确答案必不可少;筛选有没有漏掉关键信息,还要用任务结果检验。
所以,额外的筛选训练目标在 V3.2 就已明确存在,它不是一种新的 mask 补词任务。对于 V4.1 Flash,公开报告说明了索引器参数的优化与梯度汇总,但没有同样完整地给出索引器训练误差的定义。可以确认它参与训练,不能据此认定其完整训练方法与上图相同。[14]
按普通的逐 token 生成顺序,第 9 个 token 已在上一步选出。现在把它作为新输入送进模型,准备生成第 10 个 token;前面第 1~8 个位置的 K/V 仍有效地保存在缓存里。
假设某一层的全局注意力选中了位置 2、9 的记录。
没选中的第 1、3~8 条要重算吗?第 2、9 条又怎样参与生成第 10 个 token?
第 1、3~8 条不需要重算,第 2 条也不需要重算。第 2 条从旧缓存直接取出;第 1、3~8 条继续保留,只是不参加刚才这一层的全局主注意力计算。
第 9 条是本轮为新输入准备的 K/V。第 9 个 token 上一步刚被选出,现在才作为输入参与这一轮计算。因此,这里的第 9 条属于本轮新增的状态,不能与前面第 1~8 条旧缓存混为一谈。
发起查询的是第 9 个位置在当前层产生的 Q₉。它读取所选记录,影响第 9 个位置接下来的内部数值;模型最后根据这个位置的计算结果,选出第 10 个 token。
已知:第 9 个 token。本轮目标:选出第 10 个 token。前两步展开某一层、一个注意力头的两条记录;第三步接到后续模型层。
1用 K 算出各读多少
在本层的查询
第 2 条取自旧缓存,第 9 条在本轮准备。Q₉ 与 K 匹配后,经 softmax 得到读取权重;这里假设为 75% 和 25%。
2把 V 的数字乘权重,再相加
[2, 0] × 0.75 = [1.5, 0][0, 4] × 0.25 = [0, 1]第一项是 1.5 + 0,第二项是 0 + 1。得到的是本次读出的信息,还不是一个 token。
3继续模型计算,最后选出第 10 个 token
完成本层计算,传给后面的模型层
token
紫色框发生在输出层之前:有些层重新筛选,有些层沿用名单;同一次生成中,读到的记录就可能改变。
名单即使相同,各层仍用自己的 Q 算读取权重。所有层算完,才选出第 10 个 token。看下方解释
读出的信息不是直接变成文字。模型先把注意力结果作相应变换,与第 9 个位置已有的信息合并,再经过前馈网络和后续模型层。最后,输出层才给词表中的候选 token 计算原始分数,也就是词表分数(logits),随后选最高分或按概率采样,确定第 10 个 token。[12]
在生成同一个 token 的过程中,不同层就可能读取不同历史。第 9 个位置的内部数值经过前面的层后发生了变化,后面的层据此产生自己的查询,可能给历史记录打出不同分数。图中某个后续层改选第 5、8 条时,仍在为生成第 10 个 token 做计算,并没有开始生成第 11 个。
V4.1 并非每层都重新筛选。其中,共用历史缓存、根据本层索引查询重新筛选的方式叫 Reindex;连同前面选好的名单一起沿用的方式叫 Reuse。前者可以改变读取名单,后者省下再次筛选的工作。[14]
沿用名单,也不等于沿用读取权重。例如两个层都读取第 2、9 条,前一层可能分配 75%/25%,后一层可能分配 40%/60%。名单相同,但每层仍用自己的主注意力 Q 与 K 匹配,因此算出的读取比例和汇总结果可以不同;这些比例是教学举例。[12]
不连续的记录可以直接读取。位置 2、9 仍然是原来的位置,不会被重新编号成 1、2。使用第 2 条已经算好的结果,不需要把产生它时用过的前文再算一遍;读取名单也没有删除或改写原历史。
索引器为了决定选谁,可能重新给旧索引特征打分;后续层若选中第 5 条,就直接读取对应的有效缓存。重新打分和改变名单,都不等于重新生成旧 K/V。
前面讨论的 KV Cache 保存模型处理某段输入时算出的结果。DeepSeek-V4.1-Flash 还加入了一个叫 Engram 的记忆模块。
Engram 是什么?它与 KV Cache 有何不同?
Engram 是加在语言模型内部、随模型一起训练的记忆模块。它用一张属于模型权重的数字参数表保存已学到的特征:每行是一串数字,也就是一个向量;一行称为一个表项。[9][10]
它想减轻模型反复识别熟悉组合的负担。比如常见的人名、地名和固定搭配,可以先从表中取得模型已经学过的表示,再交给主模型继续理解句子和推理。[10]
连续 n 个 token 的局部组合称为 n-gram。下面把查表过程展开,再与 KV 缓存对照。
两条路径都查找数据,但数据的来源不同。下半用原文的 New York 示例展开 Engram。
KV 缓存:找回这段历史已经算出的状态
Engram:查训练表,再结合当前上下文使用
“把每个 n-gram 都存下来”也需要限定:哈希表容量有限,不是给所有可能组合各建一个无碰撞的槽。不同组合可以落到相同地址,多头哈希和训练共同处理这种压缩。与精确 KV 缓存尽量避免身份误匹配的目标相比,这里的碰撞本身属于表示设计要面对的条件。[9]
V4.1 的官方配置在第 2、15 层使用 2/3/4-gram 记忆,对应从 0 开始的代码编号 1、14。这是该模型的具体配置,原始 Engram 演示代码有另一组配置。静态表项与当前文本的计算结果融合后,仍能产生随上下文变化的结果。[12][9]
工业实践:Pi 的固定版本案例
Pi 是一款开源编程 Agent,能调用工具读写代码、运行测试。本章以 0.99.1(2026-09-29)为例,说明它怎样选择送给模型的工具结果,并处理工具变化、缓存过期和对话摘要。[19]
Agent 正在修复代码,准备读取三份测试日志。工具会返回大量通过记录和少量失败信息;模型下一步要据此定位错误。
这些日志必须完整进入对话吗?如果 Pi 先把全文存到文件,只交回关键结果,模型又怎样知道发生了什么?
工具执行过的内容,哪些会交给模型?
要区分程序拿到的原始数据与下一次实际送给模型的内容。Agent 可以保存全文,先把判断所需的证据交回模型,再按需要读取细节。
场景:Agent 读取三份测试日志,定位失败。下面的保存与筛选由本例脚本安排;箭头区分外部文件与实际送给模型的内容。
模型写脚本,Pi 执行;结果在送回之前可以先整理
Pi 已经积累十轮对话,后台也登记了日志查询工具。这个工具原先能在脚本中使用,现在模型希望把它作为独立工具直接调用。
“加载工具”究竟改变了什么?会不会为了提供新用法,把输入开头的目录和旧缓存一起改掉?
Pi 怎样提供新的工具调用方式,同时保持原有输入前缀?
这里变化的是模型随后收到的独立工具声明。Pi 会记录这次工具集合变化;支持相应接口时,让新增说明出现在后续消息位置,同时保持原有 Codemode 目录。
沿用日志场景:log_query 是教学工具名,已由 Pi 登记,并允许 Codemode 脚本调用。本轮把它加入模型可直接调用的独立工具集合。
先看能力与声明:变化发生在最下面一行
再看输入位置:原目录不跟着激活状态重写
大王:具体工具确实可以等需要时再找。Pi 提前放的是一个永远不激活的占位项,目的是让服务端的延迟加载配套提示从第一轮就存在。
Pi 作者在源码注释中记录:首次出现 defer_loading 会引入隐藏提示,没有占位项时测到了缓存完全未命中。因此,即使 log_query 在后面展开,服务端仍可能同时改变输入前部。这是 Pi 针对作者记录的行为采取的适配措施,不是动态工具加载的必需步骤。[20]
大王:工具提示向模型说明本次有哪些工具、参数怎么填、调用怎样表达。Anthropic 把工具定义、配置和系统指令等组装成模型输入,属于它采用的接口实现。JSON 本身也能提供工具定义;需要传递这些信息,不能推出“首次启用延迟加载必须改动前缀”。[34]
公开资料没有给出那段延迟加载配套提示的全文,也没有解释为何要在首次启用时改变前部。当前接口文档说明延迟工具定义在后续位置展开、可以保留缓存,但未列出 Pi 注释中的首次启用例外。这里可以说明 Pi 的处理动机,不能将其写成当前所有接口的必然行为。[31]
继续用 log_query 举例。第一轮只提供两个固定入口:search_tools 负责返回工具名、用途和参数格式;call_tool 负责检查参数并执行指定工具。在同一会话中,这两个入口的名称、说明和参数格式都保持不变。下面是教学接口的调用示意。
需要查日志时,模型先通过固定入口搜索:
search_tools({query: "读取测试日志"})
Agent 在后续消息中返回 log_query 的用途及 run_id 等参数说明。模型读懂后,再通过另一个固定入口调用:
call_tool({
name: "log_query",
arguments: {run_id: "run_42"}
})
这里的 log_query 是传给 call_tool 的参数;模型服务看到的工具入口仍是原来的两个。实际查询由 Agent 程序执行,结果继续追加到后续消息。更换具体业务工具时,只要入口定义保持稳定,就不必触发服务端的首次 defer_loading 路径,也不需要为这条路径预放占位项。
这类方式已有实际实现:Cloudflare 的 API MCP 服务通过 search 查询接口用法,通过 execute 运行调用代码;完整接口目录留在服务端,模型按需取得相关部分。它采用代码执行入口,与上面的通用 call_tool 示意不同,但共同点都是少量入口保持稳定,具体操作按需发现。[35]
这里比较的是对工具变化的控制能力:固定入口减少了对平台原生动态工具机制的依赖。它仍然需要按需提供工具信息,执行程序也要负责参数校验、权限和错误处理。这不等于已证明它在所有任务的费用、速度和正确率上都优于原生延迟加载。
假设模型刚读完一段很长的上下文,随后要等一个六分钟的测试。如果服务端缓存五分钟后过期,测试结束时,旧输入可能又要重新计算。
Pi 怎样避免等待工具时缓存过期?为了保住缓存,多发一次请求划算吗?
Pi 会在缓存预计过期前,尝试重放原来的模型请求,并把输出上限设为 1 个 token。这一步叫缓存保活:通过再次使用原来的输入,尝试让已有缓存继续可用。返回内容不加入聊天,但请求产生的用量会计入会话费用。[21]
0.86.0 加入的这项功能,按默认规则这样工作:
- 默认在任务运行期间保活。任务结束后停止;选择
idle模式,才会在任务之间的空闲期继续尝试。 - 先估算是否值得。模型需要提供可用的缓存寿命信息,请求也必须能安全重放;估算的净节省至少达到 0.05 美元,才会刷新。
- 不会无限刷新。从原来的真实请求开始计时,运行期间最长保活一小时,空闲期最长三十分钟;如果定时器延误、错过安全刷新时间,就停止,避免为了保活反而重建过期缓存。
测试日志和讨论越来越长,Agent 准备用摘要替换较早的历史。生成摘要需要读取旧内容;写好后,下一轮又会看到一份更短、但已经改变的输入。
旧对话换成摘要后,哪些缓存还能继续用?生成摘要本身又要花什么成本?
Agent 已读过旧日志并完成多轮讨论。现在用摘要替换较早历史;需要分别看生成摘要这一轮,以及替换后的下一轮。
第一步:生成摘要,请求是否沿用了原前缀?
第二步:摘要已经写好,下一轮的历史改变了
这里的“上下文压缩”改变下一次送给模型的内容;KV 压缩改变推理服务保存中间数值的方式。例如,把完整日志换成摘要会减少输入 token;把 KV 数值用更少字节保存,不会自动减少 API 计算的上下文 token 数。
理解之后,怎样用到自己的 Agent?
在任务做对的前提下,比较整个任务的费用和等待时间。缓存命中率帮助解释变化,最终要看这些变化有没有带来实际收益。
我通过 API 做一个代码助手。它会临时加载工具,等待测试运行,也会在对话太长时生成摘要。
哪些操作可能让旧缓存失效?我应该先检查哪里,怎样确认改动让整个任务更省钱、更快,并且没有影响任务完成?
通过 API 开发 Agent 时,你主要能调整输入组织、工具加载、对话摘要和请求安排,也能选择模型与服务。缓存存在哪里、怎样传输、模型内部怎样共享 K/V,通常需要服务提供者或自行部署模型的人处理。具体见缓存的存放与回收。
先检查实际发送的内容
把相邻两轮实际提交的工具定义、系统指令和消息展开比较,标出最早变化的位置。聊天界面看起来相同,不代表提交的内容相同:例如,Agent 可能每轮重写工具目录,或在输入开头插入新的时间。
固定资料尽量保持原样,新问题和工具结果按发生顺序追加;同一份资料用于回答不同问题时,把固定资料与变化的问题分开,按所用服务的规则设置缓存。只固定本来就不需要变化的内容。项目代码和要求已经变了,就应更新信息,不能为了命中缓存继续提供旧事实。
| 场景 | 在自己的 Agent 中检查什么 |
|---|---|
| 临时加载工具 | 是否重写了开头的工具目录?能否改用定义稳定的搜索/执行入口?若采用平台原生动态工具机制,再核对后续展开与首次启用的行为。 |
| 工具返回大量日志或明细 | 原始结果有多大?实际交给模型多少?能否先筛选、保存全文,再按需读取? |
| 生成并使用摘要 | 分开观察生成摘要这一轮、替换后的第一轮、随后几轮的输入量、缓存用量、费用和任务表现。 |
| 等待长时间测试 | 下一轮开始时缓存是否仍有效?保活增加的费用,能否被后续节省覆盖? |
记录用量,按现象排查
记录每次请求的用途、输入量、缓存读取与写入量(接口提供时)、输出量、费用和耗时。不同接口的同名字段可能含义不同,要按对应说明记录。
查看 Claude 与 DeepSeek 的具体用量字段
read = usage.cache_read_input_tokens
write = usage.cache_creation_input_tokens
fresh = usage.input_tokens
total_input = read + write + fresh
hit_ratio = read / total_input if total_input else 0
Claude 的 input_tokens 不包含上述读缓存和写缓存部分。服务端工具或多轮汇总还应对照相应的用量记录。[4]
hit = usage["prompt_cache_hit_tokens"]
miss = usage["prompt_cache_miss_tokens"]
hit_ratio = hit / (hit + miss) if hit + miss else 0
字段定义来自 DeepSeek 上下文缓存文档。[6]
汇总命中率时,把全部请求的命中 token 数相加,再除以全部输入 token 数;不同长度请求的命中百分比不宜直接做简单平均。
旧对话从缓存读取,新增对话写入缓存,是正常情况。需要区分正常的新增写入和旧内容未能复用,再按下面的现象排查。[4]
| 看到的现象 | 优先检查什么 |
|---|---|
| 旧历史没有读缓存,同一大段内容反复写入 | 请求前部是否被改动?对于同一份资料的不同问题,是否在固定资料末尾留下了可复用的缓存位置?追加消息后,上次写入的位置是否仍在服务的查找范围内? |
| 只命中前面很短一段 | 最早变化的内容在哪里?系统指令、工具目录或项目资料是否被重排或改写? |
| 相同内容仍未命中 | 是否达到服务要求的最短缓存长度?首轮缓存是否已经可用?间隔是否超过有效期?是否换了模型、API 服务或工作区? |
| 命中很高,但窗口仍迅速变满 | 是否一直携带已经用完的工具结果?哪些证据必须保留,哪些可以归档或摘要化?命中的 token 仍占窗口。 |
| 命中很高,但任务仍然慢 | 分别计时:等待模型开始回答、生成回答、运行工具。先找出哪一段最耗时,再决定改哪里。 |
把整个任务的费用算全
把主 Agent、子 Agent、摘要、保活和失败重试产生的请求,全部计入同一个任务。单独看主对话的费用,可能漏掉为了优化它而增加的开销。各项费用按所用服务的实际计费规则计算,包括适用的缓存读取、写入、存储和输出费用。
用相同任务比较改动前后
用同一组任务和同一套完成标准,比较改动前后。先固定模型,每次只调整一类策略;分别观察首次使用、连续对话和长时间等待后的表现,再汇总完整任务的结果。
| 要比较的结果 | 用来回答什么 |
|---|---|
| 任务通过率 | 是否仍然把事情做对? |
| 整个任务的总费用 | 包括额外请求之后,究竟省了多少钱? |
| 从开始到完成的时间 | 用户实际少等了多久?同时列出多数任务和较慢任务各要多久。 |
| 缓存用量和各阶段耗时 | 收益或损失发生在哪里? |
还可以用这组任务的全部费用 ÷ 通过验收的任务数比较平均完成成本,但必须同时列出任务通过率。API 没有提供的缓存体积、内部恢复时间,就标为无法直接测量,不把估算当作实测。
如果更换了模型或服务,也用相同任务重新比较完成质量、费用和时间。
生成要一步接一步,为什么还可以批量核验?
从草稿交付、逐层计算到核验与回退,最后比较加速条件及它与 FlashAttention 的分工。
模型正在继续同一段回答,通常要算一步、选出一个 token,再算下一步。推测解码却多安排了一个“先猜”的步骤。
什么是推测解码?多做了一份草稿,为什么反而可能更快?
先用计算较便宜的方法猜一串候选,再由真正负责回答的目标模型核验。这种生成方式叫推测解码(Speculative Decoding,也叫投机解码)。下面用一个较小的草稿模型,帮助目标模型生成后续内容。
从同一段已经确定的前文继续,每一步都按目标模型的最高分选择 token。本章 A、B、C、D 表示四个草稿候选;示例中目标模型采用 A、B,并在 C 处改选 X。每个字母各代表一个 token。
① 普通生成:目标模型连续运行 3 轮
② 推测解码:较便宜的草稿 + 目标模型 1 轮核验
草稿已经猜出 A、B、C、D。我会以为,目标模型直接拿草稿算好的 Q/K/V,判断 C 该不该改成 X,所以才能一起核验多个位置。
草稿交给目标模型的,到底是 token,还是已经算好的 Q/K/V?
本章使用两个参数不同的独立模型。草稿提供候选的 token ID(词表中的编号);目标模型查自己的嵌入向量(embedding),用自己的参数重新计算 Q/K/V。随机采样时还需要草稿的概率,供后续决定接受与补抽;这也不是把草稿 K/V 当作目标模型的缓存。
本例按位置保留完整历史 K/V。固定同一个起点:第 1~9 个 token 已确定,目标模型的 K/V 缓存到第 8 位;第 9 个刚选出,还没送回目标模型。草稿据此给出第 10~13 位的 A、B、C、D。
紫色属于草稿模型,绿色属于目标模型。实线箭头表示本轮传递的数据。
普通生成要等目标模型选出下一个 token,才知道下一轮输入什么。这里草稿已经先后给出 A、B 等候选,目标模型可以先计算“如果这段输入是 A、B……,各位置会得到什么结果”。若前面的候选被拒绝,依赖它的后续结果就不能接入最终历史。
把草稿当作输入,目标模型就能批量处理。但读完普通提示词后,通常只需最后一个位置预测回答的第一个 token;核验草稿却要检查好几个位置。
两种处理分别需要哪些词表分数?核验 C 为什么要用 B 的输出?
多个位置参与模型计算,与多个位置都输出预测,是两件事。模型最后一层的中间向量还不是词表分数;选定位置的向量送入输出层,才得到各候选 token 的原始分数,也就是 logits。
小色格表示内部数值向量,小柱形表示词表分数。列上的数字都是 token 序号。
并行① 同号虚线框之间可并行,先后依赖仍看箭头;编号只在本图内配对。
① 普通输入:读完 5 个输入 token,准备回答第一个 token
② 推测核验:取得候选附近多个位置的预测
额外 token
支持相关选项的实现会设置 logits_to_keep = candidate_length + 1,取得候选数量加一份预测。批量读入并不意味着只取最后一份预测;输出层要处理更多位置,最后未被采用的候选也可能已经花掉计算。
这里的词表分数用于选择下一个 token;注意力匹配分数用于当前模型层怎样读取历史。它们不是同一组分数。
前面的例子让目标模型每次都选最高分的 token。现在改为按概率抽取,同时仍想让较便宜的草稿模型提前提出候选、减少等待。我们希望加快生成,但保留目标模型原本的选择规则。
随机采样时,怎样核验草稿,才能保持目标模型原来的抽取概率?
先拿掉草稿:目标模型原本怎样选?
前文已经确定,现在只选紧接着的一个 token。目标模型先给词表中的候选打分,再根据本次采样设置,得到各个候选被选中的概率。
贪心选择每次都取概率最高的候选,前面 A、B、C 改成 X 的例子采用的就是这个设定。随机采样则按各自的概率抽取:概率较高的更容易被选中,其他候选也有机会。本问讨论的是后一种方式。
20% 说的是哪个机会?
为便于推演,假设这个位置只有甲、乙、丙三个候选 token。它们是同一个位置的三种备选,不是连续三个 token。下面的概率是教学设定。
| 候选 token | 目标模型的抽取概率 |
|---|---|
| 甲 | 20% |
| 乙 | 50% |
| 丙 | 30% |
按最高分选择就会选乙;按概率抽取,这一次也可能选中甲或丙。20% 指的是:固定同一段前文、同一个模型和相同采样设置,甲在紧接着的这个位置被选中的机会。
它不是“甲有 20% 的概率是正确答案”,也不要求“一段 100 个 token 的回答里必须有 20 个甲”。一旦选出一个 token 并继续生成,前文就变了,下一位置的概率会重新计算。
加入草稿后,概率可能变了
为了减少等待,先让较便宜的草稿模型提出候选,再由目标模型成批核验。两个模型的参数不同,即使读取同一段前文,也可能给出不同的概率:
| 候选 token | 目标模型原本的概率 | 草稿提出它的概率 |
|---|---|---|
| 甲 | 20% | 60% |
| 乙 | 50% | 30% |
| 丙 | 30% | 10% |
如果草稿提出什么就采用什么,最终选择会跟着右列走。例如,甲被选中的机会会从目标模型原本的 20% 变成 60%。这样虽然利用了草稿,却也改变了最终的生成规则。
核验要保住什么?
我们要利用草稿提前提出候选,但最终仍按目标模型的 20%、50%、30%来选择。因此,程序需要三个步骤:
- 草稿按自己的概率提出一个候选。
- 程序结合两个模型给出的概率,决定接受还是拒绝这个候选。
- 如果拒绝,再按专门修正过的概率补选。
这里的“校正”调整的是接受与补选的规则,不需要重新训练或修改两个模型的参数。下面沿用表里的数字,具体算一遍:草稿提出的候选保留多少,被拒绝后又怎样补选。[54]
目标比例:甲 20% · 乙 50% · 丙 30%。横条的完整底槽都代表 100% 概率;不是生成了多少个 token。
① 先接受:甲只留三分之一,乙、丙全部保留
② 再补抽:拒绝的 40%,一半补给乙,一半补给丙
③ 合起来:恢复目标模型的 20%/50%/30%
从分布差异到平均单步接受概率
仍固定上图的同一段前文、同一个待生成位置。记目标模型的抽取分布为 p,草稿模型的为 d;i 枚举词表里的候选 token。这两组概率须已经包含温度、截断等实际采样设置。
把每个候选的概率差取绝对值,加起来再除以 2,得到总变差距离(Total Variation,TV)。它衡量两组分布有多少概率质量没有重合:
按本问的标准推测采样规则,候选尚未抽出时,平均单步接受概率 α 为:
原因是:候选 i 被草稿提出的机会是 di,提出后以 min(1, pi/di) 的机会接受;两者相乘,对应的直接接受概率就是 min(pi, di)。把所有候选的贡献相加,就得到 α。[54]
p=(0.20, 0.50, 0.30),d=(0.60, 0.30, 0.10)。
TV=(0.40+0.20+0.20)÷2=0.40;α=1−0.40=60%,正好对应图中的“直接接受 60%”。
这不是每个候选都以 60% 被接受:如果已经抽到甲,它的条件接受机会仍是 1/3。平均接受概率也不是实际加速比。
原论文 §3.2 把这里的 TV 记为 DLK;它与前文 KL 散度的 DKL 是不同的量,不能仅凭相近的记号混用。[54]
每推进一个位置,前文和两组分布都可能变化。实际核验只保留连续接受的开头,不能把一个位置的 TV 当作整段草稿固定的接受率;连续接受长度与执行成本要一起看。
大王:是的。当草稿这次已经提出甲时,程序一定接受它。接受概率最多是 1,也就是 100%;比值超过 1,就按 1 处理,不会出现“300% 的接受概率”。
计算时始终用目标概率除以草稿概率,再取这个比值与 1 中较小的那个:接受概率 = min(1, 目标概率 / 草稿概率)。本例是 60% ÷ 20% = 3,实际接受概率取 100%。
| 说的是哪件事 | 本例的概率 |
|---|---|
| 草稿提出甲 | 20% |
| 已经提出甲之后,程序接受它 | 100% |
| 通过“草稿提出并直接接受”这条路径,最终选中甲 | 20% × 100% = 20% |
“提出甲就必接受”,并没有让最终结果全部变成甲。草稿本来只有 20% 的机会提出甲,这条路径只能贡献 20% 的最终概率。目标模型希望甲最终占 60%,还差的 40 个百分点,会通过其他候选被拒绝后的补选补回来。[54]
前面的图采用相反的给定概率:草稿给甲 60%,目标只给甲 20%,所以接受概率仍是 20% ÷ 60% = 1/3。改变的是两边给定的概率,除法顺序不变。
回到前面出现拒绝的例子:草稿模型猜出 A、B、C、D;目标模型核验后接受 A、B,在 C 处出现分歧并改选 X,C 后面的候选 D 本轮不再采用。
已经算出的候选 K/V 留哪些,刚选出的 X 又在哪里?
观察时刻:核验刚结束,接受 A、B,用 X 替换 C。横向数字是本例的 token 序号;上下对齐比较文字与目标模型的 K/V。
① 核验结束:文字到 12,能接着使用的 K/V 到 11
② 下一轮:把 X 送进目标模型,才补上这份 K/V
前缀缓存与推测解码可以配合:前缀缓存复用已经确定的旧输入状态;推测解码尝试更快地确定后面的新内容。
小王模型已经试算了四个草稿 token,最后只接受前两个。逐条保存 K/V 时,可以不再使用后两个候选的记录;如果四步都更新了同一份状态,怎样恢复到只处理完前两个候选的时刻?需要事先留下什么?
大王:仍用本问的 A、B、C、D:我们需要恢复处理完 B 后,各层应该保留的状态,然后再输入 X。固定大小的状态没有四条可直接裁掉的尾部记录,需要按实际保留的信息选择办法:
- 保留了 B 后的状态快照:直接恢复那一刻各层的状态。
- 只保留了试算 A 之前的快照:恢复它,再按原计算路径处理 A、B。
- 更新是可撤销的,并且保留了必要贡献:例如前文的加法累计,若保留 C、D 在每层加入的矩阵和向量贡献,实数运算下可将它们减去。浮点加减未必逐位还原原值;其他递归更新也不能照搬这一办法。
快照必须保护那一刻的旧数值。如果只是保留一个指向同一块、随后会被原地更新的数组的引用,就没有真正保存旧状态。实现需要实际复制或采用能保护旧值的存储方式。[69]
目标模型仍要算候选的注意力和词表分数,还可能算了最后没采用的 C、D。为什么增加这些工作仍可能更快?“一块数据供多个位置使用”,听起来又像 FlashAttention。
推测解码省的是哪些等待?它和 FlashAttention 分别改变什么?
算术运算量与等待时间不必同步变化。批量核验让同一批读入的模型参数服务多个位置,也可能用上原本没充分利用的计算能力;草稿、更多位置的预测和拒绝掉的计算则是额外开销。
KL、TV 与实际加速比,分别回答什么?
不能仅凭 Draft Model 与 Target Model 的 KL 散度,推断实际推测解码加速比。这里仍记目标分布为 p、草稿分布为 d,比较同一前文下的实际采样分布。
| 量 | 衡量什么 | 怎样用于判断 |
|---|---|---|
| KL 散度 | 以指定参照分布加权的平均对数概率比,衡量有方向的分布偏离。 | 能作为分布匹配的指标;它不是单步接受概率,也没有包含运行耗时。 |
| TV | 两组概率中没有重合的部分。 | 在前文说明的标准采样规则下,1−TV 是同一前文下的平均单步接受概率;仍不能直接得到整段连续接受长度。 |
| 实际加速比 | 在相同任务、硬件、并发负载和采样设置等比较条件下,普通生成耗时 ÷ 推测解码耗时。 | 还要计入连续接受多少候选、每轮实际确认多少 token,以及草稿、目标核验和状态管理的成本。 |
执行成本包括草稿生成、目标模型成批核验的实际耗时、并行效率、候选 K/V 的暂存与回退,以及数据搬运、通信和调度等待。额外显存占用也可能影响能同时处理多少请求;这些都不包含在 KL 或 TV 中。[54]
即使设定每一步两模型的分布都完全一致,KL=TV=0,草稿也全部接受,仍不能只凭这一点保证加速。如果草稿生成本身已经花去接近普通生成的时间,再加上目标核验和状态管理,整次执行仍可能更慢。下面用实际推进的 token 数和每轮耗时作比较。
并行① 同号虚线框之间可并行,先后依赖仍看箭头;编号只在本图内配对。
① 一块参数读入后,可供多个位置的线性计算使用
② 用一笔教学耗时账比较:都确认 A、B、X 三个 token
小王草稿已经给出 A、B、C、D 四个连续候选,目标模型可以把它们作为同一批输入,在每一层里成批计算。这样,读入的一块模型权重就有机会供多个位置使用,而不用像逐个生成时那样,反复为下一步读取数据。
这是不是能减少 GPU 显存与芯片内部之间的数据搬运?FlashAttention 也是把计算分成小块,让读入的数据在芯片内部接着使用,减少显存往返。它们省掉的是同一种开销吗?推测解码算不算 FlashAttention 的升级版?
大王:推测解码确实可能减少一部分重复的数据读取,但它与 FlashAttention 优化的是不同环节,不是后者的升级版。这里比较的是 GPU 显存与芯片内部高速存储之间的数据搬运。
先分清两类数据:模型参数 W是训练得到的参数;Q/K/V是本次处理具体输入时算出的中间数值。
| 比较什么 | 推测解码 | FlashAttention |
|---|---|---|
| 首先遇到的困难 | 后续 token 未知,通常只能先选一个,再处理下一个。 | Q/K/V 已给定,但注意力会产生大量中间分数和权重,在显存与芯片内部之间搬运。 |
| 怎样减少数据搬运 | 草稿先给出候选,目标模型一轮处理多个位置,让读入的模型参数和历史 K/V 有机会供多个位置使用,减少逐轮重复读取。[54] | 按小块读取 Q/K/V,分数算出后在芯片内部接着完成注意力计算,避免把完整的分数表和权重表写入显存、再读回来。[49] |
| 作用范围 | 生成流程:草稿、核验、接受与回退。 | 模型层内部的注意力计算。 |
| 能否配合 | 可以。推测解码的目标模型在成批核验草稿时,内部注意力仍然可以使用 FlashAttention。 | |
小王先判断,大王再解析
只需纸笔:先从日志定位问题,再评审缓存实现,最后比较状态的保存、读取与恢复。先作判断,再展开补充事实和参考解析。
题一 · 用量日志 题二 · 实现评审 题三 · 状态与恢复
从用量日志定位命中率异常
第一步:只看日志
小王拿到一个代码 Agent 在 Claude API 上的日志,模型是 Claude Opus 5。他还不知道每次请求具体改了什么,先只用下面的题设和规则判断。
- 输入按 tools、system、messages 排列;初始 tools 为 2,800 token,system 为 4,200 token。
- 一个显式缓存断点固定在 system 末尾,另一个由自动缓存放在整个请求最后的内容块上。断点是告诉服务端“缓存到这里”的标记。
- 缓存有效期五分钟;写缓存按普通输入的 1.25 倍计费,读取按 0.1 倍。
- 本题只有上述两个断点;所有缓存写入来源都在表 1 中,没有未列出的写入或刷新。
本题的保活请求可以安全重放:降低输出上限不会改变思考预算、effort、有效输入前缀或其他匹配条件,具体原因见第 28 问。延迟加载的改法按下文约定的输入增量计账;真实工具发现新增的消息、请求和输出费用不计入本题账本。
可以查阅的规则
- 缓存按完整前缀匹配,顺序是 tools → system → messages。每个断点只写一个条目,代表从开头到断点的整段前缀。
- 每个断点从自身开始向前最多检查 20 个位置,找以前实际写入的条目。普通内容块各算一个位置;连续的 tool_use 块合算一个,连续的 tool_result 块也合算一个。
- 有效期从读或写该条目的请求开始时计算;每次读取重新计时。
- 写入请求开始返回响应之后,别的请求才读得到新条目。
- 改变 tools 定义会使旧缓存失配;改变 tool_choice——规定这一轮是否必须调用某个工具的参数——保留 tools 与 system 缓存,但 messages 部分失配。
上述规则已对照 2026-10-02 的 Anthropic 动态文档;输入量与价格按本题固定条件计算。[4] write 是写入缓存的 token,read 是读取缓存的 token;本表两者之和就是本次输入总量。时间均从会话起点计。
| 行 | 开始 | 耗时 | 输入总量 | write | read |
|---|---|---|---|---|---|
| 1 | 00:00 | 20s | 8,500 | 8,500 | 0 |
| 2 | 00:30 | 15s | 10,000 | 1,500 | 8,500 |
| 3 | 00:55 | 18s | 11,500 | 1,500 | 10,000 |
| 4 | 01:20 | 25s | 32,800 | 21,300 | 11,500 |
| 5 | 01:55 | 15s | 34,650 | 34,650 | 0 |
| 6 | 02:20 | 150s | 36,150 | 1,500 | 34,650 |
| 7 | 07:55 | 20s | 37,650 | 37,650 | 0 |
| 8 | 08:25 | 15s | 39,150 | 1,500 | 37,650 |
| 9 | 08:50 | 15s | 40,650 | 33,300 | 7,350 |
| 10 | 09:15 | 15s | 42,150 | 3,000 | 39,150 |
| 11 | 09:40 | 20s | 51,870 | 44,520 | 7,350 |
| 12 | 10:10 | 25s | 82,370 | 30,500 | 51,870 |
| 13 | 10:10 | 25s | 82,370 | 30,500 | 51,870 |
| 14 | 10:10 | 25s | 82,370 | 30,500 | 51,870 |
全段 token 命中率为 read 合计除以输入总量合计,约 52.6%。
- 按题设口径,先从 write 中扣掉“本次比父请求增加的输入”,算出被重新处理的旧内容。第 1 行是建缓存基线,不计算;第 12~14 行都以第 11 行为父请求,其余行以上一行为父请求。哪些行不为 0?第 4 行命中率只有约 35%,它有问题吗?
- 对不为 0 的行,read 停在哪里?只凭表 1 能确定什么、不能确定什么,还需要查什么?
- 第 12~14 行按上述算法为 0,它们是否仍可能存在问题?
这是本题约定的单线增量口径,第 1 行不参与比较。
大王 · 第一步参考分析(只用表 1)
问题 1:第 5 行为 32,800,第 7 行为 36,150,第 9 行为 31,800,第 10 行为 1,500,第 11 行为 34,800。第 1 行不参与比较,其余行按题设口径算出 0。
第 4 行没有因此暴露异常:它写入的 21,300 正好等于输入增量,read 11,500 也接上了第 3 行。命中率约 35%,是因为这次新增内容多,不能只凭命中率低就判定浪费。
问题 2:read 可以帮助定位命中的边界,但不是故障原因的直接编码。这里只有 system 末尾和完整请求末尾两个断点;完整请求最短也有 8,500 token,又没有其他写入来源,所以 7,350 只能是 tools+system 前缀条目。初始两者合计 7,000,确实多了 350;还不能判断这 350 加在哪里、何时加入。
| 行 | read | 可以判断 | 仍须核对 |
|---|---|---|---|
| 5 | 0 | 输入比上一行增加 1,850,开始时间仅隔 35 秒。五分钟自然到期不能单独解释这次失配。与后面的 7,350 对照,额外 350 是优先调查的线索。 | 总量变化本身不能定位新增内容;需要检查 tools、system、messages 与配置。 |
| 7 | 0 | 距第 6 行开始为 335 秒,距完成为 185 秒。按题设从开始计时、且没有其他刷新,到期足以解释失配。 | 用请求开始时间作判断,不把它推广成真实服务的所有条目一起过期。 |
| 9 | 7,350 | 完整请求最短是 8,500,且只在两个断点写入;因此命中的是 tools+system 末尾条目,比初始 7,000 多 350。 | 350 加在 tools 还是 system、何时加入,以及 messages 为何未命中,都尚未确定。 |
| 10 | 39,150 | 重新匹配第 8 行的完整前缀,说明那个较早条目仍可用。 | 可能是参数临时改变后恢复,也可能是历史内容临时改写后恢复;数字不能二选一。 |
| 11 | 7,350 | 又停在 tools+system 边界;后续三个请求能读到本行写出的完整前缀。 | 检查历史/配置变化,及旧入口是否被新增内容块推到回看范围之外。 |
例如,第 9 行把 messages 中一段文字临时换成等长内容,第 10 行再改回,也可能先退到短前缀、再命中第 8 行旧条目。因此,第一步可以认出 tools+system 的边界,却不能确定其内部各占多少,也不能唯一认定 messages 失配是参数切换造成的。
问题 3:第 12~14 行各写 30,500,开始时间相同。它们可能重复写入共有内容,但这时还不知道新增内容究竟有多少相同。需要查共有段、断点位置及发送先后。以第 11 行为父请求的增量算法,发现不了三个分支之间的重复。
第二步:补充事实,再确定原因与改法
展开补充事实,再做第二步
| 行 | 这次请求相对父请求的变化 |
|---|---|
| 1 | 首个请求:tools + system + 用户任务 1,500 |
| 2、3、6–10 | 各加一轮工具调用 1,500(模型输出 300:一段文字加一个 tool_use;工具结果 1,200) |
| 4 | 加一轮,工具结果是 21,000 token 的日志 |
| 5 | 加一轮;agent 的外层程序往 tools 数组中间插入了新工具 db_query(350) |
| 6 | 从请求开始到响应完成共 150 秒 |
| 7 | 发出前在本地跑了 3 分 05 秒测试 |
| 9 | tool_choice 从 auto 改成强制调用 submit_patch |
| 10 | tool_choice 改回 auto |
| 11 | 加模型的最终回复 120;用户拖入 24 个文件,每个文件一个内容块,共 9,600 |
| 12–14 | 三个子代理同一秒发出。每个请求 = 第 11 行的全部输入 + 模型回复 300(三者相同)+ 同一份代码仓库摘要 repo map 30,000 + 各自不同的指令 200。断点配置不变 |
- 结合补充事实,确定每一处的实际原因。
- 每一处怎样修改?哪些只是优化,哪些会改变调用保证?全部修改后的命中率和输入成本分别是多少?
大王 · 问题 4、5 的原因与改法
补充事实说明 tools 增加了 350,所以这时才可以确认:7,350 是修改后的 tools 3,150 加 system 4,200。第 9、10 行的实际原因也已明确为 tool_choice 改变后又恢复。
| 行 | 实际原因 | 怎样改 | 改后 write / read | 单独修改节省的输入当量 |
|---|---|---|---|---|
| 5 | tools 中插入新定义,使原前缀失配。 | 从首轮稳定延迟加载机制;新工具通过平台支持的发现或引用路径加入后续对话,过程见表后。 | 1,850 / 32,800 | 36,915 |
| 7 | 第 6 行 02:20 开始,五分钟到 07:20;下一次已是 07:55。 | 06:50 安全重放上一请求,保持缓存身份不变,读取 36,150;按五分钟缓存计账。 | 1,500 / 36,150 | 37,957.5(已扣保活) |
| 9、10 | 第 9 行切换 tool_choice;第 10 行恢复,重新接上较早前缀。 | 是否能取消强制,要看调用保证;具体见表后说明。 | 1,500 / 39,150;1,500 / 40,650 | 38,295(模型照做时) |
| 11 | 新增 25 个位置,上次末尾成为第 26 个回看位置,超出 20 个的范围。 | 把文件合为一个内容块;或在当前请求携带的历史中,给上一轮最后一个内容块加显式断点。 | 9,720 / 42,150 | 40,020 |
| 12~14 | 同时发出,且共有 repo map 的末尾没有断点。 | 在共有段末尾放断点;先发 A,等该条目可读后再发 B、C。 | A 不变;B、C 各 200 / 82,170 | 69,690 |
“输入当量”是把费用换算成相当于多少普通输入 token;上表不计输出。保活一行保留到半个当量,便于核对总账。使用一小时 TTL 是另一种策略,要采用对应写入价格另算,不能直接套进这份五分钟账本。
第 5 行:先准备加载机制,再按需加入工具
defer_loading: true 把工具完整定义留在平台可发现的列表中,暂不写进开头的工具提示;工具发现返回引用时,再在对话当前位置展开定义。Pi 的适用接口也会通过 tool_addition 携带工具引用来启用它。这需要接口与外层执行程序支持,不能只追加一段文字就获得调用能力。[31][20]
Pi 从第一轮就声明一个不会被调用的延迟加载占位工具。该版本源码作者记录,第一次出现延迟工具会引入隐藏提示,曾造成整段失配;占位项把这项提示结构提前固定。提前准备的是机制,不要求第一轮预知未来所有工具。完整过程见第 27 问。
因此,表中的 1,850/32,800 是按约定增量推演的结果。真实工具搜索或引用可能多出消息、请求和输入,不能承诺实际 API 得到同样数字。
第 7、11 行:刷新旧身份,查找旧入口
保活要命中原条目,不能因降低输出上限而改变思考预算或前缀;Pi 如何检查见第 28 问。本题已满足这个前提。
第 11 行的断点加在当前请求携带的历史里,指向上一轮请求的最后一个内容块。查找便能触及上一轮由自动缓存写出的条目;不需要回头修改已经提交的旧请求。
子代理:两个条件缺一不可
三个子代理新增的 300-token 回复与 30,000-token repo map 都相同,各自的 200-token 指令在后面。因此,共有段长度是 82,170。要先有共有段末尾的缓存条目,还要等它已经可读。只错开发送却不增加这个断点,不能复用没有单独保存的共有段。
另一办法是先发 max_tokens=0 的预热请求,等完成后再并发。2026-10-02 的 Claude API 文档要求它为非流式,并且不能同时启用手动 extended thinking、结构化输出格式、强制 tool/any 或 Message Batches;还要保持相同的缓存身份。与 A 先发相比,本题这条路径多一次 82,170 token 的读取;全修复账本采用 A 先发。[4]
强制工具调用:先决定必须保住什么保证
题设中的强制 tool_choice 要求这一轮调用 submit_patch;改成 auto 加文字要求,并不保留这项单次调用保证。
若不需要硬性要求,可以直接用 auto。若仍需保证,外层程序必须检查:确实调用后才继续;未调用时,按题设支持的操作强制重发并再次检查。请求失败或结果未通过检查时,应按未完成处理,不能把“写了要求”当作模型一定照做。重试等待和工具副作用也需要单独考虑。
本题使用文档支持强制工具调用的 Opus 5 配置,不启用会阻止该操作的手动 extended thinking。具体接口不支持强制时,不能套用强制回退这条账本;外层仍须检查结果。[29]
被重新处理的旧内容,不等于可以消除的浪费。第 10 行是连带后果;必须强制且模型没有照做时,第 9 行的额外开销可能是满足要求的成本。反过来,三个分支重复写入共有资料,也可能被单线增量算法漏掉。
大王 · 全部修复后的条件账本
这里采用五分钟缓存、增加一次保活,并让第一个子代理先建立可读的共有段;取消临时强制之后,假定模型按指令调用工具。它不是任意失败概率下都成立的结果。
| 项目 | 原方案 | 全部修复后 |
|---|---|---|
| 请求数 | 14 | 15(多一个保活请求) |
| 写入 token | 280,420 | 82,770 |
| 读取 token | 311,760 | 545,560 |
| token 命中率 | 52.65% | 86.83% |
| 输入成本:普通输入 token 当量 | 381,701 | 158,018.5 |
只算固定输入增量的费用,不含真实工具发现的额外开销、输出、重试等待或工具副作用。
修复后的输入成本约为原来的 41.4%,也就是约 1/2.42。保活请求本身全部命中;若仅把它从命中率统计中剔除,其余请求的命中率约为 86.0%,但计算费用时仍要把保活算进去。
第三步:只改一个条件,先预测
完成第二步后,再展开四个变式。先写出预测,再查看各自的解析。
展开问题 6 的四个变式
小王 · 问题 6 以下四个变式分别从原题出发,每次只改所说的一个条件,不叠加其他修复。
(a) 第 11 行的 24 个文件改成 18 个,或 19 个,read 各是多少?
(b) 第 6 行从请求开始到响应完成的总耗时从 150 秒缩短到 60 秒,测试时长不变,第 7 行的 read 是多少?
(c) 三个子代理只是错开 3 秒发送,其他条件不变,B、C 各写多少?
(d) 第 9 行先用 auto 加指令;若模型没调用 submit_patch,再用强制 tool_choice 重发同一请求。模型不照做的概率超过多少,这个做法才不如一律强制?
大王 · 变式 (a) 参考解析
18 个文件时,read 为 42,150;19 个文件时为 7,350。新增位置还包括模型最终回复的一个块。断点自身算第一个回看位置,所以 18 个文件把旧入口放在第 20 个位置,仍能找到;19 个文件把它推到第 21 个,就够不到了。
大王 · 变式 (b) 参考解析
第 7 行改在 06:25 发出,距第 6 行开始只有 245 秒,所以 read 为 36,150,write 为 1,500。测试仍然用了 3 分 05 秒;变化的是测试开始前,整次请求已经消耗了多少有效期。
大王 · 变式 (c) 参考解析
B、C 仍然各写 30,500。仅调整发送时间,没有为 repo map 的末尾建立断点;前一个子代理写出的完整条目包含它自己的指令,后一个不同指令的请求仍不能用这个条目替代共有段的入口。
大王 · 变式 (d) 参考解析
临界概率约为 90.4%。先列出给定三条路径:
| 给定路径 | 普通输入 token 当量 |
|---|---|
| 一律强制 | 50,025 |
| 先 auto,模型照做 | 11,730 |
| 先 auto,模型不照做,再强制 | 54,090 |
auto 成功时,相对一律强制省下 38,295 当量;auto 失败并回退时,比 auto 成功多花 42,360 当量。因此,失败概率的分界是:
超过这个概率,给定的 auto+强制回退路径更贵;相等时输入成本相同。
这不是完整任务的最优策略结论。这里没有计入额外输出、等待和工具副作用,也仍要求外层检查调用是否真正发生。
评审一个最小前缀缓存
小王在一个 0.6B 参数的开源模型上写了前缀缓存。模型有 28 层,每层都是普通的因果全注意力。他把设计和四组观察交给你,请你判断哪些差异可以解释,哪些说明实现有错。
设计描述
- 每 16 个 token 一块,只缓存写满的块。
- 块编号由“前一块编号+本块 16 个 token”的 hash 得到;第一块的前序编号由模型名和数值精度生成。
- 新请求从第一块逐块查找,遇到第一个缺失就停止,后面的 token 全部现算。即使全部找到,也至少留最后一个 token 现算。
- 每块记录有几个请求正在使用。容量满时,从没人使用的块里淘汰最久未使用的一块。登记或命中一串块时,从前到后依次标为刚使用过。
- 命中时,先把 K/V 复制为请求私有的一份;复制完成后,这个请求不再读取缓存表。
实验比较 fp32(32 位浮点)与 bf16(16 位,包含隐含位共 8 位有效二进制位)。词表分数(logits) 是输出层对候选 token 的原始分数,尚未转成概率。
实验一 · 数值
两条请求共享 1,210 token 前缀,第二条共 1,230 token。先发第一条,再发第二条,比较第二条预测下一个 token 的 logits。除第⑤行另有注明外,表中数值为最大绝对差;“逐位相同”表示保存的数值逐位一致。
| 比较的两条计算路径 | fp32 | bf16 |
|---|---|---|
| ① 同一种算法重复两次 | 逐位相同 | 逐位相同 |
| ② 用缓存 vs 不用缓存(一次算完 1,230 个 token) | 3.7e-5 | 0.33 |
| ③ 不查缓存表,只把第二条请求切成前 1,200、后 30 两段先后算 vs 一次算完 | 3.7e-5 | 0.33 |
| ④ 用缓存 vs ③ 的两段算法 | 逐位相同 | 0.37 |
| ⑤ 前 1,200 个 token 的 K/V:在第一条请求里算出的 vs 在第二条请求里算出的 | 逐位相同 | 相对误差中位 0.6% |
| ⑥ 后 30 个 token 在第一层第一个矩阵乘的输出:放在 1,230 行里算 vs 单独 30 行算 | 9.5e-6 | 未测 |
| ⑦ 一次算完:单独算 vs 与另一条请求拼成一批算 | 3.8e-6 | 逐位相同 |
实验二 · 生成
两条路径每一步都读取相同的已生成文字,再比较本步选择。ε 是两条路径 logits 的最大差;m 是不用缓存时第一名与第二名的分差。5 个问题各 150 步,共 750 次比较。
| 观察项 | fp32 | bf16 |
|---|---|---|
| 每步 ε:中位 / 最大 | 1.4e-5 / 5.4e-5 | 0.26 / 1.0 |
| m ≤ 2ε 的步数(每步选最高分,即贪心) | 0 | 95 |
| 两条路径选出不同 token 的步数(贪心) | 0 | 8 |
| 同上(温度 1 的采样,两条路径用同一份随机数) | 0 | 9 |
同一问题还有一组命中长度对照:命中 1,200 token 时,第 32 步出现首次选择差异,这条记录属于上述 750 步中的第一题;命中 1,216 token 时,第 154 步首次出现差异,来自另一次运行。
实验三 · 接错地方
取出一段 1,191 token 文档在原请求中的 K/V,接到另一段前文后,与正确重算比较,精度为 fp32。表中的 KL 衡量两份候选概率分布有多不同,0 表示完全相同。
| 做法 | KL | 最可能的 token |
|---|---|---|
| 合法的前缀缓存(实验一的 ②) | 5.8e-8 | 相同 |
| 接到长度相同、内容不同的前文后面 | 3.1e-2 | 相同 |
| 接到长度不同的前文后面 | 6.0e-2 | 变了 |
实验四 · 容量压力
(a) 容量 12 块。请求 A 长 195 token,写满 12 块后结束。接着来一个需要另占 1 块的请求 C。A 再来时,命中 0 token。
(b) 一个请求命中 192 token,K/V 已复制完成并进入解码。此时清空缓存表,该请求的输出没有变化。
(c) 另一版把 K/V 放入固定数量的共享页,一页一块。请求只保存页号,解码时继续从页中读取;淘汰一页意味着可以交给其他请求覆盖。下面使用 fp32,与无压力时相同请求的结果比较。
| 场景 | 做法 | 报告结果 |
|---|---|---|
| A 解码到一半,B 到达,空页不够 | 不记谁在用,只有"最近使用"顺序 | B 覆盖了 A 的页;A 之后 16 步里 12 步选出不同 token,最大 logit 差 24.5 |
| 同上 | 记"几个请求在用" | B 被拒绝,等 A 结束;A 逐位相同 |
| A1、A2 共享前 11 页,A1 先结束,随后 B 到达 | 只记"是否在用" | 共享页被 B 覆盖;A2 之后 20 步里 19 步 token 没变,最大 logit 差 5.2 |
| 同上 | 记"几个请求在用" | 逐位相同 |
| 淘汰 1 页后 A 再来 | 先淘汰一串里最前面的页 | 命中 0/195,另外 11 页再也查不到;重算结果与不用缓存逐位相同 |
| 同上 | 先淘汰一串里最后面的页 | 命中 176/195;重算结果与不用缓存差 6.3e-5,12 步 token 全同 |
小王 · 六个问题
- 实验一中,第二条请求命中多少 token、现算多少?原样再发一次呢?“至少留一个 token 现算”是数学上必须的吗?
- 正常使用缓存与不用缓存,在数学上相同吗?依据是什么?
- fp32 的②中差异从哪里来?哪些行可以排除缓存数据错误?bf16 多了什么来源?不用缓存是唯一标准答案吗?
- 证明 m > 2ε 时两条路径的贪心选择相同;反过来成立吗?仅凭 8/750,能否判断首次分叉要多少步,还缺什么信息?采样时怎样使用这个判据?第 32/154 步这两次命中长度不同的记录,对评测意味着什么?
- 实验三两种拼接为什么错?缓存身份必须包含什么?链式 hash 是唯一正确方式吗?
- 实验四中:(a) 为什么只淘汰一块,命中就归零?(b) 清空表后输出不变说明什么,能否推断复制期间也不需要保护?(c) 19/20 的 token 都没变,为什么仍然错误?压力测试应该怎样判定通过?
大王 · 问题 1 参考解析
第二条命中 1,200 token,现算 30:共同前缀是 1,210,只能复用其中 75 个完整块。原样再发第二条,已有 76 个完整块,可命中 1,216,现算 14。
“至少留一个 token 现算”的规则,在请求长度正好是 16 的倍数时才额外起作用。在本题只复用完整块的设计下,这会少复用一整块,而不是只少一个 token。
这不是数学上无条件必须。为了预测下一个 token,需要前缀末尾位置经过最后一层后的中间表示(hidden state),或者已经算出的词表分数;本题只存 K/V,没有保存这些输出,所以要补算。如果额外保存它们,可以采用另一种恢复设计。vLLM 所引用版本也有最后 token 的限制,其源码说明移除限制可能略微改善性能。[30]
大王 · 问题 2 参考解析
正常前缀复用在数学上等价。这里固定模型权重、位置规则与精度,且只比较题设中匹配正确的前缀。因果注意力不能读取未来位置,所以位置 i 在各层的 K/V 由第 1~i 个 token 及相关配置决定,不会因后面追加什么而改变。
因此,可以直接复用相同依赖下的数学对象。但有限精度的具体计算路径仍可能不同,“数学等价”和“逐位相同”不是同一个验收条件。
大王 · 问题 3 参考解析
这组 fp32 记录把差异定位到后 30 个 token 的计算路径,而不是缓存表里的前缀数据:
- ⑤:前 1,200 个 token 的 K/V 逐位相同,支持两条路径的前缀数据一致。
- ④:使用缓存与只分成前 1,200、后 30 两段计算,结果逐位相同,说明本组记录中查表与拼接没有额外引入差异。
- ⑥:还没读取任何 K/V,第一层第一个矩阵乘就已有 9.5e-6 的差异。同样的行放在不同形状的矩阵运算中,计算与累加路径可能改变;浮点加法的不同累加顺序会产生不同舍入,后续计算最终表现为 3.7e-5 的最大差。
bf16 还多了一处差异来源:⑤ 中前缀 K/V 本身不再逐位相同,相对误差中位数为 0.6%;④ 的最大差也达到 0.37。
“不用缓存”不是唯一的标准答案。③ 支持缓存与分段计算的差异在本记录中同量级。⑦ 的同批计算是另一个对照:本环境下 fp32 差异更小,bf16 则逐位相同,不能把结论扩大成所有分批计算都有同样大小的误差。
大王 · 问题 4 参考解析
先证明贪心选择何时不变
把不用缓存时的第一名记为 a,其他候选记为 j。a 比任何 j 至少高 m。另一条路径每个分数最多变化 ε;最不利时,a 降 ε、j 升 ε,差距仍至少剩下 m − 2ε。
此时 a 仍严格领先所有候选,所以两条路径的贪心选择相同。
反过来不成立。m ≤ 2ε 只是允许选择变化,并非必然变化。bf16 有 95 步满足这个条件,却只有 8 步的贪心选择不同;fp32 没有一步满足,所以这 750 步的贪心选择一致。
合计 8/750,不能告诉我们首次分叉要等多久
这 750 步比较时,两条路径每一步都读取相同的给定历史。8 是选择不同的次数,不是首次分叉时间的分布。要回答首次分叉发生在哪一步,还需要每个问题的首次分叉位置,以及观察结束时仍未分叉的问题。
反例:8 个差异都落在同一题的最后 8 步,或者落在 4 道题各自的前 2 步,都符合 8/750,却有完全不同的首次分叉情况。不能凭这个合计假设各步独立、差异概率恒定。
采样与评测怎样看?
原来的 m 是未加随机性的贪心分差,不能直接保证采样结果相同。一种理解温度 1 采样的方式,是给各候选分数加适当的随机扰动后选最大值;若两条路径使用相同扰动,应在加扰动后的分数上重新计算分差,再使用上述判据。
本样本的采样差异次数为 9,贪心为 8,只能说观察到的次数接近,不能据此证明两者概率相同。随机条件不同时,单次文字差异还混入采样随机性;应另做分布层面的比较。
第 32 步属于本组第一题,命中长度为 1,200;第 154 步来自命中 1,216 的另一次运行。它们提示评测需记录缓存状态和命中长度,但不能把第二次运行混入这 750 步合计。
补充实验记录:先回答问题 4,再展开
比较方式没有改变:五题各 150 步,每一步都给两条路径喂入相同的给定历史,再比较选择;不是从第一次分歧起分别自由续写。
环境:Qwen3-0.6B,revision c1899de289a04d12100db370d81485cdf75e47ca;PyTorch 2.14.1 CPU;transformers 5.18.0;2 线程;fp32/bf16。
| 观察项 | bf16 贪心 | 温度 1,共同随机数采样 |
|---|---|---|
| 150 步内出现首次选择差异 | 3/5 题 | 4/5 题 |
| 首次差异位置集合(已排序) | 22、32、54 | 8、11、20、79 |
| 观察结束仍未出现差异 | 2 题 | 1 题 |
| 按题号顺序的全部差异次数 | 1、0、4、3、0(合计 8) | 2、2、2、3、0(合计 9) |
位置集合已排序,不能按列出的顺序逐项对应题号。已明确对应的只有:第一题命中 1,200 token,在第 32 步首次出现差异。
小王 · 追问 有了这些逐题记录,哪些判断可以更新?观察结束仍未出现差异的题怎样记?现在能否算出所有题的首次差异平均等待时间,或推断所有任务都遵循相同概率?
大王 · 补充记录能更新哪些结论?
现在可以具体描述这五题:贪心有 3 题在 150 步内出现差异,2 题到观察结束仍没有;共同随机数采样有 4 题出现,1 题仍没有。这比合计 8 次和 9 次多告诉了我们“差异分布在哪些题、最早何时出现”。
未出现差异的题应记为“截至第 150 步未观察到”,不能写成“第 150 步发生”。它们之后何时出现、会不会出现,都还不知道,所以不能给出所有题完整的平均等待时间,更不能恢复“约 90 步、约 65 步”的旧推算。
8 次与 9 次仍是固定历史下的选择差异次数。五题不足以证明普遍概率,也不能据此预测两条路径自由生成后会偏离多远。贪心的分差证明与采样的随机条件仍按上面的解释分别判断。
大王 · 问题 5 参考解析
从第二层起,同一个 token 的 K/V 可以受此前全部可见内容影响。改接到内容不同的前文后,依赖已经变了;前文长度也不同时,位置也变了。相同的文档 token,并不足以使这些 K/V 仍然可用。
等长错误拼接的 KL 为 3.1e-2,约是合法前缀缓存 5.8e-8 的 50 万倍;最可能的 token 仍相同,不能证明拼接正确。
缓存身份必须涵盖会改变计算的条件:完整前缀、位置规则、权重版本、数值精度,以及适用的 LoRA、图片内容等。图片的占位 token 一样,并不代表图片一样。
不必使用链式 hash。完整前缀 hash,或沿前缀树逐 token 匹配,也可以表达完整依赖身份。链式 hash 方便在原编号上增量加入一块,并让不同请求共享已匹配的块。
租户权限是另一项检查。它规定谁可以共享,不决定数值是否兼容;即使计算状态相同,也不代表不同用户有权共用。
大王 · 问题 6 参考解析
(a) 查找从第一块开始,一处缺失就停止。按题设从前到后登记“刚用过”的顺序,第一块最旧,先被淘汰;后面 11 块仍占容量,却无法通过本题的逐块查找继续命中。反过来先淘汰末块,可以保住前 11 块,命中 176/195。所引用的 vLLM 实现释放块时采用倒序,正是相关的管理选择。[30]
(b) 清空发生在复制已经完成之后。输出不变,说明这个请求随后不再读取表内源数据;它不能证明复制过程中也无需保护。
| 设计 | 保护何时可以结束 |
|---|---|
| 复制为请求的私有副本 | 复制完成后,请求不再依赖缓存表内的源数据。 |
| 每一步直接读取共享页 | 最后一个使用者不再读取这些页之后。 |
保护可以通过引用计数、锁或其他所有权安排完成。若教学实现完全串行,复制期间不可能被淘汰,可以不加并发保护;原因是执行条件排除了冲突,不是“用了复制就永远不需要保护”。
(c) 一个“是否在用”的布尔标记分不清还有几个使用者。A1 结束后把共享页标成空闲,B 覆盖它,A2 却还在读取。即使后面 20 步有 19 步的文字没变,A2 仍读到了错误数据,最大 logit 差已经达到 5.2。
压力测试分别检查三件事
- 计算正确性:与相同请求、相同计算条件下的无压力结果比较 logits,不能只看输出文字。正常数值差异应由合法缓存与分段计算的对照解释,不能拿来掩盖读错数据。
- 数据生命周期:加入去掉计数或改用布尔标记的失败对照,检查测试能否发现仍在使用的页被覆盖。
- 复用效率:记录淘汰后还剩多少可命中前缀。先删首块的实现仍可正确重算,但命中归零;正确性通过不代表淘汰策略高效。
大王 · 最后归类:分别验收什么?
实验一、二中的正常缓存与非缓存路径,讨论数学等价计算在有限精度下的数值差异。实验三的错误拼接,以及实验四共享页被覆盖,属于实现的正确性错误,不能用浮点误差解释。有界重放等主动改变恢复计算的方案,属于有意近似,需要另外评价任务质量代价。
因此,输出文字大多没变,不代表没有读错数据;命中率差,也不一定意味着计算错误。正确性、数据生命周期与复用效率要分别验收。
存得下、读得动,能从哪里恢复?
小王要为长对话保存状态。他发现:有的方案保存量小了,却仍被多个层反复读取;有的最终状态很小,却不能直接回到资料结束的地方。下面只比较三个教学配置,不对应某个真实模型。
先固定这次比较的条件
每个会话已处理完 4,096 个 token:前 3,072 个是资料,后 1,024 个是旧问题。模型均为 32 层,所有状态数值用 fp16,每个数 2 字节。1 KiB=1,024 字节,1 MiB=1,024 KiB。
扣除模型权重、工作区和管理开销后,实际留给这些会话状态的预算为 640 MiB;按会话保存,不跨会话共享。不另计原文、页表、索引或临时张量;原始 token 保存在别处,可供重放。这里没有额外的压缩器状态。
| 方案 | 保留哪些状态 | 每层下一步读取什么 |
|---|---|---|
| A · 完整历史 K/V | 32 层各存一份完整 K/V;每个 token 的 K、V 各有 2 个头,每头 64 个数。 | 每层读取自己全部 4,096 个历史 token 的 K/V,各读一次。 |
| B · 共享全局+局部窗口 | 32 层分 4 组,每组 8 层,共用一份全局 K/V;每个 token 的 K、V 形状同 A。每层另有独立的 128-token 局部窗口,K、V 形状也同 A。 | 每层完整读取本组的全局 K/V,再读取自己的 128-token 窗口。两份内容分别计算,不合并读取。 |
| C · 固定大小的递归状态 | 32 层各保留一个 64×256 的数字矩阵;每读入一个 token,就用当前矩阵与本层输入更新它。 | 每层把自己的整个矩阵读一次,再产生更新后的矩阵;不读取逐 token 历史 K/V。 |
三种方案都只留下表中所说的状态,没有资料末尾的额外快照。B 的全局 K/V 与局部 K/V 是不同状态;全局保存全部 4,096 个位置,各层局部只剩末尾 128 个。本题窗口长度指 128 个旧 token,不含本步新 token;每步先读旧状态,再追加和更新。C 只保存最终矩阵;初始矩阵均为零,可按规则重建,不另占每会话预算。题设没有提供逆向或回退算法。
状态更新是确定的,允许按相同计算路径从初始状态重放来恢复。B 另有一种可选短段办法:只重放资料末尾 128 个 token,并截断这段之前的局部依赖;它接受近似,不能保证还原原状态。
小王 · 三个问题
- 当前三种方案各需保存多少状态?按 640 MiB 预算,各能保留多少个这样的会话?哪些部分随文本变长而增长?
- 现在送入下一个已知 token,准备生成再下一个。仅统计读取已有 4,096-token 历史所需的状态,不计本步新状态的写入、当前 token 的处理及权重读取。按表中规则,三种方案每步各读多少?保存量较小,就一定读得更少、更快吗?
- 现在换了问题:要保留相同资料,去掉旧问题,再接一个新问题。三种方案能直接从资料末尾继续吗?缺少什么、要从哪里补算?增加哪个快照会改变结论?
大王 · 问题 1:保存量与容量上限
先算一个 token 在一层的一对 K/V:K 和 V 两份,各有 2×64 个数,每个数 2 字节,共 512 字节。因此 4,096 个 token 占 2 MiB,128 个 token 的窗口占 64 KiB。
| 方案 | 保存量 | 640 MiB 最多容纳 |
|---|---|---|
| A | 每层 2 MiB;32 层共 64 MiB | 10 个相同长度的会话 |
| B | 4 份全局共 8 MiB;32 个局部窗口共 2 MiB;合计 10 MiB | 64 个会话 |
| C | 每层矩阵 32 KiB;32 层共 1 MiB | 640 个会话 |
A 的全部历史、B 的全局部分随 token 数增长;B 的窗口填满后保持固定大小,C 的矩阵大小始终固定。这里的 10、64、640 只是相同长度、只按容量算出的上限,不是能同时满足响应延迟的请求数;继续生成后,增长的状态也会占用更多空间。
大王 · 问题 2:存一份,可能被读取多次
| 方案 | 怎样相加 | 逻辑读取量 |
|---|---|---|
| A | 32 层各读 2 MiB 历史 | 64 MiB/步 |
| B | 32 层各读 2 MiB 共享全局;再各读 64 KiB 局部窗口 | 66 MiB/步 |
| C | 32 层各读 32 KiB 当前矩阵 | 1 MiB/步 |
B 只存四套全局 K/V,但每套被组内八层读取,所以不能用保存的 8 MiB 代替每步的全局读取量。在这个题设中,B 比 A 少存很多,逻辑读取反而多了局部窗口的 2 MiB。
逻辑读取量是按各层“需要读几次”相加;实际硬件可能从更近的缓存复用数据,也可能因分块、布局和搬运产生额外流量。没有给出有效带宽、执行效率和排队条件,就不能把这些数字换成生成速度。
大王 · 问题 3:恢复到资料末尾
现在要接入的是已经给定的新问题,不是先预测资料后的第一个 token;因此这里检查继续读入新输入需要的状态,不额外要求保存资料末尾的词表分数。
| 方案 | 现在能从哪里继续 | 哪个快照会改变结论 |
|---|---|---|
| A | 各层前 3,072 个 token 的 K/V 仍在。取出这段前缀,丢开旧问题的后缀,即可接入新问题。 | 不缺额外的中间快照;题设已保留完整历史。 |
| B | 资料的全局 K/V 仍在,但各层窗口只剩旧问题末尾的 3,969~4,096,缺资料末尾的局部状态。需补算。 | 保存处理完 token 3,072 时全部 32 层的局部窗口,合计 2 MiB;配合已保留的全局前缀即可恢复。 |
| C | 只剩处理完 token 4,096 的矩阵;没有题设允许的回退办法。可从初始矩阵顺序重放资料的 3,072 个 token。 | 保存 token 3,072 之后全部 32 层的矩阵快照,合计 1 MiB;加载后接新问题。 |
B 没有快照时,可以从初始状态按原路径重放资料,精确补回局部状态。只重放尾部 128 个的可选办法更省计算,却截断了早先依赖,属于有意近似。C 的最终矩阵不能按本题给定的操作拆成任意前缀状态;这不等于所有递归结构在数学上都绝对不可逆。
这里“精确恢复”要求恢复继续计算应有的状态,不能只看最后文字是否相同。保存历史、补算缺失状态、接受近似,是三种不同选择。
把推理中的预测变成可观察的对照
固定一家服务和一个模型,直接看输入改变之后,哪些内容还能从缓存读取。这里使用 Claude API 的显式缓存标记。
B0 · 基础命中 B1 · 回看范围 B2 · TTL B3 · 并发入口
B0 · 先确认缓存与用量记录正常
下面保留原来的四次请求。它先验证显式边界前后改变内容时怎样复用;后面的 B1~B3 再检验附录 A 中较容易混淆的预测。
准备一份 UTF-8 编码的公开资料,保存为 public_notes.txt,长度需达到所选模型的最低缓存要求。四次请求保持模型和其他设置相同,按下表顺序发送;先确认缓存已经建立,再比较变化。[4]
| 请求 | 只改变什么 | 观察什么 |
|---|---|---|
| 1 · 准备缓存 | 首次提交本轮资料,问“用一句话概括”。 | 是否出现缓存写入用量。 |
| 2 · 确认命中 | 完整重复第 1 次输入。 | 是否出现缓存读取用量;没有读命中就停止。 |
| 3 · 更换问题 | 资料不变,末尾问题换成“列出三个要点”。 | 资料对应的缓存是否仍被读取。 |
| 4 · 改动前部 | 保持第 3 次问题,只把资料开头的版本标记 A 改为 B。 | 前部变化后,缓存读取和写入怎样改变。 |
脚本给每轮实验加一个唯一标识,四次请求共用它,减少误用上一次实验缓存的机会。缓存标记放在资料末尾;问题放在后面的用户消息中。四次均不带回上次回答,以保持比较对象清楚。
运行会产生 API 费用。脚本最多发送四次请求,每次输出上限为 96 token;发生错误或第二次未确认命中,就停止,不自动重试。下面提供代码,本文未填写虚构的实测结果。
展开完整 Python 脚本与运行方法
仅使用 Python 3 标准库。把代码保存为 kv_cache_probe.py,将命令中的 YOUR_MODEL_ID 换成你账户中可用、支持提示缓存的模型 ID。API Key 从环境变量读取;没有设置时会提示输入且不回显。
python3 kv_cache_probe.py public_notes.txt --model YOUR_MODEL_ID > kv-probe.jsonl
#!/usr/bin/env python3
"""Four sequential Claude API requests; running this file incurs API charges."""
import argparse
import getpass
import json
import os
from pathlib import Path
import sys
import time
import urllib.error
import urllib.request
import uuid
def request_once(case, prefix, question, model, api_key):
payload = {
"model": model,
"max_tokens": 96,
"stream": True,
"system": [{"type": "text", "text": prefix,
"cache_control": {"type": "ephemeral"}}],
"messages": [{"role": "user", "content": question}],
}
request = urllib.request.Request(
"https://api.anthropic.com/v1/messages",
data=json.dumps(payload, ensure_ascii=False).encode("utf-8"),
headers={"content-type": "application/json", "x-api-key": api_key,
"anthropic-version": "2023-06-01"},
method="POST",
)
started = time.perf_counter()
first_text = None
usage = {}
complete = False
error = None
response_model = None
stop_reason = None
try:
with urllib.request.urlopen(request, timeout=180) as response:
for line in response:
if not line.startswith(b"data:"):
continue
event = json.loads(line[5:].strip())
kind = event.get("type")
if kind == "message_start":
message = event["message"]
usage.update(message.get("usage", {}))
response_model = message.get("model")
elif kind == "message_delta":
# Output usage is cumulative: replace it, do not add it again.
usage.update(event.get("usage", {}))
stop_reason = event.get("delta", {}).get("stop_reason")
elif kind == "content_block_delta":
delta = event.get("delta", {})
if delta.get("type") == "text_delta" and delta.get("text"):
if first_text is None:
first_text = time.perf_counter() - started
elif kind == "error":
error = event.get("error", {}).get("type", "stream_error")
break
elif kind == "message_stop":
complete = True
break
except urllib.error.HTTPError as exc:
error = f"HTTP {exc.code}"
except (OSError, ValueError) as exc:
error = type(exc).__name__
if not complete and error is None:
error = "stream_ended_without_message_stop"
inputs = [usage.get(k) for k in (
"input_tokens", "cache_read_input_tokens", "cache_creation_input_tokens")]
return {
"case": case, "requested_model": model, "response_model": response_model,
"first_text_s": round(first_text, 3) if first_text is not None else None,
"request_s": round(time.perf_counter() - started, 3),
"total_input_tokens": sum(inputs) if all(type(v) is int for v in inputs) else None,
"usage": usage, "stop_reason": stop_reason,
"complete": complete, "error": error,
}
def main():
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("document", help="UTF-8 public reference text")
parser.add_argument("--model", required=True, help="An available Claude model ID")
args = parser.parse_args()
document = Path(args.document).read_text(encoding="utf-8")
if not document.strip():
parser.error("The reference text is empty.")
api_key = os.environ.get("ANTHROPIC_API_KEY") or getpass.getpass("API Key: ")
if not api_key.strip():
parser.error("An API key is required.")
run_id = uuid.uuid4().hex
prefix = f"实验标识:{run_id}\n材料版本:A\n请依据以下资料回答问题。\n{document}"
changed = prefix.replace("材料版本:A", "材料版本:B", 1)
cases = [
("1_prepare", prefix, "用一句话概括这份资料。"),
("2_confirm", prefix, "用一句话概括这份资料。"),
("3_new_question", prefix, "列出这份资料的三个要点。"),
("4_changed_prefix", changed, "列出这份资料的三个要点。"),
]
for case, material, question in cases:
result = request_once(case, material, question, args.model, api_key)
print(json.dumps(result, ensure_ascii=False), flush=True)
if not result["complete"]:
print("请求未完整结束,已停止;已知用量保留在记录中,不自动重试。", file=sys.stderr)
return 1
if case == "2_confirm" and not (result["usage"].get("cache_read_input_tokens") or 0):
print("尚未确认缓存命中,已停止。请检查资料长度、缓存规则与有效期。", file=sys.stderr)
return 1
return 0
if __name__ == "__main__":
raise SystemExit(main())
结果文件每行是一条请求记录。usage 保留 API 返回的用量;first_text_s 是从开始请求到收到第一个文字片段的时间,request_s 是整次请求耗时。缺失字段记为未知,不当成零;未完整结束的请求,也保留当时已收到的用量。中断记录可能不完整,费用以服务账单为准。
费用按实际选定模型的普通输入、缓存写入、缓存读取和输出价格计算,并记下所用价格与日期。先分别看四次请求,再合计本轮实验全部费用,包含第一次建立缓存的开销。
第二次未命中时,先检查资料长度、缓存标记和有效期。第三次仍命中、第四次读取量下降或重新写入,才为“复用依赖前缀”提供了这次运行的证据。只有一次运行,不能据此认定稳定的速度提升;这个实验用于验证缓存行为,完整任务的收益仍按前文方法比较。
继续之前:三个方案都尚未执行
B1~B3 是可选实验设计,不是实测结果。使用当前接口支持的同一模型和配置,资料达到最低缓存长度;每组用独立前缀标识,写在共同资料的开头。先限定费用和请求数,发生错误就停止,不自动补跑。
每次统一记录:模型与接口、思考/effort/工具/断点/TTL 等配置、实际请求结构、请求开始/首响应/响应完成/探测时间,以及 API 原始 input、read、write 字段。Claude 的 input_tokens 不含缓存读写,要另加两项才能得到总输入。首响应指最早响应事件,不能只用首个可见文字代替。未获得的结果留空或记未知。
B1 · 18/19 个文件块,旧入口还够得到吗?
要检验的预测:上一轮输入末尾已有自动缓存条目。下一轮追加一个固定的 assistant 文本块,再追加 18 个或 19 个文件块;按从当前断点自身向前检查 20 个位置的规则,旧入口分别落在第 20、21 个位置。加一个指向旧入口的显式断点,应能改变后一种结果。[4]
控制条件:数的是 API 内容块,不是 token,也不是物理缓存页。尽量保持正文、总 token、模型与其他配置一致,仅把同一批文件文本拆成 18 块或 19 块;记录实际 token 差异。历史本身不改,全部在有效期内完成。
请求安排:做“18/19 块 × 无/有额外显式断点”四组,每组独立前缀。每组先发一次准备请求,在旧末尾写入;等它完成并保留写入回执,再发一次探测。显式对照是在这次探测所携带的历史里,给上次最后一个内容块加标记,不改旧请求。最多 8 次请求。
重点记录:追加块的类型和顺序、旧入口在回看中的序号、断点位置、共同前缀长度与读写量。
怎样解释:18 块能复用旧长前缀、19 块不能,而为 19 块加显式断点后恢复,才支持“回看范围”这个解释。若四组都未命中,先查写入、长度或身份;不能直接宣布回看规则错误。
B2 · 五分钟从请求开始,还是响应完成计时?
要检验的预测:若 TTL 从读写请求开始计时,响应耗时也会消耗有效期;按完成时间倒数,会高估可复用时间。[4]
控制条件:保持前缀与匹配配置不变,使用真实、完整接收的响应时间。不能故意延迟读取响应,或把响应后的 sleep 算成生成时间。每个缓存条目只探测一次,避免读取本身刷新有效期。
请求安排:一组独立前缀做短间隔命中对照,另一组做计时探测,每组准备与探测各一次,最多 4 次。探测时刻要落在两种解释会给出不同预测的窗口内。例如实际观测若是 00:00 开始、02:00 完成,可在 05:30 探测:距开始 330 秒,距完成 210 秒。这里是选时算例,不是观测数据;真实响应太短、窗口不足时记“本次未能区分”。
重点记录:使用同一单调时钟记录开始、首响应、完成、探测四个时间;确认期间没有其他请求读写同一条目。等待只用于安排探测,不改变已经记录的响应耗时。
怎样解释:短间隔对照命中、区分窗口内未命中,与“从开始计时”相符;一次未命中仍可能受淘汰或可用性影响。窗口内命中也需检查未记录的刷新和时间误差。条件不齐全,不强行二选一。
B3 · 有共有段入口,与入口已经可读,是同一件事吗?
要检验的预测:错开发送只可能解决可读时机,不能为未保存的共有段补出入口;有了断点,也不代表其他请求马上读得到。[4]
控制条件:每组两个分支使用相同的长资料和固定回复,只有末尾分支指令不同;前后顺序、模型与配置不变,组与组之间使用独立前缀。
| 共有段末尾断点 | 发送时机 | 要区分的问题 |
|---|---|---|
| 无 | 同时发送 | 既无专门入口,也不等待对方;作为对照。 |
| 无 | A 已开始返回且有写入回执后,再发 B | 只等待,能否复用尚未单独保存的共有段? |
| 有 | 同时发送 | 入口在本轮建立,但 B 开始处理时是否已经可读? |
| 有 | A 已开始返回且有写入回执后,再发 B | 按文档可读条件等待后,是否能复用完整共有段? |
请求安排:每组各发 A、B 两次,最多 8 次,不额外预热共有段。这里用文档的首响应与写入回执作为可读依据;“错开三秒”不是该依据。同时发出的请求也可能在服务端被串行处理,不能仅靠客户端发送时刻认定 B 必然读不到。
重点记录:共有段末尾是否有断点、A 的首响应与写入回执、B 的发送时间,以及 B 实际复用了多长的前缀。
怎样解释:先在同一种断点条件下比较时机,再在同一种时机下比较断点。等待后仍只能复用较短前缀,可能缺少共有段入口;有断点却在同时发送时未命中,可能是尚不可读。不能同时补断点、改时机后,就断定其中一项单独是原因。
这些方案只规定怎样取证,不填示例数充当测量。执行时还要合计建立缓存、对照和失败请求的费用;不为追求一个命中结果无限追加请求。
把缓存亲手做出来:完整代码与实际结果
附带代码和结果来自一个随机初始化的小模型,验证状态复用与数值计算,不测自然语言能力或生产速度。原始运行环境为 Python 3.13.5、NumPy 2.3.5、CPU;结果与附录 A 的 Qwen 实验分别保留。
先看要验证什么,再运行代码
实验使用三层因果 Transformer:4 个 Q 头、2 个 KV 头、每头 8 维,带 RoPE、归一化、前馈与输出层。权重由固定随机种子产生,输入为词表中的整数 ID。
最小模型从零初始化,不下载模型;它用于暴露计算依赖和缓存管理问题。
三条路径固定权重、位置和输入
前缀缓存按 16 个 token 一块保存,缓存键纳入权重身份、命名空间和前缀链;命中后复制为私有状态。另有独立的 PagePool 模拟器验证共享计数与写时复制。两者分开,避免把“有页管理类”写成已经实现 GPU 分页内核。
| 随附运行结果 | 命中 | 现算 | 缓存 vs 整段:最大绝对差 | 缓存 vs 分段:最大绝对差 |
|---|---|---|---|---|
| float64 | 32 | 15 | 0 | 0 |
| float32 | 32 | 15 | 0 | 0 |
随附环境中,这两种精度的上述比较均观察到 0 差异。重跑时应记录自己的数值并按容差检查;不同硬件和计算形状可能出现小差异。
| 其他对照结果 | 随附结果 | 能说明什么 |
|---|---|---|
| 等长不同前文:文档第一层 K 最大差 | 0 | 本模型第一层在读前文前按位置独立变换 |
| 同一文档第二层 K 最大差 | 2.112007 | 高层状态已经受不同前文影响 |
| 错误拼接后的下一 token logits 最大差 | 1.311954 | 不改变位置也可能拼错 |
| 在新前文后完整重放,再接续的最大差 | 0 | 完整重放提供正确修复对照,不是选择性修复结果 |
| 在线 softmax vs 全量结果最大差 | 5.55e-17 | 验证全局累计合并的关系 |
| 2,048 个数的 INT4 数据+尺度 | 1,024 + 512 = 1,536 字节 | 实际数组字节数,不含 Python 对象与运行工作区 |
大王:先预测命中长度和错误拼接会影响哪一层,再运行测试。读代码时沿 TinyDecoder.forward → PrefixCache.lookup → store 走一遍;再看 online_attention 与 PagePool。这样每次只检查一条机制,不必同时消化所有实现。
点击链接下载配套文件;打开网页或下载文件不会执行其中的代码。
大王:把主脚本、测试脚本与依赖版本放在同一目录。已有 NumPy 时不必再次安装;随附记录使用 Python 3.13.5、NumPy 2.3.5、CPU。下面的命令在你自己的终端执行,不会因打开网页而自动运行。
python -m pip install -r requirements.txt
python -m unittest -v
python kv_lab.py --output my_results.json随附记录设置 OPENBLAS_NUM_THREADS=1。其他平台或线程配置可能改变浮点累加路径;请保留自己的环境与结果,按测试中的容差验收,不要求复制出这份 JSON 的每一位。这里没有 BF16 实验,也没有把 CPU 计时换成 GPU 加速倍数。
| 入口 | 沿着它检查什么 |
|---|---|
| TinyDecoder.forward | 输入位置包含已缓存长度;当前 Q/K 施加 RoPE;缓存追加的是 KV 头;后续注意力不能读取未来 |
| PrefixCache.lookup / store | 完整块的链式身份;首次缺失停止;命中先私有复制;容量限制与回收 |
| online_attention | 分块保留最大值、分母、加权和;最后一次全局归一化 |
| pack_int4 / unpack_int4 | 真正把两个码装进一字节;每组尺度另占空间;格式不是 NVFP4 |
| PagePool | 引用计数、释放、分支写入前复制;此模拟不直接驱动模型注意力 |
完整主脚本:模型、缓存与实验
kv_lab.py · 与上方保存按钮的内容一致
#!/usr/bin/env python3
"""Small, offline KV-cache laboratory. This is NOT a pretrained language model.
Run: python kv_lab.py --output results.json
Requires Python >=3.10 and NumPy. No downloads, credentials, or API requests.
The model is a randomly initialized, 3-layer causal decoder with GQA and RoPE.
Its purpose is to test state reuse, not language ability or production speed.
"""
from __future__ import annotations
import argparse
import hashlib
import json
import math
import platform
import sys
from collections import OrderedDict
from dataclasses import dataclass
from pathlib import Path
from typing import Any
import numpy as np
Array = np.ndarray
State = list[tuple[Array, Array]]
def softmax(x: Array) -> Array:
e = np.exp(x - x.max(axis=-1, keepdims=True))
return e / e.sum(axis=-1, keepdims=True)
def rmsnorm(x: Array) -> Array:
return x / np.sqrt(np.mean(x * x, axis=-1, keepdims=True) + 1e-6)
def rotate(x: Array, positions: Array) -> Array:
"""x: [heads, tokens, head_dim]; interleaved even/odd RoPE pairs."""
d = x.shape[-1]
freq = 10000.0 ** (-np.arange(0, d, 2, dtype=np.float64) / d)
angles = positions[:, None] * freq[None, :]
c, s = np.cos(angles).astype(x.dtype), np.sin(angles).astype(x.dtype)
y = np.empty_like(x)
y[..., 0::2] = x[..., 0::2] * c - x[..., 1::2] * s
y[..., 1::2] = x[..., 0::2] * s + x[..., 1::2] * c
return y
@dataclass(frozen=True)
class Config:
vocab: int = 41
width: int = 32
layers: int = 3
q_heads: int = 4
kv_heads: int = 2
head_dim: int = 8
seed: int = 7
dtype: str = "float64"
class TinyDecoder:
def __init__(self, cfg: Config = Config()):
if (cfg.width != cfg.q_heads * cfg.head_dim or
cfg.q_heads % cfg.kv_heads or cfg.head_dim % 2):
raise ValueError("Invalid head dimensions or GQA grouping")
self.cfg = cfg
dtype = np.dtype(cfg.dtype)
if dtype not in (np.dtype('float32'), np.dtype('float64')):
raise ValueError("This lab supports float32 and float64 only")
rng = np.random.default_rng(cfg.seed)
self.embed = (rng.normal(size=(cfg.vocab, cfg.width)) * 0.3).astype(dtype)
shapes = [(cfg.width, cfg.q_heads * cfg.head_dim),
(cfg.width, cfg.kv_heads * cfg.head_dim),
(cfg.width, cfg.kv_heads * cfg.head_dim),
(cfg.width, cfg.width), (cfg.width, cfg.width * 2),
(cfg.width * 2, cfg.width)]
self.weights = [[(rng.normal(size=sh) / math.sqrt(sh[0])).astype(dtype)
for sh in shapes] for _ in range(cfg.layers)]
self.lm_head = (rng.normal(size=(cfg.width, cfg.vocab)) /
math.sqrt(cfg.width)).astype(dtype)
h = hashlib.sha256(json.dumps(cfg.__dict__, sort_keys=True).encode())
for a in [self.embed, self.lm_head] + [w for row in self.weights for w in row]:
h.update(a.tobytes())
# Includes actual weights, not just a model name. RoPE rule is fixed by code.
self.identity = 'tiny-gqa-rope-v1:' + h.hexdigest()
self.processed_positions = 0
def empty_state(self) -> State:
c = self.cfg
return [(np.empty((c.kv_heads, 0, c.head_dim), dtype=c.dtype),
np.empty((c.kv_heads, 0, c.head_dim), dtype=c.dtype))
for _ in range(c.layers)]
def forward(self, tokens: list[int], past: State | None = None) -> tuple[Array, State]:
"""Processes ONLY tokens supplied here; past has already been computed.
Token indices in the causal mask are ABSOLUTE, including the cached offset.
This function returns new arrays; it never mutates a supplied cache.
"""
c = self.cfg
if not tokens or any(type(t) is not int or not 0 <= t < c.vocab for t in tokens):
raise ValueError("Provide a nonempty list of integer IDs within the vocabulary")
past = self.empty_state() if past is None else past
if len(past) != c.layers:
raise ValueError("Cache layer count mismatch")
start = past[0][0].shape[1]
for k, v in past:
expected = (c.kv_heads, start, c.head_dim)
if k.shape != expected or v.shape != expected:
raise ValueError("Inconsistent cache shapes")
if k.dtype != np.dtype(c.dtype) or v.dtype != np.dtype(c.dtype):
raise ValueError("Cache dtype mismatch")
pos = np.arange(start, start + len(tokens))
x = self.embed[tokens].copy()
out_state = []
self.processed_positions += len(tokens)
for layer, (wq, wk, wv, wo, w1, w2) in enumerate(self.weights):
h = rmsnorm(x)
q = (h @ wq).reshape(-1, c.q_heads, c.head_dim).transpose(1, 0, 2)
k = (h @ wk).reshape(-1, c.kv_heads, c.head_dim).transpose(1, 0, 2)
v = (h @ wv).reshape(-1, c.kv_heads, c.head_dim).transpose(1, 0, 2)
q, k = rotate(q, pos), rotate(k, pos)
k = np.concatenate([past[layer][0], k], axis=1)
v = np.concatenate([past[layer][1], v], axis=1)
out_state.append((k, v)) # Persist KV heads, not repeated Q heads.
kr = np.repeat(k, c.q_heads // c.kv_heads, axis=0)
vr = np.repeat(v, c.q_heads // c.kv_heads, axis=0)
scores = (q @ kr.transpose(0, 2, 1)) / math.sqrt(c.head_dim)
allowed = np.arange(k.shape[1])[None, :] <= pos[:, None]
scores = np.where(allowed[None, :, :], scores, -np.inf)
a = (softmax(scores) @ vr).transpose(1, 0, 2).reshape(-1, c.width)
x = x + a @ wo
z = rmsnorm(x) @ w1
x = x + (z / (1 + np.exp(-z))) @ w2
return rmsnorm(x) @ self.lm_head, out_state
@dataclass
class Entry:
tokens: tuple[int, ...] # Collision guard; each entry stores a full block only.
state: State # This block's K/V across all layers, never a whole prefix.
class PrefixCache:
"""Serial educational prefix cache. Hits are copied before model execution.
Source blocks cannot be evicted midway because this implementation is serial.
Production async transfers need ownership protection until the copy completes.
This class stores neither final logits nor hidden states, so it leaves >=1 input.
"""
def __init__(self, model: TinyDecoder, block_size: int = 16,
capacity_blocks: int = 128, namespace: str = "local-demo"):
if block_size < 1 or capacity_blocks < 1:
raise ValueError("Block size and capacity must be positive")
self.model = model
self.block_size = block_size
self.capacity = capacity_blocks
self.base = hashlib.sha256((model.identity + ':' + namespace).encode()).digest()
self.blocks: OrderedDict[bytes, Entry] = OrderedDict()
def keys(self, tokens: list[int]):
parent = self.base
b = self.block_size
for end in range(b, len(tokens) + 1, b):
block = tuple(tokens[end-b:end])
raw = np.asarray(block, dtype='<i8').tobytes()
key = hashlib.sha256(parent + raw).digest()
yield end, key, block
parent = key
def lookup(self, tokens: list[int]) -> tuple[int, State | None]:
rows: list[State] = []
hit = 0
for end, key, block in self.keys(tokens):
if end >= len(tokens):
break # Need a final hidden state, not only K/V.
entry = self.blocks.get(key)
if entry is None or entry.tokens != block:
break
self.blocks.move_to_end(key)
rows.append(entry.state)
hit = end
if not rows:
return 0, None
state = [(np.concatenate([r[i][0] for r in rows], axis=1).copy(),
np.concatenate([r[i][1] for r in rows], axis=1).copy())
for i in range(self.model.cfg.layers)]
return hit, state
def store(self, tokens: list[int], state: State) -> None:
# Reverse insertion makes the beginning newer than the tail in this toy LRU.
# It preserves a useful prefix when one old block must be evicted.
for end, key, block in reversed(list(self.keys(tokens))):
if key not in self.blocks:
if len(self.blocks) >= self.capacity:
self.blocks.popitem(last=False)
sliced = [(k[:, end-self.block_size:end].copy(),
v[:, end-self.block_size:end].copy()) for k, v in state]
self.blocks[key] = Entry(block, sliced)
self.blocks.move_to_end(key)
def request(self, tokens: list[int]) -> tuple[Array, State, dict[str, int]]:
hit, state = self.lookup(tokens)
logits, state = self.model.forward(tokens[hit:], state)
self.store(tokens, state)
return logits[-1], state, {"input_tokens": len(tokens), "hit_tokens": hit,
"computed_tokens": len(tokens)-hit}
def online_attention(q: Array, k: Array, v: Array, block: int = 7) -> Array:
"""One query, all keys visible. Online global softmax; not a GPU kernel."""
m = -np.inf
l = 0.0
acc = np.zeros(v.shape[-1], dtype=np.float64)
for i in range(0, len(k), block):
s = k[i:i+block] @ q / math.sqrt(q.size)
new_m = max(m, float(s.max()))
alpha = math.exp(m-new_m)
p = np.exp(s-new_m)
acc = alpha * acc + p @ v[i:i+block]
l = alpha * l + float(p.sum())
m = new_m
return acc / l
def pack_int4(x: Array, group: int = 16) -> tuple[Array, Array, tuple[int, ...]]:
"""Uniform signed INT4, NOT NVFP4. One FP32 scale per group, no zero point.
Codes use [-7,7]; +8 offset enables two codes per uint8. Pad zeros at the tail.
"""
if group < 2 or group % 2:
raise ValueError("Group must be positive and even")
f = np.asarray(x, dtype=np.float32).reshape(-1)
if not np.isfinite(f).all():
raise ValueError("Quantization requires finite input")
n = ((len(f) + group - 1) // group) * group
a = np.pad(f, (0, n-len(f))).reshape(-1, group)
scales = np.max(np.abs(a), axis=1).astype(np.float32) / 7
scales[scales == 0] = 1
codes = (np.rint(a / scales[:, None]).clip(-7, 7).astype(np.int8) + 8).astype(np.uint8)
codes = codes.reshape(-1)
packed = codes[0::2] | (codes[1::2] << 4)
return packed, scales, x.shape
def unpack_int4(packed: Array, scales: Array, shape: tuple[int, ...], group: int = 16) -> Array:
codes = np.empty(packed.size * 2, dtype=np.int16)
codes[0::2] = packed & 15
codes[1::2] = packed >> 4
a = ((codes - 8).reshape(-1, group) * scales[:, None]).reshape(-1)
return a[:math.prod(shape)].reshape(shape)
class PagePool:
"""Small single-threaded page-ownership simulation (not model execution)."""
def __init__(self, capacity: int = 4):
self.capacity = capacity
self.pages: dict[int, Array] = {}
self.refs: dict[int, int] = {}
def allocate(self, data: Array) -> int:
for p in range(self.capacity):
if self.refs.get(p, 0) == 0:
self.pages[p] = data.copy()
self.refs[p] = 1
return p
raise MemoryError("All pages are held by readers")
def retain(self, p: int) -> None:
if self.refs.get(p, 0) <= 0:
raise RuntimeError("Cannot retain a free page")
self.refs[p] += 1
def release(self, p: int) -> None:
if self.refs.get(p, 0) <= 0:
raise RuntimeError("Double release or unknown page")
self.refs[p] -= 1
def private_for_write(self, p: int) -> int:
if self.refs.get(p, 0) <= 0:
raise RuntimeError("Cannot write a free page")
if self.refs[p] == 1:
return p
# Allocate before dropping the old reference, so failure is safe.
new_p = self.allocate(self.pages[p])
self.release(p)
return new_p
def max_error(a: Array, b: Array) -> float:
return float(np.max(np.abs(a-b)))
def experiments() -> dict[str, Any]:
rng = np.random.default_rng(20261009)
prefix = rng.integers(0, 41, size=42).tolist()
request_a = prefix + [4, 5, 6]
request_b = prefix + [7, 8, 9, 10, 11]
rows = []
for dtype in ('float64', 'float32'):
model = TinyDecoder(Config(dtype=dtype))
cache = PrefixCache(model)
cache.request(request_a)
cached, _, usage = cache.request(request_b)
full, _ = model.forward(request_b)
_, pre = model.forward(request_b[:usage['hit_tokens']])
split, _ = model.forward(request_b[usage['hit_tokens']:], pre)
tolerance = 1e-10 if dtype == 'float64' else 2e-5
assert max_error(cached, full[-1]) < tolerance
assert max_error(cached, split[-1]) < tolerance
rows.append({'dtype': dtype, **usage,
'cached_vs_full_max_abs': max_error(cached, full[-1]),
'split_vs_full_max_abs': max_error(split[-1], full[-1]),
'cached_vs_split_max_abs': max_error(cached, split[-1])})
model = TinyDecoder()
full, state = model.forward(request_b)
_, old = model.forward([1, 2, 3, 4] + prefix)
_, new = model.forward([5, 6, 7, 8] + prefix)
first = max_error(old[0][0][:,4:], new[0][0][:,4:])
higher = max_error(old[1][0][:,4:], new[1][0][:,4:])
assert first < 1e-10 and higher > 1e-6
# Same-length prefixes: isolate content dependency without a RoPE-offset change.
_, lead = model.forward([5, 6, 7, 8])
wrong = [(np.concatenate([lead[i][0], old[i][0][:,4:]], axis=1),
np.concatenate([lead[i][1], old[i][1][:,4:]], axis=1))
for i in range(model.cfg.layers)]
wrong_logits, _ = model.forward([9], wrong)
right_logits, _ = model.forward([9], new)
wrong_error = max_error(wrong_logits, right_logits)
assert wrong_error > 1e-6
# Replay document under its new prefix: exact repair baseline, not CacheBlend.
repaired_logits, repaired = model.forward(prefix, lead)
repaired_next, _ = model.forward([9], repaired)
assert max_error(repaired_next, right_logits) < 1e-10
q = rng.normal(size=16)
k = rng.normal(size=(37,16))
v = rng.normal(size=(37,12))
dense = softmax(k @ q / 4) @ v
online = online_attention(q,k,v)
online_error = max_error(dense, online)
assert online_error < 1e-12
data = rng.normal(size=(4,32,16)).astype(np.float32)
packed, scales, shape = pack_int4(data)
recovered = unpack_int4(packed, scales, shape)
# Quantized model state errors are measured, never interpreted as task quality.
quant_state = []
for kk,vv in state:
quant_state.append(tuple(unpack_int4(*pack_int4(a)).astype('float64') for a in (kk,vv)))
ref_next,_ = model.forward([12],state)
quant_next,_ = model.forward([12],quant_state)
return {'lab_version':'1.0', 'run_date':'2026-10-09',
'environment': {'python':platform.python_version(), 'numpy':np.__version__,
'platform':platform.platform(), 'device':'CPU'},
'scope':'Random tiny model; numerical and state-management tests only. No API or pretrained-model benchmark.',
'config':model.cfg.__dict__, 'model_identity':model.identity,
'prefix_tests':rows,
'context_dependency': {'first_layer_document_k_max_abs': first,
'second_layer_document_k_max_abs': higher,
'wrong_splice_next_logits_max_abs': wrong_error,
'full_replay_repair_max_abs': max_error(repaired_next,right_logits)},
'online_softmax_max_abs': online_error,
'quantization': {'format':'uniform signed INT4; FP32 scale per 16 values',
'original_fp32_bytes':int(data.nbytes),
'hypothetical_fp16_bytes':int(data.size * 2),
'packed_data_bytes':int(packed.nbytes),
'scale_bytes':int(scales.nbytes),
'total_payload_bytes':int(packed.nbytes + scales.nbytes),
'reconstruction_max_abs':max_error(data,recovered),
'next_logits_max_abs':max_error(ref_next,quant_next)},
'timing_note':'No latency benchmark: toy CPU timing is not evidence of GPU speedup.'}
def main() -> None:
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument('--output', type=Path, default=Path('results.json'))
args = parser.parse_args()
result = experiments()
args.output.parent.mkdir(parents=True, exist_ok=True)
args.output.write_text(json.dumps(result, ensure_ascii=False, indent=2)+'\n',encoding='utf-8')
print(json.dumps(result, ensure_ascii=False, indent=2))
if __name__ == '__main__':
main()
完整自动测试:15 项
test_kv_lab.py · 与上方保存按钮的内容一致
"""Run with python -m unittest -v. Tests exercise deliberate failure controls."""
import unittest
import numpy as np
from kv_lab import (TinyDecoder, Config, PrefixCache, PagePool, softmax,
online_attention, pack_int4, unpack_int4, max_error)
class KVLabTests(unittest.TestCase):
def setUp(self):
self.model = TinyDecoder()
self.tokens = (list(range(31)) * 2)[:53]
def test_incremental_matches_full(self):
full, _ = self.model.forward(self.tokens)
_, pre = self.model.forward(self.tokens[:32])
tail, _ = self.model.forward(self.tokens[32:], pre)
self.assertLess(max_error(full[32:], tail), 1e-10)
def test_prefix_hit_processes_only_suffix(self):
cache = PrefixCache(self.model)
cache.request(self.tokens)
b = self.tokens[:42] + [39, 40, 38]
before = self.model.processed_positions
logits, _, usage = cache.request(b)
self.assertEqual(usage['hit_tokens'], 32)
self.assertEqual(self.model.processed_positions-before, 13)
ref,_ = self.model.forward(b)
self.assertLess(max_error(logits,ref[-1]),1e-10)
def test_full_block_hit_leaves_one_block(self):
cache = PrefixCache(self.model)
cache.request(self.tokens[:48])
_,_,u = cache.request(self.tokens[:48])
self.assertEqual(u['hit_tokens'], 32)
self.assertEqual(u['computed_tokens'], 16)
def test_early_change_breaks_hash_chain(self):
cache = PrefixCache(self.model)
cache.request(self.tokens)
changed = self.tokens.copy();changed[0]=40
self.assertEqual(cache.lookup(changed)[0], 0)
def test_identity_includes_weights_and_namespace(self):
c1=PrefixCache(self.model,namespace='alpha')
c2=PrefixCache(TinyDecoder(Config(seed=8)),namespace='alpha')
c3=PrefixCache(self.model,namespace='beta')
self.assertNotEqual(c1.base,c2.base)
self.assertNotEqual(c1.base,c3.base)
def test_past_not_mutated_and_no_private_alias(self):
_, past = self.model.forward(self.tokens[:32])
saved=past[0][0].copy()
self.model.forward(self.tokens[32:],past)
np.testing.assert_array_equal(saved,past[0][0])
cache=PrefixCache(self.model);cache.request(self.tokens)
_,hit=cache.lookup(self.tokens)
hit[0][0][:]=0
_,hit2=cache.lookup(self.tokens)
self.assertGreater(np.max(np.abs(hit2[0][0])),0)
def test_cache_shape_counts_kv_heads_not_q_heads(self):
_,state=self.model.forward(self.tokens)
c=self.model.cfg
size=sum(k.nbytes+v.nbytes for k,v in state)
self.assertEqual(size,2*c.layers*len(self.tokens)*c.kv_heads*c.head_dim*8)
def test_online_global_softmax(self):
rng=np.random.default_rng(2);q=rng.normal(size=8)
k=rng.normal(size=(29,8));v=rng.normal(size=(29,5))
ref=softmax(k@q/np.sqrt(8))@v
for block in [1,3,7,29,50]:
self.assertLess(max_error(ref,online_attention(q,k,v,block)),1e-12)
def test_packed_int4_bytes_and_bound(self):
x=np.linspace(-3,3,64,dtype=np.float32).reshape(4,16)
packed,scales,shape=pack_int4(x)
y=unpack_int4(packed,scales,shape)
self.assertEqual(packed.nbytes,32)
self.assertEqual(scales.nbytes,16)
self.assertLessEqual(max_error(x,y),float(scales.max())/2+1e-6)
z=np.zeros(17,dtype=np.float32)
np.testing.assert_array_equal(unpack_int4(*pack_int4(z)),z)
def test_reference_count_protects_second_reader(self):
pool=PagePool(1);p=pool.allocate(np.array([1.0,2.0]))
pool.retain(p);pool.release(p)
with self.assertRaises(MemoryError):pool.allocate(np.array([9.0]))
np.testing.assert_array_equal(pool.pages[p],[1.0,2.0])
pool.release(p)
self.assertEqual(pool.allocate(np.array([9.0])),p)
def test_copy_on_write_separates_branches(self):
pool=PagePool(2);p=pool.allocate(np.array([1.0,2.0,0.0]))
pool.retain(p);branch=pool.private_for_write(p)
pool.pages[branch][2]=9.0
self.assertEqual(pool.pages[p][2],0.0)
self.assertEqual(pool.refs[p],1);self.assertEqual(pool.refs[branch],1)
def test_double_release_rejected(self):
pool=PagePool();p=pool.allocate(np.zeros(3));pool.release(p)
with self.assertRaises(RuntimeError):pool.release(p)
def test_wrong_splice_is_detectable(self):
doc=[8,9,10,11,12]
_,old=self.model.forward([1,2]+doc)
_,newlead=self.model.forward([3,4])
wrong=[(np.concatenate([newlead[i][0],k[:,2:]],axis=1),
np.concatenate([newlead[i][1],v[:,2:]],axis=1))
for i,(k,v) in enumerate(old)]
bad,_=self.model.forward([13],wrong)
good,_=self.model.forward([3,4]+doc+[13])
self.assertGreater(max_error(bad[-1],good[-1]),1e-6)
def test_eviction_recovers_correctly(self):
cache=PrefixCache(self.model,capacity_blocks=2)
cache.request(self.tokens)
self.assertLessEqual(len(cache.blocks),2)
out,_,_=cache.request(self.tokens)
full,_=self.model.forward(self.tokens)
self.assertLess(max_error(out,full[-1]),1e-10)
def test_invalid_inputs_rejected(self):
for t in [[],[-1],[41],[1.2]]:
with self.assertRaises(ValueError):self.model.forward(t)
if __name__=='__main__':unittest.main()
原始测量结果
results.json · 与上方保存按钮的内容一致
{
"lab_version": "1.0",
"run_date": "2026-10-09",
"environment": {
"python": "3.13.5",
"numpy": "2.3.5",
"platform": "Linux-6.18.44-x86_64-with-glibc2.41",
"device": "CPU"
},
"scope": "Random tiny model; numerical and state-management tests only. No API or pretrained-model benchmark.",
"config": {
"vocab": 41,
"width": 32,
"layers": 3,
"q_heads": 4,
"kv_heads": 2,
"head_dim": 8,
"seed": 7,
"dtype": "float64"
},
"model_identity": "tiny-gqa-rope-v1:d71a873dd3c45a619daae621b2fb66f86897f5f6b16f068a738f212adb3e60f2",
"prefix_tests": [
{
"dtype": "float64",
"input_tokens": 47,
"hit_tokens": 32,
"computed_tokens": 15,
"cached_vs_full_max_abs": 0.0,
"split_vs_full_max_abs": 0.0,
"cached_vs_split_max_abs": 0.0
},
{
"dtype": "float32",
"input_tokens": 47,
"hit_tokens": 32,
"computed_tokens": 15,
"cached_vs_full_max_abs": 0.0,
"split_vs_full_max_abs": 0.0,
"cached_vs_split_max_abs": 0.0
}
],
"context_dependency": {
"first_layer_document_k_max_abs": 0.0,
"second_layer_document_k_max_abs": 2.112007194820824,
"wrong_splice_next_logits_max_abs": 1.3119541099416636,
"full_replay_repair_max_abs": 0.0
},
"online_softmax_max_abs": 5.551115123125783e-17,
"quantization": {
"format": "uniform signed INT4; FP32 scale per 16 values",
"original_fp32_bytes": 8192,
"hypothetical_fp16_bytes": 4096,
"packed_data_bytes": 1024,
"scale_bytes": 512,
"total_payload_bytes": 1536,
"reconstruction_max_abs": 0.21534329652786255,
"next_logits_max_abs": 0.09491987950877667
},
"timing_note": "No latency benchmark: toy CPU timing is not evidence of GPU speedup."
}
原始测试输出
tests.txt · 与上方保存按钮的内容一致
test_cache_shape_counts_kv_heads_not_q_heads (test_kv_lab.KVLabTests.test_cache_shape_counts_kv_heads_not_q_heads) ... ok
test_copy_on_write_separates_branches (test_kv_lab.KVLabTests.test_copy_on_write_separates_branches) ... ok
test_double_release_rejected (test_kv_lab.KVLabTests.test_double_release_rejected) ... ok
test_early_change_breaks_hash_chain (test_kv_lab.KVLabTests.test_early_change_breaks_hash_chain) ... ok
test_eviction_recovers_correctly (test_kv_lab.KVLabTests.test_eviction_recovers_correctly) ... ok
test_full_block_hit_leaves_one_block (test_kv_lab.KVLabTests.test_full_block_hit_leaves_one_block) ... ok
test_identity_includes_weights_and_namespace (test_kv_lab.KVLabTests.test_identity_includes_weights_and_namespace) ... ok
test_incremental_matches_full (test_kv_lab.KVLabTests.test_incremental_matches_full) ... ok
test_invalid_inputs_rejected (test_kv_lab.KVLabTests.test_invalid_inputs_rejected) ... ok
test_online_global_softmax (test_kv_lab.KVLabTests.test_online_global_softmax) ... ok
test_packed_int4_bytes_and_bound (test_kv_lab.KVLabTests.test_packed_int4_bytes_and_bound) ... ok
test_past_not_mutated_and_no_private_alias (test_kv_lab.KVLabTests.test_past_not_mutated_and_no_private_alias) ... ok
test_prefix_hit_processes_only_suffix (test_kv_lab.KVLabTests.test_prefix_hit_processes_only_suffix) ... ok
test_reference_count_protects_second_reader (test_kv_lab.KVLabTests.test_reference_count_protects_second_reader) ... ok
test_wrong_splice_is_detectable (test_kv_lab.KVLabTests.test_wrong_splice_is_detectable) ... ok
----------------------------------------------------------------------
Ran 15 tests in 0.086s
OK
完整运行说明
README.md · 与上方保存按钮的内容一致
# KV 缓存最小实验:先把状态复用做出来
这是补充教材的可运行伴随实验,版本 1.0,整理于 2026-10-09。
**它不是原附录 A 中 Qwen3-0.6B 实验的复现,也不是训练好的语言模型。**
## 跑什么
`kv_lab.py` 从固定随机种子构建三层因果 Transformer:4 个 Q 头、2 个 KV 头、每头 8 维、RoPE、RMSNorm、前馈层和词表输出。词表为 41 个整数 ID,无分词器、外部数据集、模型下载或付费 API。
它包含完整前缀缓存(16 token 一块、链式 SHA-256、权重和命名空间身份、私有复制)、在线 softmax、实际打包的 INT4,以及独立的共享页/写时复制模拟器。
## 运行
需要 Python 3.10 及以上。本次验证环境是 Python 3.13.5、NumPy 2.3.5、CPU。
已有 NumPy 时可直接运行;需要安装时由你执行:
```bash
python -m pip install numpy==2.3.5
python -m unittest -v
python kv_lab.py --output my_results.json
```
在 macOS / Linux 上可用 `OPENBLAS_NUM_THREADS=1 python kv_lab.py --output my_results.json` 固定 BLAS 线程数。本次记录使用该设置。测试不要求每一种硬件都与所附 JSON 逐位相同,而是检查 FP64 / FP32 对应的数值容差。NumPy 2.3.5 的 Python 支持范围以安装环境为准;Python 3.10 用户可使用支持该版本的 NumPy,实际结果应记录版本。
## 先预测,再看结果
1. 两个输入共享前 42 个 token,缓存以 16 token 为单位,只保存整块。第二个输入共 47 token。命中 32,重算 15;不是共享的 42 都能复用。
2. “不查表,只把输入分两段计算”是必要对照:它能分离分段执行带来的数值差异与缓存管理错误。
3. 把文档的旧 K/V 接到长度相同、内容不同的前文后,位置不变仍然可能错。重放文档是精确修复对照;本实验**没有实现 CacheBlend**。
4. `PagePool` 中先让两个使用者共享一页,再结束其中一个;另一人没结束之前,不能覆盖。写共享尾页之前先复制。
5. INT4 两个码打包为 1 字节。每 16 个数一个 FP32 缩放系数,因此 2,048 个数的载荷是 1,024 + 512 = 1,536 字节。**这不是 NVFP4 格式**,也未计 Python 对象、对齐及工作区。
## 文件
- `kv_lab.py`:完整模型与缓存实验;会写入你指定的结果 JSON。
- `test_kv_lab.py`:15 项自动测试。
- `results.json`:本次实际运行的测量值与环境,不是示例数字。
- `tests.txt`:本次测试原始输出。
- `requirements.txt`:本次 NumPy 版本。
- `SOURCE_NOTES.md`:机制依据与实验范围。
## 有意没有实现的东西
没有真实 GPU 内核、异步网络传输、跨进程并发、语义正确率评测、生产安全隔离、缓存持久化或训练。`PrefixCache` 的复制与回收完全串行;`PagePool` 是另一个独立生命周期模拟,不是 `TinyDecoder` 的分页注意力后端。不能据此声称已经复现 vLLM、FlashAttention、NVFP4、CacheBlend 或云端服务。
缓存权重身份包含实际数组的 SHA-256,而不是只用模型名字;实验中的命名空间是本地隔离示例,不是线上鉴权或秘密 salt 的替代。JSON 中的 `run_date` 是本版记录的整理日;重跑时应另外保存自己的日期/环境。
资料、代码与算例口径
固定版本与核对日期见各条来源。教学算例、附录 A 的既有实验记录、附录 B 的可选 API 验证、附录 C 的离线小模型分别标注;随附结果保留原始运行环境。
使用 2026-09-30 的固定提交,说明 Q/K/V、逐步生成与 KV 缓存的基础机制。本文对第一层与高层依赖关系的说明,是基于该计算结构的推导。
使用 2026-09-30 的固定提交,说明块缓存、父块 hash、身份信息与完整块复用规则。实际引擎版本、注意力后端和混合架构可能带来额外条件。
直接读取的公开代码;定位 hash_block_tokens,可见父块 hash 与当前 token 的组合。该固定版本也包含部分块长度相关字段;正文的完整块算例有意限定为只复用整块的方案,不宣称所有新版本都只有这一种行为。
2026-10-02 核对:完整前缀、显式与自动断点、从自身向前回看 20 个位置、TTL 从读写请求开始计时、并发可读时机及预热限制。动态入口,不是九月快照;附录 A 的价格与输入量按题设固定。
核对版本:eb9c9ee99d47bf4c526a06cd84da59cd9cf4e2a5。RadixKey.match 支持单 token 精度;_insert_helper 将连续内容保存在一个节点中,_match_prefix_helper 与 _split_node 在需要时拆出匹配边界。缓存树节点与实际 K/V 数据分别管理。
2026-10-02 核对:API 文档明确联系 SWA 与前缀单元,仍给出 A+B、A+C、A+D 示例,并声明尽力而为。结合来源 14 解释状态恢复约束;未执行线上请求。
原始架构、共享全局 KV、前半部分的有界状态及复杂度分析。论文中的性能倍数不能直接当作 DeepSeek V4.1 的测量结果。
源码定位:SelfDecoder、CrossDecoder.forward、skip_cross_decoder。本文关于 Prefill 能省哪些位置的解释,与其 K/V 生成和跨层读取依赖对应。
直接读取的公开代码。定位:NgramHashMapping._get_ngram_hashes、MultiHeadEmbedding、Engram.forward、TransformerBlock.forward。可以看到多头取模寻址、训练向量表、投影、门控与短卷积;它是演示实现,不是 V4.1 的完整推理实现。
原论文的公开摘要与官方仓库说明:条件记忆、固定规模的 n-gram 查找、确定性寻址和主机内存预取。本文不借用其特定实验倍率来估算 V4.1 的收益。
代码固定到 2026 年 9 月 30 日的提交,最后核对日期为 2026 年 10 月 1 日。LlamaModel 逐层处理输入并准备各层 K/V;LlamaForCausalLM 可通过 logits_to_keep 只取末尾输入 token 对应的表示,计算下一 token 的词表分数。生成流程 ↗在支持该选项且调用方未指定时设置为 1。这说明省去历史位置的词表预测,不等于省去构造深层 K/V 所需的中间计算。本轮还核对 LlamaAttention.forward:Q、K 先旋转,再更新 K/V 缓存;V 不施加这一层的 RoPE 旋转。
固定提交 dba1be0a40aa45a94ad051997016db3960a90277。配置中的 K/V 来源为代码层 2、8、14、20,索引来源另含 24、28、32、36。Indexer.forward 在全量评分后应用候选掩码;Transformer.forward 仍运行传入位置的全部骨干层。 官方配置 ↗ 官方模型说明 ↗ 同提交的注意力计算 ↗用同一个 kv 数组匹配与汇总;model.py 的 Attention.forward 在汇总后对 RoPE 部分作逆旋转。 Indexer.forward 取得入选编号后按原编号排序,注意力内核再按对应编号读取数据;筛选不重编号历史位置。
核对版本:866fa130fac1e1252330a680fbed45ad05eba64f。get_request_block_hasher 从已有编号后开始,逐组生成链式编号;BlockPool.touch 与 free_blocks ↗逐个更新使用计数和回收队列。这些代码说明编号与管理需要做哪些工作;正文数量和空间表是给定条件下的算例,不是实际服务性能测量。
固定版技术报告。§2.2 说明编码器/解码器的依赖关系;§2.3.2 把候选池限定在解码器;§3.2.1 回顾 V4 的全局缓存与局部快照管理,§3.2.2 说明接受近似的短段恢复。报告部署方案与来源 12 的参考执行路径分开说明。§3.1.2 说明索引器参数优化与梯度汇总,但未像来源 37 一样完整列出索引器训练误差的定义;2026-10-09 核对。
GLM-5.3 的共享分支复用历史位置的筛选结果,K/V 仍由各层分别生成。GLM-5.3-Flash 实现 ↗与官方配置 ↗显示线性注意力和稀疏注意力混合,各层维护自己的状态。代码固定在本次核对的版本。
读取历史 K/V 的注意力层分别生成 K/V,并按层号更新缓存。官方配置 ↗列出 KDA 与 Gated MLA 的混合安排;本次未将这种混合结构计为 YOCO。
对照Flash-Next 实现 ↗及Qwen3.8-2.4T-A95B 所用架构实现 ↗:需要历史 K/V 的注意力层各自生成缓存,并按层号保存;这些实现没有采用 YOCO 的后半多层共读前半 K/V 结构。 Flash-Next 的 QSAIndexer 还用索引向量给小块历史评分并筛选,筛选粒度与正文的单记录教学例不同。
说明中间层的局部和全局 K/V 由后续多个层共用。模型调用代码 ↗与共享 K/V 的读取代码 ↗可以核对实际跨层复用;这里只认定同类共享设计,不称其完整照搬原版 YOCO。
本章核对日期为 2026-10-01,代码固定为该版本提交 d86654abb8862e201933517d6f1fce9f88dd117f。更新日志 ↗记录动态工具加载、缓存保活及摘要请求的相关变更。
Anthropic 适配器包含延迟加载占位项及工具增加、撤下路径;占位项所避免的服务端现象为源码作者记录。OpenAI 对话中的工具追加 ↗、Codemode 目录稳定处理 ↗与不适用时的回退条件 ↗可分别核对。
核对 isReplayable 对思考预算变化的拦截、刷新时间、1 token 输出上限、净节省阈值、持续时间上限和延迟退出;用量记录到会话费用,返回内容不进入模型对话。默认模式与设置 ↗、请求中的接入位置 ↗提供配套依据。
completeSummarization 设置 cacheRetention: "none";buildSummarizationContext 与 generateSummary 使用专门的总结指令和整理后的对话文本。改动说明 ↗解释避免一次性摘要缓存写入费的目的。
2026-04-30 的工程说明介绍 cache-safe forking:保留原系统提示、工具与历史,在末尾追加摘要要求,以复用生成摘要时的输入前缀。与 Pi 本章固定版本的独立摘要请求作机制比较,不作为两者费用高低的实测结果。
多层卸载说明采用 CPU 主存连接 GPU 与其他存储层。固定版本的块管理代码 ↗通过使用计数区分正在使用和可重新分配的块;加载前后的保护 ↗防止传输期间被淘汰,写入完成处理 ↗分别处理成功与失败。正文中的传输时间是教学算例。
文档编号 DU-09799-001_v001。第 02 章说明计算量、数据读取量和矩阵规模怎样共同影响 GPU 执行效率,包含按块读取与复用数据的实现方式。用于解释批量处理可以怎样摊薄开销,不用于推算某个 API 的具体价格倍率。
2026-10-02 核对:Opus 5 支持按文档示例强制工具调用;手动 extended thinking 对 any/tool 有额外限制。附录 A 固定使用支持题设操作的配置,不把 auto+文字要求当成强制保证。
固定提交 ced6857afa0ea7b2e3f0846a62e1394e90f15607,核对末尾 token 补算限制和倒序释放块。附录 A 题二的实验环境与本实现分开列出。
同提交 · 共享页引用与回收 ↗2026-10-12 核对:命中共用页时增加引用,释放时减少引用;引用归零后才进入可再分配队列。图中“撤回记录”与“回收整页”的区分据此说明。
2026-10-02 核对 defer_loading 与工具引用的加载机制:延迟定义不进入原始工具前缀,发现后在对话位置展开。Pi 使用的适配路径与占位处理另见来源 20。
固定版本中,内部调用先把结果交给脚本;脚本输出、返回值或错误进入模型可见的 content。过长输出可保存全文并返回读取位置。脚本可调用工具的定义 ↗与执行代码中的 searchTools/describeTool 说明适用范围。本轮于 2026-10-03 核对固定源码;图中日志和 token 数为教学算例。
searchAndLoad 查找尚未向模型声明的适用工具,调用 setActiveTools 激活,使下一次模型请求提供声明。这里的加载状态与工具是否已经能够在脚本中调用分别判断。2026-10-03 核对。
2026-10-04 核对当前文档:API 根据工具定义、配置和用户系统指令组装工具提示。此说明解释提示的用途,不证明首次启用延迟加载必须改变前缀,也没有公开 Pi 注释所指配套提示的全文。
核对 2026-09-28 的固定提交:search ↗执行查询接口目录的代码,execute ↗提供实际 API 调用。该版本另有 docs 入口,不能描述成始终只有两个工具;search 说明还包含产品概况,故入口名称固定本身不保证所有版本的提示文字不变。2026-02-20 的公开说明 ↗给出该模式的发布背景。本轮于 2026-10-04 查阅代码,未运行成本或命中率对比。
固定论文版本 v5,§3.2:按绝对位置旋转 Q、K,使它们的点积体现相对位置。2026-10-04 核对;正文中的两条向量、0.75/0.25 读取权重及每位置 10°,用于演示读取与旋转顺序,是教学设定,不是模型参数或本次实测。
固定论文版本 v1,§2.1.1:索引器学习主注意力形成的分布;密集预热阶段和稀疏训练阶段分别说明了训练目标。2026-10-09 核对。图中的三条记录及百分比为教学设定。密集预热冻结主模型,只训练索引器;后续稀疏阶段区分语言建模误差与索引器分布误差。这份公开训练实例不作为 V4.1 完整训练流程的证明。
NAACL 2019 原论文,§3.1:遮住部分输入 token,并根据上下文预测原 token。用于区分补词训练任务与禁止读取未来位置的注意力遮罩。2026-10-09 核对。
2023-05-22 提交;本轮用于区分 MHA、MQA、GQA 与模型设计的关系,不引用论文速度倍率作为本轮实测。
2026-10-09 重新读取此固定版本:LlamaAttention 按 KV 头投影与更新缓存;普通注意力路径的 repeat_kv 在后。代码追踪未运行该预训练模型。
2026-10-09 查阅动态文档:DynamicCache、StaticCache、量化与卸载的范围和取舍。不能把页面的“静态”解释为输入内容不变。
固定论文 v2:K、V 的数值特点、不同量化维度与近期高精度保留。本轮没有复现论文的任务指标。
2026-10-09 查阅。区分 NVFP4 与 block16 兼容配方;关注原生读取、FP8 工作区、硬件和功能组合限制。动态实现条件不能当作长期承诺。
2026-09-16 工程说明;核对初始输入、追加输入与生成的不同缓存流向。其复现实验固定提交为 c8b56b1f44d5c5370f47470ee490da3b04375e1c。本轮仅用作执行路径说明,不导入其性能倍数。
2026-10-09 查阅设计与页表寻址代码说明。正文页号、token 数和写时复制分支是新编教学例子,不是该文档的性能数据。
2026-10-09 读取公开接口说明:flash_attn_with_kvcache 支持缓存更新、GQA/MQA 与 block_table。没有运行 GPU 内核;本地在线 softmax 只验证合并关系。
分块累计、重缩放与最后相除的固定版本源码 ↗
同版本:片上分块计算与输出写回 ↗
2026-10-10 对照原论文 §2.2、§3.1 与 v2.8.3 的 softmax.h(提交 060c9188,2025-08-14)。本节数字为教学算例,已复算算术;本次没有运行 GPU 性能测试。
Fast Inference from Transformers via Speculative Decoding 原论文 v2。2026-10-12 核对 §2.1、§3.3–3.4:批量核验、内存访问与草稿开销;正文没有把减少运行轮数直接换算为速度倍率。另核对 Transformers cache_utils.py 的裁剪与缓存实现;正文的 A、B、C、D、X 是教学设定,缓存裁剪与整页回收分别说明。 缓存实现 ↗
2026-10-15 补充核对 §3.1–3.3:原文 DLK 的定义是 TV,不是 KL 散度;固定前文下的平均接受概率为 1−TV。连续接受长度与理论耗时分析另有概率和并行计算假设,不能直接当成实际加速比。
2026-10-09:自建三层随机 GQA 模型,Python 3.13.5 / NumPy 2.3.5 / CPU。15 项测试通过;完整代码和原始结果内置。不是原 Qwen 实验的复现,也不是生产模型准确率/延迟测量。
2026-10-12 核对提交 ced6857afa0ea7b2e3f0846a62e1394e90f15607:贪心路径接受连续匹配的候选,在首个分歧处输出目标选择,并停止采用草稿尾部;同文件另有随机接受与修正采样路径。新章节的 A、B、C、D、X 及缓存长度为教学例子,已复核位置关系;未运行生产模型或测量加速倍率。
随机采样补充核对:按概率接受、拒绝及追加额外 token;由目标与草稿概率之差构造补抽权重。甲、乙、丙的数字为教学设定,已用分数运算复核。
2026-10-14 核对提交 b1463f5b871aeb20d58384a98aad57905a690799:先取得候选 token 与可用分数,再运行目标模型,取得候选数量加一份预测,随后决定接受范围。目标模型各层的 Q/K/V 与层间顺序另见资料 40。本轮只追踪公开代码,没有运行预训练模型。
2026-10-15 核对固定提交 b1463f5b871aeb20d58384a98aad57905a690799:注意力部分生成并更新 K/V;路由器选择专家并给出权重;专家输出合并后完成本层。正文的四头、八专家配置为教学例子。
2026-10-15 核对官方模型卡:128 个专家、每 token 激活 8 个;注意力为 32 个 Q 头、4 个 KV 头。两种数量分别描述专家选择与注意力缓存配置。
2026-10-15 核对固定提交 b15f0dbbbe6a4bc403306175698439ef380f5fb5:不同进程保存各自负责的专家,选中后计算并汇合输出;注意力与 MoE 在模型层内依次执行。
2026-10-15 核对固定提交 b1463f5b871aeb20d58384a98aad57905a690799:Top-k 产生选中编号和权重,专家输出按权重相加;提供训练标签时计算预测损失,启用相应配置时加入均衡辅助项。图中概率为教学设定,没有运行模型训练。
2026-10-15 核对报告 v2 §2.1.2:按整批负载调节专家路由偏置,偏置只影响选择,混合权重来自原相关性分数;另保留很轻的序列级均衡损失。
2026-10-15 核对论文 v1 的路由分析:不同专家的选择可与语法、词法及位置特征有关,不能把专家编号直接当作固定的数学、代码等学科标签。
2026-10-15 核对论文 v3 §3.2–3.4:改变相似度形式后,可按矩阵乘法的结合关系累计 S、z;因果直接求和与递推使用相同规则。固定状态不代表任意 softmax 注意力都能有限维精确改写。
2026-10-15 核对固定提交 d82aca326eb228fafdf2942c9c26c343f6d1b8ef:更新 Si、Zi,以当前查询读取并归一化,返回当前状态;推理路径会原地更新数组。正文算例不运行预训练模型。
2026-10-15 核对 Transformers v4.57.1:LLaVA 将图像特征填入 inputs_embeds;Mllama 交叉注意力的 Q 来自文本 hidden_states,K/V 来自 cross_attention_states,已有缓存时可直接读取该层图像 K/V。
2026-10-15 核对原论文:基本 RoPE 用序列位置旋转 Q、K 的坐标对;固定频率时,点积中的两个旋转合并为相对位置的旋转。缓存的物理存储地址不参与此公式。
2026-10-15 核对 PyTorch v2.8.0 与 Transformers 固定提交 b1463f5b:普通交叉熵与 KL 的关系由给定公式直接展开;目标固定时比较对预测分布的更新。教学数字只作本地算术核对,没有运行模型训练。
2026-10-15 核对固定版本:PPO 实现将负的平均策略熵按系数加入要最小化的总损失;系数为正时鼓励较高熵。本文不把单个 token 的路由熵等同于整批专家负载均衡。
2026-10-15 查阅动态教程及所附训练代码:教师在无梯度路径给出目标概率,学生接收分布匹配损失,同时可结合真实标签的交叉熵。本文仅说明训练用途,没有复现其准确率数据。