Spindriftの车厢
首页归档项目音乐杂谈照片墙友链关于
封面

每轮请求都在全量重算 7K token?我读了 Anthropic 缓存机制才意识到自己多花了一个零

写作时间:2026-07-02 15:53:42
# AI Coding
# Agent
# Prompt Engineering
# Anthropic
# 实践

上一篇博客挖出了 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 字段,本篇 §三 核心概念的事实基线)

‍

avatar

Spindrift

ZZULI 软件工程大三学生。擅长后端开发与Agent应用

RECOMMENDED

Python类型Protocol vs ABC 到底怎么选

2026-06-27 21:24:57

实践出真知:为什么要采用openspec?

2026-06-30 10:20:06

准备给AgentLoop加权限 ,发现CLAUDE.md 里还写着「单轮上限」

2026-07-01 23:58:39

Table of Contents