大模型多轮对话与 Prefix Cache:从请求生命周期到缓存命中
上一篇文章已经说明了 Prefill、Decode 和 KV Cache 在一次自回归生成中的作用。继续讨论多轮对话时,会出现几个新的问题:回答结束后 KV Cache 去了哪里?第二轮为什么还要提交历史消息?Prefix Cache 命中究竟复用了什么?关闭对话一天以后,模型又如何记得昨天的内容?
本文作为推理解析的续篇,只讨论多轮请求之间的状态管理和跨请求 KV 复用。关于 Token、Transformer、QKV、GQA 和单轮生成流程,可以先阅读:大模型推理解析:从 Token、QKV 到 GQA 与 KV Cache。关于 KV Cache 显存公式和部署容量,可参考:大模型推理资源估算:权重、KV Cache、显存与部署容量。
一、先区分对话、请求与缓存
前端看起来是一个持续存在的聊天窗口,后台却通常不是“一个对话对应一个永不退出的模型进程”。更常见的结构是:
对话记录:保存在数据库中,可以存在数天或数月
模型服务:所有用户共享,模型权重长期驻留GPU
推理请求:每点击一次发送,创建一次短暂请求
KV Cache:至少在该请求生成回答期间存在
因此,一个聊天窗口可以包含多个彼此独立的推理请求:
conversation_id = chat_001
├── Request 1:User1 → Assistant1
├── Request 2:User2 → Assistant2
├── Request 3:User3 → Assistant3
└── ...
这四类状态的生命周期不同:
| 状态 | 典型位置 | 典型生命周期 |
|---|---|---|
| 模型权重 | GPU 显存 | 模型服务运行期间 |
| 历史消息文本 | 数据库、对象存储或客户端 | 长期保存 |
| 活跃 KV Cache | GPU KV Block Pool | 当前推理请求期间 |
| Prefix Cache | GPU、CPU 或分层缓存 | 尽力保留,可随时淘汰 |
最重要的一句话是:
数据库里的历史文本是可靠的长期记忆;Prefix Cache 是用来减少重复计算的临时加速数据。
二、Prefill、KV Cache 与 Prefix Cache 不是同一个概念
这些术语经常被混在一起。
Prefill
Prefill 是计算阶段。模型一次性处理 Prompt 中已有的 Token,在每一层生成这些 Token 的 K 和 V:
Prompt Token
→ Embedding
→ Transformer Layer 1...L
→ 为全部Prompt Token建立各层K/V
请求级 KV Cache
Prefill 产生的 K/V 会在本轮 Decode 中继续使用。模型每生成一个新 Token,只计算这个新 Token 的 Q/K/V,再把新 K/V 追加到缓存。
Prefill:建立输入Prompt的KV
Decode:每轮追加一个新Token的KV
它首先解决的是同一个请求内部的重复计算。
Prefix Cache
Prefix Cache 是跨请求优化:某个请求结束以后,将可复用的前缀 KV Block 暂时保留;未来请求拥有完全相同的 Token 前缀时,直接引用这些 Block,跳过相同前缀的 Transformer Prefill。
请求内KV Cache:服务于本轮持续Decode
Prefix Cache:让下一次请求复用以前计算过的前缀KV
“Prefill Cache”并不是统一标准术语。有人用它指“缓存 Prefill 的结果”,此时本质上说的仍然是 KV Cache 或 Prefix Cache。Prompt Cache 也常被当作 Prefix Cache 的近义词,但某些系统中的 Prompt Cache 可以缓存预定义模块,不只局限于普通的连续前缀。
三、完整例子:从第一次提问到第二轮回答
下面沿用一个简单对话:
User1:北京是中国的哪里?
Assistant1:首都。
User2:那上海呢?
1. 模型服务启动
模型服务启动时,权重加载到 GPU。vLLM、SGLang 一类推理引擎还可能提前建立较大的 KV Block Pool:
GPU显存
├── 模型权重
├── CUDA与计算工作区
└── KV Block Pool
├── 活跃Block
├── 可复用缓存Block
└── 空闲或可回收Block
这些通常是统一内存池中的逻辑状态,不一定是三个固定物理分区。
2. 第一次请求进行 Prefill
后台把消息转换成模型实际训练时使用的 Chat Template:
<|system|>
你是一个有帮助的AI助手。
<|user|>
北京是中国的哪里?
<|assistant|>
Tokenizer 将其转换成 Token ID。假设得到 $N_1$ 个 Token:
input_ids.shape = [1, N1]
模型对它们执行 Prefill,在所有 Transformer 层建立 KV Cache,然后逐 Token 生成:
首 → 都 → 。→ <EOS>
回答生成期间,Request 1 仍然活跃,因此对应的 KV Cache 必须存在,不能作为普通缓存淘汰。
3. 第一次请求结束
服务将文字“首都。”保存到对话数据库。此时有三种常见处理:
未启用跨请求缓存:
Request 1结束 → KV Block直接回到可用池
启用Prefix Cache:
Request 1结束 → 解除活跃引用 → 完整Block暂时保留
有状态Session:
Request 1结束 → 会话仍显式持有KV句柄,直到TTL或主动关闭
普通聊天 API 更常见的是前两种。有状态 Session 需要额外的生命周期、配额与容错设计,不能仅凭前端窗口仍然打开就假定存在。
4. 第二次请求仍提交完整历史
用户接着问“那上海呢?”。普通无状态 Chat API 通常提交:
[
{"role": "user", "content": "北京是中国的哪里?"},
{"role": "assistant", "content": "首都。"},
{"role": "user", "content": "那上海呢?"}
]
服务器再次应用 Chat Template,对完整序列 Tokenize:
System
+ User1
+ Assistant1
+ User2
+ Assistant起始标记
注意:重新 Tokenize 和执行 Transformer Prefill 不是一回事。Tokenize、Block Hash 和 Hash Table 查询通常是较轻的 CPU 操作;真正昂贵的是让 Token 穿过所有 Transformer 层。
5. 命中时只 Prefill 未缓存的后缀
如果 System + User1 + Assistant1 对应的 KV Block 仍在 Prefix Cache 中:
历史前缀:引用已有KV Block
新增User2:执行Prefill并建立新KV
新回答:逐Token Decode并继续追加KV
模型逻辑上看到了完整历史,物理上却只重新计算了未命中的尾部。
如果没有命中:
完整历史 + 新问题
→ 全部重新Prefill
→ 重新建立当前请求的KV
四、Prefix Cache 如何按 Block 实现
PagedAttention 将每个请求的 KV Cache 划分为固定 Token 数量的 Block。假设为了示意,每个 Block 只有 4 个 Token:
Block 0:A B C D
Block 1:E F G H
Block 2:I J K L
每个物理 KV Block 保存的不只是 4 个 Token ID,而是这些位置在全部相关 Transformer 层中的 K/V 数据。
链式 Hash
一个 KV Block 的内容不仅取决于当前 Block 的 Token,还取决于它之前的完整因果前缀。典型实现会构造类似的链式 Hash:
\[H_0=\operatorname{hash}(\text{tokens}_0)\] \[H_i=\operatorname{hash}(H_{i-1},\text{tokens}_i,\text{extra keys})\]extra keys 可能包含模型、LoRA、缓存隔离盐值以及其他会影响 KV 正确性的配置。然后维护映射:
H0 → Physical KV Block 21
H1 → Physical KV Block 35
H2 → Physical KV Block 8
vLLM 的 Automatic Prefix Caching 使用的核心关系就是:
hash(prefix tokens + block tokens) ↔ KV Block
相关设计可参考:vLLM Automatic Prefix Caching。
为什么不能只对当前 Block 计算 Hash
下面两个片段都出现了“首都”:
北京是中国的首都
东京是日本的首都
虽然最后的 Token 相同,但深层 Transformer 中“首都”的隐藏状态已经吸收了不同前文,所以 K/V 不同。
hash(首都) 不足以唯一确定KV
hash(完整因果前缀 + 首都) 才能用于正确复用
这也是普通 Prefix Cache 只能复用从序列开头开始完全一致的部分,而不能随意复用 Prompt 中间相同句子的原因。
五、查询的不是“最相似缓存”,而是最长连续前缀
假设第二次请求分块后是:
Block 0:System Prompt
Block 1:第一轮问题
Block 2:第一轮回答
Block 3:第二轮新问题
查询结果:
Block 0 → 命中
Block 1 → 命中
Block 2 → 命中
Block 3 → 未命中
那么引擎复用 Block 0~2,从 Block 3 开始 Prefill。
Prefix Cache 不是向量相似度搜索:
错误理解:缓存A相似度80%,缓存B相似度90%,选择B
正确理解:Token前缀完全一致则命中,否则未命中
如果 Block 1 已经不同:
Block 0 → 命中
Block 1 → 未命中
Block 2 → 即使文字看起来相同,也不能继续复用
因为 Block 2 的 K/V 依赖 Block 1。查询通常寻找的是最长连续匹配前缀,并在第一个未命中位置之后重新计算。
六、Block 对齐为什么影响实际命中
假设真实 Block Size 是 16 Token,而两个请求的前 810 Token 完全相同。
完整可复用 Block 对应:
\[\left\lfloor\frac{810}{16}\right\rfloor\times16=800\ \text{Token}\]剩余 10 个相同但未组成完整 Block 的 Token,可能要和新问题一起重新处理。不同引擎对不完整 Block 的细节可能不同,但做性能分析时应该按 Block 粒度理解,而不是假设任意一个 Token 都能独立查询和复用。
七、缓存“释放”为什么不等于显存下降
一次回答结束后,可以发生三个不同层次的释放:
释放请求所有权
Request完成
→ 请求不再引用这些Block
→ ref_count降低
释放给推理引擎内存池
Block 可以供未来请求使用,但 GPU 内存仍由引擎保留。
活跃Block → 缓存Block或可回收Block
归还给 GPU 驱动
只有真正执行底层显存释放时,nvidia-smi 才可能明显下降。高吞吐推理引擎通常希望避免频繁申请和释放显存,因此会长期保留 KV Block Pool。
所以,请求结束后看到 GPU 显存没有下降,并不意味着这个对话仍然拥有所有 KV Cache;更可能只是显存已经归还给引擎内部的池。
八、Prefix Cache 会保留多久
通常没有固定时间保证。它可能只存在几毫秒,也可能在空闲服务中存在很久,取决于:
- KV Block Pool 容量;
- 并发请求数量和上下文长度;
- 相同前缀是否频繁再次出现;
- 淘汰策略;
- 是否有多个模型或 LoRA;
- 请求是否被路由到同一推理实例。
常见淘汰逻辑是 LRU:最近最少使用、且没有活跃请求引用的缓存 Block 优先被回收。
新请求需要Block
→ 没有空闲Block
→ 选择ref_count=0的LRU缓存Block
→ 删除旧Hash映射
→ 分配给新请求
→ 覆盖旧KV数据
因此,Prefix Cache 一般不承诺“保存五分钟”或“保存一天”。关闭对话一天后,应当按历史专属 KV 已经不存在来设计系统。
九、一天后重新进入对话会发生什么
假设数据库保存:
User1:北京是中国的哪里?
Assistant1:首都。
User2:那上海呢?
Assistant2:上海是中国的直辖市。
一天后,用户打开页面时通常只是:
conversation_id
→ 从数据库读取历史文字
→ 在前端显示
仅仅打开页面不需要推理,也不需要恢复 KV。
用户继续问:
我上一个问题是什么?
后台构造:
System
+ 全部保留的历史文本
+ 当前新问题
如果历史专属 Prefix Cache 已被淘汰,就重新 Tokenize 并完整 Prefill。模型之所以知道上一个问题,是因为数据库里的历史文字重新进入了 Prompt,不是因为一天前的 GPU KV 一直存在。
高级系统确实可以将 KV Offload 到 CPU、NVMe 或远程缓存,再在需要时加载回 GPU,但这需要处理传输带宽、模型版本、位置编码、LoRA 兼容性、失效和容错,不是普通聊天系统的默认设计。
十、长对话不会自动依靠 Prefix Cache“压缩”
Prefix Cache 解决的是重复计算:
相同历史不要重复Prefill
它不解决上下文长度限制:
历史太长,超过模型最大上下文
如果模型最多支持 32K Token,输入预算必须满足:
\[N_{\text{system}}+N_{\text{history}}+N_{\text{new}}+N_{\text{output reserve}} \leq N_{\text{max context}}\]超过窗口时,应用层通常采用:
- 删除最早消息;
- 将早期历史总结成较短文本;
- 只保留最近若干 Token;
- 从数据库或向量库检索与当前问题相关的历史;
- 使用支持更长上下文的模型。
这些属于上下文管理,不是 Prefix Cache。历史一旦被总结或改写,Token 前缀也会改变,旧 Prefix Cache 通常无法继续命中。
十一、命中率应该如何理解
最直观的指标是 Prompt Token 命中率:
\[\text{Token Hit Rate} = \frac{\text{复用KV的Prompt Token数}} {\text{全部Prompt Token数}}\]例如完整 Prompt 有 1000 Token:
命中前缀:800 Token
需要Prefill:200 Token
Token命中率:80%
模型逻辑上仍然看到 1000 Token,但 GPU 只需要重新 Prefill 未命中的约 200 Token。
如果连续三个请求:
请求1:1000 Token,冷启动,命中0
请求2:1000 Token,命中900
请求3:1000 Token,命中900
总体 Token 命中率为:
\[\frac{0+900+900}{1000+1000+1000}=60\%\]第一次冷启动会拉低整体命中率。某些监控还会报告 Request Hit Rate 或 Block Hit Rate,查看指标时必须确认分子、分母的具体定义。
Prefix Cache 主要减少 Prefill 计算和 TTFT。它不会直接省掉新回答的逐 Token Decode;上下文很长时,新 Token 仍要访问相应历史 KV。
十二、如何提高 Prefix Cache 命中率
保持固定前缀稳定
System Prompt、Tool Schema、Few-shot 示例和固定长文档应尽量保持字节与 Token 顺序稳定。
不利于缓存:
System:当前时间10:01,你是AI助手……
System:当前时间10:02,你是AI助手……
动态内容在序列开头变化,会让后面整个前缀链失效。更适合把稳定内容放前面、动态内容放后面。
保持 Chat Template 与序列化稳定
以下细节都会改变 Token:
- 空格和换行;
- JSON 字段顺序;
- Tool 定义顺序;
- 特殊 Token;
- Assistant 起始或结束标记;
- 模型与 Tokenizer 版本。
对话历史尽量只追加
History + 新User + 新Assistant
天然适合 Prefix Cache。修改很早以前的一条消息,会导致从修改位置开始的后续 Block 全部重新计算。
把固定长文档放在变化问题之前
RAG 或文档问答更适合:
固定System
+ 固定长文档
+ 每次变化的问题
这样多个问题可以共享长文档前缀。如果把每次变化的问题放在文档前面,普通 Prefix Cache 很难复用后面的文档 KV。
使用缓存感知路由
多实例部署时,每台 GPU 的缓存池相互独立:
Request 1 → GPU A,建立Prefix Cache
Request 2 → GPU B,GPU B没有该缓存
Sticky Session、Prefix-aware Routing 或 Cache-aware Load Balancing 可以把共享前缀的请求尽量送到拥有缓存的实例。这里路由器可能比较不同实例能命中的 Token 数;单个引擎内部仍然是在查找最长连续前缀,而不是寻找语义最相似的缓存。
为缓存保留适当空间
KV Pool 太小会频繁淘汰,太大又会挤压模型权重、活跃请求和运行时空间。最终需要根据真实业务中的上下文长度、并发、公共前缀比例和 TTFT 目标压测。
十三、Prefix Cache 与其他缓存技术的区别
| 技术 | 保存什么 | 命中条件 | 命中后做什么 |
|---|---|---|---|
| 请求级 KV Cache | 当前请求的 K/V | 同一次生成过程 | 继续 Decode |
| Prefix Cache | 历史前缀 K/V | Token 前缀完全相同 | 跳过命中部分 Prefill |
| Response Cache | 输入与最终回答 | 输入完全相同或业务 Key 相同 | 直接返回旧回答 |
| Semantic Cache | Embedding、问题与回答 | 语义相似度达到阈值 | 可能直接返回或复用旧结果 |
| 对话数据库 | 消息文本和元数据 | conversation_id | 重建历史 Prompt |
例如:
北京天气如何?
北京现在的天气怎么样?
两句话语义相近,但 Token 不同,Prefix Cache 通常不会把变化部分判定为命中。Semantic Cache 可能认为它们相似,但那是另一套机制,并且需要考虑实时性和回答正确性。
十四、代表性技术的发展
Prefix KV 复用不是由单一项目一次性发明的,而是多种服务系统共同推进的技术方向:
- 2023 年的 vLLM/PagedAttention 将 KV Cache 按页管理,减少显存碎片,并支持灵活共享。PagedAttention 论文
- Prompt Cache 系统化研究了跨 Prompt 复用 Attention 状态,并提出显式可复用模块。Prompt Cache 论文
- SGLang 的 RadixAttention 使用基数树维护跨请求 KV 前缀,并结合 LRU 和缓存感知调度。SGLang 论文
- vLLM 的 Automatic Prefix Caching 使用 Block 链式 Hash 和全局 Hash Table 实现自动复用,不必维护前缀树。
不同系统的数据结构、调度和分层存储各不相同,但共同目标都是:
让相同Prompt前缀只执行一次昂贵的Transformer计算
十五、用伪代码串起完整流程
下面的伪代码省略了并行调度和模型结构,只表达多轮请求的数据流:
def handle_chat(conversation_id, new_message):
# 可靠状态:从数据库读取历史文字
history = conversation_db.load(conversation_id)
history.append({"role": "user", "content": new_message})
# 完整逻辑上下文
prompt = apply_chat_template(history, add_generation_prompt=True)
token_ids = tokenizer.encode(prompt)
# 跨请求缓存:查找最长连续Token前缀
cached_blocks, cached_token_count = prefix_cache.lookup(token_ids)
# 只为未命中的Prompt后缀执行Transformer Prefill
suffix = token_ids[cached_token_count:]
request_state = model.prefill(
suffix,
reused_kv_blocks=cached_blocks,
)
# 本轮请求内部使用KV Cache逐Token生成
answer = model.decode_until_eos(request_state)
# 文字长期保存
history.append({"role": "assistant", "content": answer})
conversation_db.save(conversation_id, history)
# KV Block不再属于活跃请求;是否暂存取决于缓存策略
prefix_cache.release_or_cache(request_state.kv_blocks)
return answer
如果查找结果是 0:
cached_token_count = 0
→ 完整Prompt Prefill
如果大部分历史命中:
cached_token_count ≈ 历史Token数
→ 只Prefill新问题和未对齐尾部
总结
多轮对话中最容易混淆的是前端会话与后台推理请求。一个对话可以存在很久,但每轮发送通常都是独立请求:
历史文字长期保存在数据库
模型权重长期驻留GPU
活跃KV服务于当前请求
Prefix Cache尽力跨请求复用历史KV
第二轮普通请求通常仍会把历史问题、历史回答和新问题完整 Tokenize。引擎按 Block 计算链式 Hash,找到从开头开始连续命中的最长前缀;命中部分引用已有 KV,未命中的尾部才执行 Prefill。
请求结束以后,“释放”通常先表示释放给引擎内部的 KV Block Pool,不一定意味着显存立即归还 GPU 驱动。可复用 Block 能保留多久取决于容量、流量和淘汰策略,不能作为可靠会话存储。关闭对话一天后,系统通常依靠数据库历史重建 Prompt,而不是期待一天前的 KV 仍在显存。
最后可以用一句话记住四者关系:
Prefill 是计算 Prompt;KV Cache 是这次计算留下的 K/V;Prefix Cache 是下一次遇到相同 Token 前缀时复用这些 K/V;历史文本数据库才是多轮对话长期可靠的记忆来源。