【LLM推理与Serving⑤】Prefix Cache前缀缓存:让重复prompt不再重复计算

【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块。

这带来了两个关键能力:

  1. Block是天然的复用单位:相同内容的前缀Block可以被多个请求同时引用,不需要复制数据。
  2. Copy-on-Write(写时复制):共享的Block被修改前可以安全复用;一旦请求走到了不同的地方(分叉了),只需要为后续的Block分配新空间。

3.3 Prefix Cache在Block之上做"内容寻址"

可以这么理解两者的关系:

复制代码
PagedAttention:  request_id → block_table → physical blocks
                    (按请求索引,每个请求有自己的块表)

Prefix Cache:    hash(prefix_content) → physical blocks
                    (按内容索引,相同内容指向同一块物理内存)

当一个新请求到来时,引擎会:

  1. 把prompt按Block大小切分
  2. 从第一个Block开始,尝试在Prefix Cache中查找
  3. 找到最长的匹配前缀
  4. 命中的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的关键特征

  1. 自动启用,无需改代码:只要加个启动参数,应用层完全无感知。

  2. Block级共享,不是整段前缀:vLLM的Prefix Cache是按Block粒度缓存的。这意味着:

    • 只有完整的Block才会被缓存
    • 不足一个Block的尾部前缀不会被缓存
    • 好处是管理简单,和PagedAttention天然兼容
  3. 支持多种前缀:不只是system prompt,任何重复出现的前缀都能被缓存。

  4. 与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但没收益?

可能的原因:

  1. 前缀太短,不足一个Block:如果共享前缀只有10个token,而Block大小是16,那就不会命中。
  2. 前缀不完全相同:差一个空格、一个标点,哈希就不一样了。
  3. 请求都路由到不同节点:Prefix Cache是节点本地的,跨节点不共享。
  4. 并发太低,还没"热"起来:第一个请求是冷启动,不会命中;第二个请求开始才有收益。
  5. 缓存被淘汰了:显存不够时,旧的缓存会被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下降和吞吐提升。

核心要点回顾:

  1. 核心思想:相同前缀的KV只算一次,跨请求复用,减少重复prefill。
  2. 三种缓存的区别:KV Cache是单请求内的优化,Prefix Cache是跨请求的KV复用,Response Cache是结果级缓存。
  3. 与PagedAttention的关系:建立在Block化的KV管理之上,做内容寻址和COW。
  4. vLLM APC:Block级共享、自动启用、与Continuous Batching协同。
  5. 高收益场景:system prompt、多轮对话、few-shot、RAG、Agent工具描述。
  6. 工程细节:Cache Key的设计、命中后的计算流程、COW与分叉、多租户隔离。

8.2 思考问题

  1. 如果你的应用是一个搜索引擎的AI摘要功能(每个query都不一样),Prefix Cache的收益大概有多少?值不值得开?
  2. 为什么Prefix Cache要做Block级共享,而不是精确到每个token? 如果让你来设计一个支持token级共享的方案,你会怎么做?代价是什么?
  3. Prefix Cache和Speculative Decoding可以同时使用吗? 如果可以,它们分别在哪个阶段发挥作用?
  4. 假设你有一个10节点的推理集群,每个节点有独立的Prefix Cache。你会如何设计路由策略来提高缓存命中率?(提示:考虑一致性哈希)
  5. 如果system prompt中包含动态内容(比如当前时间、用户昵称),Prefix Cache就完全失效了。你有什么办法能让这种场景也享受到缓存收益?

如果你觉得这篇文章有帮助,欢迎点赞收藏关注三连~ 下一篇我们来聊Prefill/Decode解耦架构,看看为什么长prompt会卡住短请求,以及如何从架构层面解决这个问题。

相关推荐
Rocky Ding*1 小时前
深入浅出IDM-VTON论文精读:虚拟试衣怎样同时保住服装语义与局部细节
论文阅读·人工智能·深度学习·机器学习·aigc·ai-native·idm-vton
薛定谔的悦1 小时前
光储充一体机从主控到执行单元的双层架构
数据库·mongodb·架构
严肃小白兔1 小时前
MATLAB 数值计算中的精度与误差问题:浮点运算、误差传播与数值稳定性
人工智能·算法·matlab
AI导出鸭PC端1 小时前
Perplexity批量导出没有PDF模式?AI导出鸭插件补上最后一块拼图
人工智能·pdf·c#·ai导出鸭
汽车人——Yin1 小时前
《重构汽车软件基础设施》白皮书核心解读 AUTOSAR、CAPI与AI定义汽车的产业变革
人工智能·autosar·汽车开发
weixin_468466851 小时前
视觉Transformer的“第二次革命”:ViT³如何用测试时训练重构注意力范式
人工智能·深度学习·机器学习·ai·重构·transformer·vit
code2cat1 小时前
【随笔】KV缓存量化:省下的显存,要付出多少数值误差
人工智能·缓存·大语言模型
政安晨2 小时前
【政安晨】嵌入式AI开发实践 —— ESP-Claw 深度拆解:把 AI Agent 塞进 $5 芯片,它是怎么“看见“并“握住“硬件的
人工智能·开源·esp·ai agents·esp-claw·esp-mosaico
卷无止境2 小时前
以 Obsidian 为核心的知识库体系:工具生态、AI 协同与最佳实践方案
人工智能