AI 工程基础体系 · 第 80/100 篇。内容覆盖机器学习、深度学习与生成式 AI;模型、数据、评测、权限和成本会作为同一生产系统处理。
LLM KV Cache:内存计算、分页管理、复用、失效与容量估算
1. KV Cache 解决了什么问题
Transformer 的自回归生成过程是:模型根据已有序列预测下一个 token,再把这个 token 加入序列,继续预测下一个 token。
对于第 个位置,注意力计算可以写成:
其中:
- :当前位置的隐藏状态;
- :当前位置的 Query;
- :当前位置的 Key;
- :当前位置的 Value。
因果注意力需要让当前位置的 Query 访问从第一个位置到当前位置的所有 Key 和 Value:
这里 是每个注意力头的维度。
如果每次生成一个新 token 都重新计算整个序列的 和 ,第 到 个位置对应的结果会被重复计算。KV Cache 就是把已经计算过的 Key 和 Value 保存下来,在后续生成步骤中直接复用。
它保存的是:
而不是保存注意力分数,也不是保存 Query。
原因是:
- 旧位置的 、 对后续步骤仍然有效;
- 每个新位置的 取决于当前生成状态,需要重新计算;
- 注意力分数依赖新的 ,不能直接缓存为固定结果。
因此,启用 KV Cache 后,单步解码通常只需要对新 token 计算一次新的 ,并让新 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 为例。设:
- :Transformer 层数;
- :已缓存 token 数;
- :批大小;
- :用于生成 K/V 的注意力头数;
- :每个头的维度;
- :每个元素占用的字节数;
- 因子 :分别对应 K 和 V。
单个序列的 KV Cache 内存为:
批量大小为 ,且每个序列长度相同,则:
如果不同序列长度不同,应按序列分别求和:
这个公式计算的是 KV 张量本体,不包括:
- 页表或 block table;
- 指针、元数据和引用计数;
- CUDA kernel 的临时工作区;
- 缓存量化的 scale、zero-point;
- 对齐和分配造成的内部碎片;
- 框架的其他激活和运行时开销。
因此它适合容量估算,不等于进程最终显示的全部显存占用。
1.3 MHA、MQA 与 GQA 的差异
传统多头注意力(MHA)通常满足:
其中 是 Query 头数。每个 Query 头都有独立的 K/V 头。
多查询注意力(MQA)让多个 Query 头共享一组 K/V:
分组查询注意力(GQA)位于两者之间:
所以在其他参数相同的情况下,KV Cache 的内存与 成正比。GQA 和 MQA 主要减少的是 K/V 的存储和读取量,不会按同样比例减少模型权重或 Query 的计算。
2. 完整的内存计算示例
假设模型有:
- 32 层;
- 32 个 Query 头;
- 8 个 KV 头,即 GQA;
- 每头维度 ;
- 上下文长度 ;
- FP16,单个元素 2 字节。
单个序列的 KV Cache 为:
如果这是 MHA,KV 头数为 32:
GQA 在这个例子中把 KV Cache 从 2 GiB 降到了 512 MiB,正好是 。
如果批大小为 8,且每条序列都达到 4096 token:
这 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 数 ,而不是把三条序列都按最长的 4096 计算。实际系统若按批次中的最长序列分配连续内存,可能还会产生额外的 padding 浪费。
2.1 每 GPU 的容量计算
在多 GPU 推理中,不能直接把整模型的 KV 大小平均到 GPU 数量上。需要看并行方式。
如果 Tensor Parallel 将 KV 头均匀分片,单卡大致保存:
但这只在 KV 头能够合理分片且实现确实采用该布局时成立。GQA 模型的 KV 头数较少,某些并行规模下可能出现头数不能整除、复制 KV 或采用特殊通信策略的情况。
Pipeline Parallel 则通常按层分配,每张卡只保存自己负责的层对应的 KV。两种并行方式叠加时,应按实际本地层数、本地 KV 头数和实现的复制策略计算。
2.2 可用容量而不是物理显存
设 GPU 总显存为 ,权重和运行时已经使用 ,系统预留安全空间 ,那么留给 KV Cache 的估算预算是:
单个 token 的理论 KV 成本为:
在忽略分页碎片和元数据时,理论总 token 容量为:
例如,一张 24 GiB GPU 中,权重、CUDA 图、临时缓冲等已经占用 18 GiB,预留 1 GiB:
对于前面的 32 层、8 个 KV 头、128 维、FP16 模型:
于是:
这是所有并发序列的 token 总量上限,不是“每条序列都能有 40960 token”。如果同时服务 16 条平均长度相同的序列,理论平均长度约为:
生产系统还必须扣除分页内部碎片、量化元数据和运行时波动,因此不能把这个数直接当作安全配置值。
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 可以保存连续 个 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 为 ,序列长度为 ,需要的 block 数量是:
实际分配的 token 槽位是:
内部浪费为:
如果 ,:
实际槽位为 ,浪费 个 token 槽位。
block 越大:
- block table 更小;
- 映射和调度元数据更少;
- 某些 kernel 访问更规整;
- 但短请求和未填满的尾 block 浪费更多;
- 前缀共享时粒度更粗。
block 越小则相反。实际选择还取决于 kernel 布局、GPU 架构和服务负载,不能仅根据“碎片越小越好”决定。
3.4 分页分配的状态变化
一个请求的 KV block 生命周期通常类似如下:
FREE
-> RESERVED
-> ACTIVE
-> SEALED / SHARED
-> EVICTABLE
-> FREE
含义不是所有实现都完全相同,但状态逻辑通常包括:
- FREE:物理 block 未被使用。
- RESERVED:调度器已为请求预留,但可能尚未写入完整 KV。
- ACTIVE:正在被 Prefill 或 Decode 写入。
- SEALED:block 已写满,通常不会再被原地修改。
- SHARED:多个请求通过前缀缓存引用同一 block。
- EVICTABLE:请求不再使用,或可以被缓存淘汰。
- 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)
生成 时:
- 只计算 的隐藏状态和 ;
- 用 读取 ;
- 将 追加到 Cache;
- 输出 的概率分布。
生成 时,复用 的缓存,只新增 的 K/V。
如果没有 Cache,第 步需要再次计算 的投影;序列越长,重复计算越严重。KV Cache 以显存占用换取了更低的解码重复计算。
4.2 跨请求的前缀缓存
如果多个请求拥有完全相同的前缀,例如:
系统提示 + 工具定义 + 长篇文档
只有最后的用户问题不同,那么前缀对应的 K/V 在满足条件时可以共享。
分页系统通常以 block 为单位进行前缀缓存。可以为 block 内容计算哈希:
其中使用前一个 block 的哈希作为输入,可以区分相同 token 块在不同前缀上下文中的位置。
命中流程通常是:
- 对请求进行与服务端完全一致的分词和模板展开;
- 将 token 序列切成固定大小的逻辑 block;
- 从最长前缀开始查找已有 block;
- 对命中的 block 增加引用计数;
- 只对未命中的后缀执行 Prefill;
- 新生成的 block 写入缓存索引;
- 请求结束后释放引用,而不是立即删除共享 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 生命周期回收
请求正常结束时:
- 停止为该请求分配新 block;
- 解除请求对 block table 的引用;
- 每个 block 的引用计数减一;
- 引用计数为零的 block 回收到空闲池;
- 若 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
有些模型或服务只允许最近 个 token 参与注意力。此时 KV Cache 不再随序列无限增长,理想情况下上限接近:
但具体行为取决于注意力实现。
如果直接删除最早 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))
需要区分两类实现:
- 通用模型库中的
use_cache:解决单次模型调用的增量计算,通常由模型的 Cache 结构保存每层 K/V。 - 面向高并发服务的分页 KV 管理:还需要 block pool、block table、调度器、共享引用、前缀索引和专用 attention kernel。通用 Transformers 接口本身不等同于完整的分页推理引擎。
7. Paged Attention 中的并发与调度
高并发服务通常同时存在三类工作:
- 新请求的 Prefill;
- 已有请求的 Decode;
- 请求结束后的 block 回收和前缀缓存维护。
若每次只处理一个请求,GPU 可能无法充分利用;若不限制并发,KV Cache 又会耗尽。连续批处理(continuous batching)会在每个调度周期重新组织当前活跃请求,把可执行的 Decode token 和新请求的 Prefill 合并成一个批次。
一个简化的调度过程是:
- 检查空闲 block 数量;
- 为新请求计算所需的最小 block 数;
- 优先复用命中的前缀 block;
- 只为未命中部分申请 block;
- 将能继续 Decode 的请求加入本轮批次;
- 运行 attention kernel;
- 为新生成 token 追加或分配 block;
- 处理结束、拒绝和取消请求;
- 更新引用计数与淘汰队列。
当 block 不足时,不能只看“当前已生成 token 数”。还要看:
- 每个请求未来可能追加多少 token;
- Prefill 是否需要一次性暂存更多中间结果;
- 是否允许请求被抢占;
- 是否支持将被抢占请求的 KV 卸载到 CPU;
- Prefix Cache 是否占用了大量可回收空间。
如果允许抢占,通常需要保存被抢占请求的逻辑 block 映射,并将物理 KV block 释放或转移;恢复时再重新装载或重算。重算会增加延迟,但可以换取更高并发。
8. KV Cache 量化与容量估算修正
公式中的 默认所有 K/V 元素都使用相同精度。例如:
- FP32:4 字节;
- FP16/BF16:通常 2 字节;
- INT8:理论上 1 字节;
- 低于 8 bit 的格式:不能只用“每元素字节数”简单表示,还要计算打包和元数据。
KV Cache 量化通常还需要 scale、zero-point 或分组量化参数。更准确的有效字节数可以写成:
因此:
实际的 还受以下因素影响:
- 每组量化元素数量;
- K 和 V 是否采用不同量化方式;
- scale 是按 token、按 head 还是按 group 存储;
- 数据是否按向量或 block 对齐;
- attention kernel 是否支持该数据格式;
- 解码时是否需要反量化到更高精度。
所以不能看到“INT8”就直接把 FP16 Cache 除以 2 后当作最终显存占用。正确做法是先确认具体实现的张量布局和元数据开销,再用实测分配量校正理论值。
9. 容量估算的完整方法
可以按以下顺序估算一个服务实例的 KV 容量。
第一步:确认模型结构
读取或确认:
- 层数 ;
- Query 头数 ;
- KV 头数 ;
- head dimension ;
- KV 数据类型;
- 是否使用滑动窗口;
- 是否为 Encoder-Decoder 架构;
- 是否启用 Tensor Parallel 或 Pipeline Parallel。
不要用隐藏维度直接代替 。例如隐藏维度可能满足:
但 GQA 的 KV 宽度是:
二者并不相同。
第二步:计算单 token 成本
若是每 GPU 估算,则替换为本 GPU 实际负责的层数和 KV 头数,或根据实现的分片比例计算。
第三步:计算工作集
对每条活跃序列 ,计算:
分页时改为:
批量总量是:
此外,前缀缓存还要单独计算其常驻 block 数量。前缀缓存越大,命中率可能越高,但留给正在运行请求的容量越少。
第四步:加入预算和安全余量
最终可接受的活跃请求必须满足:
其中 是扣除权重和运行时保留后的预算。
更稳妥的系统会预留一部分 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 输入长度为 ;
- Decoder 层数为 ;
- Cross-attention 的 KV 头数为 ;
那么 cross-attention 的静态 K/V 大小近似为:
它不会随 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 比较两条路径:
- 一次性输入完整序列并生成;
- 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 的核心取舍可以归纳为四组关系:
-
更长上下文 vs. 更高并发
在单 token 成本固定时,活跃 token 总量决定主要 KV 容量消耗。 -
更大 block vs. 更少元数据
大 block 降低管理开销,但增加尾部内部碎片。 -
更高前缀命中率 vs. 更少活跃容量
常驻前缀缓存占用的 block 不能同时供新请求使用。 -
更低 Cache 精度 vs. 量化误差和实现复杂度
低精度可以扩大容量,但需要确认 kernel、误差和元数据开销。
因此,容量估算不能只回答“模型最多支持多少上下文”。更有意义的问题是:
在实现层面,应把每个请求的 token 数、block 数、Cache 状态和引用关系作为可观测数据,而不是只监控整体显存。只有这样,才能区分是理论容量不足、分页碎片、缓存泄漏、错误复用,还是调度保守造成的性能问题。
KV Cache 的理论基础来自 Transformer 的自注意力结构;其工程实现则进一步涉及增量计算、分页内存、引用计数、前缀索引、请求调度和故障回收。理解这几层之间的因果关系,才能正确估算容量,也才能在 OOM、输出错误和吞吐下降时定位真正原因。
系列导航与关联阅读
- 系列入口:AI 工程完整学习路线:从机器学习与 Transformer 到 RAG、Agent 和生产治理
- 上一篇:模型量化:PTQ、QAT、INT8、INT4、精度损失与硬件适配
- 下一篇:LLM 连续批处理:调度、Prefill、Decode、公平性与吞吐延迟
官方资料
本文依据研究论文、标准组织与主流框架官方文档重新梳理;正文、示例与工程清单由 WR BLOG 编写。

评论
0 条讨论