缓存 Key 规范与 Claude 动态/静态 Prompt Cache 机制详解
适用对象:Agent 工程落地 / 毕设技术章节 / 团队知识库 主线:本地缓存 Key → 云端动静边界 →
{memory}归属 → 两层缓存对比 校订说明:本文在原始笔记基础上,按 Anthropic 官方《Prompt caching》《How Claude Code uses prompt caching》《Modifying system prompts》逐条核对,修正三处:①「静态缓存永不失效」不成立,缓存有 TTL, inactivity 会过期;② 动静边界并非在所有环境下都生效 ,Bedrock / Vertex / Foundry / LLM 网关下整块发送;③ 动态段要分清楚"system prompt 内 boundary 之后"与"system prompt 之外的 messages",{memory}属于前者,而 SideQuery 召回片段、工具结果、对话历史属于后者。其余结构与结论保留。
缓存这个词在 Agent 工程里至少指两件完全不同的事:一件是你自己代码里的本地缓存 (省 CPU),一件是Claude API 的 Prompt Cache(省 Token 和钱)。这两层经常被混为一谈,于是就会出现"我加了缓存,为什么账单没降"这类问题。
这篇把两层彻底分开讲清楚,并给出 {memory} 归属的定论。
一、本地缓存 Key 设计规范(工程层)
1.1 为什么不能用 Python 内置 hash()
hash() 有两个致命缺陷,决定了它只能用于运行期的数据结构(如 dict 查找),不能用于工程化的缓存索引。
缺陷一:进程级哈希种子随机化。
Python 默认开启哈希随机化(PEP 456,PYTHONHASHSEED)。同一个字符串、字典、列表,在不同进程、不同次启动中 hash() 值完全不同。
python
# 进程 A
hash("hello") # 比如 3134078793093316572
# 进程 B(重启后)
hash("hello") # 变成完全不同的另一个值
缓存索引的第一要求是可复现:这次进程写入、下次进程还能命中。随机化种子直接否掉了这一点。
缺陷二:不支持可变结构化类型。
list / dict 是不可哈希类型,直接调用会报错:
python
hash({"model": "claude", "temp": 0.7}}
# TypeError: unhashable type: 'dict'
而 Agent 场景里要缓存的恰恰是结构化上下文------工具参数、状态字典、消息列表。
1.2 工程标准方案:json.dumps
统一用序列化字符串作为缓存 Key:
python
cache_key = json.dumps(data, sort_keys=True, ensure_ascii=False)
| 参数 | 作用 | 不带的后果 |
|---|---|---|
sort_keys=True |
递归排序字典键 | 内容相同、键顺序不同的两个字典会生成两个 Key,缓存永远命中不了 |
ensure_ascii=False |
保留中文与特殊字符原样 | 中文被转义成 \uXXXX,Key 可读性差,且差异化排查困难 |
三个落地增强(可选,但强烈建议):
python
import hashlib, json
def make_cache_key(data, prefix="ctx") -> str:
raw = json.dumps(
data,
sort_keys=True,
ensure_ascii=False,
separators=(",", ":"), # 压缩空白,Key 更短
default=str, # 兜住 datetime / Enum / 自定义对象
)
if len(raw) > 512: # 超长 Key 二次摘要,避免撑爆内存与日志
raw = hashlib.sha256(raw.encode("utf-8")).hexdigest()
return f"{prefix}:{raw}"
要点:
separators去掉默认空格,Key 体积更小;default=str让不可 JSON 序列化的对象不至于抛异常;- 超长 Key 用
hashlib.sha256二次摘要 ------这是稳定哈希,与 Python 内置hash()的随机化哈希是两回事,不要混淆; - 加
prefix命名空间,避免不同业务域的 Key 撞车。
1.3 本层缓存的真实作用(重点)
该缓存 ≠ Claude API 官方 Prompt Cache。
它属于应用内存级缓存,唯一目的是省本地开销:
- ✅ 避免重复拼接超长 Prompt 字符串;
- ✅ 减少重复的序列化与字符串拼接 CPU 开销;
- ✅ 加速本轮上下文组装;
- ❌ 不会减少 API Token;
- ❌ 不会触发云端缓存;
- ❌ 不会降低计费。
一句话:它是性能优化,不是成本优化。 生命周期就是单次进程内存,进程退出即失效。
二、Claude 官方云端 Prompt Cache(模型 API 层)
2.1 核心原理:动静边界分割
Claude Code / Agent SDK 提供了一个边界常量:
typescript
export const SYSTEM_PROMPT_DYNAMIC_BOUNDARY = '__SYSTEM_PROMPT_DYNAMIC_BOUNDARY__'
它解决的问题非常具体:如果你的 system prompt 是"永远不变的指令"+"每请求都变的上下文"拼成的一个字符串,那么上下文一变,整个 system prompt 都变了,前面那些不变的指令也跟着一起 miss 缓存。
用数组形式传入,两侧各自成为一个独立的 text block,各自带自己的 cache breakpoint,边界标记本身会被 SDK 移除、不会送达模型:
typescript
import { query, SYSTEM_PROMPT_DYNAMIC_BOUNDARY } from "@anthropic-ai/claude-agent-sdk";
const instructions = await readFile("triage-instructions.md", "utf8"); // 每次都一样
const ticketContext = "Customer plan: Enterprise. Open tickets: 3."; // 每次都不同
for await (const message of query({
prompt: "Triage ticket 4821",
options: { systemPrompt: [instructions, SYSTEM_PROMPT_DYNAMIC_BOUNDARY, ticketContext] }
})) { /* ... */ }
几个实现细节(官方口径):
- SDK 会把边界两侧的字符串用空行连接 ,并移除标记本身;
- 标记出现多次时,只有第一个生效,其余被移除;
- 用 CLI 的
--system-prompt/--system-prompt-file时是单字符串,改为插入单独一行SYSTEM_PROMPT_DYNAMIC_BOUNDARY来分割(需 v2.1.275+); - 两个断点都命中时,
usage里会出现cache_read_input_tokens(命中)与cache_creation_input_tokens(新建)。
2.2 静态段与动态段各装什么
静态段(boundary 之前)------每请求恒定:
| 内容 | 说明 |
|---|---|
| Agent 身份与系统角色 | "You are Claude Code..." 类固定表述 |
| 工作流 / 任务执行规则 | 做事方式、风险与确认规则 |
| ReAct 循环定义、工具使用偏好 | 如优先用 Read 而不是 cat |
| 语气与风格约束 | 固定输出规范 |
动态段(boundary 之后)------随会话或随轮次变化:
| 内容 | 变化频率 |
|---|---|
memory ------ 从记忆目录加载的记忆提示 |
会话级 |
env_info ------ CWD、git status、平台、模型、shell |
会话/轮次级 |
language / output_style / token_budget |
会话级 |
| MCP 服务器指令 | 每轮重算(MCP 服务器可能在轮次之间连接或断开) |
| SideQuery / subagent 相关段落 | 会话级 |
说明:上述分段清单来自 Claude Code 实现对系统提示词分节(section)的注册方式,社区已有逆向整理;各 section 的归属会随版本调整,以你本地
claude -p的实际输出与官方文档为准。不变的是机制本身:boundary 之前恒定、之后可变。
2.3 生效条件(最容易被忽略的限定)
动静切分不是在所有环境下都发生。 官方明确:
Claude Code 只在直接调用 Claude API 或运行在 Claude Platform on AWS 时才切分 system prompt。
在其他配置下,它把整个 prompt 作为一个 block 发送,等同于传单个字符串------边界不生效,缓存粒度退化为整个前缀:
- Amazon Bedrock
- Google Cloud 的 Agent Platform
- Microsoft Foundry
- 任何 LLM 网关
- 以及设置了
CLAUDE_CODE_DISABLE_EXPERIMENTAL_BETAS=1时
写论文或汇报时,这句话必须带上:"动静边界是 1P API 通道上的优化,跨云与网关场景下不适用。"
2.4 TTL 与失效(原笔记最大的一处硬伤)
❌ 原表述:"后续动态内容怎么变,静态缓存永不失效 。" ✅ 官方口径:缓存前缀在一段不活动期之后过期(cached prefixes expire after a period of inactivity)。每次命中都会重置计时器,所以只要持续工作缓存就是热的;闲置足够久之后,下一次请求会重算完整输入并重建缓存------这就是为什么离开一会儿回来后的第一轮明显更慢。
关键事实:
| 项 | 官方口径 |
|---|---|
| TTL 选项 | 5 分钟(默认)与 1 小时(额外费用) |
| 计时方式 | 每次命中重置;从写入开始计时,不是从最后一次生成结束 |
| 计费 | 写入 1.25×(5m)/ 2×(1h)基础输入价;读取 0.1× |
| 命中条件 | 完整前缀完全一致;任意一处字节变化,该处及其之后全部 miss |
| 已知失效因素 | tool_choice 变化、prompt 中图片的有无 、thinking 配置、output_config.effort、工具 tool_use 内容块里键顺序不稳定(Go / Swift 的 JSON 转换会随机化键序,直接打断缓存) |
| 断点数量 | 单请求最多 4 个 breakpoint |
| 最小长度 | 存在最小可缓存前缀长度要求,过短则静默不缓存 (cache_creation_input_tokens 为 0),具体阈值随模型不同,见官方文档 |
| 调试手段 | cache diagnostics 让 API 对比相邻请求并报告从哪里开始分叉 |
TTL 的两个桶(Claude Code 侧):
| 请求桶 | Claude 订阅(额度内) | API key / 用量计费 / 云厂商 |
|---|---|---|
主对话(交互轮次、-p、Agent SDK turns 及其内联 helper) |
1 小时 | 5 分钟 |
| 其他一切(subagent、workflow、teammate、fork、压缩、会话标题) | 5 分钟 | 5 分钟 |
注意第二行:subagent 的请求默认只有 5 分钟 TTL ,除非显式设置 subagentPromptCacheTtl / CLAUDE_CODE_SUBAGENT_PROMPT_CACHE_TTL。这跟上一篇讲的"subagent 有独立上下文、无法共享主会话前缀"是同一个道理------它在缓存层面也是另一套账。
还有一个官方建议:频繁使用的 prompt 不要盲目换 1 小时 TTL。如果请求间隔本来就小于 5 分钟,5 分钟缓存会被免费持续刷新,换成 1 小时只是白付更高的写入单价。1 小时适合"间隔在 5 分钟到 1 小时之间"的场景(例如一个 side-agent 要跑超过 5 分钟)。
2.5 {memory} 归属:定论与精度修正
结论:{memory} 100% 属于动态段。
依据是分节注册:memory 就是动态区里注册的一个 section("loaded memory prompt from memdir"),它随会话/轮次加载的内容而变,天然落在 boundary 之后。
但这里有一个必须澄清的精度问题------"动态段"这个词其实指两个不同的位置:
| 位置 | 包含什么 | 与边界的关系 |
|---|---|---|
| system prompt 内、boundary 之后 | memory 记忆提示、env 信息(CWD/git/平台/模型/shell)、语言与输出风格、MCP 指令 |
在 system prompt 里,位于边界之后 |
| system prompt 之外的 messages 流 | 当前会话历史对话、用户每轮最新提问、文件读取结果、Shell 输出、工具返回内容、SideQuery 召回的记忆片段、本轮临时状态 | 根本不在 system prompt 里,更谈不上属于"动态段" |
也就是说:
- ✅
{memory}记忆提示 → system prompt 的动态段(boundary 之后); - ✅ 会话历史、用户输入、工具输出、SideQuery 召回片段 → messages 流,位于 system prompt 之后,连"静态/动态段"这个划分都不适用;
- 一致的结论是:它们都不可缓存为静态前缀。
原因:每一轮召回的记忆不一样、用户输入不一样、工具结果不一样,属于典型逐轮可变数据。把它们放进静态段,等于每轮主动打碎自己的缓存。
三、两层缓存彻底对比
| 维度 | 应用层本地缓存 | Claude 云端 Prompt Cache |
|---|---|---|
| 技术 | json.dumps(sort_keys=True) 作 Key(超长再 sha256) |
__SYSTEM_PROMPT_DYNAMIC_BOUNDARY__ 动静分割 + cache_control 断点 |
| 实现位置 | 你自己代码里的进程内 dict / LRU | Anthropic API 侧 |
| 作用 | 避免重复拼接超长 Prompt 字符串 | 复用不变前缀,减少云端重复计算 |
| 收益 | 降低本地 CPU 与序列化开销 | 显著降本提速:读取 0.1×、写入 1.25×/2× |
| 影响 Token | 无任何节省 | 直接减少计费 Token |
| 生命周期 | 单次进程内存 | 云端缓存,5 分钟 / 1 小时 TTL,命中即续期 |
| 失效条件 | 进程退出、Key 内容变化 | 前缀字节变化、TTL 到期、tool_choice/图片/thinking 配置变化、键序不稳定 |
| 适用环境 | 任何环境 | 仅 1P API 与 Claude Platform on AWS 支持动静切分;Bedrock / Vertex / Foundry / 网关下退化为整块前缀 |
| 排查指标 | 命中率(自己埋点) | cache_read_input_tokens / cache_creation_input_tokens |
一句话区分:本地缓存省的是"你机器的 CPU",云端缓存省的是"你的账单"。
四、工程落地 Checklist
本地 Key
- 禁用
hash()作持久化/跨进程 Key(随机化种子 + 不支持可变类型); json.dumps(sort_keys=True, ensure_ascii=False)打底,补separators与default=str;- 超长 Key 用
hashlib.sha256二次摘要,并加业务前缀命名空间; - 明确标注该层不省 Token,别拿它去解释账单。
云端缓存
- 稳定内容放前、易变内容放后:tools → system 静态段 → 【边界】→ system 动态段 → messages;
- 每请求变化的上下文(客户、工单、memory、env)一律放边界之后;
- 确认你的通道是否支持动静切分(跨云与 LLM 网关不支持);
- 保证
tool_use内容块的键顺序稳定(Go / Swift 的 JSON 转换要特别处理); - 别把
tool_choice、图片有无、thinking 配置做成"随机漂移"的开关; - 断点最多 4 个,前缀过短会静默不缓存;
- 用
cache diagnostics定位分叉点,盯cache_read_input_tokens比例; - TTL 按间隔选:小于 5 分钟用默认;5 分钟~1 小时才上 1 小时 TTL;subagent 默认只有 5 分钟。
五、总结
- 工程层缓存 Key 禁用
hash(),必须用json.dumps(sort_keys=True, ensure_ascii=False)(超长再sha256),保证全局稳定且支持字典、列表结构。 - 本地缓存只优化本地字符串拼接性能,与 Claude 云端 Prompt Cache 无关,不省 Token、不影响计费。
- Claude 通过动静边界分割实现云端缓存 :固定系统规则为静态可缓存段,
{memory}、env 信息、MCP 指令,以及 messages 流里的对话历史、工具输出、SideQuery 召回片段,全部逐轮变化、不可静态缓存。 - 动态段变更不会破坏其之前的前缀缓存 ,但缓存不是永久的 ------TTL 5 分钟(默认)或 1 小时,命中续期、闲置过期。真正的表述是"规则长期可缓存、内容实时更新、缓存按 TTL 续期",而不是"永不失效"。
附:80 字核心摘要(PPT / 论文小结用)
本地缓存用
json.dumps(sort_keys=True)生成稳定 Key,只省 CPU 不省 Token;Claude 云端靠__SYSTEM_PROMPT_DYNAMIC_BOUNDARY__切分静态规则与动态内容({memory}、工具输出、对话历史皆属后者),静态前缀可跨轮复用,但受 5 分钟 / 1 小时 TTL 约束,命中续期、闲置过期。