提示词、上下文与那笔为它买单的缓存

Agent; 上下文工程; Prompt; Prompt Cache; 缓存 2338 words 12 min read
This post is not yet available in English. Showing the original.

如果说 Prompt Engineering 是在优化”一句话怎么说”,那么 Context Engineering 就是在优化”每一轮对话中,模型看到的所有信息应该是什么”。这本质上是一个以平衡完备性与简洁性为核心的约束优化问题。

这一篇聚焦其中一个最容易被忽视、却直接花真金白银的角落:提示词的组装方式与缓存

先看一个真实的账单故事

Agent,工作正常。两周后你的账单是预期的四倍。你翻模型用量日志,发现 cache_read_input_tokens 接近零,cache_creation_input_tokens 却是满的——提示词每一轮都在从头重建。你检查系统提示,发现顶部有一句 Date.now(),是你当初为了”让助手知道当前时间”顺手加的。每一轮时间戳都不同,每一轮都是缓存未命中,每一轮都付全价。

修复只需一行。但教训更大:缓存节省在它坏掉之前是不可见的,而提示词有五六种方式静默地打破它。

一、提示词是一个组装出来的结构

一个有用的心智模型

,从顶部(最不可能变)到底部(最可能变)排列。

flowchart TD
    A["身份 / 系统指令<br/>(周级稳定)"] --> B["工具 schema + 描述<br/>(发布级稳定)"]
    B --> C["项目 / workspace 上下文<br/>(会话级稳定)"]
    C --> D["Skill 索引 + 冻结的记忆快照<br/>(会话级稳定)"]
    D --> E["较早的对话轮次<br/>(易变)"]
    E --> F["最近的工具结果<br/>(易变)"]
    F --> G["最新的用户消息<br/>(易变)"]

    style A fill:#d8f0d8
    style B fill:#d8f0d8
    style C fill:#d8f0d8
    style D fill:#d8f0d8
    style E fill:#ffe5cc
    style F fill:#ffe5cc
    style G fill:#ffcccc

稳定与易变的分界线,大致就是可缓存与不可缓存的分界线。设计提示词,大部分工作就是把东西放到这条线正确的一侧,并让它们待在那儿。

OpenCode、Hermes Agent、OpenClaw 以及领先的商业编程 Agent,都大致按这个顺序构建系统提示,并用一个确定性的合并,保证在没有实质变化时两次调用的字节序列完全一致。

二、不可变规则

最令人意外、也是大多数团队靠违反它才学会的规则:系统提示一旦构建就冻结。

如果一个工具在循环中途运行并写入了 MEMORY.md,正在运行的系统提示不会改变。这个更新会在下一个会话可见,而不是这一个。Hermes Agent 明确强制了这一点——文件支持的记忆更新故意不反映到运行中的提示里。领先的编程 Agent 也这么做。原因是机械性的:任何对前缀字节序列的改动,都会让其后每一轮的缓存失效。

这条规则有两个值得吸收的推论:

  • 你能让提示词缓存在长会话里保持温热,当且仅当没有东西在飞行途中重写前缀。后台记忆写入去磁盘,在下一次会话启动时读取。
  • “活的”提示词比冻结的贵,常常贵一个数量级。如果一个功能感觉需要活的提示词更新(“每轮给模型看当前时间”),把它放进易变的尾巴,而不是稳定的前缀。

三、缓存,用供应商中立的话说

供应商真正缓存的是你消息流的一个前缀。如果下一个请求的前缀和上一个请求的前缀逐字节匹配,供应商就跳过重新处理那些 token,只按正常价格的一个零头计费。机制因供应商而异:

  • OpenAI 形状的 API 自动缓存前缀,无需标记——如果你的 token 匹配了更早的请求,就拿折扣。
  • Anthropic 形状的 API 需要显式的 cache_control 块。你可以标记最多四个断点,供应商独立缓存到每个断点。
  • 其他供应商(Bedrock、Gemini、Vertex)介于两者之间,通常通过 SDK 的归一化层暴露。

无论哪种,给你的提示词构建器的规则都一样:保持前缀字节一致,把变化放到末尾。

关于 TTL

2026 年中,Anthropic 的临时缓存默认约每个断点五分钟,可选延长到约一小时(每 token 价格有溢价);OpenAI 形状的自动缓存用类似的供应商管理窗口。这些数字会动,调优前查你供应商的当前定价。但架构上的权衡是稳定的
(连续轮次间隔几秒到几分钟)用短 TTL 就够;突发式会话(用户问一句、走开半小时、回来)值得为长 TTL 的溢价买单。正确的设置来自看你真实流量里的轮次间隔——让你的 Agent 拉出直方图再选阈值,不要靠眼估。

四、什么会打破缓存

几乎所有诱人的东西都危险。常见的罪魁,具体地说:

  • 前缀里的 Date.now() 或任何时间戳。每轮一个新值,每轮一次未命中。
  • 工具注册表变更。加或减一个工具会改变 schema 字节,而它坐在前缀早期。按 (agent, model) 组合缓存 schema 数组,但要明白注册表变动是昂贵的。
  • 非确定性排序。如果你从 Object.entries() 或文件系统遍历组装提示词而不排序,顺序会随运行时版本、OS、心情而变。OpenClaw 用一个静态的 CONTEXT_FILE_ORDER 映射,Hermes Agent 用一个固定的段落列表。选一个顺序,把它钉死。
  • 后台记忆写入更新运行中的提示词——就是上面那条不可变规则,重申一遍因为它最容易不小心引入。
  • 注入共享前缀的用户特定数据。如果多个用户打同一个 Agent,逐用户数据属于尾巴;前缀应该对用户无关。
  • 空白和格式漂移。多一个换行就算一次未命中。如果你用模板生成提示词,锁死空白。
  • 区域相关的格式化(对数字用 toLocaleString()、对日期用 format()),在不同机器上产生不同字节。
  • 一个保存时重写提示词模板的自动格式化器/linter。一个 reformat-on-save 工具插入一个尾随换行或规范化引号,会在下次服务部署时静默让每个缓存前缀失效。

最短的调试路径

指纹——渲染后前缀的 SHA——跨轮次观察这个值。如果没有实质变化时指纹却变了,你就有一个泄漏。

分层指纹

一个覆盖整个前缀的指纹能抓到漂移,但它不告诉你漂移从哪来。便宜的升级是给前缀的每一层各记一个指纹:

debug: {
  prefixFingerprint:   sha(prefix.bytes).slice(0, 12),
  identityFingerprint: sha(prefix.identity).slice(0, 12),
  toolsFingerprint:    sha(prefix.toolSchemas).slice(0, 12),
  contextFingerprint:  sha(prefix.projectContext).slice(0, 12),
  memoryFingerprint:   sha(prefix.frozenMemory).slice(0, 12)
}

当整体哈希漂移时,逐层哈希定位原因。工具哈希跨部署变化,通常是启用工具的增减或一次描述编辑;上下文哈希在会话中变化,通常是 workspace 遍历重排或一个上下文文件被磁盘上重写;记忆哈希在会话中变化,就是不可变规则被违反了。逐层视图把*“缓存在某处坏了”变成”有人编辑了一个工具描述”*——就一行日志。

五、和上一专题的呼应

如果你读过上一专题(Claude Code),这一篇的很多结论会似曾相识:

  • Claude Code 的四级渐进压缩断路器,解决的正是”前缀被填满后怎么办”,而它的 Fork 模式要求 CacheSafeParams 五个字段字节级一致才能命中父级缓存——这就是本篇”保持前缀字节一致”的极致实践。
  • Explore / Plan Agent 主动剥离 CLAUDE.md 以省 token,注释里写”每周省 5-15 Gtoken”——这是”用量大时,前缀里每一个可省的 token 都是真金白银”的直接体现。

换句话说

原理(为什么前缀要稳定、什么打破缓存),上一专题讲的是一个顶级 Agent 怎么把这个原理用到极致。两者互为印证。

小结

  • 系统提示不是字符串,是稳定前缀 + 易变尾巴的组装结构。
  • 前缀一旦构建就冻结;后台更新去磁盘,下个会话生效。
  • 缓存的是前缀,逐字节匹配才命中;把一切变化赶到末尾。
  • 打破缓存的东西几乎都很诱人(时间戳、工具变更、格式漂移),用逐层指纹定位泄漏。

理解了这一层,你就理解了为什么”上下文工程”是 Agent 工程里省钱与提速的核心杠杆。下一部分我们进入记忆与状态——先从短期记忆和上下文压缩讲起。


本文主要整合自

Ch.04(提示词/上下文/缓存),agent开发系列《行为塑造与提示词工程》,hello-generic-agent《上下文》,以及 Hermes Agent / OpenCode 的提示词构建设计。属基于公开教程与开源项目的二次整理。