KV Cache 深度拆解:当推理引擎决定把「注意力状态」做成一套存储系统
副标题:从 PagedAttention 的虚拟内存,到 PD 分离的传输成本方程,再到分层缓存的盈亏平衡点——一次面向 Goodput 的成本工程
〇、先说结论
如果你今天在做 LLM 推理服务,还在用「吞吐量(Throughput)」当北极星指标,那你大概率在为一堆没人要的 token 付电费。
2026 年推理侧最值钱的三个认知,我先摆在这里,后文逐条拆:
- KV Cache 不是缓存,是状态。 它的正确类比不是 Redis,而是数据库的 WAL + Buffer Pool。一旦你接受这个类比,PagedAttention、前缀复用、分层落盘、跨机迁移,全都变成了存储系统的经典问题,而不是什么 AI 黑魔法。
- Prefill 和 Decode 是两种物理上冲突的负载。 一个吃算力(GEMM),一个吃带宽(GEMV)。把它们塞进同一个 GPU 进程,就像让 OLAP 和 OLTP 共用一个连接池——平均值好看,P99 稀烂。
- PD 分离不是免费的。 它用「网络传输 KV」换「计算隔离」,这笔交易有明确的盈亏平衡点。算不清这个点就上 PD 分离的团队,我见过不止一个,最后的结果是延迟没降、机器多花一倍。
这篇文章不讲「什么是大模型推理」,默认你知道 Transformer 在干嘛。我们直接从工程账本开始。
一、背景:推理服务的两本账
部署一个模型,成本分两笔。
第一笔是权重。 这笔账很好算,也很静态。一个 70B 模型,FP16 就是 140GB,量化到 INT8 就是 70GB,INT4 就是 35GB。买多少卡、装多少机器,Excel 里一拉就出来了。所有人都会算这笔账,所以这笔账不产生竞争优势。
第二笔是工作记忆,也就是 KV Cache。 这笔账是动态的,随请求数、随上下文长度、随生成长度实时变化。而且它的增长方式非常不友好。
单个请求的 KV Cache 显存占用:
KV_bytes = 2 × num_layers × num_kv_heads × head_dim × seq_len × dtype_size
那个开头的 2 是 K 和 V 各一份。注意这里是 num_kv_heads 而不是 num_heads——这就是 GQA(Grouped Query Attention)省显存的地方,也是为什么 2023 年之后基本没人做纯 MHA 了。
代入一组典型配置感受一下:
def kv_cache_bytes(num_layers, num_kv_heads, head_dim, seq_len, dtype_size=2):
return 2 * num_layers * num_kv_heads * head_dim * seq_len * dtype_size
# 一个 32 层、8 个 KV head、head_dim=128 的模型,bf16
per_token = kv_cache_bytes(32, 8, 128, seq_len=1, dtype_size=2)
print(f"每 token: {per_token / 1024:.1f} KB") # 每 token: 128.0 KB
# 32K 上下文
print(f"32K 上下文单请求: {kv_cache_bytes(32, 8, 128, 32768) / 1024**3:.2f} GB")
# 32K 上下文单请求: 4.00 GB
# 并发 64 路
print(f"64 并发: {kv_cache_bytes(32, 8, 128, 32768) * 64 / 1024**3:.1f} GB")
# 64 并发: 256.0 GB
看到问题了吗。一张 80GB 的卡,装完模型权重剩下大概 50-60GB,而 64 路 32K 并发要 256GB。你的显存不是被模型吃掉的,是被"记忆"吃掉的。
这就是所有 KV Cache 工程的起点:一块永远不够用、但又必须复用的高速存储。
再补一刀:KV Cache 的读取模式也很恶心。Decode 阶段每生成一个 token,要把整个 KV Cache 从 HBM 读一遍做 attention。所以 decode 的性能上限不是算力,是显存带宽:
理论 decode 上限 (tokens/s) ≈ HBM带宽 / (模型权重字节 + batch内KV字节)
一张卡 3TB/s 带宽、模型权重 140GB,单请求 decode 理论天花板就是 3000/140 ≈ 21 tokens/s。你怎么优化 CUDA kernel 都突破不了——除非你增大 batch,把权重读取的成本摊到更多请求上。这是理解后面所有优化的物理基础。
二、核心概念:把北极星指标从 Throughput 换成 Goodput
2.1 吞吐量的谎言
Throughput = 单位时间生成的 token 总数。这个指标的问题在于:它不关心这些 token 有没有人要。
设想一个场景:你把 batch size 开到 256,GPU 利用率 99%,吞吐量报表非常好看。但每个用户的首 token 要等 8 秒,第二个 token 要等 300ms。用户早就关掉页面走了,你还在为他生成 token。
Goodput(有效吞吐) 修正了这个问题:只统计满足 SLO 约束的请求所产生的 token。
先定义三个延迟指标,这三个词后面会反复出现:
| 指标 | 全称 | 含义 | 由谁决定 |
|---|---|---|---|
| TTFT | Time To First Token | 首 token 延迟 | Prefill 阶段(算力) |
| TPOT | Time Per Output Token | 每输出 token 耗时 | Decode 阶段(带宽) |
| ITL | Inter-Token Latency | token 间隔,约等于 TPOT | Decode + 调度抖动 |
Goodput 的计算:
from dataclasses import dataclass
@dataclass
class SLO:
ttft_ms: float # 首 token 必须在这个时间内
tpot_ms: float # 每个后续 token 的平均间隔上限
@dataclass
class Request:
ttft_ms: float
tpot_ms: float
output_tokens: int
def goodput(requests, slo: SLO, wall_time_s: float) -> float:
"""只统计满足 SLO 的请求贡献的 token"""
valid = sum(
r.output_tokens
for r in requests
if r.ttft_ms <= slo.ttft_ms and r.tpot_ms <= slo.tpot_ms
)
return valid / wall_time_s
def throughput(requests, wall_time_s: float) -> float:
return sum(r.output_tokens for r in requests) / wall_time_s
我在真实压测里见过最夸张的一次:throughput 提升 40%,goodput 下降 12%。原因是为了拉高 batch,调度器把长 prompt 和短 prompt 混在一起,短请求全被长请求的 prefill 阻塞,TTFT 集体爆表。
记住这条:任何推理优化,必须同时报 goodput 和 SLO 达标率。只报 throughput 的性能数据,默认打七折看。
2.2 为什么 SLO 必须分开定
很多团队定 SLO 时只写一句「P99 延迟 < 2s」,这是错的。因为 TTFT 和 TPOT 由完全不同的硬件资源决定:
- TTFT 差 → 加算力(更多 SM、更好的 kernel、更大的 TP)
- TPOT 差 → 加带宽(HBM 带宽、减小 batch 内 KV 体积、量化 KV)
混在一起写,你就永远不知道该买什么卡。
不同业务的 SLO 形状也完全不同,我总结成一张表:
| 场景 | TTFT 要求 | TPOT 要求 | 主要矛盾 |
|---|---|---|---|
| 对话式 Chat | 严(<500ms) | 中(<50ms,比阅读速度快即可) | Prefill 排队 |
| 代码补全 | 极严(<200ms) | 松(一次性出小段) | Prefill 全占 |
| 长文档摘要 | 松(用户能等) | 严(要流式好看) | Decode 带宽 |
| 批量离线处理 | 无 | 无 | 纯 Throughput |
| Agent 工具调用 | 中 | 严(多轮串行放大) | 端到端累积 |
最后一行是 2026 年的新变量:Agent 场景下,一次用户交互背后是 5-20 次 LLM 调用串行执行。单次 TTFT 500ms,二十次就是 10 秒。Agent 把 TTFT 的重要性放大了一个数量级,这也是前缀缓存在今天变得如此关键的原因——Agent 的每一轮调用,系统提示词和历史几乎完全相同。
三、第一层优化:PagedAttention,把显存当虚拟内存
3.1 问题:碎片化
最朴素的实现是给每个请求预分配 max_seq_len 的连续显存。假设 max_len=32K,实际平均只用了 2K,那 94% 的显存在空转。
这个问题操作系统在 1960 年代就解决过,叫分页。
PagedAttention 的做法完全照抄虚拟内存:
- 把 KV Cache 切成固定大小的 block(通常 16 或 32 个 token)
- 每个请求持有一张 block table(页表),记录逻辑 block → 物理 block 的映射
- 物理 block 在显存里不需要连续
- attention kernel 改造成能按 block table 跳着读
3.2 手写一个 mini block allocator
理解一个系统最快的方式是写一个最小实现。下面这段代码剥离了所有 CUDA 细节,只保留分配逻辑,但结构和真实引擎是同构的:
from typing import Dict, List, Optional
from collections import OrderedDict
class Block:
__slots__ = ("block_id", "ref_count", "token_ids", "hash_val")
def __init__(self, block_id: int):
self.block_id = block_id
self.ref_count = 0
self.token_ids: List[int] = []
self.hash_val: Optional[int] = None # 用于前缀复用
class BlockAllocator:
def __init__(self, num_blocks: int, block_size: int = 16):
self.block_size = block_size
self.blocks = [Block(i) for i in range(num_blocks)]
# 空闲池:LRU 顺序,方便驱逐
self.free_pool: OrderedDict[int, Block] = OrderedDict(
(b.block_id, b) for b in self.blocks
)
# 内容寻址表:hash -> block_id,前缀缓存的核心
self.hash_to_block: Dict[int, int] = {}
def allocate(self) -> Block:
if not self.free_pool:
raise MemoryError("KV cache 已满,需要抢占(preemption)")
_, blk = self.free_pool.popitem(last=False) # LRU 取最久未用
blk.ref_count = 1
return blk
def free(self, blk: Block) -> None:
blk.ref_count -= 1
if blk.ref_count == 0:
# 注意:不清空内容,放回池尾,等待可能的前缀命中
self.free_pool[blk.block_id] = blk
def try_reuse(self, hash_val: int) -> Optional[Block]:
"""前缀缓存命中路径"""
bid = self.hash_to_block.get(hash_val)
if bid is None:
return None
blk = self.blocks[bid]
if blk.block_id in self.free_pool:
# 从空闲池里"救回来",这就是零成本的 prefill 复用
del self.free_pool[blk.block_id]
blk.ref_count += 1
return blk
def register(self, blk: Block, hash_val: int) -> None:
blk.hash_val = hash_val
self.hash_to_block[hash_val] = blk.block_id
关键设计点有三个,每一个都值得单独说:
第一,free() 不清空内容。 这是前缀缓存能工作的前提。block 被释放后只是标记为可复用,内容还在显存里躺着。如果下一个请求的前缀恰好匹配,直接把它捞回来,prefill 成本归零。这个设计和 Linux 的 page cache 逻辑一模一样——释放的页进 LRU 链表,不立即归零。
第二,hash_val 是内容寻址。 block 的 hash 必须包含它自己的 token 序列 + 所有前驱 block 的 hash,形成一条哈希链:
def compute_block_hash(prev_hash: Optional[int], token_ids: List[int]) -> int:
# 必须把前驱 hash 纳入计算,否则中间 block 会被错误复用
return hash((prev_hash, tuple(token_ids)))
为什么必须链式?因为 attention 是有序的。"你好世界"的第二个 block 和"再见世界"的第二个 block,即使 token 完全相同,KV 值也不同——因为它们 attend 的前文不同。忘记链式 hash 是自研引擎最常见的正确性 bug,而且它不会崩溃,只会静默输出错误结果,极难排查。
第三,free_pool 用 OrderedDict 做 LRU。 驱逐策略直接决定前缀命中率。纯 LRU 在多租户场景下有个坑:一个跑批量任务的大客户会把所有小客户的热前缀冲掉。生产环境建议改成分段 LRU 或者给系统提示词的 block 加 pin 标记。
3.3 从 block table 到 radix tree
有了内容寻址的 block,下一步自然是把所有 block 组织成一棵前缀树(radix tree)。这样新请求进来时,可以一次性查出「最长可复用前缀」。
class RadixNode:
def __init__(self):
self.children: Dict[int, "RadixNode"] = {} # token -> node
self.block_id: Optional[int] = None
self.last_access: float = 0.0
class PrefixTree:
def __init__(self, allocator: BlockAllocator):
self.root = RadixNode()
self.allocator = allocator
self.bs = allocator.block_size
def match(self, token_ids: List[int]) -> tuple[int, List[int]]:
"""返回 (可复用 token 数, 命中的 block_id 列表)"""
node, matched, hit_blocks = self.root, 0, []
for i in range(0, len(token_ids) - self.bs + 1, self.bs):
chunk = token_ids[i:i + self.bs]
key = chunk[0]
child = node.children.get(key)
if child is None or child.block_id is None:
break
blk = self.allocator.blocks[child.block_id]
if blk.token_ids != chunk: # hash 冲突兜底,必须做全量比对
break
hit_blocks.append(child.block_id)
matched += self.bs
node = child
return matched, hit_blocks
注意 blk.token_ids != chunk 这行全量比对。很多简化实现只比 hash 就直接复用,在 hash 冲突时会静默出错。64 位 hash 在千万级 block 规模下的碰撞概率虽然低,但推理服务是长期跑的,出一次就是线上事故。加这一行的成本是 16 次整数比较,别省。
3.4 前缀缓存的真实收益边界
前缀缓存不是万能的。它的收益完全取决于你的请求前缀重复率:
| 场景 | 典型前缀重复率 | 前缀缓存价值 |
|---|---|---|
| Agent 多轮工具调用 | 85-95% | 极高,TTFT 可降一个数量级 |
| RAG(固定知识库文档) | 60-80% | 高 |
| 客服机器人(长系统提示词) | 70-90% | 高 |
| 多轮对话 | 随轮数上升 | 中到高 |
| 一次性翻译/摘要 | ~0% | 无,纯浪费管理开销 |
最后一行很重要:如果你的业务是纯无状态的一次性请求,前缀缓存开着反而是负收益——哈希计算、树维护、LRU 管理都是纯开销。这时候应该直接关掉,把显存全部给 batch。
四、第二层:Prefill 与 Decode 的物理冲突
4.1 两种负载的本质差异
前面提过一句,这里展开。
Prefill 阶段:输入 N 个 token,一次性并行计算所有位置的 K/V。Attention 里的 Q 是一个 [N, d] 的矩阵,和 K 的 [N, d] 做矩阵乘法 → 这是 GEMM(矩阵×矩阵),算术强度高,能把 GPU 的 Tensor Core 打满。
Decode 阶段:每次只有 1 个新 token,Q 是一个 [1, d] 的向量,要和已有的 [seq_len, d] 的 K 做乘法 → 这是 GEMV(向量×矩阵),算术强度极低,GPU 算力空转,瓶颈完全在把 KV Cache 从 HBM 搬到 SM 的路上。
用 Roofline 模型说:prefill 在计算屋顶下,decode 在带宽斜坡上。两者的最优硬件配置是相反的。
把它们放同一个 batch 里会发生什么?一个 4000 token 的 prefill 请求进来,它要独占 GPU 大约 100-500ms。在这期间,所有正在 decode 的请求全部停摆。用户看到的现象是:输出到一半突然卡住两秒,然后又哗哗地出。 这就是 ITL 抖动的主要来源。
4.2 Chunked Prefill:和稀泥方案
第一个解决方案是把大 prefill 切成小块,每块和 decode 请求混在一个 batch 里跑:
传统调度: [P4000] [D][D][D] [P4000] [D][D][D]
↑ 长阻塞
Chunked: [P512+D×30] [P512+D×30] [P512+D×30] ...
↑ 每个 batch 都有 decode,ITL 平滑
配置起来很简单:
vllm serve Qwen/Qwen3-32B \
--enable-chunked-prefill \
--max-num-batched-tokens 2048 \
--max-num-seqs 128
max_num_batched_tokens 是这里唯一重要的旋钮,它的调参逻辑是:
- 调小(512-1024):ITL 更平滑,TPOT 更稳,但 TTFT 变长(prefill 被切得更碎)
- 调大(4096-8192):TTFT 更好,吞吐更高,但 decode 会被周期性阻塞
我的经验规律:从 2048 起步,然后看你的 P99 ITL 和 P99 TTFT 哪个先破 SLO,往反方向调。 不要一上来就照抄别人的配置,这个值和你的平均 prompt 长度强相关。
Chunked prefill 是个好方案,成本极低(改个 flag),适用面广。但它的本质是在同一块 GPU 上做时间片轮转——它缓解了阻塞,没有消除资源竞争。Prefill 该吃的算力还是要吃,decode 该等的带宽还是要等。
4.3 PD 分离:物理隔离
当单机时间片调度榨不出更多收益时,下一步是物理分离:prefill 和 decode 跑在不同的 GPU 实例上。
┌──────────────┐
Client ─────────▶│ Proxy │
└──┬────────┬──┘
│ │
①prefill│ │③decode 请求
▼ ▼
┌───────────┐ ┌───────────┐
│ Prefill │ │ Decode │
│ Instance │ │ Instance │
│(算力优化) │ │(带宽优化) │
└─────┬─────┘ └─────▲─────┘
│ │
└──────────────┘
② KV Cache 传输
(RDMA / NVLink)
vLLM 里的启动方式(NIXL connector 路线):
# Prefill 实例:只做 prefill,产出 KV
CUDA_VISIBLE_DEVICES=0 \
VLLM_NIXL_SIDE_CHANNEL_PORT=5600 \
vllm serve Qwen/Qwen3-32B \
--port 8100 \
--kv-transfer-config '{"kv_connector":"NixlConnector","kv_role":"kv_producer"}'
# Decode 实例:接收 KV,只做 decode
CUDA_VISIBLE_DEVICES=1 \
VLLM_NIXL_SIDE_CHANNEL_PORT=5601 \
vllm serve Qwen/Qwen3-32B \
--port 8200 \
--kv-transfer-config '{"kv_connector":"NixlConnector","kv_role":"kv_consumer"}'
分离之后你获得了两个此前不可能的能力:
- 独立扩缩容。 Prefill 实例和 decode 实例的数量比可以按负载动态调整。长 prompt 短输出的业务(比如文档 QA)可能是 3:1,短 prompt 长输出的业务(比如小说续写)可能是 1:4。
- 异构硬件配比。 Prefill 实例可以用算力强但显存带宽一般的卡,decode 实例反过来。这在成本上是实打实的优化空间。
五、架构分析:PD 分离的成本方程
这一节是全文最重要的部分。PD 分离不是免费午餐,它把一个计算问题换成了一个网络问题。
5.1 传输量有多大
每次 prefill 完成后,需要把整个 prompt 的 KV Cache 传给 decode 实例。传输量就是前面那个公式:
transfer_bytes = 2 × L × H_kv × d_head × prompt_len × dtype_size
还是那个 32 层 / 8 KV head / 128 dim 的模型,每 token 128KB。一个 4K prompt 就是 512MB。
这个数字要好好体会一下。512MB,用 100Gbps 网卡(实际有效带宽约 12GB/s)传输需要 43ms。而这个 prompt 的 prefill 本身可能只要 200ms。你花了 20% 的额外时间在搬数据。
如果网络是 25Gbps(约 3GB/s),传输要 170ms——比 prefill 本身还慢。这时候 PD 分离是纯粹的负优化。
5.2 盈亏平衡方程
我们把它写成可计算的形式:
from dataclasses import dataclass
@dataclass
class ModelSpec:
num_layers: int
num_kv_heads: int
head_dim: int
dtype_size: int = 2
def kv_bytes_per_token(self) -> int:
return 2 * self.num_layers * self.num_kv_heads * self.head_dim * self.dtype_size
@dataclass
class ClusterSpec:
net_gbps: float # 实际可用带宽(不是标称)
prefill_tokens_per_s: float # prefill 实例吞吐
# 共置时 decode 被 prefill 阻塞的平均比例(实测得到,典型 0.15~0.45)
colocated_stall_ratio: float
def pd_split_gain_ms(model, cluster, prompt_len, output_len, tpot_ms):
"""
返回 PD 分离相对共置的端到端收益(毫秒)。正数=值得分离。
"""
# 成本:KV 传输时间
bytes_ = model.kv_bytes_per_token() * prompt_len
net_bytes_per_s = cluster.net_gbps * 1e9 / 8
transfer_ms = bytes_ / net_bytes_per_s * 1000
# 收益:decode 不再被 prefill 阻塞而节省的时间
decode_ms = output_len * tpot_ms
saved_ms = decode_ms * cluster.colocated_stall_ratio
return saved_ms - transfer_ms
model = ModelSpec(num_layers=32, num_kv_heads=8, head_dim=128)
# 100Gbps 网络,实测有效 ~80%
cluster = ClusterSpec(net_gbps=80, prefill_tokens_per_s=8000,
colocated_stall_ratio=0.30)
for plen, olen in [(512, 1024), (4096, 512), (4096, 4096), (32768, 128)]:
g = pd_split_gain_ms(model, cluster, plen, olen, tpot_ms=25)
verdict = "分离" if g > 0 else "共置"
print(f"prompt={plen:>6} output={olen:>5} → 收益 {g:>9.1f} ms 建议: {verdict}")
运行结果(数量级示意):
prompt= 512 output= 1024 → 收益 7024.5 ms 建议: 分离
prompt= 4096 output= 512 → 收益 3785.6 ms 建议: 分离
prompt= 4096 output= 4096 → 收益 30521.6 ms 建议: 分离
prompt= 32768 output= 128 → 收益 -3475.2 ms 建议: 共置
结论一目了然,而且违反很多人的直觉:
PD 分离的收益,和输出长度正相关,和输入长度负相关。
因为传输成本 ∝ prompt_len,而收益 ∝ output_len。所以:
- 长输入 + 短输出(文档摘要、分类打标、Rerank)→ 不要做 PD 分离,传输成本吃掉全部收益,老老实实用 chunked prefill。
- 短输入 + 长输出(创作、代码生成、Agent 推理链)→ PD 分离收益巨大。
- 长输入 + 长输出 → 分离,但必须配 RDMA,千兆以太网想都别想。
我见过的最典型翻车案例,就是一个做 RAG 文档问答的团队(8K prompt / 200 token 输出)照着博客上了 PD 分离,延迟涨了 40%,排查一周才发现是网络成了瓶颈。这个公式能让你在写代码之前就避开这个坑。
5.3 三种传输方式的取舍
传输不是只有一种做法:
| 方式 | 延迟 | 适用 | 坑 |
|---|---|---|---|
| NVLink / NVSwitch | 极低(~900GB/s) | 单机内跨卡分离 | 只能同机,扩展性受限 |
| RDMA(RoCE/IB) | 低(~12GB/s @100G) | 跨机主力方案 | 需要网络团队配合调 PFC/ECN,运维门槛高 |
| TCP | 高,且抖动大 | 只适合 POC | 生产别用,内核拷贝开销能吃掉一半带宽 |
还有一个常被忽略的维度:传输粒度。
- 整体传输:prefill 全做完,一次性发。实现简单,但 decode 端要干等。
- 逐层流水(layer-wise pipelining):算完第 1 层的 KV 就立刻开始发,边算边传。可以把传输时间几乎完全隐藏在计算时间里。
逐层流水的收益可以量化:如果 prefill 耗时 200ms、传输耗时 43ms,整体传输的总耗时是 243ms,逐层流水是 max(200, 43) + 单层传输时间 ≈ 205ms。省下 15%,只需要改变发送时机。 现代引擎基本都实现了这个,但如果你在自研,这是性价比最高的一个优化。
5.4 手写一个 KVConnector
理解 KV 传输协议最好的方式还是看接口。抽象出来是这样:
from abc import ABC, abstractmethod
from typing import Optional
import torch
class KVConnectorBase(ABC):
"""KV 传输连接器的最小接口"""
@abstractmethod
def send_kv(self, request_id: str, layer_idx: int,
k_cache: torch.Tensor, v_cache: torch.Tensor,
slot_mapping: torch.Tensor) -> None:
"""Producer 侧:发送某一层的 KV。逐层调用即为流水传输"""
@abstractmethod
def recv_kv(self, request_id: str, layer_idx: int,
timeout_ms: int = 5000) -> Optional[tuple]:
"""Consumer 侧:阻塞接收某一层的 KV"""
@abstractmethod
def finish(self, request_id: str) -> None:
"""清理该请求的传输上下文,必须调用,否则内存泄漏"""
class SimpleRdmaConnector(KVConnectorBase):
def __init__(self, role: str, peer_addr: str, num_layers: int):
self.role = role
self.num_layers = num_layers
self._agent = self._init_rdma_agent(peer_addr)
# 注册的显存区域,RDMA 要求提前 pin 住
self._registered: dict[int, "MemRegion"] = {}
self._pending: dict[str, int] = {} # request_id -> 已完成层数
def _init_rdma_agent(self, peer_addr):
# 真实实现会走 NIXL / UCX / libfabric
...
def send_kv(self, request_id, layer_idx, k_cache, v_cache, slot_mapping):
# 关键:只发 slot_mapping 指向的 block,不是整个 cache
# 这一步做错就会把整块显存都传过去,带宽直接爆炸
k_slice = k_cache[slot_mapping]
v_slice = v_cache[slot_mapping]
mr = self._registered.get(layer_idx)
if mr is None:
mr = self._agent.register_memory(k_slice, v_slice)
self._registered[layer_idx] = mr
# 异步单边写,不阻塞计算流
self._agent.write_async(
mr, remote_key=f"{request_id}:{layer_idx}",
callback=lambda: self._on_layer_done(request_id)
)
def _on_layer_done(self, request_id):
self._pending[request_id] = self._pending.get(request_id, 0) + 1
if self._pending[request_id] == self.num_layers:
# 全部层传完,通知 decode 端可以开始了
self._agent.notify(f"{request_id}:ready")
def recv_kv(self, request_id, layer_idx, timeout_ms=5000):
return self._agent.wait_for(f"{request_id}:{layer_idx}", timeout_ms)
def finish(self, request_id):
self._pending.pop(request_id, None)
self._agent.release(request_id)
三个必须注意的实现细节:
第一,k_cache[slot_mapping] 这一步。 因为 KV Cache 是分页的,一个请求的 block 在物理显存里是离散的。你必须按 slot_mapping 做 gather,只传实际占用的 block。我见过一个自研实现直接传整个 cache tensor,结果每次请求传 40GB。
第二,RDMA 需要预注册(pin)显存。 注册是有开销的(涉及页表锁定),所以要缓存 MemRegion 复用,不能每次请求都注册一遍。
第三,finish() 必须在所有异常路径上调用。 请求超时、客户端断连、decode 实例 OOM——任何一种情况下漏掉 finish,都会导致 RDMA 上下文和 pin 住的显存泄漏。这是 PD 分离生产环境最常见的稳定性问题,表现为跑几个小时后显存莫名其妙不够用。 用 try/finally 或者 context manager 强制约束。
六、第三层:KV Cache 分层存储
6.1 从「缓存」到「存储系统」
到这一步,思路要发生一次质变。
前面我们讨论的 KV Cache 都在 GPU 显存里。但既然 KV Cache 可以通过网络传给另一台机器,那它凭什么不能存到 DRAM、SSD、甚至对象存储里?
于是就有了分层存储架构:
┌─────────────────────────────────────────────────┐
│ L0: GPU HBM ~3 TB/s 80 GB 最热 │
├─────────────────────────────────────────────────┤
│ L1: 主机 DRAM ~50 GB/s 1 TB 温 │
├─────────────────────────────────────────────────┤
│ L2: 本地 NVMe SSD ~7 GB/s 10 TB 冷 │
├─────────────────────────────────────────────────┤
│ L3: 分布式对象存储 ~1 GB/s ∞ 归档 │
└─────────────────────────────────────────────────┘
这个图和 CPU 的 L1/L2/L3/内存 层次结构、和数据库的 buffer pool / 磁盘 层次结构,是同一个东西。LLM 推理引擎正在重新发明存储系统,而且是被迫的。
LMCache、Mooncake 这类项目做的就是这一层。它们把 KV Cache 从「进程内的一块显存」变成「集群级的一个存储服务」。
6.2 关键问题:什么时候读缓存比重算更慢
这是分层存储最反直觉的地方。从慢速介质加载 KV,可能比在 GPU 上重新算一遍还慢。
我们来算这个盈亏平衡点。
重算成本:prefill N 个 token 的时间。这个近似正比于 N(长序列因为 attention 的平方项会更慢,但在常见长度下线性近似够用):
recompute_ms = N / prefill_throughput * 1000
加载成本:从某层介质读 N 个 token 的 KV:
load_ms = (N × kv_bytes_per_token) / bandwidth * 1000
令两者相等,得到盈亏平衡带宽:
break_even_bandwidth = kv_bytes_per_token × prefill_throughput
注意,这个式子里 N 被消掉了。这意味着:是否值得从某层加载,与序列长度无关,只取决于介质带宽和 GPU 的 prefill 吞吐。 这是个非常干净的结论。
代入实际数字:
def break_even_bandwidth_gbps(model: ModelSpec, prefill_tps: float) -> float:
"""低于这个带宽的存储介质,加载 KV 还不如直接重算"""
bytes_per_s = model.kv_bytes_per_token() * prefill_tps
return bytes_per_s * 8 / 1e9
model = ModelSpec(32, 8, 128) # 每 token 128KB
for tps in (2000, 8000, 20000):
print(f"prefill {tps:>6} tok/s → 盈亏平衡带宽 "
f"{break_even_bandwidth_gbps(model, tps):>8.1f} Gbps")
prefill 2000 tok/s → 盈亏平衡带宽 2097.2 Gbps
prefill 8000 tok/s → 盈亏平衡带宽 8388.6 Gbps
prefill 20000 tok/s → 盈亏平衡带宽 20971.5 Gbps
2000 Gbps = 250 GB/s。 这个数字很吓人:它意味着只有 HBM(3TB/s)和高端 NVLink 能稳赢重算;DRAM(50GB/s)勉强够用;本地 NVMe(7GB/s)和对象存储(1GB/s)在纯带宽维度上是完败的。
那为什么大家还在做 SSD 层的 KV 缓存?三个原因,这三点是这套架构成立的全部理由:
- GPU 是稀缺资源,SSD 不是。 重算要占 GPU 算力,那块算力本来可以服务别的请求。加载只占 IO 带宽和一点 CPU。从集群总吞吐看,即使单请求变慢,总 goodput 可能更高。
- prefill_throughput 在高并发下会暴跌。 上面用的是空载吞吐。GPU 排队时,「重算」的实际成本包含排队时间,可能是 10 倍以上。此时加载的相对优势就出来了。
- 超长上下文的平方项。 Attention 的计算复杂度是 O(N²),而加载是 O(N)。当 N 大到一定程度(经验值 32K 以上),重算成本会非线性上涨,加载反超。
所以正确的决策不是「一律加载」或「一律重算」,而是一个运行时决策函数:
def should_load_from_tier(
n_tokens: int,
tier_bandwidth_gbps: float,
gpu_queue_depth: int, # 当前 prefill 队列深度
base_prefill_tps: float,
model: ModelSpec,
attention_quadratic_threshold: int = 32768,
) -> bool:
# 排队会稀释有效 prefill 吞吐
effective_tps = base_prefill_tps / max(1, gpu_queue_depth)
# 超长序列的平方项惩罚
if n_tokens > attention_quadratic_threshold:
penalty = (n_tokens / attention_quadratic_threshold) ** 0.5
effective_tps /= penalty
recompute_ms = n_tokens / effective_tps * 1000
load_ms = (n_tokens * model.kv_bytes_per_token()) / (tier_bandwidth_gbps * 1e9 / 8) * 1000
return load_ms < recompute_ms
model = ModelSpec(32, 8, 128)
# 空载时,SSD 加载不划算
print(should_load_from_tier(8192, 56, gpu_queue_depth=1,
base_prefill_tps=8000, model=model)) # False
# 高负载排队时,SSD 反而是对的
print(should_load_from_tier(8192, 56, gpu_queue_depth=20,
base_prefill_tps=8000, model=model)) # True
这个函数是我认为整篇文章里最有实践价值的一段代码。 大部分分层缓存方案的默认策略是「命中就加载」,这在低负载时是负优化。把 queue_depth 纳入决策,是自研时值得投入的一个小时。
6.3 KV 量化:最被低估的优化
在讨论怎么搬 KV 之前,其实还有一个更朴素的办法:让 KV 变小。
把 KV Cache 从 FP16 量化到 FP8,体积直接减半:
vllm serve Qwen/Qwen3-32B --kv-cache-dtype fp8
这一个 flag 带来的连锁收益是:
- 显存占用减半 → batch size 可以翻倍 → decode 吞吐接近翻倍
- PD 分离的传输量减半 → 前面的盈亏平衡点直接右移一倍
- 分层存储的加载时间减半
代价是精度。经验规律:
| 量化 | 体积 | 精度影响 | 建议 |
|---|---|---|---|
| FP16 → FP8 | 50% | 极小,多数任务无感 | 默认开启 |
| FP16 → INT8 | 50% | 小,需要 scale 校准 | 硬件不支持 FP8 时用 |
| FP16 → INT4 | 25% | 明显,长上下文尤其 | 只在显存极度紧张时用 |
我的建议非常直接:FP8 KV Cache 应该是 2026 年的默认配置,除非你的任务对数值精度极度敏感(比如需要精确复现的科学计算)。这是投入产出比最高的一个开关——改一个参数,等于白捡一倍显存。
而且注意它和前面所有优化的乘法关系:KV 减半 → 传输减半 → PD 分离的适用范围扩大 → 分层存储的盈亏平衡点降低。先做量化,再做架构改造,顺序反了会让你在错误的基线上做决策。
七、第四层:调度器必须变成「缓存感知路由器」
7.1 问题
假设你有 8 个推理实例,每个都有自己的前缀缓存。一个新请求进来,负载均衡器按 round-robin 分配。
结果是什么?同一个系统提示词的 KV,在 8 个实例上各存了一份,而且每次请求都可能打到没有缓存的那台。 缓存命中率被负载均衡器给稀释了 8 倍。
这不是理论问题。我见过一个线上服务,单机压测前缀命中率 88%,上了 8 实例集群之后掉到 19%。团队一开始以为是缓存实现有 bug,其实是路由策略的问题。
7.2 缓存感知路由
正确做法是让路由决策同时考虑两件事:缓存亲和性和负载均衡。
import hashlib
from dataclasses import dataclass, field
@dataclass
class InstanceState:
name: str
running_reqs: int = 0
queued_tokens: int = 0
# 该实例缓存的前缀 hash 集合(近似,由实例定期上报)
cached_prefixes: set = field(default_factory=set)
class CacheAwareRouter:
def __init__(self, instances, block_size=16,
cache_weight=1.0, load_weight=1.0):
self.instances = instances
self.block_size = block_size
self.cache_weight = cache_weight
self.load_weight = load_weight
def _prefix_hashes(self, token_ids):
"""生成链式前缀 hash 序列"""
out, prev = [], None
for i in range(0, len(token_ids) - self.block_size + 1, self.block_size):
chunk = tuple(token_ids[i:i + self.block_size])
h = hashlib.blake2b(
repr((prev, chunk)).encode(), digest_size=8
).hexdigest()
out.append(h)
prev = h
return out
def route(self, token_ids):
hashes = self._prefix_hashes(token_ids)
total_blocks = max(1, len(hashes))
best, best_score = None, float("-inf")
max_load = max(1, max(i.queued_tokens for i in self.instances))
for inst in self.instances:
# 命中长度:连续匹配的前缀 block 数
hit = 0
for h in hashes:
if h in inst.cached_prefixes:
hit += 1
else:
break # 前缀必须连续,断了就停
cache_score = hit / total_blocks # 0~1
load_score = 1 - (inst.queued_tokens / max_load) # 0~1
score = (self.cache_weight * cache_score
+ self.load_weight * load_score)
if score > best_score:
best, best_score = inst, score
return best
两个权重的调法:
cache_weight高 → 缓存命中率高,但可能出现热点实例过载load_weight高 → 负载均匀,但缓存被稀释
起步建议 1:1,然后观察两个指标:前缀命中率 和 实例间队列深度的标准差。 哪个更糟就往哪边加权重。对于 Agent 类业务(前缀重复率 90%+),我一般把 cache_weight 调到 2.0 甚至 3.0,因为命中一次省下的 prefill 时间,远大于稍微排队的代价。
7.3 cached_prefixes 怎么同步
上面代码里最脏的地方是 inst.cached_prefixes 这个集合。实例上有几十万个 block,全量同步到 router 是不现实的。
三种实用做法:
- Bloom Filter 上报。 每个实例定期(比如 1s)把自己的 block hash 集合压成一个 Bloom Filter 发给 router。10 万个 hash、1% 误判率的 BF 大概 120KB,完全可接受。误判的代价只是路由略微次优,不影响正确性。
- 只上报「热前缀」。 大部分场景下,前缀分布极度长尾——20 个系统提示词覆盖 80% 的请求。只同步 top-1000 热前缀,效果和全量同步差不多。
- 一致性哈希兜底。 对没有缓存信息的新前缀,用一致性哈希决定去哪台。这样至少保证相同前缀的请求会稳定落到同一台,缓存能自然建立起来。这一条是最省事的,很多团队只做这一条就拿到了 80% 的收益。
我的建议:先上第 3 条(一致性哈希),跑一周看数据,不够再上第 1 条。 不要一开始就搞 Bloom Filter 同步,复杂度和收益不匹配。
八、性能优化清单(可直接执行)
按投入产出比排序,从上往下做:
| 优先级 | 优化项 | 典型收益 | 成本 |
|---|---|---|---|
| P0 | 开启前缀缓存(重复率>30% 时) | TTFT 降 30-90% | 一个 flag |
| P0 | KV Cache 量化到 FP8 | 显存减半,batch 翻倍 | 一个 flag |
| P0 | 调 max-num-batched-tokens | ITL/TTFT 平衡 | 压测 2 小时 |
| P1 | Chunked Prefill | P99 ITL 大幅平滑 | 一个 flag |
| P1 | 缓存感知路由(一致性哈希版) | 集群命中率恢复到单机水平 | 半天开发 |
| P1 | GQA / MQA 模型选型 | KV 体积降 4-8 倍 | 换模型 |
| P2 | PD 分离(先算盈亏方程!) | 特定负载 goodput +50-90% | 一周 + RDMA 网络 |
| P2 | 逐层流水传输 | 传输时间隐藏 80% | 引擎改造 |
| P3 | DRAM 层 KV 卸载 | 长上下文重复请求受益 | 中等 |
| P3 | SSD/对象存储层 | 只在高排队时有正收益 | 高,慎入 |
几条参数调优的经验规律:
gpu-memory-utilization从 0.90 起步,不要上 0.95。 留出的空间是给 CUDA graph、通信 buffer 和碎片的。0.95 在压测时能过,线上遇到长请求就 OOM。max-num-seqs和max-num-batched-tokens要联动看。 前者限制请求数,后者限制 token 数。只调一个的话,另一个会变成隐藏的瓶颈,表现为「GPU 没打满但吞吐上不去」。- 先量化再分离。 前面说过,KV 量化会改变 PD 分离的盈亏平衡点。顺序反了,你会基于错误的基线做出错误的架构决策。
- 压测必须用真实的 prompt 长度分布。 用固定长度压测得到的最优参数,在真实长尾分布下往往是次优的。至少要造一个符合线上 P50/P90/P99 长度的混合负载。
九、十条踩坑清单
这部分是血泪,每一条都对应一次线上问题。
1. 前缀 hash 没做链式,静默输出错误结果。
第三节讲过。这个 bug 不崩溃、不报错,只是输出变得「有点怪」。可能上线三个月才被用户投诉发现。自研引擎必须写单测:构造两个前缀不同、中间 block token 相同的请求,断言输出不同。
2. hash 命中后没做全量 token 比对。
64 位 hash 在千万 block 规模下会碰撞。加 16 次整数比较的成本,换正确性,无脑做。
3. PD 分离忘了在异常路径调 finish()。
RDMA 上下文泄漏,pin 住的显存不释放。现象是服务跑 6-12 小时后显存莫名不足,重启就好。排查时容易误判为「内存泄漏」去查 Python 对象,实际在 RDMA 层。用 context manager 强制约束。
4. 传输时传了整个 KV tensor 而不是 slot_mapping 指向的 block。
带宽瞬间打满,网络团队来找你喝茶。
5. 长输入短输出场景上了 PD 分离。
第五节的公式。RAG、Rerank、分类打标这类业务,PD 分离是纯负优化。
6. 用 TCP 做 KV 传输然后抱怨 PD 分离没用。
内核态拷贝 + TCP 拥塞控制,有效带宽可能只有标称的 40%,而且抖动极大。PD 分离的前提是 RDMA,这不是可选项。
7. 多实例部署忘了缓存感知路由。
命中率被均分稀释。第七节。
8. gpu-memory-utilization 设太高,长请求触发 OOM 抢占雪崩。
一个请求 OOM → 触发抢占 → 被抢占的请求要重算 → 加剧显存压力 → 更多 OOM。这是个正反馈,能把整个实例打死。保守设 0.88-0.90。
9. 前缀缓存在无状态业务上开着。
纯一次性请求(翻译、单轮摘要)前缀重复率接近 0,缓存管理开销白白吃掉 3-5% 的性能。测一下你的重复率再决定开不开。
10. 只看 throughput 不看 goodput,优化了半天用户体验反而变差。
第二节。这是最贵的一个坑,因为它会让你在错误的方向上持续投入好几个月。
十、总结与展望
这套体系的核心逻辑
回头看,从 PagedAttention 到分层缓存,整条演进路径其实只有一句话:
把「注意力状态」从一个进程内的临时变量,逐步升级为一个可寻址、可复用、可迁移、可持久化的分布式存储对象。
每一步都在解决上一步暴露的新瓶颈:
- 显存碎片 → 分页(PagedAttention)
- 重复计算 → 内容寻址 + 前缀树(Prefix Cache)
- 负载冲突 → 时间片轮转(Chunked Prefill)→ 物理隔离(PD 分离)
- 隔离带来的传输成本 → RDMA + 逐层流水
- 显存容量墙 → 分层存储(DRAM/SSD/对象存储)
- 集群缓存稀释 → 缓存感知路由
这个演进序列,和分布式数据库过去二十年走过的路几乎一模一样:单机 buffer pool → 分页管理 → 读写分离 → 主从复制 → 分层存储 → 一致性哈希分片。
所以如果你有存储或数据库背景,你在这个领域的知识迁移成本极低。 反过来说,如果你只懂模型不懂系统,那 2026 年推理侧的大部分工作你是做不动的——这已经是一个彻头彻尾的系统工程问题。
三个值得押注的方向
第一,KV Cache 会变成一个独立的基础设施层。 就像对象存储从数据库里独立出来一样,全局 KV 存储服务会成为标准组件——多个推理集群共享一个 KV 池,跨模型(同架构)共享前缀,按热度自动分层。LMCache、Mooncake 这些项目现在做的事,三年后可能就是云厂商的一个标准产品。
第二,KV Cache 压缩会成为主战场。 目前主流的做法还是简单量化(FP8/INT8),但 KV Cache 里存在大量冗余:注意力权重呈现明显的稀疏性,很多历史 token 对后续生成几乎没有贡献。「按注意力重要性做有损压缩」——只保留高注意力权重的 token 的 KV,其余的丢弃或用低秩近似——这个方向的理论收益比量化大得多,是 4 倍还是 10 倍取决于任务,但目前工程成熟度还不够。这是接下来两年最值得盯的技术点。
第三,调度会从「请求级」下沉到「block 级」。 现在的调度器还是以请求为单位做决策。未来的调度器会直接以 KV block 为调度对象:这个 block 放 HBM 还是 DRAM、这个请求的哪几个 block 需要预取、哪些 block 可以跨请求共享。本质上就是给 GPU 写一个虚拟内存管理器,包括预取、换页、工作集估计这一整套。CPU 的 MMU 花了几十年演化出来的东西,GPU 侧现在正在快速补课。
最后一句
推理优化这件事,最容易犯的错误是盯着单点指标使劲——把某个 kernel 优化了 30%,结果端到端延迟纹丝不动,因为瓶颈根本不在那儿。
正确的姿势是先建立成本模型:算清楚你的 KV 每 token 多少字节,算清楚你的传输带宽够不够,算清楚你的盈亏平衡点在哪儿。这些公式都不难,难的是有耐心在动手之前先算一遍。
本文给出的几个计算函数(pd_split_gain_ms、break_even_bandwidth_gbps、should_load_from_tier)都可以直接拿去改改就用。把你自己的模型参数和硬件参数代进去,你会发现很多"业界最佳实践"在你的场景下根本不成立。
这才是工程师该干的事:不是抄配置,是算账。