大模型推理的显存账单里,权重是固定的、可预测的,而 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 tokens | 25 并发 | 12 并发 | 3 并发 |
| 200K tokens | 50 并发 | 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 heads | head_dim | FP16 KV (8K) | FP8 KV (8K) |
|---|---|---|---|---|---|
| Qwen2.5-7B | 28 | 4 (GQA) | 128 | 0.47 GB | 0.23 GB |
| Qwen2.5-13B | 40 | 8 (GQA) | 128 | 1.07 GB | 0.54 GB |
| Llama-3.1-70B | 80 | 8 (GQA) | 128 | 2.15 GB | 1.07 GB |
| Llama-2-7B | 32 | 32 (MHA) | 128 | 4.29 GB | 2.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 | 质量影响 |
|---|---|---|---|---|
| MHA | 32 | 4.29 GB | 100% | 无 |
| GQA (g=4) | 8 | 1.07 GB | 25% | 几乎无损 |
| GQA (g=8) | 4 | 0.54 GB | 12.5% | 可接受 |
| MQA | 1 | 0.13 GB | 3% | 明显退化 |
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 | 降幅 |
|---|---|---|---|---|
| 纯系统提示复用 | 512 | 45 ms | 18 ms | 60% |
| 系统提示 + few-shot | 2048 | 165 ms | 42 ms | 75% |
| RAG 长文档复用 | 8192 | 620 ms | 130 ms | 79% |
| 多轮对话(第 5 轮) | 4096 | 310 ms | 68 ms | 78% |
前缀越长,命中后的收益越大,因为省掉的 prefill 计算量与前缀长度成正比。在 8K 前缀下,TTFT 降幅可以稳定在 75%~80%。
需要注意的是,收益的前提是「命中」。如果每个请求的 prompt 都略有不同(哪怕只是开头多了一个时间戳),命中率就会跌到接近 0,缓存形同虚设。
在 vLLM 中开启前缀缓存
vLLM 0.6 以后前缀缓存是一等公民,通过参数开启:
| 参数 | 取值 | 说明 |
|---|---|---|
--enable-prefix-caching | flag | 开启自动前缀缓存,默认关闭(V1 引擎默认开启) |
--block-size | 8 / 16 / 32 / 64 | KV block 大小,影响复用粒度与碎片 |
--kv-cache-dtype | auto / fp8 | KV 存储精度,fp8 让缓存容量翻倍 |
--max-model-len | 整数 | 最大序列长度,决定 KV 峰值 |
--gpu-memory-utilization | 0~1 | KV 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 | 说明 |
|---|---|---|---|---|
| 单副本 | 1 | 85% | 70 ms | 缓存最集中 |
| 随机路由多副本 | 4 | 约 40% | 160 ms | 前缀被打散 |
| 一致性哈希路由 | 4 | 约 80% | 75 ms | 同前缀落同副本 |
| LMCache 共享 L2 | 4 | 约 85% | 72 ms | KV 存 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。做到这几点,长上下文高并发的推理成本会有量级上的改善。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。