从一次请求看懂 LLM 推理:Prefill、KV Cache 与 Prefix Cache
用一次请求的完整旅程串起 LLM 推理的核心概念:Prefill/Decode、KV Cache 容量估算、GQA、Prefix Cache、延迟指标,以及为什么开了缓存 P99 反而可能变差。
从后端接口看一次生成请求,很容易把整段等待都记成“模型耗时”。但同样是响应慢,可能发生在排队、输入计算,也可能发生在后续逐 token 生成;开启缓存又只改变其中一部分工作。
假设你把一段长文档发给远端 GPU 服务,想要一份简短摘要。服务先处理整段输入,再逐步生成回答;第二次发来相同文档时,其中一部分计算可能复用。下面沿这条请求,把 Prefill、KV Cache、前缀复用和延迟指标放回各自发生的位置。讨论默认使用标准因果注意力的自回归模型。
一、一次请求的旅程
客户端组装上下文后,请求通常要经过服务端的输入准备、调度和模型计算。下图用逻辑角色划分工作;采样所在位置和各阶段实现会随引擎变化。
请求发出前,客户端先决定把哪些内容放进上下文,这会影响 prompt 的长度和成本。像 Claude Code、Codex 这类 Agent 工具,一次用户任务往往还会往返多次模型请求。
文本进入服务后,要先转换成 token IDs,模型处理的是这些编号;输出再经过 detokenize 转回文本。调度、索引和分配管理通常由 CPU 侧逻辑承担,张量计算主要在 GPU 上进行,采样和传输等环节的具体位置则取决于实现。
这里的 CPU/GPU 都指服务端硬件。远端推理时,你自己的电脑通常负责文本处理和界面渲染;本地运行模型才会改变这层分工。
二、Prefill 与 Decode 分别处理什么
Prefill 处理输入,Decode 依赖此前输出逐步生成;两者都可以在多个请求之间批处理。
| 对比项 | Prefill(读题) | Decode(写答案) |
|---|---|---|
| 处理对象 | 输入 token,可以整段或分块调度 | 每步处理最新 token,产生后续输出 |
| 并行性 | prompt 内各位置可并行 | 后一个 token 依赖前一个,必须串行 |
| 计算形态 | 输入位置较多,通常有较高计算强度 | 大量小步运算,频繁读取历史 KV |
| 常见瓶颈 | 计算量、调度 | 显存带宽、调度、KV 容量 |
| 对应指标 | TTFT 的计算部分(还包含排队等开销) | TPOT / ITL(每 token 耗时) |
单个请求的 Decode 无法一次吐出全部答案,但服务端可以把许多请求的”当前这一步”合成一个 batch 一起算,这是提升吞吐的关键手段。
所以,看到第一个 token 等了很久,还不能直接把整段时间算给 Prefill。TTFT 还包含此前的排队、缓存查找和首 token 采样。
三、KV Cache:用显存换算力
它是什么
Attention 计算中,每个 token 会产生 Query、Key、Value。生成下一个 token 时,当前 token 的 Query 需要与可见历史及当前位置的 Key/Value 进行注意力计算。
如果没有缓存,每生成一个 token 都要把”prompt + 已生成内容”整段重新算一遍。历史长度从 1000 变成 1001,就要把前 1000 个 token 的隐藏状态和 K/V 重算一次。
KV Cache 的做法是:把每一层、每个历史 token 的 K 和 V 存起来,下一步只为新 token 计算新的 Q/K/V,再把新的 K/V 追加进去。
KV Cache 减少了历史表示的重复计算。全注意力下,当前 Query 仍需访问历史 K/V;上下文变长时,读取量会增长,实际延迟还取决于批处理、内核和硬件。
它的代价
省下重复计算之后,代价转到了缓存的保存和读取上。层数、上下文长度、KV head 数和每元素字节数增加,所需显存也线性增加;全注意力 Decode 每一步还要读取历史 K/V,因而消耗带宽。
这些空间也需要引擎分配、回收和分页,处理碎片。回到长文档摘要的例子,单条请求保留的上下文越多,同一块 GPU 留给其他请求的空间就越少,并发也会受到限制。
容量怎么估
单个请求的 KV Cache 大小:
| 符号 | 含义 |
|---|---|
| K 和 V 两份 | |
| 层数 | |
| 序列长度(prompt + 已生成 token) | |
| KV head 数 | |
| 每个 head 的维度 | |
| 每个元素的字节数(BF16/FP16 为 2) |
例如,取 ,BF16:
batch 里有 2 个请求,就是 192 MiB。这是理想值,真实引擎还有 block 取整、metadata 和碎片带来的额外开销。
由公式可以直接得出几个结论:
- S 翻倍,KV 线性翻倍(96 → 192 MiB),不是 4 倍。Prefill 的计算量里确实有 S² 项,但 KV 容量只随 S 线性增长。
- Hkv 从 4 变 16,KV 变为 4 倍(96 → 384 MiB)。
- 上下文越长越贵。 同样的模型把 S 换成 1,000,000,约 96 MiB × (1,000,000 / 2048) ≈ 45.8 GiB / 请求。这是”超长上下文”天然昂贵的原因,和是否开启缓存无关。
为什么 K/V 的 head 比 Q 少:MHA、MQA、GQA
从公式可以看到,Hkv 直接决定缓存大小。于是出现了几种设计:
| 方案 | Query head | KV head | 特点 |
|---|---|---|---|
| MHA(多头注意力) | H | H | 每个 Query head 对应独立 KV;同层数和维度下 KV 较多 |
| MQA(多查询注意力) | H | 1 | 同层数和维度下 KV 较少;质量取决于模型与训练 |
| GQA(分组查询注意力) | H(如 16) | G(如 4) | 折中:每组 Query head 共享一份 K/V |
GQA 把 Hq 个 Query head 平均分成 Hkv 组,组内共享 K/V。上面的例子里 Hq=16, Hkv=4,每 4 个 Query head 共用一份 K/V,缓存只有 MHA 的四分之一。具体模型的分组数量应查看配置,不能仅凭系列名称推断。
对应的 shape 是(布局 [batch, heads, seq, head_dim],B=2):
Q: [2, 16, 2048, 128]
K: [2, 4, 2048, 128]
V: [2, 4, 2048, 128]
这里采用 [B, H, S, Dh] 便于展示批量矩阵乘法;实现也可以使用其他布局。转置后可表示相同张量,实际性能取决于内存布局与内核。
Prefill 阶段的 attention scores 形状是 [B, Hq, S, S]。即使 K 只有 4 个 head,scores 的 head 维仍是 16,因为每个 KV head 会被它所在组的所有 Query head 使用。这个矩阵有 2×16×2048×2048 ≈ 1.3 亿个元素,BF16 完整物化约 256 MiB,所以 FlashAttention 一类的 fused kernel 会避免把它整个写回显存。
缓存放在哪、谁来管
本篇讨论的远端 GPU 服务通常把活跃 KV 放在服务端显存。客户端负责请求编排和显示,不直接管理这部分缓存。显存不够时,部分系统会把冷数据分级放到 CPU 内存甚至远端存储(称为 tiered KV cache 或 offloading),这些层级的容量与传输成本不同,需要按硬件和连接方式评估。
推理引擎管理缓存的分配和回收。以采用分页 KV 的实现为例,CPU 侧维护块映射,GPU 内核依据映射访问数据:
| 时机 | 典型管理动作 |
|---|---|
| 请求到达 | 分配固定大小的 KV block,记录逻辑位置与物理块的映射 |
| 请求生成 | 按增长的缓存长度申请新块 |
| 请求结束 | 释放请求持有的引用;可复用前缀是否保留取决于缓存策略 |
| 容量紧张 | 选择可驱逐的缓存块;活跃请求也可能需要抢占或重算 |
固定大小的 block 带来的好处是:不会出现”需要一大块连续显存却找不到”的外部碎片,仍有末块未填满和管理元数据等开销。这就是 PagedAttention 解决的问题。
CPU 只管”账本”(选哪个 block、更新 block table),真正读写 K/V 的是 GPU kernel。这有点像操作系统维护页表、由内存控制器执行读写。
这里很容易与上下文工程中的 compact 混淆。分页 KV 管理发生在引擎内部,改变的是已计算张量的存放与映射,逻辑 token 序列保持不变。Compact 则发生在应用层,用摘要替换旧对话,模型收到的文本会随之改变。若采用量化或其他近似缓存方案,还要单独判断它是否影响结果,不能把分页的结论直接套过去。
四、缓存家族:KV Cache、Prefix Cache、Semantic Cache
现在再发送一次相同文档,系统可能复用的是前缀 KV;若直接返回一份以前的回答,复用的对象又不同。下面把三类常见缓存放在一起,先看它们分别保存什么:
| 类型 | 缓存什么 | 复用范围 | 省掉什么 | 主要风险 |
|---|---|---|---|---|
| KV Cache | 当前序列各层的历史 K/V | 通常是单个请求内的连续 Decode | 历史 token 的重复计算 | 显存容量、读取带宽 |
| Prefix Cache(APC) | 已算过的、完全兼容的共享前缀的 K/V | 可跨请求 | 共享前缀的重复 Prefill | 必须精确匹配并满足兼容条件 |
| Semantic Cache | 相似问题对应的答案或中间结果 | 语义相近的请求 | 可能整次推理都省掉 | 相似不等于答案相同 |
Prefix Cache 的几个关键事实
文档看起来一样,只是复用的起点。引擎还要确认它对应同一套兼容的计算条件,找到缓存,再完成没有命中的输入和后续输出。沿这个过程看,下面几个限制就有了具体位置:
- 不是模糊匹配。 肉眼看起来一样的两段文字,只要 tokenizer、chat template、模型版本、位置编码、LoRA 或 token IDs 有一处不兼容,就不能复用。
- 引擎通过哈希或树索引等方式寻找可复用的前缀,具体逻辑随实现变化。这一步有查找与管理成本,但通常不需要先重算命中前缀的注意力。准备缓存、处理剩余输入和产生首 token 仍可能需要工作。
- 跨请求复用靠的是索引。 物理上它和 KV Cache 一样在服务端显存里,“可跨请求”是因为引擎维护了一份多个请求都能查的索引。但两个请求若落在互不知情的两台机器上,就无法复用,除非做了 cache-aware 路由或分布式 KV 共享。
- 命中不等于”零算力”。 它复用的是前缀 K/V,不缓存最终答案。没命中的 prompt 部分和后续的生成仍需计算。
- 它主要改善 TTFT,不减少输出。 答案在请求到来时还不存在,Decode 仍要逐步执行:
这里先以最后一个内容 token 可见为终点,记为 E2E_token。答案很长时,Decode 占这段延迟的大头,即使 TTFT 大幅下降,整体响应时间的改善也可能不明显。长且重复的前缀、较短的输出和对首 token 延迟的关注,使前缀复用更有机会体现收益。Agent 或 RAG 的名称本身不保证请求具备这些特征。
PagedAttention 与 APC 不是一回事
PagedAttention = KV 的内存管理机制(放在哪里、怎么管)
APC = 共享前缀的计算复用策略(哪些已算过的可以再用)
两者经常搭配:APC 可以引用 PagedAttention 管理的 block。但启用 PagedAttention 不代表自动有前缀复用,APC 也不能简单理解成”分页”。
Semantic Cache 的适用边界
它适合问题基本无状态、答案不依赖个人上下文的场景,比如”如何申请退款”这类 FAQ。多轮对话、依赖用户订单或会话状态的场景里,两句话再像,正确答案也可能完全不同。常见的缓解办法:
- 把用户 ID、会话状态、检索到的文档摘要等也纳入缓存 key。
- 只缓存中间结果(检索候选、子任务输出),最终答案结合当前上下文重新生成。
- 设置较短的有效期,或触发重新校验。
五、长 prompt 会被”分片”处理,模型还能看到全貌吗
Prompt 很长(比如贴了几千行代码)时,支持并启用分块策略的引擎可以用 Chunked Prefill:把输入按 token 顺序分成几个计算块,在调度中与其他请求的 Decode 穿插,以控制长输入对其他请求的等待影响。
这不会让模型只看到片段:
- 每一段仍按原始顺序,使用正确的位置编码和 causal mask;
- 后一段计算时能看到前面所有段已算好的 K/V;
- 全部跑完后,KV Cache 里保存的是完整 prompt 的 K/V,与一次性整段 Prefill 在数学上等价(实际有浮点误差)。
Decode 的每一步也是如此,attention 读取的是从第一个 prompt token 到当前位置的全部历史:
其中 、 是从第一个 prompt token 到当前位置的全部 Key/Value。
真正可能导致”没理解全貌”的是另外三件事:
- 超过 context window,超出的部分被截断或摘要;
- 客户端发送前做了压缩,模型看到的本来就是摘要;
- “lost in the middle”:上下文很长时,模型对中间内容的关注度可能弱于开头和结尾。这是模型能力问题,而非系统故意丢数据。
另外,“参与了计算”不等于”被可靠使用”。宣称的上下文长度、服务实际配置的上限和可用显存,是三件需要分别确认的事。
六、衡量快慢:几个必须分清的指标
在同一个观测位置,设请求开始计时时刻为 ,首个输出 token 可见时刻为 ,最后一个 token 可见时刻为 。客户端与服务端的口径可能不同,比较前需统一。 本文的 E2E_token 到最后一个内容 token 可见为止;推理压测另用 E2E_stream 表示流正常结束。两者之差是协议尾部时间 ,不能把流结束时间代入下面的逐 token TPOT 公式。
| 指标 | 含义 | 用户感受 |
|---|---|---|
| TTFT | t1 − t0,首 token 延迟 | 多久能看到第一个字 |
| ITL | 相邻两个输出 token 的间隔 | 输出是否流畅、有没有卡顿 |
| TPOT | 首 token 之后平均每个 token 的耗时 | 持续生成速度 |
| E2E_token(末 token 可见) | tN − t0 | 要等多久才拿到完整答案 |
| Throughput | 单位时间完成的工作量 | 系统整体处理能力 |
使用上述同一组时间戳,且输出 个 token 时:
Throughput 必须写清口径:output tokens/s、total tokens/s 还是 requests/s,不能只写”吞吐”。
三组常见 trade-off
- 算力 vs 显存:缓存用显存换掉重复计算,但缓存占得太多会压低并发。注意这里的”算力”和”显存”都是同一块 GPU 内部的两种资源,不涉及 CPU。
- 延迟 vs 吞吐:等凑成更大的 batch 能让 GPU 更满,总吞吐更高,但每个请求的排队时间和 TTFT 可能变长。
- 局部性 vs 负载均衡:把相同前缀的请求路由到已有缓存的 worker,命中率更高,但可能造成热点,拖累排队和尾延迟。
优化一个指标后,需要检查排队、缓存容量和其他阶段的延迟,确认成本是否发生转移。
七、动手读一段代码:causal attention 与 cache 的位置
下面是一个朴素的单头 causal attention,用来看清 shape、mask 和 cache 该放在哪:
import math
import torch
from torch import nn
class CausalSelfAttention(nn.Module):
def __init__(self, model_dim: int, head_dim: int):
super().__init__()
self.q_proj = nn.Linear(model_dim, head_dim, bias=False)
self.k_proj = nn.Linear(model_dim, head_dim, bias=False)
self.v_proj = nn.Linear(model_dim, head_dim, bias=False)
self.out_proj = nn.Linear(head_dim, model_dim, bias=False)
def forward(self, x: torch.Tensor):
# x: [B, S, D]
q = self.q_proj(x)
k = self.k_proj(x)
v = self.v_proj(x)
scores = q @ k.transpose(-2, -1)
scores = scores / math.sqrt(q.size(-1))
seq_len = x.size(1)
causal_mask = torch.tril(
torch.ones(seq_len, seq_len, dtype=torch.bool, device=x.device)
)
scores = scores.masked_fill(~causal_mask, float("-inf"))
weights = torch.softmax(scores, dim=-1)
context = weights @ v
return self.out_proj(context), weights
各步骤的 shape:
x: [B, S, D]
q, k, v: [B, S, Dh]
k.transpose(-2,-1): [B, Dh, S]
scores / weights: [B, S, S]
causal_mask: [S, S] (对 batch 维广播)
context: [B, S, Dh]
out_proj(context): [B, S, D]
先看 causal mask。torch.tril 保留主对角线及以下,因此第 i 个 token 只能看到位置 0..i。其余位置填成 -inf,经过 softmax 后概率变为 0,后面的 token 就不会参与当前位置的注意力结果。
再看缓存应该接在哪里:K/V 投影之后,计算 scores 之前。这样新 Query 就能同时读取历史缓存和本轮新算出的 K/V:
1. 为当前新 token 计算 q_new、k_new、v_new
2. 从 cache 读出 past_k、past_v
3. k_all = concat(past_k, k_new),v_all = concat(past_v, v_new)(沿序列维)
4. 用 q_new 与 k_all、v_all 计算当前输出
5. 把 k_new、v_new 追加回本层 cache
为什么只缓存 K/V,不缓存 Q?因为下一步需要的是”新 query 对所有历史 K/V”,历史的 Query 之后不会再被用到。真实模型里每一层都有各自的 K/V Cache。
这段教学代码未实现 KV Cache,也没有针对长序列优化:
- 每次 forward 都新建
[S, S]的 mask,并物化[B, S, S]的 scores 和 weights,元素数随 S² 增长,长序列时显存和带宽都吃紧。可以复用 mask buffer,或直接用 PyTorch SDPA、FlashAttention 这类 fused kernel。 - 若直接拿它做自回归生成,每步都要把整段序列重新送进来,等于没有 KV Cache。
八、案例:开了 Prefix Cache,平均 TTFT 降了,P99 却升了
用一个假设负载推导排查顺序:8k 的 system prompt、64-token 的回答、70% 的请求共享前缀;在可比的测量中,启用 Prefix Cache 后平均 TTFT 下降,P99 上升。共享比例不等于实际缓存命中率。
这并不矛盾
命中请求的改善可能拉低平均值。在同一组请求上,如果一部分变快、其余保持不变,延迟分位数不会上升。若实际测量的 P99 变差,应先排除负载差异、计时口径和采样波动,再检查缓存带来的额外成本或系统交互。可以调查以下情况:
- 未命中路径的额外成本:未命中请求仍需处理输入,还可能增加查找或管理开销。命中与未命中形成两个群体本身不足以解释 P99 上升。
- 长前缀挤占 KV 容量:保留大量 8k 前缀引起 eviction、碎片、preemption 或重算,少量冷请求在内存压力下明显变慢。
- 局部性造成负载倾斜:为了提高命中率,请求被集中路由到持有热前缀的 worker,这些 worker 排队变长,其他 worker 却相对空闲。
具体系统中还可能是 cache 锁竞争、跨节点 KV 传输或观测开销。结论要靠分阶段数据验证,不能凭现象断言。
优先检查哪 5 项
- 命中情况:请求命中率、按 token 加权的命中率、实际复用的 token 数,并把 hit / partial hit / miss 分开统计。
- TTFT 分阶段分位数:p50/p95/p99,观察排队、缓存准备、未复用输入计算和首 token 采样/返回。
- 队列与调度:队列深度、并发数、batch size、调度等待、preemption/重算次数。
- KV 内存:占用率、空闲 block、分配延迟、碎片、eviction 数和被驱逐的 token 数。
- 逐 worker 的负载与命中率:看 cache-aware 路由是否造成了热点。
同时要观察 TPOT/ITL、E2E 和 goodput,避免 TTFT 的改善只是把瓶颈转移到 Decode 或 SLO 违约上。
如何设计对照实验
做一个最小的 cache off/on × cold/warm 受控实验:
| 组别 | Prefix Cache | 初始状态 | 目的 |
|---|---|---|---|
| A | off | cold | 无缓存基线 |
| B | off | warm | 分离模型/内核 warmup 的影响 |
| C | on | cold(清空) | 测缓存功能本身和首次填充开销 |
| D | on | 已预热相同前缀 | 测真实命中收益和尾延迟 |
要固定的变量:模型、权重、tokenizer、chat template、引擎版本、硬件;同一份请求 trace(8k 输入、64-token 输出、70% 共享比例、相同的前缀热度分布);相同的到达时间表、并发准入上限、采样参数、seed 和停止条件,并记录实际到达率与活跃并发;相同的 worker 数和路由策略。每组 warmup 后至少重复 3 次,保存全部原始请求时间戳,不要只报最好的一次。
分析时:
- 同时报告平均值、p50、p95、p99;
- 把 hit、partial hit、miss 分桶比较 TTFT;
- 按每个请求观察排队、缓存准备、未复用输入计算和首 token 采样/返回;
- 对比逐 worker 的队列和负载,看 eviction/preemption 是否只在 cache-on 时出现;
- 再做并发度扫描,找到 P99 开始恶化的饱和点。
判读思路:hit 请求很快但 miss 请求因 eviction 变慢,是容量问题;只有持有热前缀的 worker 队列暴涨,是局部性与负载均衡问题;所有 cache-on 请求都多出固定的查表时间,就该检查缓存实现的开销。
用户能感受到吗
- 多数用户(命中的那部分)会明显感觉首字更快。
- token 数量并没有减少。是否更便宜取决于服务商的计费,需按实际服务商当时的缓存计费规则判断,不能从延迟变化推导费用变化。
- P99 变差同样有体感:尾部请求就是用户眼里的”这次特别慢”。多步任务(比如 Agent 一次调用十几次模型)更容易撞上慢调用。假设各次慢调用独立、概率均为 1%,10 次里至少遇到一次的概率约为 (实际调用可能相关)。因此可以把与体验有关的尾延迟纳入 SLO,并同时报告平均值与分位数。
九、读懂 P50、P95、P99
分位数回答的是:把一段时间内所有请求的延迟从快到慢排好,第 X% 位置上的值是多少?
| 指标 | 含义 |
|---|---|
| P50(中位数) | 排序后 50% 位置的延迟,反映典型体验 |
| P95 | 排序后 95% 位置的延迟,观察较慢请求 |
| P99 | 排序后 99% 位置的延迟,用来观察尾部体验 |
| P99.9 | 极端尾部,多用于长尾故障排查 |
计算方式:N 个延迟排序后,P99 取第 个(不同工具在取整和插值上略有差异,跨工具比较要统一口径)。注意,P99 上升只能说明尾部变慢了,并不意味着”恰好 1% 的请求受到影响”。
只报平均值时,下面几种差异很容易被隐藏:
- 平均值会被多数正常请求稀释。99 个请求耗时 100 ms、1 个请求耗时 10 s,均值约 199 ms,看着不错,但那一位用户等了 10 秒。
- 均值相同,体验可能天差地别。“所有请求稳定在 280 ms”和”70% 是 100 ms、30% 是 700 ms”的均值相同,但后者会被抱怨”时快时慢”。
- 均值下降而 P95/P99 上升,只能说明这些统计位置的变化不同,不能据此断定分布变成双峰。直方图和逐请求记录才能帮助确认哪些群体变快、哪些变慢。
汇总多台机器的结果时,还需要保留同样的统计口径。P95/P99 应根据原始逐请求时间戳计算,不能先给每台机器或每个时间窗口算一个 P99,再对它们取平均。各自分位数的平均值,并不等于合并全部数据后重新计算的分位数。
继续阅读
容量计算可单独查看 KV Cache 显存估算,批处理见 Prefill 与 Decode,命中率与计时口径见 Prefix Cache 与 TTFT。
继续沿系统层面展开,可以看连续批处理与分页 KV、缓存局部性与路由和推理压测。