008.Prompt Caching
提示缓存是指 LLM 提供商对相同的提示前缀复用之前计算过的键值张量(Key-Value Tensors),从而避免冗余计算。请求命中缓存时可以节省费用并获得更快的响应速度
→ 在物理层面的工程本质,就是 KV Cache 的跨请求复用
LLM Inference
大模型推理分为两个阶段:
预填充阶段 (Prefill): 计算密集型。LLM 需要读取整个 Prompt,执行海量的矩阵乘法,为每一个 Token 计算出 Key-Value Tensors(KV Cache)
计算类型:GEMM (Matrix-Matrix Multiplication)
所有 Token 互相算注意力。计算的复杂度为 O(N^2)
解码阶段 (Decode): 显存(HBM)带宽密集型。逐个生成的每个 Token,只需要用到之前已经计算好并存在 显存 中的 KV Cache。每生成一个新 Token 的时间(Time To Next Token, TTNT),取决于你的显卡把 KV Cache 从 显存 读出来的速度(带宽 IO)
计算类型:GEMV (Matrix-Vector Multiplication)
只有当前 1 个 Token 的 Q 去跟历史所有 K 算内积。计算的复杂度为 O(N)
那么,其中
因果掩码带来的“前缀绝对不可变性”
原理:Decoder-only 架构强制物理隔离了未来对过去的注意力(权重设为−∞)
只要给定前缀序列的内容与相对位置不变,无论后续追加多少个新 Token,前缀在神经网络每一层生成的 KV 张量永远是绝对固定、不可变的(Immutable)。
输入张量降维引发的“硬件异构灾难”(Asymmetry)
两者本质都是 Transformer 的前向传播。在数学公式层面,它们唯一的区别仅仅是:输入张量(Tensor)的维度(Sequence Length)不同。
- Prefill ([N, D]):触发 GEMM(矩阵乘矩阵),全并发狂榨 GPU O(N 2) 算力,是典型的计算瓶颈(Compute-Bound)。
- Decode ([1, D]):退化为 GEMV(向量查矩阵),算力闲置,GPU 被迫陷入把庞大 KV 缓存从显存搬入 SRAM 的 O(N) 循环,是典型的访存瓶颈(Memory-Bound)。
The memory problem and scaling problem 记忆与扩展
经典 OS 内存问题在 KV cache 中的映射
- 碎片化存储
为 max seq len 预分配,实际用 100 token 但分配了 1024 的空间
请求在不同时间完成,GPU memory 碎片化,无法分配连续大块 - 冗余存储
100 个请求用相同 system prompt = 100 份相同 KV cache 副本
推理引擎(比如 vLLM、TensorRT-LLM、SGLang)是通过什么物理机制,把算好的 KV Cache 优雅地保留在 HBM 里,并供成千上万个并发请求复用的
目前主要依靠两大核心技术的结合 :PagedAttention(分页内存分配) 与 Radix Tree(基数树前缀路由)
物理存储机制 :PagedAttention(LLM 的“分页虚拟内存”)
- 显存切块: 引擎将 GPU 显存(HBM)切分成无数个固定大小的物理块。默认每 block = 16 tokens 的 KV 张量。
- 逻辑到物理的映射: 引擎维护一张映射表(类似于 OS 的页表 Page Table)。
- 按需分配与非连续存储: 当 Prefill 阶段算出 Prompt 的 KV Cache 时,引擎不再寻找连续的显存,而是按每 16 个 Token 一组,填入任何空闲的物理 Block 中,并在 Block Table 中记录映射关系。
- Decode 时的注意力计算: 在算 Attention 时,CUDA 算子会根据 Block Table 的指针,跨越不连续的物理显存区块,精准抓取历史 K 和 V 进行计算。
路由与复用机制
显存碎片解决了,但引擎怎么知道“请求 B”的 System Prompt 和“请求 A”是一模一样的,从而直接复用 A 的显存 Block 呢?
A. Radix Tree
Token 级别的树形检索与精确复用
引擎在内存中维护一棵全局的前缀树(Radix Tree)。当“请求 A”执行完 Prefill,它的 Token 序列构成了树的一条路径,树的节点通过指针映射到底层的物理 Block。一旦发现匹配的树分支,引擎直接将对应物理 Block 的“引用计数(Ref Count)” +1,并映射给“请求 B”的虚拟页表。全程不发生数据移动和矩阵运算。分叉(Fork): 当“请求 B”遇到不一致的新 Token 时,Radix Tree 在当前节点长出新分支,引擎仅对新 Token 执行增量 Prefill,并存入新的物理 Block
多轮对话、Agent、Tree-of-Thoughts、并行采样、结构化输出、动态分支推理
B. Block Hashing
Block 级别的哈希查表与粗粒度复用
引擎认为树结构太重了。它直接按底层的 Block 大小(比如 16 个 Token),为每个 Block 计算一个全局唯一的 Hash 值。为了保证因果顺序的绝对正确,当前 Block 的 Hash 值由“当前 16 个 Token 的内容 + 前一个 Block 的 Hash 值”共同决定。O(1) 查表匹配: “请求 B”到来时,引擎将其 Token 按 16 个一组计算 Hash,直接去全局 Hash 表里查。查到了,直接把物理 Block 挂载过来。
高并发聊天、RAG 批处理、模板化提示、简单多轮
驱逐与置换机制:GPU 与 CPU 的“Swap 交换空间”
HBM 是极其昂贵的(A 100 通常只有 80 GB)。当并发的 Subagents 太多,或者缓存的历史对话太长,物理 Block 用光了怎么办?
底层引擎实现了一套类似于操作系统的 Swap(交换)与 LRU(最近最少使用) 驱逐策略:
- LRU 驱逐: 当显存告急时,Radix Tree 会寻找那些引用计数为 0(目前没有 Agent 在用),且最久未被访问的叶子节点,释放它们占用的物理 Block。
- CPU Offload(卸载): 顶级的推理引擎(通过 PCIe/NVLink)会将暂时不用的 KV Cache 块,从昂贵的 GPU HBM 偷偷搬运到便宜的 CPU Host RAM(主机内存)中。
- Prefetch(预取): 当某个 Agent 重新唤醒,需要用到那些被踢到 CPU 里的缓存时,引擎再把它们搬回 GPU 显存。虽然这有 PCIe 带宽延迟,但搬运内存的速度,依然远远快于重新用算力去做 Prefill 矩阵乘法
KV Cache 被 Offload 到 CPU / Disk 后,会被缓存多久?没有固定时长,完全由 空间压力 + 淘汰策略 决定
如何更稳定地命中提示缓存 (实用)
当前 Agent 的核心运作机制是高频的 Tool-Calling 循环(如 ReAct),但大模型 API 是无状态的。这意味着 Agent 每执行一个新动作,都必须将庞大且重复的系统指令、工具描述和历史记录重新发送给模型进行全量预计算(Prefill),导致响应极慢且成本呈平方级爆炸。Prompt Caching 通过在云端内存中复用这些静态上下文的计算状态(KV Cache),不仅使重复 Token 的费用骤降 90%,更让 TTFT 降低 80% 到 90%
OpenAI 和 Anthropic 在他们的文档中提供了一些建议。主要思路是保持尽可能长的稳定前缀
使前缀稳定
- 把动态/用户特定内容从 system prompt 中移除,确保 system prompt 在所有用户间完全一致
- 不要截断/修改 messages 数组中已有的内容(包括 tool outputs),避免破坏前缀 hash chain
- 使用 sort_keys=True 序列化 JSON,避免语义相同但 key 顺序不同导致 cache miss
通过消息更新
Claude Code 的做法是将状态更新作为“事件”注入到动态的历史消息流(User Space)中。例如,在下一个 User Message 或 Tool Result 中隐式插入 <system-reminder> 标签来通知模型环境的变化。这保证了头部的 Kernel 缓存毫发无损。
会话中途跨模型切换必须通过 Subagent
缓存与具体模型(Model ID)是强绑定的。主脑(Opus)不应该切换自身模型,而是应该派生一个子进程(Subagent),把具体的局部任务打包成一个 Hand-off Message 交给 Haiku 执行。主脑继续维持其庞大且昂贵的缓存态。
工具集的稳定性与 “Pull-based”延迟加载
绝对不要在会话中途添加或删除工具。哪怕是为了“专注”而移除不相关的工具,也会因为破坏了缓存前缀而带来灾难性的延迟和成本。
这完美解释了为什么 Claude Code 坚持主脑只保留核心的原子工具。对于海量长尾工具(如几十个 MCP Tools),Claude Code 采用的正是 Pull-based 发现机制:
- 占位符缓存 (Stubs):在系统提示词中只放入工具的名字和 defer_loading: true(极轻量)。
- 主动拉取:Agent 通过 ToolSearch 工具在需要时再去拉取完整的 Schema。这保证了前缀始终一致,又实现了能力的无限扩展。
- 状态切换(如 Plan Mode):不通过缩减工具来实现“只读模式”,而是将 EnterPlanMode 作为工具本身,让模型自己调用来切换内部状态,维持外部工具列表的绝对静态。
内存压缩机制 :安全分叉
- 当上下文视窗耗尽(Context Rot 临界点),需要进行“记忆压缩 (Compaction)”生成摘要。如果用一个全新的干净 Prompt 来生成摘要,会面临 100% 的 Cache Miss
- 必须继承父进程的完整内存空间——使用与主会话一模一样的 System Prompt、工具集和历史记录,仅仅在最后追加一句“请总结上述对话”。这样,庞大的前缀缓存被完美命中,你只需要为最后那一句“压缩指令”的生成买单
将 Cache Hit Rate 接入监控与告警
{
"model": "claude-3-5-sonnet",
"max_tokens": 4096,
"cache_control": {"type": "ephemeral"}, // 【补充】全局开启自动跟随缓存
"system": [
// 绝对静态的 System Prompt
{"type": "text", "text": "You are a living Agent OS..."}
],
"tools": [
// 只包含极简的 20 个原子工具,长尾工具用占位符 defer_loading
{"name": "Spawn_Subagent", "description": "..."},
{"name": "ToolSearch", "description": "..."}
],
"messages": [
// 项目级静态上下文,位于最前
{"role": "user", "content": "<project_docs>...</project_docs>"},
// --- 隐式的缓存命中点,极大概率命中跨用户的 L1 Cache 【多租户共享内存】 ---
// 状态更新通过 Message 注入,绝不修改上方的前缀
{"role": "user", "content": "Let's start. <system-reminder>It's Wednesday</system-reminder>"},
// 动态对话流 (Append-only)
{"role": "assistant", "content": "..."}
// --- 自动缓存会把新的断点设置在这里,为下一次 Tick 做好准备 ---
]
}
另外
物理层极限压缩 :KV Cache 量化与稀疏化(Quantization & Pruning)
就算用了 PagedAttention 和 Radix Tree,HBM(显存)依然是极其有限的。
KV Cache 量化(FP 8 / INT 4)
默认情况下,算出来的 K 和 V 是 16 位浮点数(FP 16)。既然它们只是“内存快照”,我们完全可以通过算法把它们压缩成 8 位甚至 4 位的整数(INT 4)。 显存占用直接减半甚至缩小到四分之一
注意力稀疏化(StreamingLLM / H 2 O)
模型在生成时,并不需要看所有的历史 Token。它往往只关注“开头的几句话(Sink Tokens)”和“最近的几句话(Local Tokens)”,中间的很多废话(比如长文档的中间段落)根本用不上。底层引擎直接把这些“不重要”的 KV Cache 从显存里永久丢弃(Drop),只保留那 20% 重要的 Heavy Hitters(重磅特征)。这相当于给你的 Agent OS 加上了“潜意识过滤”功能,上下文可以无限长(Streaming),显存却永远不会爆。
架构层降维打击 :Prefill 与 Decode 分离部署(PD Disaggregation)
Prefill 集群: 专门准备一批算力极强(如 H 100)的机器,只负责算前缀。它们就像“编译器”,疯狂做 O(N 2) 的矩阵运算,产出 KV Cache。
Decode 集群: 专门准备一批显存极大、带宽极高的机器,只负责单步生成。
跨机网络传输: Prefill 机器算完 KV Cache 后,通过 RDMA(超高速网络)直接把内存快照“飞”到 Decode 机器的显存里!
算法层对抗访存瓶颈 :投机解码(Speculative Decoding)
Decode 阶段是 O(N)的访存瓶颈(GEMV),因为每次只能生成 1 个 Token。怎么打破物理规律,让它变快?让一个极小、极快的模型(比如 1 B 模型),先一口气往后“猜”出 5 个 Token(比如“今天/天气/很/不/错”)。因为它小,跑得极快。Verify(验证): 把这 5 个 Token 作为一个矩阵,丢给那个庞大的主模型(比如 70 B 模型)。此时,主模型的计算又变成了矩阵运算(GEMM,类似极小型的 Prefill)!接受/拒绝: 主模型瞬间算完,发现前 4 个词猜对了,第 5 个字猜错了。直接输出前 4 个词,抛弃第 5 个
逻辑层的前缀升维 :语义缓存(Semantic Caching)
Radix Tree 和 Block Hashing 有一个致命弱点:它们要求 Token 必须 100% 绝对一致,错一个标点符号都不行。如果用户 A 问:“如何重置密码?”用户 B 问:“怎么找回密码?”在底层引擎看来,这是两棵完全不同的树枝,必须算两次 Prefill。这就太死板了。工程实践: 在大模型网关的前面,再架设一层向量数据库(Vector DB)作为语义缓存层(比如开源的 GPTCache)。运行机制: 把用户的问题转化为 Embedding(向量)。如果发现用户 B 的问题在数学距离上和用户 A 极度相似,直接跳过大模型,把之前生成好的回答(文本字符串)丢给用户。