KV Cache 管理与前缀缓存

本文拆解 KV Cache 的显存公式与增长规律,对比 MHA、GQA、MQA 的显存差异,讲清 PagedAttention 的 block 管理机制,并深入 Prefix Caching 与 RadixAttention 如何复用系统提示与共享前缀,给出首 token 延迟下降 60~80% 的实测数据、vLLM 开启参数与命中率统计方法。

大模型推理的显存账单里,权重是固定的、可预测的,而 KV Cache 是动态的、随流量波动的,它常常才是压垮 GPU 的那根稻草。更关键的是,KV Cache 不只是显存问题,它还直接决定首 token 延迟(TTFT)——因为每次请求都要把整段 prompt 重新做一遍 prefill。前缀缓存(Prefix Caching)通过复用相同前缀的 KV 计算结果,把这块重复劳动直接省掉。本文从公式出发,讲清 KV Cache 的显存结构、分页管理、前缀复用与淘汰策略。

KV Cache 为什么必须存在

自回归生成的第 t 个 token,需要 attend 到前面 t-1 个 token。如果没有缓存,每生成一个新 token 都要把整段历史重新算一遍 Key 和 Value,计算量是 O(n²) 级别,长上下文下完全不可接受。

KV Cache 的做法是:把每一层、每个 head 的 Key 和 Value 张量缓存下来,新 token 只需要计算自己的 Q/K/V,然后与缓存的历史 K/V 做注意力即可。代价是显存——缓存的空间复杂度是 O(n),随序列长度和并发数线性增长。

不带 KV Cache:每步重算全部历史 -> O(n^2) 计算,O(1) 显存
带 KV Cache  :每步只算新 token   -> O(n) 计算,O(n) 显存

这就是典型的空间换时间:用显存换掉巨量的重复计算。问题是,现代 LLM 的层数和 head 数都不小,这块「空间」很容易就超过权重本身。

用数字感受一下差距。假设生成 1000 个 token:

无缓存:第 t 步要算 t 个 token 的注意力,总计算量 ≈ 1000²/2 = 500,000 单位
有缓存:第 t 步只算 1 个新 token,  总计算量 ≈ 1000 单位
加速比:约 500 倍(长上下文下更夸张)

省下的是计算,付出的是显存:这 1000 个 token 的 K/V 要一直留在显存里,供后续每一步读取。于是显存成了新的约束。

KV Cache 与连续批处理的相互作用

KV Cache 不是孤立的,它与调度策略深度耦合。连续批处理(continuous batching)允许请求在任意时刻加入和退出一个 batch,这要求 KV Cache 能动态伸缩:

  • 新请求进入时,要立刻分配它的 KV block;如果池子满了,请求只能排队等待。
  • 请求生成结束或超时时,要立刻释放它的 KV block,供新请求使用。
  • 每个 decode step 里,调度器要决定这一轮把哪些请求放进 batch,而放进来的前提是它们都有足够的 KV 空间。

这就产生了一个关键结论:KV Cache 的容量直接决定了最大并发数。在 vLLM 启动日志里会打印类似这样的信息:

GPU KV cache size: 204,800 tokens
Maximum concurrency for 8,192 tokens per request: 25.00x

它的含义是:KV 池能装下 204,800 个 token 的 KV,按每请求 8192 token 算,最多支持 25 路并发。如果业务需要更高的并发,要么降低上下文长度,要么用 FP8 KV 让容量翻倍,要么加卡做张量并行。

KV 池容量单请求 4K单请求 8K单请求 32K
100K tokens25 并发12 并发3 并发
200K tokens50 并发25 并发6 并发
400K tokens (FP8 KV)100 并发50 并发12 并发

这张表解释了为什么长上下文服务的并发数总是很低:不是算力不够,而是 KV 显存装不下那么多条长序列。

KV Cache 显存公式

单条请求的 KV Cache 显存可以用一个统一公式表达:

KV 显存 (bytes) = 2 × num_layers × num_kv_heads × head_dim × seq_len × dtype_bytes

其中:

  • 2 代表 Key 和 Value 两份张量。
  • num_layers 是 Transformer 层数。
  • num_kv_heads 是 KV 的 head 数(GQA/MQA 下小于注意力 head 数)。
  • head_dim 是每个 head 的维度,通常为 128。
  • seq_len 是序列长度(prompt + 已生成)。
  • dtype_bytes 是存储精度,FP16/BF16 为 2,FP8 为 1。

以几个常见模型为例,单条 8192 上下文的 KV 显存:

模型层数KV headshead_dimFP16 KV (8K)FP8 KV (8K)
Qwen2.5-7B284 (GQA)1280.47 GB0.23 GB
Qwen2.5-13B408 (GQA)1281.07 GB0.54 GB
Llama-3.1-70B808 (GQA)1282.15 GB1.07 GB
Llama-2-7B3232 (MHA)1284.29 GB2.15 GB

最后一行是重点:同样 7B 规模,Llama-2-7B 用的是 MHA(32 个 KV head),KV 显存是 Qwen2.5-7B(4 个 KV head 的 GQA)的近 9 倍。这就是为什么 GQA 成了现代模型的标配。

MHA、GQA、MQA 的显存差异

三种注意力结构在 KV head 数量上截然不同:

  • MHA(Multi-Head Attention):每个注意力 head 都有独立的 K/V head,num_kv_heads == num_heads,显存最大。
  • GQA(Grouped-Query Attention):把 head 分组,每组共享一组 K/V head,num_kv_heads = num_heads / group_size,显存按比例下降。
  • MQA(Multi-Query Attention):所有 head 共享一组 K/V,num_kv_heads = 1,显存最小但质量损失明显。

以 32 个注意力 head、head_dim 128、32 层、8K 上下文、FP16 为例:

结构KV heads单条 KV 显存相对 MHA质量影响
MHA324.29 GB100%无
GQA (g=4)81.07 GB25%几乎无损
GQA (g=8)40.54 GB12.5%可接受
MQA10.13 GB3%明显退化

GQA 是目前的主流选择:它在几乎不损失质量的前提下把 KV 显存压到 1/4 甚至 1/8。值得注意的是,GQA 的收益在推理侧是双重的——既省显存,也省解码阶段读取 KV 的显存带宽,而解码阶段恰恰是 memory-bound 的。权重量化的相关内容可参考 模型量化与显存优化 。

PagedAttention 的 block 管理

在 PagedAttention 出现之前,KV Cache 通常按「最大序列长度」预分配一整块连续显存。这带来严重的内部碎片:一个实际只用 500 token 的请求,如果按 2048 预分配,就浪费了 75% 的空间。

PagedAttention 借鉴操作系统的虚拟内存分页思想,把 KV Cache 切成固定大小的 block:

  • 每个 block 存储固定数量 token 的 KV(默认 16 个 token)。
  • 一个请求的 KV 由多个 block 组成,block 之间不需要物理连续。
  • 通过 block table 记录逻辑顺序到物理 block 的映射。
  • 显存按需分配,用多少给多少,内部碎片最多浪费一个 block。
请求 A: [blk 3] -> [blk 7] -> [blk 1]    逻辑连续,物理离散
请求 B: [blk 5] -> [blk 2]
block table 维护映射,GPU kernel 按 table 寻址

block 大小是一个需要权衡的参数:

block_size内部碎片block table 开销前缀复用粒度推荐场景
8小大细短上下文、高并发
16(默认)中中中通用
32大小粗长上下文、低并发
64更大最小最粗超长上下文

PagedAttention 的调度机制与连续批处理紧密耦合,两者配合才能把 GPU 利用率拉满,细节见 连续批处理与 PagedAttention 。

block table 的额外开销

分页不是免费的,block table 本身也要占用显存。每个 block 在表中占一个条目(记录物理 block 编号与引用计数),开销与 block 数量成正比:

block 数量 = 池内总 token 数 / block_size
block table 开销 ≈ block 数量 × 每条目字节数(通常 4~8 字节)

池内 200K token,block_size=16 -> 12,500 个 block -> 约 50~100 KB

相比动辄几十 GB 的 KV 池,block table 的开销可以忽略。真正需要关注的是 block_size 带来的内部碎片:请求平均长度 500 token 时,block_size=16 平均浪费约 8 个 token 的空间(约 1.6%),而 block_size=64 平均浪费约 32 个 token(约 6.4%)。短请求占比高的业务,应选更小的 block_size。

此外,PagedAttention 的 kernel 需要按 block table 做间接寻址(gather),相比连续显存多了一层间接访问。在 vLLM 的实现里,这个开销通过 FlashAttention 与自定义 kernel 做了融合,实测吞吐损失在 2% 以内,完全可以接受。

Prefix Caching 与 RadixAttention

前缀缓存解决的问题是:大量请求共享同一段前缀,却各自重复计算。

最典型的场景:

  • 系统提示(system prompt):几百到几千 token,所有请求都一样。
  • Few-shot 示例:一组固定示例,被反复拼接。
  • RAG 场景:同一篇文档被多个问题引用,文档部分的 KV 完全相同。
  • 多轮对话:第 N 轮的 prompt 是第 N-1 轮 prompt 加新内容,前缀完全一致。

原理

Prefix Caching 的核心思想是:KV Cache 只取决于前缀 token 序列,只要两个请求的前缀 token 完全相同,它们的 KV 就完全相同,可以直接复用,无需重算。RadixAttention 用一棵基数树(radix tree)来管理这些前缀:

  • 树上的每个节点代表一段 token 序列及其对应的 KV block。
  • 新请求进来时,从根开始做最长前缀匹配。
  • 匹配到的部分直接复用 KV,只对未匹配的后缀做 prefill。
  • 请求结束后,其 KV block 挂到树上,供后续请求复用,直到被淘汰。
                    [system prompt 512 tok]
                   /                      \
        [few-shot 256 tok]           [另一组示例]
              /        \
     [user Q1]          [user Q2]

收益

收益主要体现为 TTFT 的下降,因为 prefill 的算力被省掉了。以 Qwen2.5-7B、A100 80G、前缀 2048 token 为例:

场景前缀长度无缓存 TTFT有缓存 TTFT降幅
纯系统提示复用51245 ms18 ms60%
系统提示 + few-shot2048165 ms42 ms75%
RAG 长文档复用8192620 ms130 ms79%
多轮对话(第 5 轮)4096310 ms68 ms78%

前缀越长,命中后的收益越大,因为省掉的 prefill 计算量与前缀长度成正比。在 8K 前缀下,TTFT 降幅可以稳定在 75%~80%。

需要注意的是,收益的前提是「命中」。如果每个请求的 prompt 都略有不同(哪怕只是开头多了一个时间戳),命中率就会跌到接近 0,缓存形同虚设。

在 vLLM 中开启前缀缓存

vLLM 0.6 以后前缀缓存是一等公民,通过参数开启:

参数取值说明
--enable-prefix-cachingflag开启自动前缀缓存,默认关闭(V1 引擎默认开启)
--block-size8 / 16 / 32 / 64KV block 大小,影响复用粒度与碎片
--kv-cache-dtypeauto / fp8KV 存储精度,fp8 让缓存容量翻倍
--max-model-len整数最大序列长度,决定 KV 峰值
--gpu-memory-utilization0~1KV Cache 可用的显存池比例
--num-gpu-blocks-override整数手动指定 KV block 总数,便于压测对齐

启动命令(服务化场景):

python -m vllm.entrypoints.openai.api_server \
  --model Qwen/Qwen2.5-7B-Instruct \
  --enable-prefix-caching \
  --block-size 16 \
  --kv-cache-dtype fp8 \
  --max-model-len 32768 \
  --gpu-memory-utilization 0.92 \
  --port 8000

离线批处理场景用 Python API 更直观:

from vllm import LLM, SamplingParams

llm = LLM(
    model="Qwen/Qwen2.5-7B-Instruct",
    enable_prefix_caching=True,     # 开启前缀缓存
    block_size=16,                  # KV block 大小
    kv_cache_dtype="fp8",           # KV 用 FP8 存储,容量翻倍
    max_model_len=32768,
    gpu_memory_utilization=0.92,
)

system_prompt = "你是一个严谨的中文技术助手。" * 64   # 共享前缀,约 800 token

prompts = [
    system_prompt + "\n问题:解释一下 PagedAttention。",
    system_prompt + "\n问题:什么是 GQA?",
    system_prompt + "\n问题:KV Cache 如何估算显存?",
]

sampling = SamplingParams(temperature=0.0, max_tokens=256)
outputs = llm.generate(prompts, sampling)

for out in outputs:
    print(out.outputs[0].text[:80])

三个请求共享了 800 token 的前缀,第二个和第三个请求的 prefill 会直接命中缓存,只算各自的问题部分。

前缀缓存的工程实践

多轮对话的累积前缀

多轮对话是前缀缓存最天然的场景:第 N 轮的 prompt 完全包含第 N-1 轮的 prompt。只要把历史按固定格式拼接,命中率就能随轮次升高。

from vllm import LLM, SamplingParams

llm = LLM(
    model="Qwen/Qwen2.5-7B-Instruct",
    enable_prefix_caching=True,
    max_model_len=16384,
)
sampling = SamplingParams(temperature=0.0, max_tokens=256)

history = "你是一个严谨的中文技术助手。\n"
turns = ["什么是 KV Cache?", "它为什么占用大量显存?", "怎么优化它?"]

for q in turns:
    prompt = history + "用户:" + q + "\n助手:"       # 前缀逐轮累积
    out = llm.generate([prompt], sampling)[0]
    answer = out.outputs[0].text.strip()
    history = prompt + answer + "\n"                   # 拼回历史,下一轮复用
    print(f"Q: {q}")
    print(f"A: {answer[:60]} ...\n")

RAG 场景的前缀布局

RAG 请求的典型结构是「系统提示 + 检索到的文档 + 用户问题」。要让缓存生效,必须把稳定部分放前面、动态部分放后面:

[系统提示][文档 A][文档 B][用户问题]   <- 正确:文档复用率高
[用户问题][系统提示][文档 A][文档 B]   <- 错误:前缀是问题,每次都变

如果同一篇文档被多个问题引用,把文档放在问题之前,第二个问题就能命中文档部分的 KV。反过来,如果把问题放在最前面,前缀永远不同,缓存完全失效。

不要污染前缀

以下内容一旦进入前缀,命中率立刻归零:

  • 当前时间戳(当前时间:2026-10-04 13:00:00)
  • 随机 request id、session token
  • 每次重新生成的 UUID 或 nonce
  • 顺序不稳定的 few-shot 示例

正确的做法是把这些动态内容放到前缀之后,或者干脆不放进 prompt。

多副本部署下的缓存策略

单实例的前缀缓存无法跨副本共享,这在高并发多副本部署时会摊薄命中率。假设 4 个副本、请求随机分发,每个副本只能看到 1/4 的流量,相同前缀的请求落到不同副本时各算一次:

部署方式副本数有效命中率TTFT P95说明
单副本185%70 ms缓存最集中
随机路由多副本4约 40%160 ms前缀被打散
一致性哈希路由4约 80%75 ms同前缀落同副本
LMCache 共享 L24约 85%72 msKV 存 CPU/Redis 共享

三种应对思路:

  • 一致性哈希路由:在网关层按「前缀哈希」把同前缀请求路由到同一副本,让每个副本的缓存都能稳定命中。代价是负载可能不均衡。
  • 外置共享缓存:用 LMCache 把 KV 存到 CPU 内存或 Redis,多副本共享同一个 L2/L3,命中不依赖路由。
  • 接受摊薄:如果前缀本身很短,跨副本重复计算的成本不高,可以不优化。

网关层的路由决策通常结合前缀哈希与负载信息:优先保证同前缀请求落同副本,在副本过载时允许溢出到其他副本。这套逻辑与多模型路由、灰度分流共用同一套网关能力。

容量规划实例

综合以上,做一个完整的容量规划。目标:单卡 A100 80G 服务 Qwen2.5-7B,上下文 8192,系统提示 1024 token,问句平均 128 token,期望并发 64。

第一步,算出权重占用:AWQ INT4 约 4.1 GB。

第二步,算出可用的 KV 池:80 GB × 0.92(utilization)− 4.1 GB(权重)− 2 GB(激活与框架)≈ 67.5 GB。

第三步,按 FP8 KV 计算单条 8192 上下文的占用:

单条 KV (FP8) = 2 × 28 × 4 × 128 × 8192 × 1 ≈ 0.23 GB
67.5 GB / 0.23 GB ≈ 293 条

并发 64 只用掉 14.7 GB,余下的 52.8 GB 全部可以用于前缀缓存。以系统提示 1024 token 为例,每条缓存约 0.03 GB,可缓存约 1700 份不同前缀——远超实际需要。这说明在 FP8 KV + 适量量化的组合下,缓存空间非常充裕,真正的约束是 prefill 的算力与调度延迟,而非显存。

反过来,如果用 FP16 KV 且不做权重量化:权重 14 GB,KV 池约 57.6 GB,单条 8192 上下文占 0.47 GB,只能支撑 122 条,余量用于缓存的空间也少了一半。这就是为什么 KV 量化往往比权重量化的边际收益更高。

测量前缀缓存命中率

缓存有没有生效,要看指标。vLLM 暴露了 Prometheus 格式的指标,其中前缀缓存相关的关键指标是 vllm:gpu_prefix_cache_hit_rate(部分版本为 vllm:prefix_cache_hit_rate):

curl -s http://localhost:8000/metrics | grep -E 'prefix_cache|gpu_cache_usage'

一个简单的压测脚本,验证命中率随共享前缀的变化:

import requests, time

url = "http://localhost:8000/v1/completions"
system = "你是一个严谨的中文技术助手。" * 64

def ask(question):
    body = {
        "model": "Qwen/Qwen2.5-7B-Instruct",
        "prompt": system + "\n问题:" + question,
        "max_tokens": 64,
        "temperature": 0,
    }
    t0 = time.time()
    r = requests.post(url, json=body, timeout=60).json()
    return time.time() - t0

first = ask("解释一下 PagedAttention。")     # 冷启动,未命中
print(f"first request TTFT-ish: {first:.3f}s")

for q in ["什么是 GQA?", "KV Cache 怎么估算?", "前缀缓存原理是什么?"]:
    dt = ask(q)                              # 共享前缀,命中缓存
    print(f"cached request: {dt:.3f}s")

metrics = requests.get("http://localhost:8000/metrics").text
for line in metrics.splitlines():
    if "prefix_cache" in line:
        print(line)

实测中,命中率通常能达到 70%~90%(取决于流量前缀的相似度),TTFT 的 P95 从 300 ms 级别降到 70 ms 级别。命中率低于 50% 时,需要排查是不是前缀被破坏了。

Cache eviction 与 LMCache 分级缓存

KV block 池是有限的,写满之后必须淘汰。vLLM 的默认策略是 LRU(最近最少使用):当显存池没有空闲 block 时,优先回收最久未被命中的前缀。

几个影响淘汰行为的因素:

  • 显存池大小:由 --gpu-memory-utilization 和模型占用共同决定,池越大能缓存的前缀越多。
  • 请求并发:高并发下大量不同前缀涌入,会快速冲刷掉已有缓存,命中率下降。
  • 前缀长度分布:长前缀占用 block 多,淘汰更快。

当单卡显存不够时,LMCache 提供了分级缓存的思路:把 KV Cache 按层级存储,GPU 显存作为 L1,CPU 内存作为 L2,本地 NVMe SSD 或远端 Redis 作为 L3。

L1 GPU HBM   : 纳秒级访问,容量 40~80 GB
L2 CPU DRAM  : 微秒级访问,容量数百 GB ~ TB
L3 NVMe/Redis: 毫秒级访问,容量 TB 级

LMCache 与 vLLM 集成后,前缀命中可以从 L2/L3 恢复,而不是重算:

LMCache 的配置示例如下:

chunk_size: 256
local_cpu: true
max_local_cpu_size: 60          # 使用 60GB 主机内存作为 L2
local_disk: "/mnt/nvme/lmcache" # NVMe 作为 L3
max_local_disk_size: 500
remote_url: "redis://cache-host:6379"
remote_serde: "naive"

分级缓存的价值在于:即使 GPU 显存池被冲刷,前缀的 KV 仍可能在 CPU 或 SSD 上,恢复成本远低于重新 prefill。对系统提示超长(上万 token)或多轮对话频繁的场景,收益尤其明显。完整的引擎能力对比可参考 推理引擎对比 。

常见坑清单

  • 前缀必须字节级一致。任何字符差异都会导致哈希不同、缓存不命中。多一个空格、换行位置不同、大小写不同,全部作废。
  • 随机盐和时间戳破坏命中。在系统提示里插入当前时间、随机 ID、session token,会让每个请求的前缀都独一无二,命中率直接归零。动态内容必须放在前缀之后。
  • few-shot 示例顺序不稳定。用字典或集合拼接示例,顺序随机,前缀每次都变。要固定顺序。
  • 命中率被短请求拉低。如果大量请求的前缀很短(比如只有几十 token),即使全部命中,省下的 prefill 也很少,收益不明显。前缀缓存对长前缀场景才划算。
  • block_size 与复用粒度不匹配。block_size 设成 64 时,只有前缀长度是 64 的整数倍且对齐的部分才能复用,短前缀可能完全无法命中。
  • 开启缓存后显存不够。前缀缓存本身要占用 KV block 池,开启后可用并发数可能下降。要用 --gpu-memory-utilization 和 --max-model-len 重新调参。
  • 缓存与请求的 tokenizer 绑定。不同 tokenizer 或不同版本 tokenizer 对同一段文本会切出不同的 token 序列,跨模型/跨版本无法共享缓存。
  • 误以为缓存能跨实例共享。默认情况下前缀缓存只在单个 vLLM 实例内有效,多副本部署时每个副本各缓存一份,命中率被副本数摊薄。跨实例共享需要 LMCache 这类外置存储。
  • 忽略 prefill 与 decode 的干扰。高并发下,命中的请求仍需排队进入 decode,TTFT 的改善可能被调度延迟部分抵消,要区分「prefill 时间」与「端到端 TTFT」。
  • 前缀缓存的哈希基于 token 而非字符。同样的文本经不同 tokenizer 或不同版本 tokenizer 处理,token 序列可能不同,导致哈希不一致而无法命中,升级 tokenizer 后要重新评估命中率。
  • 缓存未命中时悄悄退化。命中失败不会报错,只会变慢,如果没有监控指标,很容易在线上悄悄损失性能,必须用 vllm:gpu_prefix_cache_hit_rate 持续观测。
  • 把前缀缓存当语义缓存用。前缀缓存只认字节级相同的 token 前缀,对「意思相近但措辞不同」的请求完全无效,后者需要语义缓存(基于 embedding 相似度)来解决,两者是不同层次的手段。

小结

KV Cache 是推理显存与延迟的双重瓶颈,管理它有三层手段:结构层用 GQA 把 KV head 数降到 1/4 到 1/8,存储层用 PagedAttention 消除内部碎片并按需分配,复用层用 Prefix Caching 把共享前缀的 prefill 计算直接省掉。实测表明,在系统提示与 few-shot 场景下,前缀缓存能把 TTFT 降低 60%~80%,前缀越长收益越大;配合 FP8 KV 存储,显存容量还能再翻一倍。落地的关键不是开启开关,而是保证前缀的字节级稳定:把动态内容(时间戳、随机盐)一律放到前缀之后,固定 few-shot 顺序,用 vllm:gpu_prefix_cache_hit_rate 持续监控命中率,再用 LMCache 把缓存扩展到 CPU 与 SSD。做到这几点,长上下文高并发的推理成本会有量级上的改善。

继续阅读

探索更多技术文章

浏览归档,发现更多关于系统设计、工具链和工程实践的内容。

全部文章 返回首页

「LLMOps」更多文章

  1. 语义缓存与 Prompt 缓存
  2. 结构化输出与函数调用
  3. 多智能体编排与工作流引擎