AI 工程基础体系 · 第 84/100 篇。内容覆盖机器学习、深度学习与生成式 AI;模型、数据、评测、权限和成本会作为同一生产系统处理。

Prompt Cache 工程:前缀复用、缓存键、隔离、失效和成本

Prompt Cache 通常指:当多个请求共享同一段 prompt 前缀时,系统保存这段前缀在 Transformer 中已经计算出的中间状态,后续请求直接复用,而不是从第一个 token 重新计算。

这里的“缓存”不是把某个问题的最终答案保存下来,也不是根据语义相似度寻找相近问题。它通常缓存的是模型执行 prefill 阶段后得到的 Key/Value 状态,因此更准确地说,是一种面向生成式模型推理的 前缀 KV cache


1. 先区分四种容易混淆的缓存

1.1 HTTP 响应缓存

HTTP 响应缓存保存:

请求 URL、请求体、请求头 → 完整响应

如果请求完全相同,可以直接返回旧响应,不必调用模型。

它适合缓存稳定、可复用的最终结果,例如:

GET /embedding-models/latest

但它不能处理“前缀相同、后缀不同”的请求。下面两个请求通常没有相同的完整响应:

系统提示词 + 用户问题 A
系统提示词 + 用户问题 B

1.2 语义缓存

语义缓存先计算问题的 embedding,再根据相似度寻找历史问题:

“如何申请退款?”
≈
“退款流程是什么?”

它可能返回历史答案,因此必须解决相似度阈值、答案时效性、权限和事实变化等问题。它不是 Transformer 前缀复用。

1.3 Transformer 的 KV cache

自回归 Transformer 每生成一个 token,都要让这个 token 关注之前的 token。工程上通常保存已经计算过的 Key 和 Value,避免在每一步重新计算历史 token。

这类 KV cache 通常只在一次请求的生成过程中使用。例如,模型已经处理:

“请用中文回答:”

接着生成第一个答案 token;在生成第二个、第三个 token 时,前面的上下文 KV 可以复用。

1.4 Prompt Cache

Prompt Cache 把 KV cache 的生命周期从“一次生成请求”延长到“多个请求之间”。

例如多个请求共享:

你是公司内部知识库助手。
回答必须使用中文。
只能根据给定文档回答。
以下是公司规章制度全文:
...

只要后面的用户问题不同,公共前缀的 KV 仍然可以复用。

因此:

Prompt Cache ⊂ 跨请求复用 KV cache

它不是模型规范中的统一 API 名称。不同云模型服务、推理框架和自建系统可能对它使用不同名称、计费方式和可配置能力。必须以具体模型服务或框架版本的文档为准,不能假设一个服务支持另一个服务的参数。


2. 为什么前缀 KV 可以复用

2.1 自回归 Transformer 的计算结构

给定 token 序列:

x1,x2,,xnx_1, x_2, \ldots, x_n

因果自注意力要求位置 ii 只能看到当前位置及之前的 token:

Attention(xi)=softmax(QiKiTdk)Vi\text{Attention}(x_i) = \text{softmax} \left( \frac{Q_i K_{\leq i}^{T}}{\sqrt{d_k}} \right) V_{\leq i}

其中:

  • QiQ_i:当前位置 ii 的 Query;
  • KiK_{\leq i}:位置 11ii 的 Key;
  • ViV_{\leq i}:位置 11ii 的 Value;
  • dkd_k:Key 向量维度。

一个 Transformer 层会为每个输入位置计算 KKVV。对于已经处理过的前缀:

x1 x2 x3 x4

如果下一个请求仍然从完全相同的 token 序列开始,那么这些 token 在相同模型和相同执行配置下产生的 Key/Value 可以复用。

新请求:

x1 x2 x3 x4 y1 y2

只需:

  1. 读取 x1...x4 的缓存 KV;
  2. y1y2 执行新增计算;
  3. 生成后续输出。

2.2 前缀复用的形式化条件

设请求 AA 的 token 序列为:

TA=(t1,t2,,tm,a1,)T_A = (t_1, t_2, \ldots, t_m, a_1, \ldots)

请求 BB 的 token 序列为:

TB=(t1,t2,,tm,b1,)T_B = (t_1, t_2, \ldots, t_m, b_1, \ldots)

如果满足:

  1. 两个序列的前 mmtoken ID 完全相同
  2. 使用同一个模型权重;
  3. 使用同一个 tokenizer 和 tokenization 配置;
  4. 位置编码规则相同;
  5. 影响前向计算的模型配置相同;
  6. KV 的 dtype、量化格式、张量布局和实现兼容;

则可以复用前 mm 个 token 的 KV 状态。

这里最重要的是“token ID 完全相同”,而不是字符串看起来相同,更不是语义相同。

例如,以下字符串差异可能导致 token 序列不同:

"请回答问题"
"请回答问题\n"
"请回答问题 "

Unicode 规范化、换行符、特殊 token、模板格式和 tokenizer 版本都可能改变 token ID。

2.3 为什么前缀必须从序列开头连续匹配

因果注意力中的位置 ii 会依赖它之前的上下文。假设两个请求只有中间部分相同:

请求 A: 系统提示词 + 用户身份 A + 文档
请求 B: 系统提示词 + 用户身份 B + 文档

“文档”虽然相同,但它在两个请求中的前置上下文不同,因此文档 token 对应的 KV 也可能不同,不能直接按文本片段独立复用。

一般缓存结构是前缀树或哈希链:

根
└── 系统提示词
    └── 工具定义
        └── 规章全文
            ├── 用户问题 A
            └── 用户问题 B

只有从根开始连续命中的路径才能复用。中间出现一个不同 token 后,后续节点通常都不能继续命中。


3. Prefill、Decode 和 Prompt Cache 的数据流

一次生成请求通常可以分为两个阶段。

3.1 Prefill 阶段

Prefill 一次处理输入 prompt:

系统提示词 + 工具定义 + 用户问题

它的目标是建立输入序列对应的隐藏状态和 KV。输入越长,prefill 计算量和显存访问量通常越大。

3.2 Decode 阶段

Decode 阶段每次生成一个或少量 token,并使用已有 KV:

输入 prompt → 输出 token 1 → 输出 token 2 → 输出 token 3

Decode 受序列长度和内存带宽影响明显。Prompt Cache 主要节省的是重复请求中的 prefill 工作,并减少需要重新写入和加载的 KV。

典型流程如下:

sequenceDiagram
    participant C as 客户端
    participant G as 网关
    participant K as Prompt Cache
    participant M as 模型服务

    C->>G: 请求:公共前缀 + 用户后缀
    G->>G: tokenizer 与规范化
    G->>K: 查找最长匹配前缀
    alt 命中公共前缀
        K-->>G: 返回前缀 KV 与已匹配 token 数
        G->>M: 仅计算未命中的后缀并生成
    else 未命中
        K-->>G: miss
        G->>M: 计算完整 prompt 并生成
        M-->>G: 返回可缓存前缀 KV
        G->>K: 写入缓存
    end
    G-->>C: 流式或完整响应

这张图中有一个重要细节:缓存命中后,模型服务仍然要处理未命中的后缀。Prompt Cache 不是“无需调用模型”,而是“减少模型调用中的一部分计算”。


4. 缓存粒度:缓存整个 prompt 还是缓存前缀节点

4.1 整个 prompt 缓存

最简单的实现把完整 token 序列作为键:

完整 token 序列 → KV

只有完全相同的 prompt 才能命中。这种实现容易管理,但无法利用“公共系统提示词 + 不同用户问题”的共享部分。

4.2 前缀树缓存

更高效的实现按 token 或 token 块建立前缀节点:

节点 N0: 空前缀
节点 N1: 前 128 个 token
节点 N2: 前 256 个 token
节点 N3: 前 384 个 token

请求到达时,系统寻找最长的已缓存前缀。例如:

缓存已有:前 256 个 token
新请求长度:410 个 token
命中长度:256 个 token
需要计算:第 257 到 410 个 token

为了降低哈希和元数据开销,工程实现常按固定大小的 token block 缓存,而不是每个 token 一个节点。

4.3 块边界的影响

如果缓存块大小为 128 token,则:

已有公共前缀:250 token
可复用块:128 token
剩余 122 token:可能不能作为完整缓存块复用

具体行为依赖实现。有些系统支持部分块,有些只缓存完整块。文档没有明确说明时,不能把“文本前缀长度”直接等同于“可复用 token 数”。


5. 缓存键:决定什么可以共享

缓存键不是简单地对 prompt 字符串做 hash。一个安全的逻辑键至少要包含影响 KV 正确性和访问权限的字段。

可以抽象为:

K=H(scope,model identity,tokenizer identity,generation context,token IDs,execution format)K = H( \text{scope}, \text{model identity}, \text{tokenizer identity}, \text{generation context}, \text{token IDs}, \text{execution format} )

其中:

  • scope:租户、用户、会话或数据权限范围;
  • model identity:模型名称、权重版本、量化版本、适配器版本;
  • tokenizer identity:tokenizer 文件或版本;
  • generation context:系统提示词、工具定义、模板和其他会进入上下文的内容;
  • token IDs:经过最终模板渲染和 tokenization 后的序列;
  • execution format:KV dtype、量化方式、张量布局和硬件后端等。

5.1 为什么只用字符串 hash 不够

以下请求文本可能完全相同:

“请总结这份文档。”

但它们使用的模型不同:

model-a-v1
model-a-v2

或者使用不同 LoRA:

base-model + legal-adapter
base-model + medical-adapter

相同 token 的 KV 也不能混用。

反过来,即使模型名称相同,服务端滚动更新权重后,旧 KV 也可能不再兼容。因此生产系统通常把不可变的模型版本或权重摘要纳入键,而不是只使用一个浮动的模型别名。

5.2 Tokenizer 版本必须进入兼容条件

缓存的 KV 对应的是 token 序列,而不是原始字符串。若 tokenizer 变化:

字符串 → token ID

这个映射可能改变。

即使模型权重名称不变,tokenizer 文件、special token 配置或 chat template 发生变化,也应视为缓存空间变化。

5.3 生成参数是否影响前缀 KV

需要区分两类参数。

通常不影响已输入前缀 KV 的参数:

  • temperature;
  • top-p;
  • top-k;
  • 最大新生成 token 数;
  • 随机种子。

这些参数主要影响 decode 阶段的采样。

可能影响前缀状态或缓存可用性的参数:

  • 模型权重或 LoRA;
  • chat template;
  • 视觉输入的预处理;
  • position ID;
  • RoPE scaling;
  • attention 实现和 KV 格式;
  • 影响输入序列的工具调用格式;
  • 多模态输入中的图像 token 或音频特征。

因此,不能简单规定“所有请求参数都必须进键”,也不能简单规定“只有 prompt 字符串进键”。正确做法是建立一份明确的兼容性清单。


6. 一个完整的缓存键例子

假设请求经过模板渲染后变为:

<|system|>
你是内部财务助手,只能引用授权文档。
<|user|>
请计算报销金额。
<|assistant|>

可以构造如下逻辑键:

{
  "tenant_id": "tenant-42",
  "model_digest": "sha256:...",
  "tokenizer_digest": "sha256:...",
  "chat_template_digest": "sha256:...",
  "adapter_digest": null,
  "rope_config_digest": "sha256:...",
  "kv_format": "bf16",
  "auth_scope": "finance-read",
  "token_prefix_hash": "sha256:..."
}

其中:

  • model_digest 保证权重兼容;
  • tokenizer_digest 保证 token ID 解释一致;
  • chat_template_digest 保证角色和特殊 token 格式一致;
  • auth_scope 防止不同权限范围错误共享;
  • token_prefix_hash 标识具体 token 前缀。

实际系统可能将部分字段放到命名空间,部分字段放到哈希输入中。关键不是 JSON 的具体形式,而是:所有影响正确性或权限的因素必须参与隔离。


7. 前缀布局决定命中率

Prompt Cache 只能复用前缀。因此,同样的信息,如果排列顺序不同,缓存收益可能完全不同。

7.1 低命中布局

系统提示词
当前时间:2025-03-08
请求 ID:abc123
用户问题
长篇知识库内容

每次请求开头都包含变化字段:

当前时间不同
请求 ID 不同

如果变化字段位于公共前缀中间,后续长篇知识库内容也无法复用。

7.2 更适合缓存的布局

系统提示词
长篇稳定知识库内容
当前时间:2025-03-08
请求 ID:abc123
用户问题

这样可以复用:

系统提示词 + 长篇稳定知识库内容

动态字段位于后部,不会破坏前缀。

但这不是无条件的建议。动态字段如果会改变模型对前面内容的解释,或者权限过滤本身需要发生在文档之前,就不能为了命中率把它盲目移到后面。缓存优化不能违反上下文语义和访问控制。

7.3 系统提示词的版本化

将公共前缀拆成稳定段和动态段:

稳定段:
- 角色定义
- 输出格式
- 工具协议
- 文档规则

动态段:
- 当前日期
- 用户身份
- 会话状态
- 请求追踪 ID

稳定段可使用显式版本:

prompt-schema: finance-assistant-v7
policy-version: 2025-03-01

版本不仅方便缓存键设计,也方便在内容更新后主动失效。


8. 成本模型:节省的是重复前缀计算

8.1 Token 级成本模型

设:

  • PP:prompt token 数;
  • CC:输出 token 数;
  • HH:命中的前缀 token 数;
  • rpr_p:未缓存 prompt token 的单价;
  • rhr_h:缓存命中 token 的单价;
  • ror_o:输出 token 的单价;
  • NN:请求数量。

未使用 Prompt Cache 时,近似成本为:

Costno-cache=N(Prp+Cro)\text{Cost}_{\text{no-cache}} = N(P r_p + C r_o)

使用缓存后,若第一次请求完整计算,后续请求命中 HH 个 token:

CostcachePrp+(N1)((PH)rp+Hrh)+NCro\text{Cost}_{\text{cache}} \approx P r_p + (N-1)((P-H)r_p + H r_h) + N C r_o

还应减去或加上缓存存储与管理成本:

Net saving=Costno-cacheCostcacheStorageCostManagementCost\text{Net saving} = \text{Cost}_{\text{no-cache}} - \text{Cost}_{\text{cache}} - \text{StorageCost} - \text{ManagementCost}

实际云服务可能按“缓存写入 token”“缓存读取 token”“普通输入 token”“输出 token”分别计价,也可能对缓存命中没有独立价格。公式必须根据具体服务的计费规则调整。

8.2 数值算例

假设:

  • 每个请求有 P=10,000P=10{,}000 个输入 token;
  • 其中 H=8,000H=8{,}000 个 token 是稳定公共前缀;
  • 每次输出 C=500C=500 个 token;
  • N=100N=100 个请求;
  • 普通输入价格为每百万 token 1 美元;
  • 缓存命中输入价格为每百万 token 0.1 美元;
  • 输出价格为每百万 token 2 美元;
  • 暂不计算存储成本。

无缓存时:

100×(10,000/1,000,000×1+500/1,000,000×2)=1.1 美元100 \times (10{,}000 / 1{,}000{,}000 \times 1 + 500 / 1{,}000{,}000 \times 2) = 1.1\text{ 美元}

若第一次请求完整计算,后续 99 次请求的 8,000 token 命中:

10,000/1,000,000×1+99×(2,000/1,000,000×1+8,000/1,000,000×0.1)+100×500/1,000,000×210{,}000 / 1{,}000{,}000 \times 1 + 99 \times \left( 2{,}000 / 1{,}000{,}000 \times 1 + 8{,}000 / 1{,}000{,}000 \times 0.1 \right) + 100 \times 500 / 1{,}000{,}000 \times 2

结果约为:

0.01+99×0.0028+0.1=0.3872 美元0.01 + 99 \times 0.0028 + 0.1 = 0.3872\text{ 美元}

这只是一个假设价格下的输入成本模型。真实收益还受以下因素影响:

  • 缓存是否真的命中;
  • 服务是否只在完整块命中时计入缓存价格;
  • 首次请求是否收费为缓存写入价;
  • 缓存 TTL;
  • 多副本之间是否共享缓存;
  • KV 在 GPU、CPU 或远程存储中的迁移成本;
  • 命中后是否仍受上下文窗口限制;
  • 缓存查询和序列拼接是否增加延迟。

8.3 延迟收益不等于成本收益

Prompt Cache 可能降低 prefill 延迟,但不一定显著降低端到端延迟。

如果输出很长,decode 阶段可能占主要时间;如果缓存 KV 需要从远程存储搬运到 GPU,读取开销可能抵消部分计算收益。

因此至少要分别观察:

cache lookup latency
prefill latency
KV restore latency
decode time
time to first token, TTFT
tokens per second
GPU memory pressure

不能只看平均总耗时判断缓存是否有效。


9. KV 的内存成本和容量估算

对于 decoder-only Transformer,KV cache 的大小可以近似写为:

SKV=B×L×T×2×HKV×D×bytesS_{\text{KV}} = B \times L \times T \times 2 \times H_{\text{KV}} \times D \times \text{bytes}

其中:

  • BB:batch 或序列数量;
  • LL:Transformer 层数;
  • TT:缓存 token 数;
  • 22:分别保存 Key 和 Value;
  • HKVH_{\text{KV}}:KV head 数;
  • DD:每个 head 的维度;
  • bytes:每个元素占用的字节数,例如 BF16 通常为 2。

例如一个模型使用:

  • 32 层;
  • 32 个 KV heads;
  • 每个 head 维度 128;
  • 10,000 个 token;
  • BF16;

单条序列的 KV 大小约为:

32×10,000×2×32×128×232 \times 10{,}000 \times 2 \times 32 \times 128 \times 2

约为 5.24 GB 的原始张量空间。

这说明一个重要事实:长公共前缀虽然可能节省大量 prefill 计算,但把它长期放在 GPU 上也可能迅速耗尽显存。实际模型可能使用 GQA 或 MQA,使 HKVH_{\text{KV}} 小于 Query head 数;也可能使用 KV 量化、分页内存或 CPU/远程分层存储,因此最终大小要以具体实现为准。


10. 缓存隔离:正确性之外的安全边界

Prompt Cache 中保存的 KV 不一定能够直接还原原始文本,但不能因此把它视为无敏感性数据。

它可能:

  • 反映用户输入或内部文档;
  • 通过模型输出影响后续请求;
  • 占用共享 GPU 和缓存容量;
  • 产生命中时间、容量和淘汰等侧信道;
  • 在实现缺陷下导致跨租户状态复用。

10.1 三种隔离级别

公开共享

适合:

所有请求都使用相同的公开系统提示词

例如公开产品说明、固定格式协议。此时可以跨用户共享,但仍需隔离模型版本和执行格式。

租户级共享

适合:

同一企业租户内共享企业知识库前缀

缓存键中至少应包含:

tenant_id + authorization_scope + document_version

不能仅因为两个用户属于同一组织,就假设他们拥有相同文档权限。

用户或会话级隔离

适合包含:

  • 用户隐私;
  • 个人聊天历史;
  • 未脱敏客户数据;
  • 权限高度动态的检索内容。

此时缓存应绑定用户、会话或更细粒度的授权上下文,牺牲共享率换取更清晰的安全边界。

10.2 权限不能只在缓存写入时检查

一种错误设计是:

  1. 用户 A 有权访问文档;
  2. 系统将文档前缀写入共享缓存;
  3. 用户 B 请求同一前缀;
  4. 系统只根据 token hash 命中,不重新检查权限。

缓存命中是一个数据访问动作,权限检查必须在命中路径上成立。可以采用:

缓存键绑定授权范围

或:

缓存内容只包含公开数据,权限数据不进入共享前缀

如果权限集合是动态的,必须将权限版本、策略版本或文档可见性版本纳入键,并在权限变化时失效。

10.3 加密不能替代隔离

静态加密和传输加密可以保护缓存介质,但不能解决:

  • 错误租户命中;
  • 错误模型复用;
  • 进程内对象引用泄漏;
  • 共享 GPU 显存的生命周期问题;
  • 通过输出或时延推断缓存存在。

加密保护的是存储路径,命名空间和授权检查保护的是访问语义,两者不能互相替代。


11. 失效:什么时候旧 KV 必须不能再用

缓存失效不是“过期时间到了就删掉”这么简单。至少应区分正确性失效、权限失效、隐私删除和容量淘汰。

11.1 正确性失效

以下变化通常要求新建缓存命名空间或主动删除旧条目:

  • 模型权重变化;
  • tokenizer 变化;
  • chat template 变化;
  • system prompt 变化;
  • 工具 schema 变化;
  • 文档内容变化;
  • 文档排序、分块或拼接方式变化;
  • RoPE 或 position ID 配置变化;
  • KV dtype、量化格式或张量布局变化。

最稳妥的方法是使用内容寻址版本:

model_digest
tokenizer_digest
prompt_policy_digest
document_snapshot_digest
kv_format

任何一个摘要变化,都会产生不同的逻辑键。

11.2 TTL 与 LRU 解决不同问题

TTL 控制条目的最长存活时间,适合处理:

即使没有显式更新,也希望定期重新计算

LRU 按最近使用情况淘汰条目,适合控制容量。

它们不是替代关系:

  • 长时间不使用的条目可能先被 LRU 删除;
  • 高频使用但内容已经不符合策略的条目仍可能存活到 TTL;
  • 发生权限撤销或隐私删除时,不能等待 TTL。

11.3 主动失效的事件链

文档更新时,推荐发出带版本的事件:

document_id = handbook-2025
old_version = v12
new_version = v13

缓存服务根据文档版本索引删除旧前缀,或者让新版本自然使用新的键。

如果只把文档 ID 放入键、不放文档版本,那么更新后旧内容仍可能命中;如果只使用 TTL,过期窗口内就可能回答旧事实。

11.4 隐私删除比逻辑失效更严格

“从索引中不可见”不等于“数据已删除”。如果法规或内部政策要求删除,可能需要处理:

  • GPU 内存中的 KV;
  • CPU 内存中的 KV;
  • Redis、对象存储或本地磁盘;
  • 副本和跨区域副本;
  • checkpoint 或快照;
  • 访问日志、debug dump 和 trace;
  • 备份恢复系统。

一个逻辑 tombstone 可以阻止后续命中,但不能自动清除已经复制的字节。

11.5 淘汰时的并发问题

考虑下面的时序:

请求 A:命中旧条目,开始恢复 KV
管理线程:删除并释放旧条目
请求 A:继续访问已释放内存

实现必须使用引用计数、租约、读写锁或不可变对象,保证正在使用的 KV 不会被提前释放。


12. 并发、重复计算和故障路径

12.1 Cache stampede

当一个热门前缀刚过期时,100 个请求可能同时 miss:

请求 1:miss → 计算 10,000 token
请求 2:miss → 计算 10,000 token
...
请求 100:miss → 计算 10,000 token

这会造成 GPU 计算突增。

常见的处理方式是 per-key singleflight:

  1. 第一个请求成为 leader;
  2. 后续请求发现该键正在构建;
  3. 后续请求等待 leader;
  4. leader 成功后,所有请求复用新条目;
  5. leader 失败时,等待者走回退路径,并设置重试退避。

singleflight 只应针对有限时间等待。不能让所有请求无限等待一个损坏的构建任务。

12.2 写入必须是原子的

缓存条目至少有以下状态:

ABSENT → BUILDING → READY
                    └→ FAILED
READY → EVICTING → ABSENT

只有 READY 条目允许被读取。不能先写入部分 KV,再把状态标记为可读,否则请求可能拿到长度不完整或层数不完整的缓存。

12.3 GPU 资源不足

命中并不保证一定能恢复成功。可能发生:

  • GPU 显存不足;
  • KV block 碎片化;
  • 当前 batch 无法拼接;
  • cache 与当前 attention kernel 不兼容;
  • 远程 KV 下载超时;
  • 模型副本正在滚动升级。

正确的故障策略通常是:

缓存恢复失败
→ 释放本次临时资源
→ 将请求按完整 prefill 执行
→ 记录失败原因
→ 不要把损坏条目标记为 READY

不能因为缓存失败就返回错误,除非业务明确要求缓存可用;Prompt Cache 通常应是性能优化,而不是正确性依赖。


13. 一个可运行的前缀缓存模型

下面的 Python 示例不执行神经网络,只模拟核心逻辑:

  • token ID 作为真实匹配单位;
  • 按租户和模型版本隔离;
  • 查找最长前缀;
  • 只有完整构建后才进入可读状态。
from dataclasses import dataclass
from typing import Dict, Tuple, Optional


TokenSeq = Tuple[int, ...]


@dataclass
class CacheEntry:
    prefix: TokenSeq
    state: str  # BUILDING or READY
    fake_kv: str


class PrefixCache:
    def __init__(self):
        self.entries: Dict[Tuple[str, str, TokenSeq], CacheEntry] = {}

    def _key(self, tenant: str, model_digest: str, prefix: TokenSeq):
        return tenant, model_digest, prefix

    def longest_ready_prefix(
        self,
        tenant: str,
        model_digest: str,
        tokens: TokenSeq,
    ) -> Optional[CacheEntry]:
        for length in range(len(tokens), 0, -1):
            prefix = tokens[:length]
            entry = self.entries.get(self._key(tenant, model_digest, prefix))
            if entry and entry.state == "READY":
                return entry
        return None

    def begin_build(
        self,
        tenant: str,
        model_digest: str,
        prefix: TokenSeq,
    ) -> CacheEntry:
        key = self._key(tenant, model_digest, prefix)
        if key in self.entries:
            raise RuntimeError("prefix is already cached or being built")

        entry = CacheEntry(
            prefix=prefix,
            state="BUILDING",
            fake_kv=f"kv-for-{prefix}",
        )
        self.entries[key] = entry
        return entry

    def commit(self, tenant: str, model_digest: str, prefix: TokenSeq):
        key = self._key(tenant, model_digest, prefix)
        entry = self.entries[key]

        if entry.state != "BUILDING":
            raise RuntimeError("only BUILDING entries can be committed")

        entry.state = "READY"

    def invalidate_model(self, model_digest: str):
        keys = [
            key for key in self.entries
            if key[1] == model_digest
        ]
        for key in keys:
            del self.entries[key]


cache = PrefixCache()

tenant = "tenant-a"
model_v1 = "model-sha256-v1"

public_prefix = (101, 102, 103, 104)
request_a = public_prefix + (201, 202)
request_b = public_prefix + (301, 302)

# 第一次请求:没有命中,构建公共前缀
entry = cache.begin_build(tenant, model_v1, public_prefix)
cache.commit(tenant, model_v1, public_prefix)

# 第二次请求:找到最长 READY 前缀
hit = cache.longest_ready_prefix(tenant, model_v1, request_b)

print("命中 token 数:", len(hit.prefix) if hit else 0)
print("需要新计算的 token:", request_b[len(hit.prefix):] if hit else request_b)

# 使用新模型版本时,旧版本缓存不会被错误复用
model_v2 = "model-sha256-v2"
miss = cache.longest_ready_prefix(tenant, model_v2, request_b)
print("新模型版本命中:", miss is not None)

预期输出:

命中 token 数: 4
需要新计算的 token: (301, 302)
新模型版本命中: False

这个示例中的 fake_kv 只是占位字符串,不能用于真实推理。真实实现还必须处理:

  • 多层、多头 KV 张量;
  • GPU 内存分配;
  • block table;
  • batch 拼接;
  • 引用计数;
  • 并发构建;
  • TTL 和 LRU;
  • 模型副本间一致性;
  • 序列位置和 attention mask。

它仍然有价值,因为它展示了一个容易被忽略的事实:缓存命中的是 token 前缀,而不是请求对象或字符串后缀。


14. Hugging Face Transformers 中的边界

Hugging Face Transformers 文档中的 use_cachepast_key_values 主要描述生成过程中的 KV 缓存接口。它们支持模型在逐 token 生成时复用历史 Key/Value,但这不自动等同于一个完整的跨请求 Prompt Cache 产品。

典型生成过程的概念代码如下:

from transformers import AutoTokenizer, AutoModelForCausalLM

model_name = "your-compatible-causal-lm"

tokenizer = AutoTokenizer.from_pretrained(model_name)
model = AutoModelForCausalLM.from_pretrained(
    model_name,
    torch_dtype="auto",
    device_map="auto",
)

inputs = tokenizer("请用中文解释因果注意力。", return_tensors="pt")
inputs = {k: v.to(model.device) for k, v in inputs.items()}

outputs = model.generate(
    **inputs,
    max_new_tokens=64,
    use_cache=True,
)

print(tokenizer.decode(outputs[0], skip_special_tokens=True))

这里的 use_cache=True 表示生成期间使用 KV cache。它通常不会替工程师完成以下工作:

  • 在两个独立请求间安全保存和查找公共前缀;
  • 处理租户隔离;
  • 识别模型权重变化;
  • 管理跨进程或跨机器 KV;
  • 执行 TTL、LRU 和主动删除;
  • 防止 cache stampede;
  • 把缓存条目与权限策略绑定。

不同 Transformers 版本、模型架构和生成接口对 past_key_values 的具体类型、形状和生命周期可能不同。直接持久化内部对象属于实现级集成,应锁定依赖版本并写兼容性测试,不能只依赖一个通用示例。

自建系统一般需要在模型服务内部实现:

tokenize
→ 找最长公共前缀
→ 将对应 KV block 映射到当前请求
→ 只对剩余 token prefill
→ 继续 decode

如果把 KV 从一个模型实例传到另一个实例,还必须保证模型权重、设备类型、dtype、attention kernel、量化格式和位置编码设置兼容。仅仅让 Python 对象可以序列化,并不代表它可以被另一个推理实例正确消费。


15. 失效和版本变更的状态设计

一个可操作的状态机可以如下:

stateDiagram-v2
    [*] --> ABSENT
    ABSENT --> BUILDING: miss / 获得构建锁
    BUILDING --> READY: KV 完整且校验通过
    BUILDING --> FAILED: 超时、OOM、模型错误
    FAILED --> ABSENT: 清理并允许重试
    READY --> READING: 请求引用
    READING --> READY: 引用释放
    READY --> EVICTING: TTL、LRU、版本失效
    EVICTING --> ABSENT: 无活动引用后释放

关键规则是:

  1. BUILDING 不能被普通读请求当作可用缓存;
  2. READY 必须通过长度、模型版本和张量元数据校验;
  3. EVICTING 不能立即释放仍被请求引用的 GPU 内存;
  4. 版本失效应阻止新请求继续命中旧条目;
  5. 失败条目不能无限阻塞后续请求。

若模型正在灰度发布,建议把缓存空间按模型摘要隔离:

model-v1 digest → cache namespace v1
model-v2 digest → cache namespace v2

切换流量后再逐步回收旧空间。这样可以避免滚动升级过程中不同副本对同一个键返回不同格式的 KV。


16. 监控指标必须能解释“为什么没有收益”

只记录一个 cache_hit=true/false 不够。至少需要:

16.1 命中相关

request_count
full_miss_count
prefix_hit_count
hit_tokens
requested_prompt_tokens
hit_ratio_by_tokens
hit_ratio_by_requests

请求命中率和 token 命中率可能差异很大:

100 个请求中 90 个命中 10 token
10 个请求中 10 个命中 10,000 token

按请求统计会显得命中率很高,但实际节省的计算可能很少。成本分析更应关注 token 加权命中率。

16.2 原因分类

miss 应区分:

NO_ENTRY
TOKENIZER_MISMATCH
MODEL_VERSION_MISMATCH
AUTH_SCOPE_MISMATCH
PREFIX_TOO_SHORT
ENTRY_EXPIRED
EVICTED
KV_RESTORE_FAILED
OOM

否则“命中率下降”无法判断是 prompt 布局变化,还是模型升级导致的预期失效。

16.3 资源相关

GPU KV bytes
CPU KV bytes
remote KV bytes
entry count
eviction count
build wait time
singleflight waiters
restore latency
prefill latency before/after cache

如果命中率很高,但 KV restore latency 和 GPU 内存回收时间上升,系统仍可能变慢。


17. 常见错误与反例

17.1 误以为语义相同即可命中

“请解释 Transformer”
“请介绍 Transformer”

即使语义近似,token 序列通常不同,前缀缓存不会因此命中。需要语义缓存的是另一套系统,不能混用正确性假设。

17.2 把动态请求 ID 放在公共前缀中

系统提示词 + request_id + 长文档 + 用户问题

request_id 每次变化会阻断长文档复用。应将其移到不会破坏稳定前缀的位置,或者不要把它放入模型上下文。

17.3 只按 prompt 文本做键

忽略模型版本、模板、适配器和 KV 格式,可能出现两类问题:

  • 错误复用:模型读取不兼容的 KV;
  • 隐蔽错误:没有崩溃,但输出质量下降。

后一类更危险,因为它很难通过基础可用性监控发现。

17.4 把检索结果直接放进共享缓存

RAG 系统中,检索结果可能带有用户权限。如果把“公共系统提示词 + 当前用户可见文档”作为跨租户公共前缀,后续用户可能错误复用到不应看到的上下文。

更安全的拆分是:

跨用户共享:公开、稳定的系统前缀
租户内共享:租户授权一致的文档前缀
用户级缓存:用户特有检索内容

17.5 把 Prompt Cache 当作答案缓存

Prompt Cache 命中后,模型仍然需要处理新的后缀并生成答案。因此它不能保证:

  • 相同输出;
  • 不再消耗输出 token;
  • 不再受随机采样影响;
  • 不再需要模型调用。

如果业务目标是“相同问题直接返回历史答案”,应设计响应缓存并单独处理答案新鲜度和权限。

17.6 只看输入 token 价格,忽略显存

长前缀重复率很高时,缓存可能在账单上节省输入费用,但大量 GPU KV 会降低并发能力,导致:

更多请求排队
decode 速度下降
OOM 增加
模型副本数量上升

成本优化必须同时计算 token 成本、GPU 成本和延迟成本。


18. 生产系统中的边界决策

是否值得建立 Prompt Cache,取决于以下条件是否同时成立:

  1. 请求之间有稳定且足够长的 token 前缀;
  2. 这些请求能安全共享相同的授权范围;
  3. KV 生命周期和模型版本可被可靠管理;
  4. KV 存储成本没有抵消 prefill 节省;
  5. 命中后的恢复路径不会成为新的瓶颈;
  6. miss 时可以透明回退到普通推理。

尤其要测量“前缀长度分布”,而不是只看平均 prompt 长度。一个系统的平均 prompt 可能有 20,000 token,但如果每次请求前 2,000 token 都不同,Prompt Cache 的收益仍然有限。

可以用下面的估算指标:

ReusableTokenRatio=iHiiPi\text{ReusableTokenRatio} = \frac{\sum_i H_i}{\sum_i P_i}

其中:

  • PiP_i:第 ii 个请求的输入 token 数;
  • HiH_i:第 ii 个请求实际命中的前缀 token 数。

再结合:

EffectiveSaving=TokenSavingKVStorageCostRestoreCostCapacityPenalty\text{EffectiveSaving} = \text{TokenSaving} - \text{KVStorageCost} - \text{RestoreCost} - \text{CapacityPenalty}

只有 EffectiveSaving 为正,并且没有引入权限或数据删除风险时,缓存才是生产优化,而不是把计算成本转移成内存和运维成本。

Prompt Cache 的核心不是“把 prompt 存起来”,而是维护一个满足以下不变量的中间状态系统:

相同且兼容的 token 前缀
→ 可复用的 KV 状态
→ 在正确权限范围内被安全读取
→ 在模型、策略、数据或隐私要求变化时及时失效
→ 在缓存失败时不影响普通推理

其中任何一个条件不成立,缓存都可能从性能优化变成错误来源、数据隔离漏洞或不可控成本。


系列导航与关联阅读

官方资料

本文依据研究论文、标准组织与主流框架官方文档重新梳理;正文、示例与工程清单由 WR BLOG 编写。