改一次提示词就要多付一笔钱——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 里还有一组具体的优化手段,我挑几个印象深的:

  1. 日期记忆化:日期在会话开始时取一次,之后不再变。真的跨过了午夜,就把新日期作为一条附加信息追加在消息的尾部,而不是去改系统提示词;
  2. 降低精度:工具提示词里如果需要日期,只精确到月。模型写代码不需要知道今天几号;
  3. 动态列表挪成附件:可用的子 agent 列表原本写在工具描述里,MCP 一连上、插件一刷新,描述就变了。挪到消息尾部的附件以后,全局的缓存写入量少了大约 10%;
  4. 用占位符消除用户差异:临时目录的路径里带着用户 ID,每个用户都不一样。换成 $TMPDIR 这样的占位符,提示词就跨用户逐字节一致了;
  5. 条件段落省略:宁可不说,也不要说了又删。一段内容要么一直包含,要么挪到动态区,要么做成附件。

这些手段可以归纳成一棵决策树,遇到一个会变的字段时,依次问自己:

  1. 它必须出现在前缀里吗?不必须,就挪到消息尾部或者附件里;
  2. 能消除不同用户之间的差异吗?能,就用占位符或者做标准化;
  3. 能降低它的变化频率吗?能,就做记忆化、降低精度;
  4. 能限制它的变化幅度吗?能,就加预算、做条件省略;
  5. 都不行,再把它标成不缓存的动态区。

五、缓存中断是静默的

最后说一下检测。缓存失效的时候,没有任何报错,只是账单变贵了、响应变慢了,你很难知道是哪里出了问题。

Claude Code 专门有一个只负责观测的子系统,分两个阶段:

  1. 请求发出前,给系统提示词、工具定义、模型、请求头等十几个字段算哈希,和上一次比对,记下哪些变了、怎么变的;
  2. 响应回来后,看缓存读取量有没有明显下降(同时满足下降超过 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

改动的核心有三点:

  1. 系统提示词里只剩稳定的人格与规则,不再包含任何因人、因时而变的内容;
  2. 时间降到"日期"的精度,并且在会话开始时就固定下来;
  3. 用户相关的动态状态统一注入到最新一条用户消息里,放在请求的尾部。

另外,我在调用记录里加了缓存命中的 token 数。模型是经 LiteLLM 统一接入的,返回的 usage 里有 prompt_tokens_details.cached_tokens,直接记下来就行。它是缓存是否健康的唯一可靠指标,以后谁改了提示词导致命中率掉了,一眼就能看出来。

总结

  1. Prompt Cache 是逐字节的前缀匹配,越靠前的内容变化,失效的范围越大;
  2. 系统提示词要分出静态区和动态区,静态区不能有任何因会话而变的条件分支;
  3. 易变段落要付出"API 摩擦"的代价,能改措辞让它自然为空的,就别引入易变段落;
  4. 会影响请求字节的状态,第一次评估后就锁存,抖动比次优更有害;
  5. 动态内容往请求尾部推,降低频率、消除用户差异,并且一定要监控缓存命中的 token 数。

相关资料