改一次提示词就要多付一笔钱——Prompt Cache的原理,以及Agent怎么设计才不打破缓存
Agent 原型跑了一段时间,我顺手看了一眼调用记录里的缓存命中,低得离谱。
排查下来发现,问题出在系统提示词的第一行。为了让模型知道"现在几点""在跟谁聊",我在最前面放了当前时间(精确到秒)和用户昵称。结果每一次请求的开头都不一样,缓存一次都没命中过。
改起来倒是不难,但这件事让我意识到,缓存不是"开个开关"那么简单。于是又回头去看 Claude Code 是怎么对待缓存的,发现它几乎是把缓存当成一等公民在设计。
一、Prompt Cache 是什么
Prompt caching 通过从提示词中的特定前缀继续处理,来减少处理时间和成本。
简单的说,模型处理过的一段前缀,服务端会把中间结果存起来。下一次请求如果开头一模一样,这一段就不用重新算了,直接读缓存。
读缓存很便宜。以 Anthropic 为例,命中缓存的部分只按正常输入价格的十分之一计费,写缓存则要贵一点。OpenAI、DeepSeek 也都有类似的机制,只不过是自动的,不用显式标记。
这里最关键的一点是:缓存是逐字节的前缀匹配。
请求是按"系统提示词 → 工具定义 → 消息历史"的顺序拼起来的。只要前面某个字节变了,从这个字节往后的所有内容都不能复用缓存。越靠前的内容变化,失效的范围就越大。
对 agent 来说,这件事的分量很重。一次请求里,系统提示词加上工具定义,动辄就是几万 token 的"固定开销",而且每一轮都要发一次。我的原型把时间放在了第一行,等于每一轮都在为这几万 token 付全价。
所以 Claude Code 里有一条原则:改一次提示词,就是一笔实打实的成本。
二、提示词要分出静态区和动态区
1. 动态边界
Claude Code 的系统提示词不是一整个字符串,而是拆成了很多有名字的段落,中间插了一个边界标记:
// 示意:系统提示词的最终结构
;[
intro, system, doingTasks, actions, usingTools, tone, efficiency, // 静态区:所有人完全相同
'__SYSTEM_PROMPT_DYNAMIC_BOUNDARY__', // 只是一个信号,不会发给模型
sessionGuidance, memory, envInfo, language, outputStyle, mcpInstructions, // 动态区:因会话而异
]
边界之前是静态区,对所有组织、所有用户、所有会话都逐字节相同,所以可以在全局范围共享缓存。边界之后是动态区,放的是 CLAUDE.md、环境信息、语言设置这些因人而异的东西。
源码里这个边界标记旁边写着一行注释:不要移动,不要删除。移动它属于架构变更。
静态区有一条铁律:不能有任何因会话而变的条件分支。
为什么?假设静态区里写了一个 if (hasAgentTool),那么有这个工具和没这个工具的用户,就会得到两个不同的前缀。每多一个这样的条件,前缀的变体数就翻一倍,N 个条件就是 2 的 N 次方种。变体一多,每一种被复用的概率就趋近于零。据说 Claude Code 自己就踩过这个坑,有人在静态区加了一个条件判断,全局缓存的命中率从 95% 跌到了不到 10%。
2. 分段记忆化,以及刻意的 API 摩擦
每个段落都通过一个函数注册:
// 普通段落:第一次计算后缓存,整个会话内不再变化
systemPromptSection('memory', () => loadClaudeMd())
// 易变段落:每一轮都重新计算,必须写明理由
DANGEROUS_uncachedSystemPromptSection(
'mcp_instructions',
() => renderMcpInstructions(),
'MCP server 可能在两轮之间连上或断开',
)
可以看到,想要一个每轮都变的段落,就得用一个带 DANGEROUS_ 前缀的函数,还必须填一个 reason 参数。
这是刻意加的 API 摩擦。函数名本身就在提醒你"这样做很危险",必填的理由则是给代码审查留的锚点。想破坏缓存可以,先说清楚为什么。
3. 让段落"自然为空"
还有一个我很喜欢的技巧。Claude Code 里有一个 token 预算相关的段落,原本是易变段落,因为预算一切换,内容就会变,破坏掉一大截缓存。
后来的做法是改写措辞,让这段话在没有预算的时候自然成为一句"空操作":说的是"如果设定了预算,就……",没设定的时候这句话不起作用,但文本本身不变。这样它就从易变段落变回了可以缓存的普通段落。
先想办法改措辞,让内容不随状态变化,实在不行再引入易变段落。
三、锁存:抖动比次优更有害
除了提示词的文本,还有很多东西会影响请求的字节:缓存的 TTL、请求头里的 beta 标记、各种功能开关……
Claude Code 对这类状态的处理方式叫锁存(latching):第一次评估出结果以后就锁定,整个会话期间都不再改变。
举个例子。某个功能需要在请求头里加一个 beta 标记,用户在会话中途把这个功能关掉了。按理说应该把这个标记撤掉,但 Claude Code 的做法是继续发送,直到用户执行 /clear 或 /compact 开启新的上下文为止。源码注释里写着,中途切换会让五六万 token 的缓存失效。
功能关了还发 header,看起来不太"正确",但这是一个算过账的选择:多发一个无害的 header,代价几乎为零;撤掉它,代价是一整段缓存。
宁可用一个稍微过时的值,也不要让缓存键在会话中途变化。 状态来回抖动,比停在一个次优的状态更有害。
四、优化模式与决策树
Claude Code 里还有一组具体的优化手段,我挑几个印象深的:
- 日期记忆化:日期在会话开始时取一次,之后不再变。真的跨过了午夜,就把新日期作为一条附加信息追加在消息的尾部,而不是去改系统提示词;
- 降低精度:工具提示词里如果需要日期,只精确到月。模型写代码不需要知道今天几号;
- 动态列表挪成附件:可用的子 agent 列表原本写在工具描述里,MCP 一连上、插件一刷新,描述就变了。挪到消息尾部的附件以后,全局的缓存写入量少了大约 10%;
- 用占位符消除用户差异:临时目录的路径里带着用户 ID,每个用户都不一样。换成
$TMPDIR这样的占位符,提示词就跨用户逐字节一致了; - 条件段落省略:宁可不说,也不要说了又删。一段内容要么一直包含,要么挪到动态区,要么做成附件。
这些手段可以归纳成一棵决策树,遇到一个会变的字段时,依次问自己:
- 它必须出现在前缀里吗?不必须,就挪到消息尾部或者附件里;
- 能消除不同用户之间的差异吗?能,就用占位符或者做标准化;
- 能降低它的变化频率吗?能,就做记忆化、降低精度;
- 能限制它的变化幅度吗?能,就加预算、做条件省略;
- 都不行,再把它标成不缓存的动态区。
五、缓存中断是静默的
最后说一下检测。缓存失效的时候,没有任何报错,只是账单变贵了、响应变慢了,你很难知道是哪里出了问题。
Claude Code 专门有一个只负责观测的子系统,分两个阶段:
- 请求发出前,给系统提示词、工具定义、模型、请求头等十几个字段算哈希,和上一次比对,记下哪些变了、怎么变的;
- 响应回来后,看缓存读取量有没有明显下降(同时满足下降超过 5% 和超过 2000 token 才算),确认中断了,就用第一阶段记下的差异来解释原因。
如果客户端什么都没变、也没超过 TTL,缓存还是掉了,就报告为"大概率是服务端原因"。根据他们的统计,这种情况占了九成左右。
这个系统本身什么都不修,它只负责回答"缓存为什么掉了"。先观察,再修复,这也是 Claude Code 一贯的思路。
六、回到我们的原型
照着上面的思路,我把原型请求的组装方式改成了这样:
def build_messages(session: Session, user_input: str) -> list[dict]:
# 1. 静态区:人格与规则,所有用户、所有会话完全相同
messages = [{"role": "system", "content": PERSONA_AND_RULES}]
# 2. 历史消息:只追加,不修改
messages.extend(session.history)
# 3. 动态状态:统一放在最新一条用户消息里,而不是写进系统提示词
context = (
"<context>\n"
f"日期:{session.start_date}\n" # 会话开始时取一次,只到天
f"用户称呼:{session.user.nickname}\n"
"</context>\n"
)
messages.append({"role": "user", "content": context + user_input})
return messages
改动的核心有三点:
- 系统提示词里只剩稳定的人格与规则,不再包含任何因人、因时而变的内容;
- 时间降到"日期"的精度,并且在会话开始时就固定下来;
- 用户相关的动态状态统一注入到最新一条用户消息里,放在请求的尾部。
另外,我在调用记录里加了缓存命中的 token 数。模型是经 LiteLLM 统一接入的,返回的 usage 里有 prompt_tokens_details.cached_tokens,直接记下来就行。它是缓存是否健康的唯一可靠指标,以后谁改了提示词导致命中率掉了,一眼就能看出来。
总结
- Prompt Cache 是逐字节的前缀匹配,越靠前的内容变化,失效的范围越大;
- 系统提示词要分出静态区和动态区,静态区不能有任何因会话而变的条件分支;
- 易变段落要付出"API 摩擦"的代价,能改措辞让它自然为空的,就别引入易变段落;
- 会影响请求字节的状态,第一次评估后就锁存,抖动比次优更有害;
- 动态内容往请求尾部推,降低频率、消除用户差异,并且一定要监控缓存命中的 token 数。