解码 Prompt Caching:从 PagedAttention 原理到 10 倍降本增效
@dejavucoder 这篇文章深入剖析了 Prompt Caching(提示词缓存)的底层原理,特别是基于
@vllm_project PagedAttention 技术。作者结合自己在开发中的“踩坑”经历,纠正了许多开发者对于缓存机制的常见误解,提供了极具实操性的优化建议。
核心误区与真相:缓存是全局的,不是私有的
· 误区:作者(和咱们很多人一样)最初认为,Prompt Caching 是基于“用户会话”的。也就是说,只有同一个用户在同一个对话框里的后续发言,才能利用之前的缓存。
· 真相:Prompt Caching 是基于内容的,而非基于用户。
· 核心逻辑:只要你的系统提示词或工具定义是完全一致的文本,那么 用户 A 产生的缓存,完全可以被 用户 B 复用。
· 意义:这意味着在高并发场景下,只要前缀一致,系统可以实现“全局复用”,极大降低重复计算。
为什么需要缓存?(成本与速度)
LLM 的推理过程分为两个阶段,理解这个区别是理解缓存价值的关键:
· 预填充阶段:处理你输入的一大段提示词,计算出它们的 KV Cache。这个过程是 计算密集型 的,非常消耗算力。
· 解码阶段:逐个生成回复的 token。这个过程是 内存带宽密集型 的。
· 如果没有缓存:每次请求进来,即便前面 90% 的 Prompt 都是一样的,模型都要重新算一遍 Prefill,既慢又贵。
· 命中缓存:可以直接跳过繁重的 Prefill 计算,输入 token 的成本可降低 10 倍,且首字生成速度显著提升。
技术揭秘:PagedAttention
传统的 KV Cache 管理非常低效,必须预先分配一大块连续显存,容易造成碎片化浪费。vLLM 引入了操作系统管理内存的思路——分页。
· 分块(Blocks):
系统不再分配连续大内存,而是将 KV Cache 切分成固定大小的“块”。这些块在物理显存里可以是分散的,不连续的。
· 哈希链(Block Hashing)——缓存生效的关键:
系统如何知道“这段话以前算过”?它会计算块的哈希值。
· 父块依赖:一个块的哈希值,不仅取决于它自己的内容,还取决于前一个块的哈希值。
· 连锁反应:这就像区块链一样。只有当“从头开始的所有内容”都完全一致时,当前的哈希值才会匹配。这保证了因果关系的正确性——你不能只复用中间的一段,必须是前缀完全匹配。
· 全局查找:
新请求进来时,系统计算其提示词的块哈希,去全局哈希表中查找。如果命中,直接指向已有的显存块,完全不需要计算。
给开发者的实操建议:如何“骗”过系统命中缓存?
为了最大化缓存命中率,你需要让系统认为不同的请求是“一样”的。文章给出了几条黄金法则:
· 保持前缀稳定(Stable Prefix)
将所有静态内容(系统提示词、工具定义、示例文本)放在 最前面。
· 反例:如果你把“当前时间”或“用户名”放在提示词的开头,那么整个哈希链从一开始就断了,后面的内容即使一样也无法复用缓存。
· 确定性序列化(Deterministic Serialization)
在使用 JSON 格式传递数据时(例如工具调用),必须保证键的顺序固定。
· 技巧:在 Python 中使用 json.dumps(..., sort_keys=True)。因为 { "a": 1, "b": 2 } 和 { "b": 2, "a": 1 } 虽然语义相同,但生成的字符串不同,会导致缓存未命中。
· 仅追加模式(Append-only)
在维护多轮对话历史时,尽量只在末尾添加新内容。不要去修改或截断中间的历史记录,一旦中间变了,后面的缓存链就全失效了。
· 警惕工具定义的变化
工具定义通常被模型拼接在系统提示词附近。如果你动态地为不同用户开启/关闭不同的工具,会导致前缀发生变化,从而导致缓存失效。
总结
Prompt Caching 的本质不是“记忆”,而是“复用计算结果”。理解了底层的 分块 和 哈希链 机制,开发者就能明白为什么“前缀”如此重要。
一句话总结:把所有不变的东西(系统指令、背景文档、工具列表)永远放在最前面,把变化的东西(用户提问、动态变量)放在最后面。
阅读原文