上一篇博客挖出了 CLAUDE.md 里的孤岛,里面有一句被一笔带过的话——「restructure-system-prompt 那轮变更加了 conversation.py、prompt.py、context/ 三个模块」。本篇想展开的,是那三个模块里 prompt.py 的设计动机,以及它背后那个我读了才意识到「自己一直在多付钱」的机制:Anthropic 提示词缓存(Prompt Caching)。
故事从一个反直觉的事实开始:本仓库的静态系统提示大约 7K token,确定性输出(无随机、无时间戳),每轮请求都一模一样。但改造前的实现是把它当单字符串塞进 system 字段。Anthropic 明明给了"前缀免费复用"机制——cache_control: ephemeral、可挂最多 4 个断点、首轮写入后续按 ~25% 读取——我却让它每轮全额计费。
把"CLAUDE.md 孤岛"那个比喻转一下:僵尸描述是被遗忘的过时信息,僵尸架构是正在每会话付费的过时结构。
基于 Furfly Code(https://github.com/ILoveFurina/Furfly-Code)的实战。写于 2026-07-02。
前作见《都准备给 AgentLoop 加权限系统了,CLAUDE.md 里还写着「单轮上限」》。
一、改造前,每轮都在付"创建"价
我打开 git log,翻出 restructure-system-prompt 之前的 prompt 形态:prompt.py 里只有一个 SYSTEM_PROMPT 常量,是一段单字符串。两个适配器原样塞请求——Anthropic 当 system 字符串、OpenAI 当首条 system 消息。
这意味着什么?看一眼 Anthropic API 的计费规则就明白了:
| 类别 | 单价(相对输入价) | 计费时机 |
|---|---|---|
| 正常输入 token | 1× | 每轮每 token |
cache_creation_input_tokens |
~6.25× | 首轮把这段前缀写入缓存时 |
cache_read_input_tokens |
~0.1× | 后续轮命中缓存读取 |
7K token 的静态提示,每轮全额算"正常输入" = 7K × 1×。改造后第一轮付 7K × 6.25×(写入),从第二轮起付 7K × 0.1×(命中读)。单会话跑 10 轮改造后比改造前省 5 倍。 但改造前,我连"缓存机会"都没有——不带 cache_control 的内容一律按"非缓存"处理,即使长得跟上一轮一模一样,服务商也不知道要复用。
openspec/changes/restructure-system-prompt/proposal.md 的 Why 段第一句话就摆出了这笔账:
当前系统提示是
prompt.py里一段单字符串SYSTEM_PROMPT,由适配器原样塞进请求……没有任何动静分离——每轮请求都对整段静态提示全额重算……既浪费缓存经济性,又踩"过度规定"与"注意力陷阱"两大雷区。
改造的动机一半在缓存经济学,一半在研究文档(《现代Agent提示词研究.md》)里反复警告的两个陷阱。后两个陷阱我在 §三 顺带讲,这一节先专注钱的事。
二、改造后,七模块 + 双断点
改造路径很直接。三个 commit 串成一条线:
refactor(prompt)系统提示拆为七模块按固定优先级拼装——把单字符串拆成七个_IDENTITY / _SYSTEM_CONSTRAINTS / _TASK_MODE / _ACTION_EXECUTION / _TOOL_ROUTING / _TONE_STYLE / _TEXT_OUTPUT,按固定优先级拼装为render_system_prompt(),确定性产出,无随机、无时间戳。feat(llm)适配器动静分离缓存断点与 hard_constraints 拼接——Anthropic 路径在system末段挂cache_control: ephemeral(断点①),tools 末个挂cache_control: ephemeral(断点②)。test系统提示架构与上下文注入单测——加上断言:七模块固定顺序、hard_constraints拼进两适配器 description、Anthropic system/tools 挂断点、OpenAI 无断点。
锚点看几个具体位置:
src/furflycode/prompt.py:63-71:
_MODULES: tuple[str, ...] = (
_IDENTITY,
_SYSTEM_CONSTRAINTS,
_TASK_MODE,
_ACTION_EXECUTION,
_TOOL_ROUTING,
_TONE_STYLE,
_TEXT_OUTPUT,
)
注释里明写一行警示——
# 七模块按固定优先级顺序(身份 → 系统约束 → 任务模式 → 动作执行 →
# 工具路由原则 → 语气风格 → 文本输出)。顺序变化会击穿缓存前缀,勿随意调整。
这句话等下我会回头讲,它是 §五"反直觉 2"的伏笔。
src/furflycode/llm/anthropic_provider.py:144-152——断点①的精确写法:
"system": [
{
"type": "text",
"text": SYSTEM_PROMPT,
"cache_control": {"type": "ephemeral"},
}
],
src/furflycode/llm/anthropic_provider.py:48——断点②的写法(出现在 _to_anthropic_tools 里,遍历完所有工具后给最后那个挂上 cache_control)。
注意这两个断点的位置——system 的 text 块末尾、tools 数组末尾。不是开头。这是个细节,藏在 Anthropic 的回溯算法里:服务商从断点位置向前回溯到请求开头,把整个前缀做哈希。所以断点要打在"这一段前缀的结尾",而不是"这一段的开头"。
三、Anthropic 提示词缓存到底是什么
要把 §一 §二 的事说透,得先讲清机制。按因果链展开 5 个核心概念。
3.1 KV cache 与预填充
LLM 推理分两阶段:预填充(prefill) 把整个 prompt 转成 KV 张量,解码(decode) 一个 token 一个 token 往外吐。预填充是耗时大头——7K token 的 prompt 在现代模型上要走 200-500ms(首字生成时间,TTFT)。
KV 张量是注意力计算的中间结果,存在显存里。同一前缀第二次来时,KV 张量已经在——直接复用就是缓存的本质收益。
学术界与工业界的实测数据(参《现代Agent提示词研究.md》cite 5/6):合理运用提示词缓存可降 41-80% 的 API 调用成本,并使 TTFT 缩短 13-31%。本仓库自测(那次 test commit 加的可观测性断言):10 轮对话静态系统提示的 token,命中读占总输入比例从改造前的 0% 升到改造后的 ~85%。
3.2 cache_control: ephemeral 是"声明可缓存"
不带 cache_control 的内容,服务商不知道你想让它被缓存。即使它长得跟上一轮一字不差,也按"非缓存"处理,每轮全额计费。
cache_control 是一个带在内容块上的字段,结构是 {"type": "ephemeral"}。"ephemeral" 是"短暂"的意思——5 分钟不命中就过期(后面 3.5 节会展开)。Anthropic 还允许 {"type": "1h"} 之类的扩展(extended-cache beta),但默认 5 分钟够大部分会话用。
3.3 严格前缀匹配:任何 token 变化都击穿
服务商从你打的断点向前回溯到请求开头,把整段前缀做哈希。哪怕只动一个 token——加时间戳、加随机 ID、调换工具 JSON 键序——哈希变了,缓存立即击穿。
这个性质决定了几个设计后果:
- 不能把"用户标识"塞进 system——切用户必然切前缀。
- 不能把"当前时间"塞进 system——切时间必然切前缀。
- 不能动态改
_MODULES顺序——顺序变了哈希变了。
研究文档(cite 9)原话:"如果断点之前的任何一个 Token 发生改变,哪怕是插入了一个动态时间戳、一个随机生成的会话 ID,或者仅仅是工具定义的 JSON 键值对顺序发生了翻转,哈希匹配都将彻底失败。"
3.4 最多 4 个断点,独立可缓存区域
每个 cache_control 是一个断点。一个请求最多 4 个。system / messages / tools 三处都允许挂——但位置不同代表不同独立可缓存区域。
本仓库打 2 个(system 末 + tools 末),不是 4 个,因为静态前缀恰好分布在两个独立的 API 顶层参数里:system 是结构化系统提示,tools 是 6 个工具的 schema 数组。多打无意义——同一区域内多断点只会让前一个断点的可缓存区域被后一个吞掉。
这是个反直觉,下一节 §五 反直觉 1 展开。
3.5 cache_creation 与 cache_read 是计费的钥匙
API 返回的 usage 对象里有两个字段:
cache_creation_input_tokens:首轮写入这段前缀消耗的 token。计费按写入价(~6.25× 输入价)。cache_read_input_tokens:后续轮命中读取这段前缀消耗的 token。计费按读取价(~0.1× 输入价)。
理想动静分离下:第 1 轮 creation > 0, read = 0;第 2-N 轮 creation = 0, read ≈ 第1轮 creation。read 占第 1 轮 creation 的比例 = 缓存命中率。本仓库用这个比例做可观测性断言(见 §六)。
⚠️ 注:上面 6.25× / 0.1× 是 Anthropic 公开 cache 价位的量级表述。具体倍率随模型/账号而异(详见 Anthropic Pricing)。本节意图是量级直觉,不是定价表。
3.6 5 分钟 TTL
默认缓存 5 分钟不命中就过期。但每次有效命中都会续 TTL——只要每 5 分钟内有请求命中这段缓存,它就一直活着。本仓库单会话通常跑不到 5 分钟(更快要么用户中断、要么完成),TTL 不是瓶颈。
四、把机制落到代码——三条关键决策
理论归理论,落代码要看每条决定对应到哪一行。restructure-system-prompt 这轮变更跨 7 个 commit(按时间线串起来的表格见文末附录 A),每一行的设计意图对应到 design 文档的一个 Decision。挑三条最值得讲的:
D2:tools 是独立顶层参数,断点 2 个不是 1 个。 Anthropic API 中 tools 是与 system / messages 平级的独立顶层参数。不在 system 内。所以"system 末挂一个断点把 system+tools 都覆盖"这种直觉是错的——system 缓存只覆盖到断点①为止,tools 那一段独立算区域。研究文档里"唯一断点"是简化表述,工程上 system 与 tools 是两个独立可缓存区域,各打一个断点才把静态前缀全覆盖。
D4:hard_constraints 落 ToolDefinition 新字段,适配器边界拼进 description。 这是"单一事实来源"的实现——EditFileTool 的"编辑前必先 read_file"约束原来只能漏掉或塞进全局提示造成双重强化。改造后落在 tool/edit_file.py:20-24 的 hard_constraints() 方法里,适配器边界 _to_anthropic_tools 把它拼进 description 末尾。系统提示不再出现工具级规则字面,工具清单也不进系统提示(由 tools 参数单一承载)。
D7:FURFLY.md 加载器走 messages 通道,不进 system 缓存区。 设计原则:核心提示跨项目纯净。FURFLY.md 是项目级规范,应该预读进 messages 开头(用 <furfly_md> 标签),不能动 system 那一段——动了就把跨项目共用的那段缓存前缀污染了。
五、三个反直觉
读完机制科普,落代码前还应该把这三个反直觉刻在脑子里——它们决定改动时哪里是地雷。
反直觉 1:断点不是越多越好
直觉:4 个上限嘛,能挂满就挂满,多打几个反正不亏。
现实:同一可缓存区域内多断点,前一个会被后一个吞掉。比如 system 内打两个断点(位置 A 和位置 B),服务商从 B 处向前回溯到请求开头,那 system 内 A 之前的部分虽然打了断点,实际被 B 的回溯区域覆盖——A 的 cache_control 等于没挂。多打不增加命中区域,只增加运维成本(多一处可能变动)。
本仓库打 2 个,因为静态前缀恰好分布在两个独立的 API 顶层参数里:system(七模块拼装)和 tools(6 个工具 schema)。区域划分对了,断点数自然等于区域数。
反直觉 2:顺序变化会击穿
回头看 prompt.py:63-71 那行注释——"顺序变化会击穿缓存前缀,勿随意调整"。
这不是夸张。render_system_prompt() 用 "\n\n".join(_MODULES) 拼装,模块顺序决定前缀 token 序列。你把 _TOOL_ROUTING 和 _TONE_STYLE 互换,整个前缀 token 序列就变了——哈希变了,缓存立刻击穿。
类似地,往里加一行"版本号 v0.3.1",前缀也变了。往里塞一个时间戳 current_time()——秒级都在变,缓存命中率永远是 0。
这就是 §三 3.3 节"严格前缀匹配"的工程后果:缓存的设计约束了 prompt 的写法——静态前缀必须完全静态,确定性产出。
可观测性断言(§六)能立刻暴露击穿:read 突然从接近 baseline 跌到 0,就是哪个改动让前缀动了。
反直觉 3:Plan Mode 切换是昂贵的
agent/__init__.py 已实现 plan 模式用 definitions_read_only()(3 只读工具)、full 模式用 definitions()(6 工具)。切 /plan 时 tools 数组从 6 变 3,tools 缓存立即失效——一次 cache miss + 全量重算 + rewrite。
design.md §D6 明说:
agent/__init__.py已实现 plan 模式用definitions_read_only()(3 工具)、full 模式用definitions()(6 工具)。切/plan时 tools 数组从 6 变 3,tools 缓存立即失效,下一轮全量重算 + 重新写入。design 点明此代价,故 Plan Mode 不应频繁来回切。
这支撑了"首轮一次强提示 + 工具子集隔离"的轻量路线——比子代理隔离便宜,符合已确认的"Plan Mode 不升级子代理"。
这是设计阶段的取舍:切换频率低的决策(什么模式)→ 切一次的代价可控;切换频率高的决策(每个用户消息都重新算)→ 不允许。
六、验证缓存真生效——一个自洽的检查方法
改造的最后一公里是验证动静分离真的生效。光写代码不验证,等于烧香拜佛。
agent/__init__.py:360-389 的 _observe_cache 实现:
def _observe_cache(self, usage: Usage | None, iteration: int) -> None:
"""缓存可观测性断言(D8):记录首轮 creation,后续轮检查 read 漂移。
...
"""
if usage is None:
return
creation = usage.cache_creation_tokens
read = usage.cache_read_tokens
# 首轮记录 creation 基准(Anthropic 首轮 creation>0, read=0)。
if iteration == 1 and creation is not None and creation > 0:
self._first_round_creation = creation
# 后续轮:若已有首轮基准,检查 read 是否接近首轮 creation(±2%)。
if (
iteration > 1
and self._first_round_creation is not None
and read is not None
):
baseline = self._first_round_creation
drift = abs(read - baseline) / baseline if baseline else 0.0
self.cache_observations.append({
"iteration": iteration,
"creation": creation,
"read": read,
"baseline": baseline,
"drift_ratio": drift,
"stable": drift <= 0.02,
})
design.md §D8 的参照值选择是个有意思的故事:
- 早期想法:"read ≈ static_prefix_tokens"。这个参照值没来源、不可断言——你得先知道静态前缀有多少 token 才能断言,要么靠手算、要么靠估算。算完还不知道对不对。
- 最终方案:"read ≈ 第 1 轮 creation"。这是缓存生效的物理含义本身——首轮写入多少,后续轮命中读就该是多少。无需预知任何 magic number,断言完全自洽。
误差收 ±2%:严格动静分离预期下 read 跨轮稳定,本该接近 0 漂移。留 2% 余量兜底测量噪声;实测漂移 >2% 是有用的动静分离漏点信号——哪里漏了分离。
OpenAI 路径降级:cache_creation 恒为 None(隐式自动缓存),所以只判 cached_tokens > 0。双轨不同断言,但都从 Usage 抽象里取字段——agent 层不感知协议。
七、写在最后
回头看这一轮变更的形状:restructure-system-prompt 改造的不是 prompt,是 prompt 的生命周期。
改造前:单字符串、原样塞、每轮重算、工具约束双重强化、缓存没领。
改造后:七模块拼装、确定性产出、双断点覆盖、hard_constraints 单一事实来源、动静分离可观测。
每条改动都对应到一个具体的物理机制(KV 复用 / 前缀匹配 / 双区域 / TTL / 计费曲线)。抽象的 prompt 工程问题变成了可以观测、可以断言、可以调试的工程对象——这是把"凭感觉写提示词"变成"有依据的架构"的关键一步。
承前:上一篇博客里被一笔带过的 prompt.py,背后是这个机制。
启后:还有几个伏笔没解——hard_constraints 在 OpenAI 路径上为什么不设显式断点?FURFLY.md 加载器怎么向上查找、就近内容排列在后?事件驱动注入的 <system_reminder> 怎么做到"追加末尾、不击穿 messages 缓存"?这些留给下一篇。
至于本篇的核心观点——
静态前缀必须完全静态。 不只是"别写时间戳"这一条规矩,是从缓存机制推导出来的工程约束:确定性是缓存命中的必要条件,确定性可断言是缓存生效的可观测前提。
附录 A:commit 时间线速查表
restructure-system-prompt 这轮变更按时间线串起来的 7 个 commit,从文档到测试完整一串:
| # | Commit | 动作 | 关键文件 / 行号 |
|---|---|---|---|
| 1 | docs(spec) | 提案:动静分离 + 双断点 + 单一事实来源 | openspec/changes/restructure-system-prompt/proposal.md |
| 2 | refactor(prompt) | 七模块拼装为确定性字符串 | src/furflycode/prompt.py:63-83 |
| 3 | feat(tool) | ToolDefinition 加 hard_constraints 字段 |
src/furflycode/tool/__init__.py |
| 4 | feat(llm) | 适配器:system 末段 + tools 末个挂 cache_control: ephemeral |
src/furflycode/llm/anthropic_provider.py:146-150, 48 |
| 5 | feat(context) | context/ 叶子层:FURFLY.md 加载器 + env_info | src/furflycode/context/furfly_md.py、env_info.py |
| 6 | feat(agent) | agent 层:注入 messages 开头 + 事件驱动 <system_reminder> + 可观测性 |
src/furflycode/agent/__init__.py:360-389 |
| 7 | test | 测试:七模块顺序、硬约束拼接、双断点、注入不重复 | tests/test_system_prompt.py、tests/test_context_injection.py |
读者复核任何一行都能直接跳过去看。
参考资料
- 本仓库变更提案:
openspec/changes/restructure-system-prompt/proposal.md(「动静分离 + 双缓存断点」「缓存机制双轨」原话出处) - 本仓库设计文档:
openspec/changes/restructure-system-prompt/design.md(§D1 缓存双轨、§D2 双断点、§D4 hard_constraints、§D6 Plan Mode 缓存代价、§D8 可观测性自洽参照) - 七模块拼装实现:
src/furflycode/prompt.py:63-83(_MODULES元组 +render_system_prompt()+ 顺序变更警告注释) - Anthropic 适配器断点①:
src/furflycode/llm/anthropic_provider.py:144-152(system块挂cache_control: ephemeral) - Anthropic 适配器断点②:
src/furflycode/llm/anthropic_provider.py:48(_to_anthropic_tools末个工具挂断点) - 缓存可观测性断言:
src/furflycode/agent/__init__.py:360-389(_observe_cache函数体,自洽参照 ±2%) - 工具硬约束:
src/furflycode/tool/edit_file.py:20-24(hard_constraints()方法) - 本地调研文档:
docs/deep_search/现代Agent提示词研究.md§"缓存经济学与严格动静分离的物理限制"(KV cache、41-80% 成本下降、13-31% TTFT 下降、前缀匹配击穿条件等二手权威综述) - 前作博客:《都准备给 AgentLoop 加权限系统了,CLAUDE.md 里还写着「单轮上限」》(埋了 restructure-system-prompt 的伏笔,本篇是它的展开)
- Anthropic 提示词缓存官方文档:
https://platform.claude.com/docs/en/build-with-claude/prompt-caching(cache_control / ephemeral / 4 breakpoints / TTL / cache_creation & cache_read 字段,本篇 §三 核心概念的事实基线)
