5 minute read

上一篇文章已经说明了 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;历史文本数据库才是多轮对话长期可靠的记忆来源。