一题一页 · 运行机制 · 也叫 KV cache、键值缓存

为什么 AI 回答越长越慢,显存还越占越多?

模型每多算一个位置(一个 token,不一定正好是一个汉字),前面所有位置的中间结果本来要重算一遍。存下来就不用重算,代价是随对话变长不断吃显存。

先看懂

运行机制
KV 缓存
把算过的中间结果存下来
不是存你说的话,是存计算的半成品

此刻在看:静态解释,这一页的 4 个部分都是这一种。

主张依据 2 类:原始论文、编辑解释。

核实状态:这一页没有需要核实的结论,所以这一维是空的。

三个维度分别看:演示方式、主张依据、核实状态

演示方式

表示此刻展示方式,不是准确性等级。

  • 顶部三秒卡 静态解释
  • 举例与能/不能三栏 静态解释
  • 主张与依据 静态解释
  • 展开后的正文与关系说明 静态解释

主张依据

可多选,每种都有实际引用;「本站实验」需有记录。

  • 原始论文 Efficient Memory Management for Large Language Model Serving with PagedAttention
    「它能」第二条说的前缀复用,对应摘要里让开头相同的请求共用缓存块那一点,本站只核对过摘要。
    两条主张都转述摘要里的那几句,本站只核对过摘要。
    「一个从操作系统借来的解法」那一段转述摘要里说的分页思路与共用块,本站只核对过摘要。
  • 编辑解释
    本站自己写的,没有外部引用。
    「把算过的中间结果存下来」与「不是存你说的话」是本站为三秒卡写的说法。
    「写到第 101 个位置」那个例子与「它不能」三条是本站写的界定。
    估算式子、「逻辑张量存储估计」那段限定与 part_of 关系说明都是本站写的,式子里的假设配置不对应任何一个真实模型。

核实状态

附范围、时间及必要原因,不等于全页绝对正确。

这一页没有需要核实的结论,所以这一维是空的——不会因为它挂着论文就替它标一个「已核对」。

举个例子

写到第 101 个位置时,前 100 个位置的中间结果直接从显存里取,只算新的这一个。

它能
  • 让生成速度不随对话变长而急剧下降
  • 在服务端显式实现前缀复用时,让开头相同的多个请求共用同一批缓存块
它不能 / 不是
  • 省不了显存,恰恰相反,它就是显存的主要消耗者之一
  • 不会自动跨会话留着:默认这一轮生成结束就释放,除非服务端显式做了前缀缓存
  • 存的不是文字而是中间向量,直接读读不出句子;但它由输入算出来,不能因此当成安全的
想看细节再点开

为什么必须有它

模型算下一个位置时,要用到前面所有位置的中间结果。如果什么都不存,算第 100 个位置就要把前 99 个位置的中间结果重算一遍,算第 1000 个位置要重算 999 个。越算越慢,而且慢得很快。

(这一段说的位置就是模型真正数的单位:一个位置是一个 token,不一定正好是一个汉字。日常把它说成「1 个字 ≈ 1 个位置」只是粗算;下面那个式子和第 2 级的旋钮数的都是位置。)

存下来之后,每个位置只算一次。这就是「上下文越长越贵」这句话的物理来源。

它为什么吃显存

每个位置(这里用一个输入向量代表一个 token,不是一个汉字)在每一层都留下两个向量,一个叫 K,一个叫 V。要估它占多少,用这个式子:

KV 缓存字节数 ≈ B × L × N × 2 × H_kv × d_head × bytes_per_element

  B                 同时在跑的序列条数(batch)
  L                 层数
  N                 上下文里的位置数
  2                 K 和 V 各一份
  H_kv              KV 头数(用了 GQA/MQA 时它小于 Q 的头数)
  d_head            每个头的维度
  bytes_per_element 每个数占几字节(fp16 是 2)

这是逻辑张量存储估计:只算张量本身,不含显存分配器的碎片、临时缓冲区、以及多卡分片时的通信副本。按 1024² / 1024³ 换算就写 MiB / GiB;写 MB / GB 时用的是十进制的 10⁶ / 10⁹,两者在 GiB 量级差约 7%。要把它对到某个具体模型上,得先核对那个模型公布的 LH_kvd_head,不能照搬本页的假设配置。

在「完整保留全部历史、结构固定」这一种策略下,N 一涨,占用就跟着线性涨,一直到显存装不下为止。滑动窗口、前缀裁剪、KV 量化都会改变这条线,本页不覆盖它们。

一个从操作系统借来的解法

早期做法是按「这次对话最长可能多长」一次性划一大块显存。实际用不满,剩下的就白占着。PagedAttention 的想法是把缓存切成固定大小的小块,用多少给多少,还能让多个请求共用相同开头的块。这正是操作系统管内存用了几十年的分页思路。

它和其他概念的关系

  • 是……的组成部分大语言模型(LLM)它是模型生成过程中的一块临时数据,不是模型的一部分

看它发生

教学脚本 约 4 分钟
为什么 AI 回答越长越慢,显存还越占越多?
存下来,就不用每次重算
已写的字 缓存里的 K/V 只算新的字 接着写 省下的是重算的时间,付出的是不断增长的显存
教学简化模型:场景按剧本逐屏播放;那几屏的占用数字按一个假设配置算出,不是某个真实模型的实测读数显存的具体数字是按一个假设配置算的示意,不是某个真实模型的实测值;按 1024² / 1024³ 换算,所以标 MiB / GiB
你看到的
还没输入
幕后:显存里存了什么
显存还是空的
你让 AI 写一篇长文。开头很快,越往后越慢,显存也越占越多。
第 1 步 / 8

按暂停会把这一步的文字整段显示出来。这段字本来就是预先写好的,逐字出现只是展示方式,不是模型正在生成。 键盘:焦点在这个实验里时,← → 切换步骤,空格 播放或暂停。

拖一下

拖动位置数,看两笔账怎么长

左边是不缓存时 K/V 投影要算多少次,右边是缓存时。再看下面缓存占了多少,它只会一直往上。

这是账 A:只数 K 和 V 两个投影的乘加次数(MAC = 一次乘法加一次累加,不要当成 FLOPs)。 缓存那一路 = 2 × L × D² × N,不缓存那一路 = 2 × L × D² × N(N+1)/2,比值恰好是 2/(N+1)。 它不数 Q 投影、注意力打分和加权求和,所以这不是整体速度。 第 3 级那个脚本报的是另一笔账(账 B,四项都数),同一个配置下数字不一样,两笔账不能互相替代; 两边共用的 K/V 投影这一项由 tests/golden/kv-account-a.json 在两侧各核对一遍。 一个「位置」代表一个 token,不是一个汉字。这里的 D 是假定每层 K/V 向量的维度, 并假定 K、V 同维、投影是方阵;缓存元素数 2 × L × D × N 同样基于这个简化配置, 不代表任意模型的 KV 维度。「按每数 2 字节」是逻辑张量存储估计,不含分配器、额外缓冲与分片通信开销。 线性增长只对「完整保留全部历史、结构固定」这一种策略成立,不覆盖滑动窗口、裁剪与量化。

此刻在看:浏览器计算 + 教学脚本——2 个部分各自标着自己的那一种,没有一个徽章能概括整页。

主张依据 3 类:原始论文、本站实验、编辑解释。

部分核对 核实状态:部分核对 · 核于 2026-09-08。

三个维度分别看:演示方式、主张依据、核实状态

演示方式

表示此刻展示方式,不是准确性等级。

  • 分步场景:存下来,就不用每次重算 教学脚本
    教学简化模型:场景按剧本逐屏播放;那几屏的占用数字按一个假设配置算出,不是某个真实模型的实测读数
  • 位置数与层数旋钮 浏览器计算
    教学简化模型:浏览器当场算的是账 A(只数 K 与 V 两个投影),并假定 K、V 同维、投影是方阵
    「简化」说的是模型简化,这一块此刻仍然在真的算。

主张依据

可多选,每种都有实际引用;「本站实验」需有记录。

  • 原始论文 Efficient Memory Management for Large Language Model Serving with PagedAttention
    缓存被碎片与重复浪费、以及分页管理这一段,对着论文摘要原文核过
  • 本站实验 仓库内记录 docs/execution/KV_EQUIVALENCE.md本站条目 demos/kv-cache-hands-on仓库内记录 docs/execution/KV_ACCOUNTS.md
    「随位置数线性增长」这一条由第 3 级那个脚本在本站的玩具模型上实测过
    旋钮与第 3 级脚本共用的 K/V 投影那一项,由跨语言黄金样例在两侧各核对一遍
  • 编辑解释
    本站自己写的,没有外部引用。
    这一屏的占用数字由本站按假设配置算出,用来说明量级,不能拿去引用

核实状态

附范围、时间及必要原因,不等于全页绝对正确。

  • 部分核对
    本站核对这条结论: 核的范围:「缓存随位置数线性增长」这一条由第 3 级实验脚本在本站的玩具模型上实测过,输出逐字粘在实验页上;这一屏里的显存数字是按一个假设配置算的示意,本站没有测量任何 GPU 读数
    滑动窗口、裁剪、量化都会改变这条曲线,所以限定语要跟着结论走
这个动画没有说的事
  • 显存的具体数字是按一个假设配置算的示意,不是某个真实模型的实测值;按 1024² / 1024³ 换算,所以标 MiB / GiB
  • 不同模型的层数、维度、注意力结构差别很大,同样长度占的显存能差好几倍
  • 缓存里存的是中间向量,不是你说过的话,直接读读不出句子;但它由你的输入算出来,仍然是要保护的数据
  • 旋钮算的是账 A(只数 K 和 V 两个投影),不是整体速度;第 3 级脚本报的是另一笔账,同配置下数字不一样
  • 「线性增长」只对完整保留全部历史这一种策略成立,滑动窗口、裁剪、量化都会改变它
这一场戏的说明

这个单元把「AI 回答越长越慢、越占显存」拆成七步。看完之后,你应该能说出:缓存省下的是什么,付出的又是什么。

这个实验解释了哪些词

改一改

自己动手 · 约 12 分钟
都说 KV 缓存很重要,它到底省了多少?省完就不慢了吗?
自己跑一遍,量两笔账
缓存去不掉「每步看一遍前面」

想自己动手,看这个 一个约 4 秒跑完的脚本(预热 2 轮、测量 10 轮),量出缓存到底省了多少

此刻在看:历史记录展示 + 教学脚本 + 静态解释——3 个部分各自标着自己的那一种,没有一个徽章能概括整页。

主张依据 2 类:本站实验、编辑解释。

作者已逐字核对 核实状态:作者已逐字核对 · 核于 2026-09-08。

三个维度分别看:演示方式、主张依据、核实状态

演示方式

表示此刻展示方式,不是准确性等级。

  • 正文解读 静态解释
  • 可下载的 kv_cache.py 与页面上的代码块 教学脚本
    教学简化模型:脚本跑的是一个 2 层、维度 32 的极小注意力模型,用来把两种执行方式摆在一起量,不是任何一个真实模型
  • 逐字粘贴的运行输出 历史记录展示
    教学简化模型:这段输出来自上面那个极小模型,跑的是真程序,量的不是任何一个真实模型
    「简化」说的是模型简化,这一块此刻仍然在真的算。
  • 已经撤下的旧账 静态解释
    这一块只支持已经撤下的旧结论,不支持这一页现行的任何结论。

主张依据

可多选,每种都有实际引用;「本站实验」需有记录。

  • 本站实验 仓库内记录 docs/execution/KV_EQUIVALENCE.md仓库内记录 docs/execution/KV_ACCOUNTS.md仓库内记录 docs/execution/KV_TIMING.md
    两条路径数学等价、以及两笔账各自的口径,各由一份记录守着
    计时方法学与乘加次数的口径,各由一份记录守着
    旧脚本两条路径算的不是同一件事,这份记录写了它错在哪里;旧输出已经从页面撤下,只留这一段说明
  • 编辑解释
    本站自己写的,没有外部引用。
    每一栏该怎么读、哪一列不能拿去引用,由本站解释

核实状态

附范围、时间及必要原因,不等于全页绝对正确。

  • 作者已逐字核对
    本站核对这条结论: 核的范围:乘加次数与缓存占用由 tests/demo/test_kv_cache.py 在 27 个配置、1080 个前缀上核对,页面粘贴的那段输出与当前脚本 stdout 逐行比对;耗时那几列随机器与当次波动变,不在核过的范围里
    verified 只表示作者真的运行并逐字粘贴(D9),不表示访问者正在运行,也不表示实验设计一定正确
这个实验现在就能运行 只需要 python3,不用装任何库;默认预热 2 轮、测量 10 轮,作者机器上约 4 秒跑完 下载脚本
  1. 先想清楚:不存会怎样

    模型算下一个位置时要看一遍前面所有位置。如果什么都不存,算第 40 个位置就要把前 39 个连同这一个,从头过一遍全部两层。这不是比喻,是真的在重算,下面把它跑出来量一量。

    这个脚本处理的是 40 个固定随机向量,没有词表、没有分词器,也没有文本解码,屏幕上不会出现一个汉字。所以下面一律说「位置」——在真实模型里一个位置对应一个 token,不是一个汉字。

  2. 一个真会跑的极小模型

    两层注意力,每层维度 32。两个生成函数算的是同一件事,区别只在于前面那些位置要不要重算。注意上面那个函数:一层里先把所有位置都更新完,才进下一层——少了这一步,第二层的历史位置就还吃着原始输入,两条路径根本对不上。

    python
    def forward_all_layers(model, tokens, cnt=None):
        """不存:把整个前缀从头过一遍所有层。"""
        layer_in = [list(t) for t in tokens]
        per_layer = []
        for layer in model:
            keys   = [matvec(layer["k"], t) for t in layer_in]   # 全部重算
            values = [matvec(layer["v"], t) for t in layer_in]   # 全部重算
            out = []
            for i, t in enumerate(layer_in):                     # 每个位置只看 0..i
                q = matvec(layer["q"], t)
                out.append(attend(q, keys[: i + 1], values[: i + 1], cnt))
            per_layer.append(out)
            layer_in = out                                       # 整层更新完才进下一层
        return per_layer
    
    def gen_with_cache(model, new_token, cache, cnt=None):
        """存:只算新位置的 K/V,前面的直接从缓存里取。"""
        x = list(new_token)
        for li, layer in enumerate(model):
            cache[li]["k"].append(matvec(layer["k"], x))  # 只算这一个
            cache[li]["v"].append(matvec(layer["v"], x))  # 只算这一个
            q = matvec(layer["q"], x)
            x = attend(q, cache[li]["k"], cache[li]["v"], cnt)
        return x

    全文 534 行,注意力、softmax 都是手写的,没有任何依赖;上面这段数值部分只占 130 行左右,剩下的是下面要说的计时方法学和那份机器可读记录。两条路径必须给出同一个结果,这条由 tests/demo/test_kv_cache.py 在 27 个配置、1080 个前缀上逐个核对。

  3. 跑它

    bash
    python3 kv_cache.py
    真实运行输出
    KV 缓存:同一个计算的两种执行方式,在这台机器上各跑一遍
    配置      2 层 × 每层维度 32 × 40 个位置 · 随机种子 0
              一个位置就是一个输入向量。没有词表、没有分词器,屏幕上不会出现汉字。
    计时口径  权重与输入向量在计时区外一次生成;乘加计数单独跑一趟,也不进计时区
              一轮 = 完整跑完 40 个位置;缓存那一路每轮换一份新缓存
              预热 2 轮,测量 10 轮,两路先后顺序逐轮交替(不缓存先 5 轮 / 缓存先 5 轮)
              下面报 10 轮的中位数,方括号里是这几轮的最小值与最大值
              「一轮总计」是那一轮各步测量值之和,不含轮与轮之间的开销
    缓存占用  最后一列数的是「缓存里存了多少个数」,不是内存占用
              Python 的 list 与 float 每个元素实际要几十字节,不等于「元素数 × 2 字节」
              「每数 2 字节」是 fp16 的逻辑张量存储估计,属于第 2 级旋钮那笔账
              纯 CPU 本机运行,没有测量任何 GPU 显存
    运行环境  Python 3.14.7(CPython)· macOS-26.6.2-arm64-arm-64bit-Mach-O · arm64 · 10 逻辑核
    运行日期  2026-09-08
    脚本哈希  sha256 dac090c15fd670a6b115fc2b8a9f552bd3cb4297a8511378104d05d1d0f9261d
    加速器    纯 Python、单线程、CPU;没有用加速器,所以不报告型号与同步耗时
    
        位置     不缓存·本步 [最小, 最大]    缓存·本步 [最小, 最大]  中位数比      缓存占用
    --------------------------------------------------------------------------------------
           5     1.36 ms [  1.31,   1.47]    0.28 ms [ 0.27,  0.30]     4.8×      640 个数
          10     2.84 ms [  2.74,   2.98]    0.30 ms [ 0.30,  0.32]     9.5×    1,280 个数
          20     6.16 ms [  5.98,   6.65]    0.35 ms [ 0.34,  0.44]    17.5×    2,560 个数
          30     9.99 ms [  9.76,  10.75]    0.40 ms [ 0.39,  0.51]    24.8×    3,840 个数
          40    14.48 ms [ 14.10,  16.21]    0.45 ms [ 0.44,  0.50]    32.0×    5,120 个数
    --------------------------------------------------------------------------------------
    一轮总计   267.04 ms [261.88, 307.73]   14.41 ms [14.16, 15.88]    18.5×
    总乘加次数  不缓存  6,507,520    缓存    350,720
                缓存这一路是不缓存的 5.4%
                (只统计 Q/K/V 投影与注意力这四项的乘加,不含 softmax、缩放和循环开销;
                  这个配置:2 层 × 维度 32 × 40 个位置,换配置数字就会变)

    这段输出实际跑出来: 在哪里跑的:作者本机,arm64 macOS,纯 CPU 单线程,Python 3.14.7 你现在打开这一页不会有任何程序被执行。

    这段输出是本页作者在 2026-09-08 逐字粘贴的真实运行结果,环境就写在上面第 8、9 行,脚本哈希写在第 10 行——脚本改一个字节,那串哈希就变,页面不重贴就会被 tests/demo/test_kv_cache.py 第 10 组挡住。毫秒那几列跟机器快慢和当次波动有关,你跑出来会不一样,所以它们在门禁里是被放过的;乘加次数、缓存占用、轮数与哈希是精确的,用固定随机种子,你跑出来应该一模一样。

  4. 先说怎么量的,再说量出了什么

    上面那块「计时口径」不是客套话,它决定了这些毫秒能不能信。权重和输入向量在计时区外一次生成,乘加计数也单独跑一趟——计数器每次加一都是真实开销,混进计时区就成了在量计数器。一轮是把 40 个位置完整跑一遍,缓存那一路每轮换一份新缓存,否则第二轮会接着上一轮的历史往下算。先预热 2 轮再测量 10 轮,两条路径的先后顺序逐轮交替(各 5 轮),免得固定次序让先跑的那一路单方面吃亏。报的是这 10 轮的中位数,方括号里是最小值和最大值。

    方括号不是装饰。看「一轮总计」那一行:不缓存的中位数是 267.04 毫秒,最大值却是 307.73——10 轮里有一轮慢了 15%。只跑一次而恰好撞上那一轮,这一页写出来就是另一个数字。另外,「一轮总计」是那一轮各步测量值之和,不是这一轮的墙钟时间,轮与轮之间的开销不在里面。

  5. 先看第二列:不缓存有多惨

    这次运行里,第 5 个位置的中位数是 1.36 毫秒,第 40 个位置是 14.48 毫秒,涨了十倍多。因为算第 40 个位置时,前面 39 个位置要连同这一个一起,从头过一遍全部两层。位置越多重算得越多:投影那部分跟着位置数线性涨,注意力打分那部分按平方涨,合起来比线性更陡。

  6. 再看第四列:这次运行快了 32 倍

    到第 40 个位置时,缓存那一路的中位数比不缓存快 32.0 倍——这是这台机器这 10 轮的中位数之比,换台机器会变。不随机器变的是乘加次数:从 6,507,520 降到 350,720,缓存这一路只占 5.4%(分母是不缓存那一路的 6,507,520 次,只数 Q/K/V 投影与注意力这四项)。这也是推理框架普遍会实现它的原因——本站没有逐个统计过哪些框架实现了它。

    耗时比和计数比是两笔不同的账,不要互相替代:前者随机器、随当次波动,中位数之比也只是这台机器这 10 轮的中位数之比;后者是这个配置下的精确整数。

  7. 别把两笔账混起来:旋钮那笔和这里这笔不一样

    第 2 级那个可拖的旋钮和这个脚本,报的不是同一个百分比,这不是谁算错了,是统计范围不同。旋钮只数 K 和 V 两个投影(账 A),脚本还要加上 Q 投影、注意力打分和加权求和(账 B)。同一个配置下,账 A 是 4.878%,账 B 是 5.389%(页面上写 5.4%)。两个数字都对,各自的分母和范围就写在下面这张表里,谁也不能替谁。

    text
    配置:2 层 × 维度 32 × 40 个位置,种子 0
    
                 账 A(第 2 级旋钮)        账 B(第 3 级脚本)
    数什么       K 投影 + V 投影            Q/K/V 投影 + 打分 + 加权求和
    不缓存       3,358,720 次乘加           6,507,520 次乘加
    缓存           163,840 次乘加             350,720 次乘加
    缓存/不缓存   2/41 = 4.878%             350,720/6,507,520 = 5.389%
    为什么不同   分母里少了 Q 投影与注意力那两项,
                 而缓存那一路省下的正好集中在 K/V 投影上
    
    两笔账都不含 softmax 的 exp 与除法、1/sqrt(d) 缩放乘、
    循环与内存分配,所以两个「合计」都不是「所有计算」。
    MAC = 一次乘法加一次累加,不要不加说明地当成 FLOPs。

    两边共用的那一项(K/V 投影)由跨语言黄金样例 tests/golden/kv-account-a.json 双向核对——Node 侧 pnpm kv:accounts 比对旋钮的实现,Python 侧 pnpm kv:test 第 11 组真的跑一遍这个脚本再读计数器。不是靠在文档里写一句「两边一样」。

  8. 关键在第三列:缓存之后,还是在变慢

    很多人以为有了缓存,每个位置的耗时就固定了。看第三列的中位数:0.28、0.30、0.35、0.40、0.45 毫秒,一路往上。但别把它读成「每一次都更慢」——方括号里那些范围就是当次抖动,这一次第 20、30、40 三个采样点的范围直接彼此重叠(0.34~0.44、0.39~0.51、0.44~0.50 毫秒),单挑某一轮出来看,第 20 个位置完全可能比第 40 个位置还慢。真正严格增长的是精确计数:算第 N 个位置时,注意力要做 N 次打分、N 次加权求和,这一项跟着 N 线性涨,和机器状态无关。

    缓存去掉的是「重算」,去不掉「每一步都要看一遍前面所有位置」。所以长对话还是会越来越慢,只是慢得没那么快。

  9. 最后一列:缓存占用一直在涨,而且停不下来

    缓存占用从 640 个数涨到 5,120 个数,严格线性。这个极小模型每个位置只占 128 个数(2 层 × 32 维 × K 和 V 两份)。真实模型要按「批大小 × 层数 × 位置数 × 2 × KV 头数 × 每头维度 × 每个数几字节」估(式子的完整定义在第 1 级那张卡的正文里),层数几十、KV 头几个到几十、每头上百维,一个位置常常是 MiB 量级——具体多少得查那个模型公布的配置,不能照搬本页。上下文越长越贵,物理来源就在这一列。

    这一列的单位是「个数」,不是内存占用,更不是显存读数——这一趟是纯 CPU 本机运行,没有测量任何 GPU 显存。真要按字节算也别拿这个数直接乘:Python 的 list 与 float 每个元素实际要几十字节,不等于「元素数 × 2 字节」;第 2 级旋钮那栏「按每数 2 字节」是 fp16 的逻辑张量存储估计,属于账 A,不含分配器、额外缓冲与分片通信。所以这一列能证明的只有一件事:随位置数严格线性增长,而且没有上限。

  10. 所以真实系统要在这一列上做文章

    既然在「完整保留全部历史、结构固定」这一种策略下缓存会一直涨,剩下的问题就是别浪费。PagedAttention 论文摘要说的正是这件事:显存可能因碎片和重复被大量浪费,它借操作系统的分页思路把缓存切成小块,做到近零浪费,同等延迟下吞吐提升 2 到 4 倍。

    这句是从论文摘要原文核对的,来源见页尾。

  11. 进阶(可以先跳过):换配置,或者要一份机器可读的记录

    到这里读者要做的事已经做完了。下面这些参数是给想自己接着量的人用的:同一份结果加上 --json 就变成机器可读的 JSON,里面有 config(这次跑的是什么配置)、method(预热与测量轮数、统计量、两路顺序怎么平衡、哪些东西被排除在计时区外)、environment(Python 版本、平台、CPU、逻辑核数、日期)、script(脚本名与 sha256)、counts(四项乘加的分项与合计)、timing(每个采样点的中位数、最小值、最大值和全部样本,外加每一轮的总耗时与结束时的缓存长度)。可读摘要和这份 JSON 是同一次测量的两种渲染,不是跑了两次。

    bash
    python3 kv_cache.py --json                 # 同一份结果,机器可读
    python3 kv_cache.py --repeats 30           # 想让波动范围更可信就多测几轮
    python3 kv_cache.py --tokens 80 --layers 4 # 换配置:位置数、层数、维度、种子都能改
    python3 kv_cache.py --help                 # 全部参数

    换了配置,页面上这些数字就全都不作数了——它们只属于「2 层 × 维度 32 × 40 个位置 × 种子 0」这一种配置。JSON 里的 config 段就是为了让别人一眼看出你跑的是哪一种。

  12. 为什么这一页的数字和 2026-09-07 之前不一样

    这一页原先写的是「降到 8.5%、快 14.4 倍」。那组数字来自一个有错的旧脚本:它的「不缓存」路径每层只更新最后一个位置,历史位置进第二层时还带着原始输入,所以两条路径压根不是同一个计算的两种执行方式——从第 2 步起输出就差到 2.67。旧结果已归档为「旧实验实现有误,不作为等价性能对照」,不作数,也不保留原日期充当对照基线。

    现在这版先证明两条路径数学等价(27 个配置逐前缀比对,实测最大绝对误差 6.7e-16),再报告数字。修好之后的比值比原来更好看,也照实写。2026-09-07 的那一版还有一处不能信:毫秒是单次采样、不预热、而且计数器一路跟着跑量出来的,只能读作「这台机器这一次的观测」。2026-09-08 按上面那套方法重量了一遍,所以毫秒那几列的数字也跟着变了——不是结论变了,是量法变了。

这个脚本的说明

这一页是「KV 缓存」的第三级。卡片告诉你它是什么,场景演了一遍它怎么发生,这里让你自己把数字量出来。

整个脚本在作者机器上约 4 秒跑完(预热 2 轮、测量 10 轮),不需要显卡,不需要装任何东西。

查证据

这一场戏的结论

在完整保留全部历史这一种策略下,缓存随位置数线性增长,一直吃显存。

成立条件
  • 「线性增长」只对完整保留全部历史、结构固定这一种策略成立
  • 一个位置是一个 token,不一定正好是一个汉字
这个脚本的结论

在 2 层 × 维度 32 × 40 个位置的小模型上,按账 B 的口径,缓存这一路的乘加次数是不缓存的 5.4%。

成立条件
  • 2 层 × 维度 32 × 40 个位置的小模型
  • 账 B 的口径:Q/K/V 投影与注意力这四项的乘加次数
  • 随机种子 0,纯 CPU 本机运行
「为什么 AI 回答越长越慢,显存还越占越多?」的完整结论:必要条件、指标口径与已归档的旧结论

看完你应该能说出

模型每多写一个位置(一个 token,不一定正好是一个汉字),都要用到前面所有位置的中间结果。不存就得反复重算,存下来就不用重算;在「完整保留全部历史、结构固定」这一种策略下,缓存随位置数线性增长,一直吃显存。这就是「上下文越长越贵」的物理来源。

成立条件:少一条,上面那句话就不成立
  • 「线性增长」只对完整保留全部历史、结构固定这一种策略成立
  • 一个位置是一个 token,不一定正好是一个汉字
这条结论的边界
  • 不同模型的层数、维度与注意力结构差别很大,同样长度占的显存能差好几倍
  • 缓存里存的是中间向量,不是你说过的话,直接读读不出句子
核验
部分核对 · 本站核对这条结论: · 核的范围:「缓存随位置数线性增长」这一条由第 3 级实验脚本在本站的玩具模型上实测过,输出逐字粘在实验页上;这一屏里的显存数字是按一个假设配置算的示意,本站没有测量任何 GPU 读数(滑动窗口、裁剪、量化都会改变这条曲线,所以限定语要跟着结论走)
证据
  • 本站条目 demos/kv-cache-hands-on
  • 仓库内记录 docs/execution/KV_EQUIVALENCE.md
  • 本站条目 sources/paged-attention-paper
「都说 KV 缓存很重要,它到底省了多少?省完就不慢了吗?」的完整结论:必要条件、指标口径与已归档的旧结论

做完你应该能说出

在这个 2 层、维度 32、40 个位置的小模型上,按账 B 的口径(Q/K/V 投影加注意力共四项),缓存这一路的乘加次数是不缓存的 5.4%,分母是不缓存那一路的 6,507,520 次。第 2 级旋钮报的是另一笔账(账 A,只数 K/V 投影),同配置下是 4.878%,两个数字不能互相替代。但每一步的耗时仍然在涨,缓存占用也一直在涨:它去掉的是「重算前面所有位置」,去不掉「每一步都要看一遍前面所有位置」。

成立条件:少一条,上面那句话就不成立
  • 2 层 × 维度 32 × 40 个位置的小模型
  • 账 B 的口径:Q/K/V 投影与注意力这四项的乘加次数
  • 随机种子 0,纯 CPU 本机运行
这条结论的边界
  • 耗时那几列只属于作者这台机器这 10 轮的中位数,换台机器会变
  • 缓存占用那一列数的是「缓存里存了多少个数」,不是内存占用,也不是 GPU 显存读数
  • 换配置这些数字就全都不作数,它们只属于这一种配置
这条结论引用的数

上面那一栏是整条结论的公共条件;下面每一行另有自己的口径,所以单独摘走一行也带着口径。

这个数是什么数值它在什么范围内成立
账 B 口径下缓存这一路的乘加次数占不缓存的比例5.4%
账 B 只数 Q/K/V 投影与注意力这四项的乘加,不含 softmax、缩放与循环开销;配置是 2 层 × 维度 32 × 40 个位置 × 随机种子 0
分子 350,720 ÷ 分母 6,507,520
公式 350720 / 6507520 * 100
不缓存那一路的乘加次数6,507,520 次
同一配置下账 B 的分母,是精确整数,与机器快慢无关
账 A 口径下缓存这一路的乘加次数占不缓存的比例4.878%
账 A 只数 K 与 V 两个投影,是第 2 级旋钮报的那笔账;同一配置下与账 B 不能互相替代
分子 163,840 ÷ 分母 3,358,720
公式 163840 / 3358720 * 100
核验
作者已逐字核对 · 本站核对这条结论: · 核的范围:乘加次数与缓存占用由 tests/demo/test_kv_cache.py 在 27 个配置、1080 个前缀上核对,页面粘贴的那段输出与当前脚本 stdout 逐行比对;耗时那几列随机器与当次波动变,不在核过的范围里(verified 只表示作者真的运行并逐字粘贴(D9),不表示访问者正在运行,也不表示实验设计一定正确)
证据
  • 仓库内记录 docs/execution/KV_EQUIVALENCE.md
  • 仓库内记录 docs/execution/KV_ACCOUNTS.md
  • 仓库内记录 docs/execution/KV_TIMING.md
脚本版本
public/demo/kv_cache.py@sha256:dac090c15fd670a6b115fc2b8a9f552bd3cb4297a8511378104d05d1d0f9261d
已退休的旧结论:留在这里公开,不作数,也不当对照基线

这一页在 2026-09-07 之前写的是「缓存把乘加次数降到 8.5%、快 14.4 倍」。那组数字来自一个有错的旧脚本:它的「不缓存」路径每层只更新最后一个位置,历史位置进第二层时还带着原始输入,两条路径根本不是同一个计算的两种执行方式。

  • 旧实验实现有误,不作为等价性能对照

已失效 · 本站核对这条旧结论: · 核的范围:附录 A 在未改动的旧实现上复现了失效:单层通过,两层从第 2 步起输出就差到 2.66997(已归档,不作数,也不保留原日期充当对照基线;替代它的正式结论就在同一条目里)

主张与依据

  • PagedAttention 论文摘要指出,KV 缓存显存可能因碎片和重复而被大量浪费。 [ paged-attention-paper ]
  • 论文摘要称该方法受操作系统虚拟内存与分页机制启发,实现 KV 缓存显存近零浪费,并在同等延迟下把吞吐提升 2 到 4 倍。 [ paged-attention-paper ]

档案目录

  1. 论文英文

    paged-attention-paper

    Efficient Memory Management for Large Language Model Serving with PagedAttention

    出版方/保存方
    arXiv
    原页语言
    英文
    来源发布
    本站访问
    核对程度:只读到摘要

    摘要全文;分页机制的实现细节与实验各节没有读
    访问结果:正常取到

    vLLM 与 PagedAttention 的论文。摘要原文:显存「可能因碎片和重复而被大量浪费」;方法受操作系统虚拟内存与分页启发;实现 KV 缓存显存近零浪费;同等延迟下吞吐提升 2-4 倍。2026-09-07 直接核对摘要。

    打开原始来源