AI 工程基础体系 · 第 80/100 篇。内容覆盖机器学习、深度学习与生成式 AI;模型、数据、评测、权限和成本会作为同一生产系统处理。

LLM KV Cache:内存计算、分页管理、复用、失效与容量估算

1. KV Cache 解决了什么问题

Transformer 的自回归生成过程是:模型根据已有序列预测下一个 token,再把这个 token 加入序列,继续预测下一个 token。

对于第 tt 个位置,注意力计算可以写成:

Qt=XtWQ,Kt=XtWK,Vt=XtWVQ_t = X_tW_Q,\qquad K_t = X_tW_K,\qquad V_t = X_tW_V

其中:

  • XtX_t:当前位置的隐藏状态;
  • QtQ_t:当前位置的 Query;
  • KtK_t:当前位置的 Key;
  • VtV_t:当前位置的 Value。

因果注意力需要让当前位置的 Query 访问从第一个位置到当前位置的所有 Key 和 Value:

Attention(Qt,K1:t,V1:t)=softmax(QtK1:tTdh)V1:t\operatorname{Attention}(Q_t,K_{1:t},V_{1:t}) = \operatorname{softmax} \left( \frac{Q_tK_{1:t}^{T}}{\sqrt{d_h}} \right)V_{1:t}

这里 dhd_h 是每个注意力头的维度。

如果每次生成一个新 token 都重新计算整个序列的 KKVV,第 11t1t-1 个位置对应的结果会被重复计算。KV Cache 就是把已经计算过的 Key 和 Value 保存下来,在后续生成步骤中直接复用。

它保存的是:

{K1,V1},{K2,V2},,{Kt,Vt}\{K_1,V_1\},\{K_2,V_2\},\ldots,\{K_t,V_t\}

而不是保存注意力分数,也不是保存 Query。

原因是:

  • 旧位置的 KKVV 对后续步骤仍然有效;
  • 每个新位置的 QtQ_t 取决于当前生成状态,需要重新计算;
  • 注意力分数依赖新的 QtQ_t,不能直接缓存为固定结果。

因此,启用 KV Cache 后,单步解码通常只需要对新 token 计算一次新的 Q,K,VQ,K,V,并让新 Query 访问历史 KV。

1.1 Prefill 与 Decode

推理通常分为两个阶段。

Prefill 是处理用户输入的阶段。例如输入有 4096 个 token,模型会一次性或分块处理这 4096 个 token,并构造对应的 KV Cache。

Decode 是逐 token 生成阶段。每次生成一个 token,读取已有 KV Cache,计算新 token 的 KV 并追加到缓存末尾。

数据流可以表示为:

flowchart LR
    A[输入 Prompt] --> B[Prefill]
    B --> C[生成全部 Prompt 的 K/V]
    C --> D[KV Cache]
    D --> E[Decode 第一个新 token]
    E --> F[读取历史 K/V]
    F --> G[追加新 token 的 K/V]
    G --> H[输出下一个 token]
    H --> I{是否继续生成}
    I -- 是 --> E
    I -- 否 --> J[释放或回收 Cache]

Prefill 的主要特点是矩阵运算规模大、并行度高;Decode 的每一步计算量较小,但需要不断读取越来越大的 KV Cache,因此 Decode 常常受显存带宽和 KV Cache 容量限制。

1.2 为什么 KV Cache 会占用大量内存

以一个 Decoder-only Transformer 为例。设:

  • LL:Transformer 层数;
  • SS:已缓存 token 数;
  • BB:批大小;
  • HkvH_{kv}:用于生成 K/V 的注意力头数;
  • dhd_h:每个头的维度;
  • bb:每个元素占用的字节数;
  • 因子 22:分别对应 K 和 V。

单个序列的 KV Cache 内存为:

MKV,seq=2LSHkvdhbM_{\text{KV,seq}} = 2L S H_{kv}d_h b

批量大小为 BB,且每个序列长度相同,则:

MKV,batch=2LBSHkvdhbM_{\text{KV,batch}} = 2L B S H_{kv}d_h b

如果不同序列长度不同,应按序列分别求和:

MKV,batch=2LHkvdhbi=1BSiM_{\text{KV,batch}} = 2L H_{kv}d_h b \sum_{i=1}^{B}S_i

这个公式计算的是 KV 张量本体,不包括:

  • 页表或 block table;
  • 指针、元数据和引用计数;
  • CUDA kernel 的临时工作区;
  • 缓存量化的 scale、zero-point;
  • 对齐和分配造成的内部碎片;
  • 框架的其他激活和运行时开销。

因此它适合容量估算,不等于进程最终显示的全部显存占用。

1.3 MHA、MQA 与 GQA 的差异

传统多头注意力(MHA)通常满足:

Hkv=HqH_{kv}=H_q

其中 HqH_q 是 Query 头数。每个 Query 头都有独立的 K/V 头。

多查询注意力(MQA)让多个 Query 头共享一组 K/V:

Hkv=1H_{kv}=1

分组查询注意力(GQA)位于两者之间:

1<Hkv<Hq1 < H_{kv} < H_q

所以在其他参数相同的情况下,KV Cache 的内存与 HkvH_{kv} 成正比。GQA 和 MQA 主要减少的是 K/V 的存储和读取量,不会按同样比例减少模型权重或 Query 的计算。

2. 完整的内存计算示例

假设模型有:

  • 32 层;
  • 32 个 Query 头;
  • 8 个 KV 头,即 GQA;
  • 每头维度 128128
  • 上下文长度 40964096
  • FP16,单个元素 2 字节。

单个序列的 KV Cache 为:

M=2×32×4096×8×128×2=536,870,912 bytes=512 MiB\begin{aligned} M &=2\times32\times4096\times8\times128\times2\\ &=536{,}870{,}912\text{ bytes}\\ &=512\text{ MiB} \end{aligned}

如果这是 MHA,KV 头数为 32:

MMHA=2×32×4096×32×128×2=2 GiBM_{\text{MHA}} = 2\times32\times4096\times32\times128\times2 = 2\text{ GiB}

GQA 在这个例子中把 KV Cache 从 2 GiB 降到了 512 MiB,正好是 8/32=1/48/32=1/4

如果批大小为 8,且每条序列都达到 4096 token:

Mbatch=8×512 MiB=4 GiBM_{\text{batch}} = 8\times512\text{ MiB} = 4\text{ GiB}

这 4 GiB 还没有计入模型权重、激活、通信缓冲区和运行时保留空间。

可以用下面的 Python 程序复算不同模型配置:

from dataclasses import dataclass


@dataclass
class KVConfig:
    num_layers: int
    num_kv_heads: int
    head_dim: int
    bytes_per_element: float


def kv_bytes(config: KVConfig, sequence_lengths: list[int]) -> float:
    """
    返回整个 batch 的 KV 张量理论大小。
    sequence_lengths 中每个元素是一条序列的有效 token 数。
    """
    total_tokens = sum(sequence_lengths)
    return (
        2
        * config.num_layers
        * config.num_kv_heads
        * config.head_dim
        * config.bytes_per_element
        * total_tokens
    )


def format_bytes(value: float) -> str:
    units = ["B", "KiB", "MiB", "GiB", "TiB"]
    for unit in units:
        if abs(value) < 1024 or unit == units[-1]:
            return f"{value:.2f} {unit}"
        value /= 1024
    raise AssertionError("unreachable")


config = KVConfig(
    num_layers=32,
    num_kv_heads=8,
    head_dim=128,
    bytes_per_element=2,  # FP16/BF16 通常为 2
)

for lengths in ([4096], [4096] * 8, [1024, 2048, 4096]):
    size = kv_bytes(config, list(lengths))
    print(lengths, "->", format_bytes(size))

预期输出近似为:

[4096] -> 512.00 MiB
[4096, 4096, 4096, 4096, 4096, 4096, 4096, 4096] -> 4.00 GiB
[1024, 2048, 4096] -> 896.00 MiB

第三个结果来自总 token 数 1024+2048+4096=71681024+2048+4096=7168,而不是把三条序列都按最长的 4096 计算。实际系统若按批次中的最长序列分配连续内存,可能还会产生额外的 padding 浪费。

2.1 每 GPU 的容量计算

在多 GPU 推理中,不能直接把整模型的 KV 大小平均到 GPU 数量上。需要看并行方式。

如果 Tensor Parallel 将 KV 头均匀分片,单卡大致保存:

MKV,local2LSHkvdhbNTPM_{\text{KV,local}} \approx \frac{ 2L S H_{kv}d_h b }{N_{\text{TP}}}

但这只在 KV 头能够合理分片且实现确实采用该布局时成立。GQA 模型的 KV 头数较少,某些并行规模下可能出现头数不能整除、复制 KV 或采用特殊通信策略的情况。

Pipeline Parallel 则通常按层分配,每张卡只保存自己负责的层对应的 KV。两种并行方式叠加时,应按实际本地层数、本地 KV 头数和实现的复制策略计算。

2.2 可用容量而不是物理显存

设 GPU 总显存为 MGPUM_{\text{GPU}},权重和运行时已经使用 MbaseM_{\text{base}},系统预留安全空间 MreserveM_{\text{reserve}},那么留给 KV Cache 的估算预算是:

Mbudget=MGPUMbaseMreserveM_{\text{budget}} = M_{\text{GPU}} - M_{\text{base}} - M_{\text{reserve}}

单个 token 的理论 KV 成本为:

mtoken=2LHkvdhbm_{\text{token}} = 2L H_{kv}d_h b

在忽略分页碎片和元数据时,理论总 token 容量为:

Tmax=MbudgetmtokenT_{\max} = \left\lfloor \frac{M_{\text{budget}}}{m_{\text{token}}} \right\rfloor

例如,一张 24 GiB GPU 中,权重、CUDA 图、临时缓冲等已经占用 18 GiB,预留 1 GiB:

Mbudget=24181=5 GiBM_{\text{budget}}=24-18-1=5\text{ GiB}

对于前面的 32 层、8 个 KV 头、128 维、FP16 模型:

mtoken=2×32×8×128×2=131072 bytes=128 KiBm_{\text{token}} = 2\times32\times8\times128\times2 = 131072\text{ bytes} = 128\text{ KiB}

于是:

Tmax=5×1024 MiB128 KiB=40960T_{\max} = \left\lfloor \frac{5\times1024\text{ MiB}}{128\text{ KiB}} \right\rfloor = 40960

这是所有并发序列的 token 总量上限,不是“每条序列都能有 40960 token”。如果同时服务 16 条平均长度相同的序列,理论平均长度约为:

40960/16=256040960/16=2560

生产系统还必须扣除分页内部碎片、量化元数据和运行时波动,因此不能把这个数直接当作安全配置值。

3. 连续 KV Cache 与分页 KV Cache

3.1 连续分配的问题

最直接的实现是为每条序列分配一段连续 KV 内存:

序列 A: [token 0][token 1][token 2]...[token 4095]

这种布局访问简单,但存在两个问题。

第一,服务端通常不知道请求最终会生成多少 token。如果一开始按最大长度分配,短请求会浪费大量内存;如果逐步扩容,又可能需要搬迁已有 KV。

第二,多请求并发时,序列的结束时间不同,显存会出现空洞。即使空闲空间总量足够,也可能找不到足够大的连续区域,这就是外部碎片问题。

例如总空间有 16 个 token 槽位:

[A A A A][空 空][B B B B][空 空][C C C C]

此时空闲总量为 4,但如果新请求需要连续 4 个槽位,仍可能无法分配。

3.2 分页管理的基本思想

分页 KV Cache 通常把 KV 内存切成固定大小的物理 block。每个 block 可以保存连续 PP 个 token 的 K/V。

逻辑序列不再要求物理连续:

逻辑序列 A:
token 0..15   -> physical block 7
token 16..31  -> physical block 2
token 32..47  -> physical block 11

调度器维护一个 block table,将逻辑 block 映射到物理 block。注意力 kernel 根据逻辑位置找到对应的物理 block,再读取其中的 K/V。

flowchart LR
    A[请求逻辑序列] --> B[逻辑位置]
    B --> C[逻辑 Block 编号]
    C --> D[Block Table]
    D --> E[物理 Block 编号]
    E --> F[KV Pool 中的 K/V]
    F --> G[Paged Attention Kernel]
    H[调度器] --> D
    H --> I[空闲 Block Pool]

例如 block size 为 16:

  • 逻辑位置 0 到 15 属于逻辑 block 0;
  • 逻辑位置 16 到 31 属于逻辑 block 1;
  • 逻辑位置 32 到 47 属于逻辑 block 2。

如果 block table 是:

logical 0 -> physical 7
logical 1 -> physical 2
logical 2 -> physical 11

则序列在逻辑上仍然连续,但物理上可以分散在显存各处。

分页管理解决的是连续分配和外部碎片问题,并不会减少单个 token 本身需要的 KV 字节数。

3.3 Block 的大小与内部碎片

假设 block size 为 PP,序列长度为 SS,需要的 block 数量是:

Nblock=SPN_{\text{block}}=\left\lceil\frac{S}{P}\right\rceil

实际分配的 token 槽位是:

NblockPN_{\text{block}}P

内部浪费为:

W=NblockPSW=N_{\text{block}}P-S

如果 S=100S=100P=16P=16

Nblock=100/16=7N_{\text{block}}=\lceil100/16\rceil=7

实际槽位为 112112,浪费 1212 个 token 槽位。

block 越大:

  • block table 更小;
  • 映射和调度元数据更少;
  • 某些 kernel 访问更规整;
  • 但短请求和未填满的尾 block 浪费更多;
  • 前缀共享时粒度更粗。

block 越小则相反。实际选择还取决于 kernel 布局、GPU 架构和服务负载,不能仅根据“碎片越小越好”决定。

3.4 分页分配的状态变化

一个请求的 KV block 生命周期通常类似如下:

FREE
  -> RESERVED
  -> ACTIVE
  -> SEALED / SHARED
  -> EVICTABLE
  -> FREE

含义不是所有实现都完全相同,但状态逻辑通常包括:

  1. FREE:物理 block 未被使用。
  2. RESERVED:调度器已为请求预留,但可能尚未写入完整 KV。
  3. ACTIVE:正在被 Prefill 或 Decode 写入。
  4. SEALED:block 已写满,通常不会再被原地修改。
  5. SHARED:多个请求通过前缀缓存引用同一 block。
  6. EVICTABLE:请求不再使用,或可以被缓存淘汰。
  7. FREE:引用计数归零后回收到空闲池。

追加一个 token 时,系统一般检查当前尾 block 是否还有空槽位;若没有,则从空闲池申请新 block,并更新 block table。请求结束、取消或失败时,递减这些 block 的引用计数,只有引用计数为零的 block 才能真正释放。

4. KV Cache 的复用

“复用”至少包含三种不同含义:同一请求内的历史复用、不同请求间的前缀复用,以及分支搜索中的共享。

4.1 同一请求内的追加复用

这是 KV Cache 最基本的用途。

假设输入是:

[t0, t1, t2]

Prefill 后缓存:

K/V(t0), K/V(t1), K/V(t2)

生成 t3t3 时:

  1. 只计算 t3t3 的隐藏状态和 Q3,K3,V3Q_3,K_3,V_3
  2. Q3Q_3 读取 K/V(t0..t2)K/V(t0..t2)
  3. K/V(t3)K/V(t3) 追加到 Cache;
  4. 输出 t4t4 的概率分布。

生成 t4t4 时,复用 t0..t3t0..t3 的缓存,只新增 t4t4 的 K/V。

如果没有 Cache,第 t3t3 步需要再次计算 t0..t2t0..t2 的投影;序列越长,重复计算越严重。KV Cache 以显存占用换取了更低的解码重复计算。

4.2 跨请求的前缀缓存

如果多个请求拥有完全相同的前缀,例如:

系统提示 + 工具定义 + 长篇文档

只有最后的用户问题不同,那么前缀对应的 K/V 在满足条件时可以共享。

分页系统通常以 block 为单位进行前缀缓存。可以为 block 内容计算哈希:

hi=H(hi1,token_idsiP:(i+1)P,model_identity,position_context)h_i = H( h_{i-1}, \text{token\_ids}_{iP:(i+1)P}, \text{model\_identity}, \text{position\_context} )

其中使用前一个 block 的哈希作为输入,可以区分相同 token 块在不同前缀上下文中的位置。

命中流程通常是:

  1. 对请求进行与服务端完全一致的分词和模板展开;
  2. 将 token 序列切成固定大小的逻辑 block;
  3. 从最长前缀开始查找已有 block;
  4. 对命中的 block 增加引用计数;
  5. 只对未命中的后缀执行 Prefill;
  6. 新生成的 block 写入缓存索引;
  7. 请求结束后释放引用,而不是立即删除共享 block。

一个重要边界是:token 文本相同并不自动意味着 KV 可以复用。至少需要保证以下上下文一致:

  • 模型权重和模型版本一致;
  • tokenizer、chat template 和 token ID 序列一致;
  • RoPE 或其他位置编码配置一致;
  • Adapter、LoRA、量化方式及其参数一致;
  • attention mask 语义一致;
  • block 对应的绝对位置一致;
  • 没有会改变注意力可见性的特殊前缀或 packed sequence 规则。

例如,同样的文本在两个不同 chat template 下可能变成不同 token 序列;同样的 token 序列在不同 RoPE scaling 配置下也可能产生不同 K/V。

跨租户共享还涉及数据隔离。若前缀中包含用户私密内容,不能因为 token 前缀相同就让其他租户命中。常见做法是把租户、权限域或数据分类纳入缓存键,或者只允许明确标记为公共的前缀进入全局缓存。

4.3 Beam Search 与 Copy-on-Write

Beam Search 会从一个序列分裂出多个候选分支。分裂时,所有分支通常共享已有前缀:

共享前缀: [t0, t1, t2, t3]
分支 A:   [t4a, t5a]
分支 B:   [t4b, t5b]

如果为每个分支复制完整前缀,内存会按 beam 数量膨胀。更有效的方式是:

  • 共享只读的前缀 block;
  • 每个分支只为新 token 分配自己的尾部 block;
  • 某个分支要修改共享尾 block 时,执行 Copy-on-Write,复制后再写入。

这要求已经封存的共享 block 不得被原地覆盖。引用计数为 1 时可以直接追加;引用计数大于 1 时,必须分裂或申请新 block。

4.4 Speculative Decoding 的临时缓存

推测解码会让较小的草稿模型一次提出多个候选 token,再由目标模型批量验证。验证结果可能只接受候选前缀的一部分。

因此目标模型的 KV Cache 需要支持:

  • 暂存候选 token 的 K/V;
  • 接受时提交这些 K/V;
  • 拒绝时回滚未接受部分;
  • 必要时保留被接受 token 之后的正确状态。

如果实现无法高效回滚,可能出现:

  • 把拒绝 token 错误保留在 Cache;
  • 每次验证都复制整条 Cache;
  • 临时 block 泄漏,最终导致 OOM。

所以推测解码需要“追加”和“提交”分离,或者具备明确的尾部截断能力。

5. KV Cache 的失效与回收

失效不是单一事件。需要区分“不能再用于正确计算”和“仍然正确但应该被淘汰”。

5.1 正确性失效

以下变化会使已有 KV Cache 不能继续用于同一推理上下文:

  • 模型权重更新;
  • 使用了不同的模型版本或不同的 checkpoint;
  • 更换 LoRA、Adapter 或其他参数高效微调权重;
  • 修改 RoPE、ALiBi、位置偏置等配置;
  • 修改 KV 量化策略或量化参数;
  • token ID 序列发生变化;
  • attention mask 或可见性规则发生变化;
  • 序列位置发生变化;
  • 需要从不同的历史上下文继续生成。

例如,向已有序列中间插入一个 token 会改变后续 token 的位置和注意力输入。不能只把插入 token 的 K/V 加到尾部;通常需要从插入点之后重新计算。

5.2 生命周期回收

请求正常结束时:

  1. 停止为该请求分配新 block;
  2. 解除请求对 block table 的引用;
  3. 每个 block 的引用计数减一;
  4. 引用计数为零的 block 回收到空闲池;
  5. 若 block 同时进入前缀缓存,则转为可淘汰状态而不是立即释放。

请求取消、客户端断开、生成达到最大 token 数、过滤器触发停止,都应走同一套释放路径。生产系统中最危险的不是单次分配失败,而是异常路径没有释放 block,导致“已无活跃请求但显存一直不降”的泄漏表现。

5.3 容量不足与淘汰

当空闲 block 不足时,调度器必须在以下动作之间作出选择:

  • 暂停或拒绝新请求;
  • 暂停部分 Decode 请求;
  • 淘汰不活跃的前缀缓存;
  • 将部分状态转移到 CPU 或其他设备;
  • 触发 OOM 错误。

前缀缓存常使用 LRU、按时间或按成本加权的淘汰策略。淘汰只会删除可重新计算的 K/V,不应影响正在使用且引用计数不为零的 block。

一个常见误解是“把 Cache 清空就能解决 OOM”。如果 OOM 来自正在运行请求的 KV,清空可复用前缀缓存可能释放不了多少内存;如果 OOM 来自权重或 kernel workspace,清空 KV 也不会解决根因。

5.4 滑动窗口与 Sink Token

有些模型或服务只允许最近 WW 个 token 参与注意力。此时 KV Cache 不再随序列无限增长,理想情况下上限接近:

Mwindow=2LWHkvdhbM_{\text{window}} = 2L W H_{kv}d_h b

但具体行为取决于注意力实现。

如果直接删除最早 token,所有位置编码和可见性语义必须仍然成立。某些实现会保留少量开头 token 作为 attention sink,再维护一个滚动窗口:

保留区: [sink0, sink1]
窗口区: [最近 W 个 token]
丢弃区: [更早的 token]

不能因为配置了最大序列长度,就假设任意模型都能安全丢弃历史 KV。必须确认模型的注意力机制和推理实现支持这种截断。

6. 框架中的使用方式

Hugging Face Transformers 的模型调用通常支持 use_cache,并通过模型输出返回过去的 Key/Value。不同 Transformers 版本可能使用传统 tuple 结构或新的 Cache 抽象,因此具体字段类型和传参方式应以当前版本文档和模型实现为准。

下面展示标准的“首次调用—继续调用”生命周期。它适合理解机制;生产服务还需要处理 batch 变化、padding、位置 ID 和取消请求。

import torch
from transformers import AutoTokenizer, AutoModelForCausalLM

model_id = "gpt2"  # 示例模型;实际模型应确认支持 use_cache
device = "cuda" if torch.cuda.is_available() else "cpu"

tokenizer = AutoTokenizer.from_pretrained(model_id)
model = AutoModelForCausalLM.from_pretrained(
    model_id,
    torch_dtype=torch.float16 if device == "cuda" else torch.float32,
).to(device)
model.eval()

prompt = "KV cache 的作用是"
inputs = tokenizer(prompt, return_tensors="pt").to(device)

with torch.inference_mode():
    # 第一次调用:Prefill
    first = model(
        **inputs,
        use_cache=True,
    )

    past_key_values = first.past_key_values
    next_token = first.logits[:, -1, :].argmax(dim=-1, keepdim=True)

    # 继续调用:只输入新增 token
    # attention_mask 的长度必须覆盖历史 token 加当前 token。
    next_attention_mask = torch.cat(
        [
            inputs["attention_mask"],
            torch.ones(
                (inputs["attention_mask"].shape[0], 1),
                dtype=inputs["attention_mask"].dtype,
                device=device,
            ),
        ],
        dim=1,
    )

    second = model(
        input_ids=next_token,
        attention_mask=next_attention_mask,
        past_key_values=past_key_values,
        use_cache=True,
    )

    second_token = second.logits[:, -1, :].argmax(dim=-1)
    print("第一个新 token:", tokenizer.decode(next_token[0]))
    print("第二个新 token:", tokenizer.decode(second_token))

这段代码中,第一次调用输入完整 Prompt,模型为 Prompt 中的每个位置构造 K/V。第二次调用只输入一个新 token;past_key_values 提供历史 K/V,attention_mask 则告诉模型历史加当前 token 的有效范围。

这里有几个容易出错的地方:

  • 第二次调用不能再次把完整 Prompt 和 past_key_values 一起传入,否则历史会重复;
  • attention_mask 通常需要覆盖 past 长度与当前输入长度,而不只是当前 token;
  • 使用 padding 的 batch 需要正确处理 padding_side 和 mask;
  • 一些模型需要显式处理 position_ids
  • 新版 Transformers 的 Cache 类型和可选参数可能随版本变化;
  • generate() 通常会自动管理 Cache,但如果要手动增量调用,就必须自行维护这些状态。

如果只想进行普通生成,代码通常更简单:

with torch.inference_mode():
    output_ids = model.generate(
        **inputs,
        max_new_tokens=32,
        use_cache=True,
    )

print(tokenizer.decode(output_ids[0], skip_special_tokens=True))

需要区分两类实现:

  1. 通用模型库中的 use_cache:解决单次模型调用的增量计算,通常由模型的 Cache 结构保存每层 K/V。
  2. 面向高并发服务的分页 KV 管理:还需要 block pool、block table、调度器、共享引用、前缀索引和专用 attention kernel。通用 Transformers 接口本身不等同于完整的分页推理引擎。

7. Paged Attention 中的并发与调度

高并发服务通常同时存在三类工作:

  • 新请求的 Prefill;
  • 已有请求的 Decode;
  • 请求结束后的 block 回收和前缀缓存维护。

若每次只处理一个请求,GPU 可能无法充分利用;若不限制并发,KV Cache 又会耗尽。连续批处理(continuous batching)会在每个调度周期重新组织当前活跃请求,把可执行的 Decode token 和新请求的 Prefill 合并成一个批次。

一个简化的调度过程是:

  1. 检查空闲 block 数量;
  2. 为新请求计算所需的最小 block 数;
  3. 优先复用命中的前缀 block;
  4. 只为未命中部分申请 block;
  5. 将能继续 Decode 的请求加入本轮批次;
  6. 运行 attention kernel;
  7. 为新生成 token 追加或分配 block;
  8. 处理结束、拒绝和取消请求;
  9. 更新引用计数与淘汰队列。

当 block 不足时,不能只看“当前已生成 token 数”。还要看:

  • 每个请求未来可能追加多少 token;
  • Prefill 是否需要一次性暂存更多中间结果;
  • 是否允许请求被抢占;
  • 是否支持将被抢占请求的 KV 卸载到 CPU;
  • Prefix Cache 是否占用了大量可回收空间。

如果允许抢占,通常需要保存被抢占请求的逻辑 block 映射,并将物理 KV block 释放或转移;恢复时再重新装载或重算。重算会增加延迟,但可以换取更高并发。

8. KV Cache 量化与容量估算修正

公式中的 bb 默认所有 K/V 元素都使用相同精度。例如:

  • FP32:4 字节;
  • FP16/BF16:通常 2 字节;
  • INT8:理论上 1 字节;
  • 低于 8 bit 的格式:不能只用“每元素字节数”简单表示,还要计算打包和元数据。

KV Cache 量化通常还需要 scale、zero-point 或分组量化参数。更准确的有效字节数可以写成:

beff=bdata+bscale+bmetadatab_{\text{eff}} = b_{\text{data}}+b_{\text{scale}}+b_{\text{metadata}}

因此:

MKV=2LSHkvdhbeffM_{\text{KV}} = 2L S H_{kv}d_h b_{\text{eff}}

实际的 beffb_{\text{eff}} 还受以下因素影响:

  • 每组量化元素数量;
  • K 和 V 是否采用不同量化方式;
  • scale 是按 token、按 head 还是按 group 存储;
  • 数据是否按向量或 block 对齐;
  • attention kernel 是否支持该数据格式;
  • 解码时是否需要反量化到更高精度。

所以不能看到“INT8”就直接把 FP16 Cache 除以 2 后当作最终显存占用。正确做法是先确认具体实现的张量布局和元数据开销,再用实测分配量校正理论值。

9. 容量估算的完整方法

可以按以下顺序估算一个服务实例的 KV 容量。

第一步:确认模型结构

读取或确认:

  • 层数 LL
  • Query 头数 HqH_q
  • KV 头数 HkvH_{kv}
  • head dimension dhd_h
  • KV 数据类型;
  • 是否使用滑动窗口;
  • 是否为 Encoder-Decoder 架构;
  • 是否启用 Tensor Parallel 或 Pipeline Parallel。

不要用隐藏维度直接代替 HkvdhH_{kv}d_h。例如隐藏维度可能满足:

dmodel=Hqdhd_{\text{model}}=H_qd_h

但 GQA 的 KV 宽度是:

dKV=Hkvdhd_{\text{KV}}=H_{kv}d_h

二者并不相同。

第二步:计算单 token 成本

mtoken=2LHkvdhbeffm_{\text{token}} = 2L H_{kv}d_h b_{\text{eff}}

若是每 GPU 估算,则替换为本 GPU 实际负责的层数和 KV 头数,或根据实现的分片比例计算。

第三步:计算工作集

对每条活跃序列 ii,计算:

Mi=mtokenSiM_i = m_{\text{token}}S_i

分页时改为:

Mipaged=mtokenSiPPM_i^{\text{paged}} = m_{\text{token}} \left\lceil\frac{S_i}{P}\right\rceil P

批量总量是:

Mactive=iMipagedM_{\text{active}} = \sum_iM_i^{\text{paged}}

此外,前缀缓存还要单独计算其常驻 block 数量。前缀缓存越大,命中率可能越高,但留给正在运行请求的容量越少。

第四步:加入预算和安全余量

最终可接受的活跃请求必须满足:

Mactive+Mprefix+Mmetadata+MworkspaceMbudgetM_{\text{active}} + M_{\text{prefix}} + M_{\text{metadata}} + M_{\text{workspace}} \le M_{\text{budget}}

其中 MbudgetM_{\text{budget}} 是扣除权重和运行时保留后的预算。

更稳妥的系统会预留一部分 block 用于:

  • 当前 Decode 的追加;
  • Prefill 的临时峰值;
  • 异常回收延迟;
  • batch 重组;
  • CUDA kernel 工作区。

如果把所有物理 block 都分配给已知请求,下一次追加一个 token 时可能没有空间,最终表现为请求在“看似仍有少量显存”的情况下失败。

10. Encoder-Decoder 模型的额外 Cache

前面的公式主要针对 Decoder-only 模型的 self-attention。

Encoder-Decoder 模型还存在 Decoder 对 Encoder 输出的 cross-attention。对每个 Decoder 层,Encoder 的 K/V 可以由完整输入序列预先计算,并在生成多个 decoder token 时复用。

如果:

  • Encoder 输入长度为 SeS_e
  • Decoder 层数为 LdL_d
  • Cross-attention 的 KV 头数为 Hkv,cH_{kv,c}

那么 cross-attention 的静态 K/V 大小近似为:

Mcross=2LdSeHkv,cdhbM_{\text{cross}} = 2L_d S_e H_{kv,c}d_h b

它不会随 Decoder 已生成长度增长,但会随 Encoder 输入长度和并发请求数增长。Decoder self-attention Cache 仍然随生成长度增长,因此两部分都必须纳入容量预算。

11. 诊断:如何判断 Cache 配置出了问题

11.1 显存逐步增长直到 OOM

可能原因包括:

  • 请求结束路径未递减 block 引用计数;
  • 取消请求没有进入统一清理流程;
  • Speculative Decoding 的临时 block 未回滚;
  • 前缀缓存没有上限或淘汰策略;
  • 异常后仍保留请求对象对 Cache 的引用。

诊断时应同时观察:

  • 活跃请求数;
  • 已分配 block 数;
  • 活跃 block 数;
  • 共享 block 引用计数;
  • 可淘汰 block 数;
  • 前缀缓存命中数和大小;
  • 请求结束后 block 是否回到 FREE 状态。

如果活跃请求为零而分配 block 仍持续增加,优先检查生命周期和引用计数,而不是先降低最大上下文长度。

11.2 新请求频繁排队或被拒绝

可能是 KV 容量不足,也可能是调度策略预留过于保守。应区分:

  • 物理 block 确实耗尽;
  • 只剩下大量无法满足当前请求的零散容量;
  • 前缀缓存占满但可淘汰;
  • 请求要求的最大输出长度导致过度预留;
  • Prefill 峰值暂时占用 block;
  • Tensor Parallel 下某一张 GPU 比其他 GPU 更早耗尽。

只看整卡 nvidia-smi 的显存使用量,通常无法判断具体是哪种情况。

11.3 输出错误但没有 OOM

这类问题更可能是 Cache 语义错误:

  • 重复传入完整输入和 past K/V;
  • attention mask 没有覆盖历史长度;
  • position IDs 与缓存位置不一致;
  • beam 分支错误地共享了可写 block;
  • 拒绝的 speculative token 没有回滚;
  • 不同 Adapter 或模型版本误命中前缀缓存;
  • padding 后的 batch 映射错误。

验证方法是对同一 Prompt 比较两条路径:

  1. 一次性输入完整序列并生成;
  2. Prefill 后逐 token 传入 past K/V。

在相同精度和采样设置下,贪心解码结果应一致;若不一致,应逐层检查缓存长度、位置 ID、mask 和 token 顺序。

12. 常见误解与边界

12.1 KV Cache 不会减少首次 Prefill 的全部计算

KV Cache 主要减少 Decode 阶段对历史 K/V 的重复投影计算。首次处理 Prompt 时,仍需对输入执行模型计算。长 Prompt 的 Prefill 仍可能消耗大量时间和激活内存。

12.2 KV Cache 不等于模型权重缓存

权重是模型参数,通常在多个请求之间长期共享;KV Cache 是特定输入序列产生的中间状态,依赖 token、位置、模型配置和注意力上下文。两者生命周期、失效条件和容量管理完全不同。

12.3 分页不会自动降低理论内存需求

对于相同 token 数、相同精度和相同模型结构,分页与连续存储的 KV 张量本体大小基本相同。分页主要降低外部碎片,并允许请求按需增长和跨物理区域布局。

12.4 增大 batch 不一定提高吞吐

增大 batch 可以提高 GPU 并行度,但每增加一条序列都会增加 KV Cache,Decode 还会读取更多历史数据。当显存带宽、KV 容量或调度等待成为瓶颈时,继续增大 batch 可能使延迟和拒绝率上升。

12.5 前缀缓存命中不代表总延迟必然降低

命中前缀可以减少重复 Prefill,但还会引入:

  • 前缀哈希计算;
  • block 索引查找;
  • 引用计数维护;
  • 缓存占用;
  • 共享数据的安全隔离;
  • 可能的物理 block 访问不连续。

如果前缀很短、命中率低,维护缓存的成本可能超过收益。

13. 生产取舍

KV Cache 的核心取舍可以归纳为四组关系:

  1. 更长上下文 vs. 更高并发
    在单 token 成本固定时,活跃 token 总量决定主要 KV 容量消耗。

  2. 更大 block vs. 更少元数据
    大 block 降低管理开销,但增加尾部内部碎片。

  3. 更高前缀命中率 vs. 更少活跃容量
    常驻前缀缓存占用的 block 不能同时供新请求使用。

  4. 更低 Cache 精度 vs. 量化误差和实现复杂度
    低精度可以扩大容量,但需要确认 kernel、误差和元数据开销。

因此,容量估算不能只回答“模型最多支持多少上下文”。更有意义的问题是:

可服务并发=f(模型结构,KV 精度,每请求长度,输出上限,分页大小,前缀命中率,GPU 预算,调度策略)\text{可服务并发} = f( \text{模型结构}, \text{KV 精度}, \text{每请求长度}, \text{输出上限}, \text{分页大小}, \text{前缀命中率}, \text{GPU 预算}, \text{调度策略} )

在实现层面,应把每个请求的 token 数、block 数、Cache 状态和引用关系作为可观测数据,而不是只监控整体显存。只有这样,才能区分是理论容量不足、分页碎片、缓存泄漏、错误复用,还是调度保守造成的性能问题。

KV Cache 的理论基础来自 Transformer 的自注意力结构;其工程实现则进一步涉及增量计算、分页内存、引用计数、前缀索引、请求调度和故障回收。理解这几层之间的因果关系,才能正确估算容量,也才能在 OOM、输出错误和吞吐下降时定位真正原因。


系列导航与关联阅读

官方资料

本文依据研究论文、标准组织与主流框架官方文档重新梳理;正文、示例与工程清单由 WR BLOG 编写。