ELL3N HSU

Available in Chinese only.

← 文章

从一次请求看懂 LLM 推理:Prefill、KV Cache 与 Prefix Cache

用一次请求的完整旅程串起 LLM 推理的核心概念:Prefill/Decode、KV Cache 容量估算、GQA、Prefix Cache、延迟指标,以及为什么开了缓存 P99 反而可能变差。

从后端接口看一次生成请求,很容易把整段等待都记成“模型耗时”。但同样是响应慢,可能发生在排队、输入计算,也可能发生在后续逐 token 生成;开启缓存又只改变其中一部分工作。

假设你把一段长文档发给远端 GPU 服务,想要一份简短摘要。服务先处理整段输入,再逐步生成回答;第二次发来相同文档时,其中一部分计算可能复用。下面沿这条请求,把 Prefill、KV Cache、前缀复用和延迟指标放回各自发生的位置。讨论默认使用标准因果注意力的自回归模型。

一、一次请求的旅程

客户端组装上下文后,请求通常要经过服务端的输入准备、调度和模型计算。下图用逻辑角色划分工作;采样所在位置和各阶段实现会随引擎变化。

客户端发送上下文,服务端进行鉴权、分词与调度,GPU 执行 Prefill 并返回首个 token,之后逐步解码并流式返回,直到停止条件成立。客户端发送上下文,服务端进行鉴权、分词与调度,GPU 执行 Prefill 并返回首个 token,之后逐步解码并流式返回,直到停止条件成立。
图:远端推理的简化时序。省略缓存命中分支和调度重叠;客户端若执行工具调用,会把结果加入上下文后发起新请求。

宽图可横向滚动。查看原图查看原图

请求发出前,客户端先决定把哪些内容放进上下文,这会影响 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 大小:

KV bytes=2×L×S×Hkv×Dh×p\text{KV bytes} = 2 \times L \times S \times H_{kv} \times D_h \times p
符号含义
22K 和 V 两份
LL层数
SS序列长度(prompt + 已生成 token)
HkvH_{kv}KV head 数
DhD_h每个 head 的维度
pp每个元素的字节数(BF16/FP16 为 2)

例如,取 L=24, S=2048, Hkv=4, Dh=128L=24,\ S=2048,\ H_{kv}=4,\ D_h=128,BF16:

2×24×2048×4×128×2=100,663,296 bytes=96 MiB / 请求2 \times 24 \times 2048 \times 4 \times 128 \times 2 = 100{,}663{,}296\ \text{bytes} = 96\ \text{MiB / 请求}

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 headKV head特点
MHA(多头注意力)HH每个 Query head 对应独立 KV;同层数和维度下 KV 较多
MQA(多查询注意力)H1同层数和维度下 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 仍要逐步执行:
E2Etoken≈TTFT+Decode 时间\mathrm{E2E}_{\text{token}} \approx \text{TTFT} + \text{Decode 时间}

这里先以最后一个内容 token 可见为终点,记为 E2E_token。答案很长时,Decode 占这段延迟的大头,即使 TTFT 大幅下降,整体响应时间的改善也可能不明显。长且重复的前缀、较短的输出和对首 token 延迟的关注,使前缀复用更有机会体现收益。Agent 或 RAG 的名称本身不保证请求具备这些特征。

PagedAttention 与 APC 不是一回事

PagedAttention = KV 的内存管理机制(放在哪里、怎么管)
APC            = 共享前缀的计算复用策略(哪些已算过的可以再用)

两者经常搭配:APC 可以引用 PagedAttention 管理的 block。但启用 PagedAttention 不代表自动有前缀复用,APC 也不能简单理解成”分页”。

Semantic Cache 的适用边界

它适合问题基本无状态、答案不依赖个人上下文的场景,比如”如何申请退款”这类 FAQ。多轮对话、依赖用户订单或会话状态的场景里,两句话再像,正确答案也可能完全不同。常见的缓解办法:

  1. 把用户 ID、会话状态、检索到的文档摘要等也纳入缓存 key。
  2. 只缓存中间结果(检索候选、子任务输出),最终答案结合当前上下文重新生成。
  3. 设置较短的有效期,或触发重新校验。

五、长 prompt 会被”分片”处理,模型还能看到全貌吗

Prompt 很长(比如贴了几千行代码)时,支持并启用分块策略的引擎可以用 Chunked Prefill:把输入按 token 顺序分成几个计算块,在调度中与其他请求的 Decode 穿插,以控制长输入对其他请求的等待影响。

这不会让模型只看到片段:

  • 每一段仍按原始顺序,使用正确的位置编码和 causal mask;
  • 后一段计算时能看到前面所有段已算好的 K/V;
  • 全部跑完后,KV Cache 里保存的是完整 prompt 的 K/V,与一次性整段 Prefill 在数学上等价(实际有浮点误差)。

Decode 的每一步也是如此,attention 读取的是从第一个 prompt token 到当前位置的全部历史:

ot=softmax⁡ ⁣(qtK1:t⊤dk)V1:to_t = \operatorname{softmax}\!\left(\frac{q_t K_{1:t}^{\top}}{\sqrt{d_k}}\right) V_{1:t}

其中 K1:tK_{1:t}、V1:tV_{1:t} 是从第一个 prompt token 到当前位置的全部 Key/Value。

真正可能导致”没理解全貌”的是另外三件事:

  1. 超过 context window,超出的部分被截断或摘要;
  2. 客户端发送前做了压缩,模型看到的本来就是摘要;
  3. “lost in the middle”:上下文很长时,模型对中间内容的关注度可能弱于开头和结尾。这是模型能力问题,而非系统故意丢数据。

另外,“参与了计算”不等于”被可靠使用”。宣称的上下文长度、服务实际配置的上限和可用显存,是三件需要分别确认的事。

六、衡量快慢:几个必须分清的指标

在同一个观测位置,设请求开始计时时刻为 t0t_0,首个输出 token 可见时刻为 t1t_1,最后一个 token 可见时刻为 tNt_N。客户端与服务端的口径可能不同,比较前需统一。 本文的 E2E_token 到最后一个内容 token 可见为止;推理压测另用 E2E_stream 表示流正常结束。两者之差是协议尾部时间 tend−tNt_{end}-t_N,不能把流结束时间代入下面的逐 token TPOT 公式。

指标含义用户感受
TTFTt1 − t0,首 token 延迟多久能看到第一个字
ITL相邻两个输出 token 的间隔输出是否流畅、有没有卡顿
TPOT首 token 之后平均每个 token 的耗时持续生成速度
E2E_token(末 token 可见)tN − t0要等多久才拿到完整答案
Throughput单位时间完成的工作量系统整体处理能力

使用上述同一组时间戳,且输出 N>1N>1 个 token 时:

TPOT=tN−t1N−1=E2Etoken−TTFTN−1\text{TPOT} = \frac{t_N - t_1}{N - 1} = \frac{\mathrm{E2E}_{\text{token}} - \text{TTFT}}{N - 1} E2Etoken=TTFT+∑i=2NITLi\mathrm{E2E}_{\text{token}} = \text{TTFT} + \sum_{i=2}^{N} \text{ITL}_i

Throughput 必须写清口径:output tokens/s、total tokens/s 还是 requests/s,不能只写”吞吐”。

三组常见 trade-off

  1. 算力 vs 显存:缓存用显存换掉重复计算,但缓存占得太多会压低并发。注意这里的”算力”和”显存”都是同一块 GPU 内部的两种资源,不涉及 CPU。
  2. 延迟 vs 吞吐:等凑成更大的 batch 能让 GPU 更满,总吞吐更高,但每个请求的排队时间和 TTFT 可能变长。
  3. 局部性 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 变差,应先排除负载差异、计时口径和采样波动,再检查缓存带来的额外成本或系统交互。可以调查以下情况:

  1. 未命中路径的额外成本:未命中请求仍需处理输入,还可能增加查找或管理开销。命中与未命中形成两个群体本身不足以解释 P99 上升。
  2. 长前缀挤占 KV 容量:保留大量 8k 前缀引起 eviction、碎片、preemption 或重算,少量冷请求在内存压力下明显变慢。
  3. 局部性造成负载倾斜:为了提高命中率,请求被集中路由到持有热前缀的 worker,这些 worker 排队变长,其他 worker 却相对空闲。

具体系统中还可能是 cache 锁竞争、跨节点 KV 传输或观测开销。结论要靠分阶段数据验证,不能凭现象断言。

优先检查哪 5 项

  1. 命中情况:请求命中率、按 token 加权的命中率、实际复用的 token 数,并把 hit / partial hit / miss 分开统计。
  2. TTFT 分阶段分位数:p50/p95/p99,观察排队、缓存准备、未复用输入计算和首 token 采样/返回。
  3. 队列与调度:队列深度、并发数、batch size、调度等待、preemption/重算次数。
  4. KV 内存:占用率、空闲 block、分配延迟、碎片、eviction 数和被驱逐的 token 数。
  5. 逐 worker 的负载与命中率:看 cache-aware 路由是否造成了热点。

同时要观察 TPOT/ITL、E2E 和 goodput,避免 TTFT 的改善只是把瓶颈转移到 Decode 或 SLO 违约上。

如何设计对照实验

做一个最小的 cache off/on × cold/warm 受控实验:

组别Prefix Cache初始状态目的
Aoffcold无缓存基线
Boffwarm分离模型/内核 warmup 的影响
Concold(清空)测缓存功能本身和首次填充开销
Don已预热相同前缀测真实命中收益和尾延迟

要固定的变量:模型、权重、tokenizer、chat template、引擎版本、硬件;同一份请求 trace(8k 输入、64-token 输出、70% 共享比例、相同的前缀热度分布);相同的到达时间表、并发准入上限、采样参数、seed 和停止条件,并记录实际到达率与活跃并发;相同的 worker 数和路由策略。每组 warmup 后至少重复 3 次,保存全部原始请求时间戳,不要只报最好的一次。

分析时:

  1. 同时报告平均值、p50、p95、p99;
  2. 把 hit、partial hit、miss 分桶比较 TTFT;
  3. 按每个请求观察排队、缓存准备、未复用输入计算和首 token 采样/返回;
  4. 对比逐 worker 的队列和负载,看 eviction/preemption 是否只在 cache-on 时出现;
  5. 再做并发度扫描,找到 P99 开始恶化的饱和点。

判读思路:hit 请求很快但 miss 请求因 eviction 变慢,是容量问题;只有持有热前缀的 worker 队列暴涨,是局部性与负载均衡问题;所有 cache-on 请求都多出固定的查表时间,就该检查缓存实现的开销。

用户能感受到吗

  • 多数用户(命中的那部分)会明显感觉首字更快。
  • token 数量并没有减少。是否更便宜取决于服务商的计费,需按实际服务商当时的缓存计费规则判断,不能从延迟变化推导费用变化。
  • P99 变差同样有体感:尾部请求就是用户眼里的”这次特别慢”。多步任务(比如 Agent 一次调用十几次模型)更容易撞上慢调用。假设各次慢调用独立、概率均为 1%,10 次里至少遇到一次的概率约为 1−0.9910≈9.6%1 - 0.99^{10} \approx 9.6\%(实际调用可能相关)。因此可以把与体验有关的尾延迟纳入 SLO,并同时报告平均值与分位数。

九、读懂 P50、P95、P99

分位数回答的是:把一段时间内所有请求的延迟从快到慢排好,第 X% 位置上的值是多少?

指标含义
P50(中位数)排序后 50% 位置的延迟,反映典型体验
P95排序后 95% 位置的延迟,观察较慢请求
P99排序后 99% 位置的延迟,用来观察尾部体验
P99.9极端尾部,多用于长尾故障排查

计算方式:N 个延迟排序后,P99 取第 ⌈0.99×N⌉\lceil 0.99 \times N \rceil 个(不同工具在取整和插值上略有差异,跨工具比较要统一口径)。注意,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、缓存局部性与路由和推理压测。