【LLM推理与Serving⑤】Prefix Cache前缀缓存:让重复prompt不再重复计算
大家好,我是你的AI Infra学习搭子。上一篇我们聊了Speculative Decoding如何让大模型"一次蹦好几个字",今天我们来聊另一个在生产环境中收益巨大的优化------Prefix Cache(前缀缓存)。如果说Speculative Decoding是在Decode阶段"挤牙膏",那Prefix Cache就是在Prefill阶段"薅羊毛"------同样的prompt前缀,凭什么每次都要重新算一遍?
一、开篇引入:如果很多请求有相同前缀,为什么还要重复计算prefill?
1.1 一个真实的场景
想象你在运营一个AI客服系统,每个用户进来都会看到同样的开场白:
你是一个专业的电商客服助手,请遵守以下规则:
1. 回答要友好、耐心
2. 涉及退款请引导至售后页面
3. 无法回答的问题请转人工
...(以下省略500字)
加上用户的问题,每个请求的prompt大概长这样:
[500字系统提示词] + [用户的具体问题,通常30-50字]
问题来了:假设有1000个用户同时提问,系统要做多少次prefill?
答案是:1000次。每次都要把那500字的系统提示词从头到尾算一遍。
这合理吗?当然不合理!系统提示词每次都一样,为什么不能算一次存起来,后面直接用?
1.2 Prefix Cache是什么?
Prefix Cache(前缀缓存) 的核心思想很简单:
如果多个请求的prompt拥有完全相同的前缀,那么这段前缀对应的KV Cache只需要计算一次,后续请求可以直接复用。
听起来是不是很像CPU的缓存或者CDN?原理是一样的------把重复计算的结果存起来,下次直接用。
但它和普通的缓存又不一样,因为它缓存的不是最终答案,而是中间状态(KV Cache)。这有什么好处呢?答案是:不会改变模型输出,只是加速了计算过程。
二、三个容易混淆的缓存:KV Cache、Prefix Cache、Response Cache
刚接触LLM推理的同学很容易被各种"Cache"搞晕。下面我们来一次性把它们分清楚。
2.1 三种缓存对比
| 名称 | 缓存对象 | 作用阶段 | 是否改变输出分布 | 类比 |
|---|---|---|---|---|
| KV Cache | 单个请求内已计算token的K/V向量 | Prefill / Decode | 否,推理本身就需要 | 草稿纸:做题过程中记下中间结果 |
| Prefix Cache | 多个请求共享前缀的KV Block | Prefill | 否,只复用相同前缀的KV | 模板库:常见公式直接抄,不用再推导 |
| Response Cache | 完整prompt对应的最终输出文本 | 请求级 | 可能,直接返回旧答案 | 标准答案库:相同题目直接给答案 |
2.2 用一个例子理解区别
假设你的应用是一个翻译软件,每次都有固定的prompt前缀:
你是一个专业的翻译官,请把下面的中文翻译成英文,注意语气要正式:
[用户输入的中文文本]
-
KV Cache:翻译第一句话时,每生成一个英文单词,都要把前面的KV存起来,避免重复计算。这是单个请求内部的优化。
-
Prefix Cache:第一个用户翻译时,系统把"你是一个专业的翻译官..."这段前缀的KV算好存起来。第二个用户来翻译时,直接复用这段KV,只需要算用户输入的那部分。这是跨请求的优化。
-
Response Cache:如果有人问"你好"怎么翻译,系统直接返回"Hello",连模型都不用跑。这是结果级的缓存。
一句话总结:KV Cache是单次请求的时间旅行,Prefix Cache是多次请求的平行宇宙,Response Cache是开卷考试的小抄。
三、Prefix Cache与PagedAttention的关系:在Block之上做内容寻址
3.1 没有PagedAttention行不行?
答案是:行,但很麻烦。
在PagedAttention出现之前,KV Cache是按请求连续存储的。如果想做前缀缓存,你需要:
- 手动管理不同长度的前缀
- 处理内存碎片(不同请求的前缀长度不一样)
- 复制粘贴KV数据(从缓存区复制到请求区)
这就像没有虚拟机的时代,每个程序都要自己管理物理内存,苦不堪言。
3.2 PagedAttention提供了什么?
PagedAttention把KV Cache切成了固定大小的Block (通常是16或32个token),就像操作系统的内存页。每个请求通过Block Table来管理自己的KV块。
这带来了两个关键能力:
- Block是天然的复用单位:相同内容的前缀Block可以被多个请求同时引用,不需要复制数据。
- Copy-on-Write(写时复制):共享的Block被修改前可以安全复用;一旦请求走到了不同的地方(分叉了),只需要为后续的Block分配新空间。
3.3 Prefix Cache在Block之上做"内容寻址"
可以这么理解两者的关系:
PagedAttention: request_id → block_table → physical blocks
(按请求索引,每个请求有自己的块表)
Prefix Cache: hash(prefix_content) → physical blocks
(按内容索引,相同内容指向同一块物理内存)
当一个新请求到来时,引擎会:
- 把prompt按Block大小切分
- 从第一个Block开始,尝试在Prefix Cache中查找
- 找到最长的匹配前缀
- 命中的Block直接引用,未命中的部分正常prefill
3.4 代码示例:简化的Prefix Cache查找逻辑
python
from typing import List, Tuple
import hashlib
class PrefixCache:
"""简化版前缀缓存:基于Block的内容寻址"""
def __init__(self, block_size: int = 16):
self.block_size = block_size
# key: 前缀哈希 -> value: 物理Block列表
self.cache = {}
# 引用计数,用于LRU淘汰
self.ref_count = {}
def _hash_block(self, tokens: List[int]) -> str:
"""计算一个Block内容的哈希值"""
# 实际实现中会用更高效的哈希,比如结合token ids和位置信息
content = str(tokens).encode()
return hashlib.md5(content).hexdigest()
def lookup(self, prompt_tokens: List[int]) -> Tuple[int, List]:
"""
查找最长匹配前缀
Args:
prompt_tokens: 完整的 prompt token 列表
Returns:
matched_tokens: 匹配的 token 数量
matched_blocks: 匹配到的物理 Block 列表
"""
matched_blocks = []
matched_tokens = 0
# 按 Block 切分 prompt
num_blocks = len(prompt_tokens) // self.block_size
for block_idx in range(num_blocks):
start = block_idx * self.block_size
end = start + self.block_size
block_tokens = prompt_tokens[start:end]
# 计算当前Block的哈希(实际是累积前缀哈希)
block_hash = self._hash_block(prompt_tokens[:end])
if block_hash in self.cache:
# 命中!直接引用物理Block
matched_blocks.append(self.cache[block_hash])
matched_tokens += self.block_size
self.ref_count[block_hash] = self.ref_count.get(block_hash, 0) + 1
else:
# 未命中,停止查找(前缀必须连续)
break
return matched_tokens, matched_blocks
def insert(self, prefix_tokens: List[int], physical_blocks: List):
"""将计算好的前缀KV插入缓存"""
num_blocks = len(prefix_tokens) // self.block_size
for block_idx in range(num_blocks):
end = (block_idx + 1) * self.block_size
block_hash = self._hash_block(prefix_tokens[:end])
if block_hash not in self.cache:
self.cache[block_hash] = physical_blocks[block_idx]
self.ref_count[block_hash] = 1
注意:上面的代码做了大量简化。实际实现中,哈希值是累积计算的(前缀哈希),而且每个Block的key不仅包含token内容,还可能包含模型版本、请求参数等信息。
四、vLLM Automatic Prefix Caching (APC)
4.1 什么是APC?
vLLM是目前最流行的LLM推理框架之一,它的Prefix Cache功能叫做Automatic Prefix Caching(APC) ,也叫Prefix Caching。
开启方式很简单,启动vLLM时加一个参数:
bash
python -m vllm.entrypoints.openai.api_server \
--model Qwen/Qwen2-7B-Instruct \
--enable-prefix-caching
4.2 APC的关键特征
-
自动启用,无需改代码:只要加个启动参数,应用层完全无感知。
-
Block级共享,不是整段前缀:vLLM的Prefix Cache是按Block粒度缓存的。这意味着:
- 只有完整的Block才会被缓存
- 不足一个Block的尾部前缀不会被缓存
- 好处是管理简单,和PagedAttention天然兼容
-
支持多种前缀:不只是system prompt,任何重复出现的前缀都能被缓存。
-
与Continuous Batching协同:Prefix Cache不会影响调度策略,命中的请求只是prefill更快完成。
4.3 为什么只共享完整Block?
你可能会问:为什么不能精确到每个token?非要对齐到Block边界?
这是一个工程权衡:
- PagedAttention的基本管理单位是Block
- 如果支持非Block对齐的共享,Block Table的管理会复杂很多
- 实际场景中,共享前缀通常很长(几百到几千token),损失一个Block的精度(16-32 token)影响很小
- 简单=稳定=高性能
就像你买东西找零,整钱好找,零钱麻烦。为了那点"零钱"增加大量复杂度,不值当。
五、命中率分析:哪些场景收益最大?
Prefix Cache不是万能的,它的收益完全取决于前缀重复率。下面我们来盘点一下哪些场景最赚。
5.1 System Prompt(系统提示词)
命中率:★★★★★
这是最经典的场景。几乎所有的对话类应用都有固定的system prompt,长度从几十到几千token不等。
典型例子:
- 客服机器人的角色设定
- 代码助手的指令约束
- 翻译软件的风格要求
如果system prompt有1024 token,而用户问题只有32 token,那么命中率可以达到:
命中率=10241024+32≈97% \text{命中率} = \frac{1024}{1024 + 32} \approx 97\% 命中率=1024+321024≈97%
也就是说,prefill阶段97%的计算都被省掉了。TTFT(首token延迟)会大幅下降。
5.2 多轮对话
命中率:★★★★☆
多轮对话中,每一轮的prompt都包含之前的对话历史。比如第三轮对话的prompt是:
system prompt + 第一轮用户问 + 第一轮AI答 + 第二轮用户问 + 第二轮AI答 + 第三轮用户问
如果同一个用户的多轮对话都落在同一个GPU节点上,那么前面几轮的KV都可以复用。
但注意:多轮对话的缓存是会话级的,需要用户粘性(session affinity)才能命中。如果请求被路由到不同的节点,缓存就没用了。
5.3 Few-Shot Prompting
命中率:★★★★☆
Few-Shot就是在prompt里给几个例子,让模型照着做。比如:
请按照以下格式回答问题:
例子1:
问题:...
答案:...
例子2:
问题:...
答案:...
现在回答:
问题:[用户的问题]
如果每个请求都用同样的examples,那examples部分就是共享前缀,命中率很高。
5.4 RAG(检索增强生成)
命中率:★★★☆☆
RAG的prompt通常长这样:
system prompt + 检索到的文档 + 用户问题
这里有两个层面的共享:
- System prompt是固定的(高命中)
- 检索文档可能重复(取决于用户问的问题是否相似)
如果很多用户问相似的问题,检索到相同的文档,那么文档部分也能共享。但如果问题差异大,命中率就低了。
5.5 Agent工具调用
命中率:★★★☆☆
Agent场景通常有很长的工具描述,比如:
你可以使用以下工具:
1. search: 搜索工具,参数:query
2. calculate: 计算器,参数:expression
3. ...(十几个工具)
这部分工具描述对每个请求都是一样的,属于高命中前缀。
六、工程实现细节
6.1 Cache Key包含什么?
Prefix Cache的key不能只是token列表的哈希,还需要考虑很多因素:
cache_key = hash(
token_ids, # 前缀的token序列
model_name, # 模型名称(不同模型KV不通用)
model_version, # 模型版本(LoRA权重不同KV也不同)
tokenizer_version, # 分词器版本
# ... 其他影响KV的因素
)
为什么要这么复杂? 因为如果key不对,可能会出现"缓存击穿"------用了错误的KV,导致输出完全乱掉。
6.2 命中后的计算流程
当一个新请求到来时,完整的prefill流程是这样的:
1. 对 prompt 做 tokenize
2. 按 Block 切分,查找 Prefix Cache
3. 找到最长匹配前缀长度 L
4. 对于命中的部分(前 L 个 token):
- 直接把缓存的物理 Block 加入请求的 Block Table
- 不需要跑模型
5. 对于未命中的部分(L 之后的 token):
- 正常做 prefill 计算
- 计算产生的新 Block 插入 Prefix Cache
6. prefill 完成,进入 Decode 阶段
6.3 COW(Copy-on-Write)与分叉
Prefix Cache有一个很重要的问题:多个请求共享同一个物理Block,如果其中一个请求要修改它怎么办?
答案是:Copy-on-Write(写时复制)。
共享的Block是只读的。当请求走到前缀结束的位置,开始生成自己的内容时:
- 前面共享的Block继续共享(只读)
- 新生成的token需要分配新的物理Block
- 每个请求有自己独立的后续Block
这就像你和室友共用一本教材(共享前缀),但你们各自的笔记写在自己的笔记本上(分叉后的内容)。
6.4 与Continuous Batching的协同
Prefix Cache和Continuous Batching是什么关系?答案是:协同工作,互不干扰。
- Prefix Cache在prefill阶段发挥作用,减少计算量
- Continuous Batching在调度层面发挥作用,提高GPU利用率
- 命中Prefix Cache的请求,prefill更快完成,能更早进入decode batch
可以这么理解:Prefix Cache让每个请求的"入场费"更便宜,Continuous Batching让"场地"的利用率更高。
七、常见问题
7.1 开了Prefix Cache但没收益?
可能的原因:
- 前缀太短,不足一个Block:如果共享前缀只有10个token,而Block大小是16,那就不会命中。
- 前缀不完全相同:差一个空格、一个标点,哈希就不一样了。
- 请求都路由到不同节点:Prefix Cache是节点本地的,跨节点不共享。
- 并发太低,还没"热"起来:第一个请求是冷启动,不会命中;第二个请求开始才有收益。
- 缓存被淘汰了:显存不够时,旧的缓存会被LRU淘汰。
7.2 Prefix Cache会改变输出吗?
不会。 因为它缓存的是KV Cache,而KV Cache是模型计算的中间状态。相同的输入,经过相同的模型,产生的KV一定是一样的。复用KV就相当于你把同样的计算题又做了一遍,然后直接抄了上次的答案------答案肯定是一样的。
这和Response Cache不一样,Response Cache是直接返回最终文本,可能因为模型更新或者采样随机性导致"应该不一样但返回了一样的"。
7.3 Prefix Cache会占更多显存吗?
短期看是,长期看不一定。
- 刚开启时,缓存需要占额外的显存来存共享的KV Block。
- 但如果有很多请求共享前缀,原来每个请求都要存一份相同的KV,现在只需要存一份,反而省显存。
举个例子:100个请求共享1024 token的前缀,每个token的KV占200字节:
- 没有Prefix Cache:100×1024×200=20MB100 \times 1024 \times 200 = 20MB100×1024×200=20MB
- 有Prefix Cache:1×1024×200=2MB1 \times 1024 \times 200 = 2MB1×1024×200=2MB
省了18MB!但如果请求之间没什么共享前缀,那就是纯开销了。
7.4 多租户场景能共享吗?
这是一个安全问题。如果不同租户的请求共享Prefix Cache,会不会泄露信息?
答案是:取决于你的安全策略。
- 如果所有租户用同一个模型、同一个system prompt,那共享是安全的,因为大家都知道system prompt的内容。
- 如果不同租户有不同的system prompt(比如定制化的角色设定),那就不能跨租户共享,否则可能泄露其他租户的提示词。
工程上通常的做法是:按租户ID隔离缓存,或者只缓存所有人都可见的公共前缀。
八、小结与思考
8.1 小结
Prefix Cache是一个"低投入、高回报"的优化------大部分情况下,只需要加一个启动参数,就能获得显著的TTFT下降和吞吐提升。
核心要点回顾:
- 核心思想:相同前缀的KV只算一次,跨请求复用,减少重复prefill。
- 三种缓存的区别:KV Cache是单请求内的优化,Prefix Cache是跨请求的KV复用,Response Cache是结果级缓存。
- 与PagedAttention的关系:建立在Block化的KV管理之上,做内容寻址和COW。
- vLLM APC:Block级共享、自动启用、与Continuous Batching协同。
- 高收益场景:system prompt、多轮对话、few-shot、RAG、Agent工具描述。
- 工程细节:Cache Key的设计、命中后的计算流程、COW与分叉、多租户隔离。
8.2 思考问题
- 如果你的应用是一个搜索引擎的AI摘要功能(每个query都不一样),Prefix Cache的收益大概有多少?值不值得开?
- 为什么Prefix Cache要做Block级共享,而不是精确到每个token? 如果让你来设计一个支持token级共享的方案,你会怎么做?代价是什么?
- Prefix Cache和Speculative Decoding可以同时使用吗? 如果可以,它们分别在哪个阶段发挥作用?
- 假设你有一个10节点的推理集群,每个节点有独立的Prefix Cache。你会如何设计路由策略来提高缓存命中率?(提示:考虑一致性哈希)
- 如果system prompt中包含动态内容(比如当前时间、用户昵称),Prefix Cache就完全失效了。你有什么办法能让这种场景也享受到缓存收益?
如果你觉得这篇文章有帮助,欢迎点赞收藏关注三连~ 下一篇我们来聊Prefill/Decode解耦架构,看看为什么长prompt会卡住短请求,以及如何从架构层面解决这个问题。