打开 Anthropic 的 prompt cache。每个 cached request 的成本最多下降 90%。感觉像捡钱。两周后你发了一个 patch,hit rate 悄悄掉到零,直到月底账单来了才发现。
问题不在 cache。问题在于现代软件工程里,几乎没有其他东西是字节脆弱的。日志能容忍空白;JSON 能容忍 key 重排;gRPC 能容忍字段新增。Prompt cache 什么都不容忍。System prompt 末尾多一个换行,效果上等同于删除那条 cache entry。
这篇文章讲的是:代码库持续演进时,要怎样才能让 prompt cache 活着。更具体地说,是我们在 Shannon 里经历足够多次险些出事之后,专门构建的字节稳定性测试纪律。
为什么 Prompt Caching 是字节脆弱的
Anthropic 的 prompt cache 以 prefix hash 为 key。当你发送带 cache_control block 的请求时,provider 会对每个 breakpoint 之前的 prefix 字节计算 hash。Cache hit 要求这些字节和之前某次写入完全一致。
这里“完全”两个字,分量很重。看两个在人类眼里一模一样的请求:
两个请求,只差一个字节。Cache 会把它们当成毫不相关的请求。每次 miss 都要付 1.25× input token 成本——每一次调用都是如此,直到有人在月底账单上看见。
漂移来源并不华丽,而且很多:
- 工具描述编辑。 这是最常见的一类。有人把
description: "Search the web"改成description: "Search the web for factual information"。所有 active session 下一轮都会 cache miss。 - 工具顺序。 新工具被注册进来;列表位置来自某个 dict 的
.keys()顺序。变化是什么: prefix 里的 tool block 被重排。为什么重要: prefix hash 依赖整个 tool array 的字节顺序,所以 position 0 之后的重排会让后面每一个字节都失效。 - 模板空白。 Prompt template 里新增或删除了一个
strip()。Jinja block 末尾多了一个\n。 - 把 timestamp 或 session ID 注入 system prompt,理由是“方便调试”。
- 模型变化。 从
claude-sonnet-4-6升级到claude-sonnet-4-7会切换 cache pool;旧 session 下一次回来就 miss。
这些看起来都只是平凡的一行代码改动。但每一个都是影响所有 live session 的 cache invalidation event。
Fan-In 问题
一个系统通常不是只有一个地方在组装 prompt。它有几十个。
Shannon 的 Python LLM service 目前大约有二十个不同 call site 会调用 Anthropic provider,每个 call site 都带一个 cache_source 字符串,同时作为 cache TTL 路由提示(anthropic_provider.py):
真正的威胁模型不是恶意 bug,而是普通 bug。tool_select 里一次 refactor 改变了工具序列化方式,就会让每个抵达 tool-selection loop 的 agent 失去 cache。Code review 关注的是逻辑,不是字节相等。
既然所有路径都汇入同一个 serialization boundary,防线也必须立在那里:一个地方,对每个 payload 强制 invariant,不管是哪一个 PR 改了哪一个 caller。
保持 Prefix 稳定的四种模式
Shannon 通过四个互补模式强制 cache 稳定性;每个模式都有测试,一旦 invariant 被破坏,CI 就会失败。
1. Tool Schema Freeze
一组工具第一次构建完成后,Shannon 会按 name-set 冻结得到的 schema。后续调用只要工具名字相同,就返回冻结副本,即使 description 在 session 中途漂移了也一样。来自 anthropic_provider.py:600:
key = str(sorted(
(f.get("name") or (f.get("function") or {}).get("name", "") or "",
bool(f.get("defer_loading") or ...))
for f in functions
))
if self._frozen_tools and self._frozen_tools_key == key:
return [dict(t) for t in self._frozen_tools]
约束这件事的测试很直接:
def test_same_tools_return_frozen_copy(self):
tools1 = provider._convert_functions_to_tools(functions)
tools2 = provider._convert_functions_to_tools(functions_v2) # same names, drifted descriptions
assert tools1[0]["description"] == tools2[0]["description"]
如果有人重构这个函数,忘了保留 freeze,这个测试会在 CI 里立刻失败,而不是等到凌晨三点才在成本 dashboard 上暴露出来。
2. Deterministic Tool Ordering
工具在序列化前按名字排序。永远如此。这让 prefix 里的 tool block 不受注册顺序、Python dict iteration 或其他不确定性来源影响:
tools.sort(key=lambda t: t["name"])
一行代码。由 test_tools_sorted_by_name 守住。未来如果有人为了“逻辑分组顺序”新增工具导致测试失败,他就必须解释为什么要撤销这个稳定性保证。
3. Cache-Break Detector
每个 provider instance 都带一个 CacheBreakDetector,在每次请求时 snapshot (system_text, tool_names, model),并 diff 连续调用。检测到 break 时,它会记录结构化事件:{"changed": ["system"], "tools_added": [...], "tools_removed": [...]}。这样 break 会先出现在 telemetry 里,再出现在账单上。
这是可观测性,不是预防。但它把“我们发现账单变高了”变成了“部署后三小时我们在日志里看到了 break,然后回滚了”。
4. TTL-Aware Serialization Tests
cache_control block 本身也需要字节稳定。Shannon 的测试会在 wire 层(序列化后的字节)断言:相同输入产生相同的 serialized payload,精确到 cache_control 放置位置和 ephemeral TTL marker。
这是最重要的测试。它也是 review 里看起来最无聊的测试:一次 request body 的 golden-file comparison。无聊测试会抓住有趣 bug。
伴随问题:不是每次 Cache Write 都值得
字节稳定性让 cache 活着。第二个问题是:你一开始到底想不想写这条 cache。
Anthropic 的定价是:写入 5 分钟 cache 成本为 1.25× input tokens。写入 1 小时 cache 成本为 2× input tokens。读取两者都是 0.1×。1 小时选项只有在 prefix 确实会在一小时内被重复读取时才划算。
对大多数 AI-agent workload,答案通常是“不值得”:
- Cron 触发的 webhook processor:一次性,prefix 用完即丢。
- 内部 subagent spawn:每个 parent turn 跑一次,然后结束。
- 单次分类 endpoint:cache write 花了钱,没有 reader。
对某些路径,答案是“绝对值得”:
- 交互式 terminal(TUI),用户会在轮次之间思考。
- Chat channel(Slack、LINE、Feishu、Telegram),有典型的人类节奏。
- 长时间运行的 CLI agent,同一个 base prompt 会跨很多 turn 摊销。
Shannon 从 anthropic_provider.py 里的一张表路由这个决策:
_LONG_CACHE_SOURCES = frozenset({
"slack", "line", "feishu", "lark", "telegram",
"tui", "shanclaw", "oneshot_interactive", "cache_bench",
})
def _ttl_block(request) -> Optional[Dict[str, str]]:
# 1. Operator escape hatch
force = os.environ.get("SHANNON_FORCE_TTL", "").strip().lower()
if force == "off": return None
if force == "5m": return CACHE_TTL_SHORT
if force == "1h": return CACHE_TTL_LONG
# 2. Source-based routing
src = (getattr(request, "cache_source", None) or "").strip().lower()
if src in _LONG_CACHE_SOURCES:
return CACHE_TTL_LONG
return CACHE_TTL_SHORT
三种路由结果,每一种都有原则:
未知 source → 5 分钟 TTL。这是“fail cheap”的默认值:如果调用恰好在 cache-friendly 路径上,write 会在 5 分钟内摊销;如果没有,premium 是 1.25×,而不是 2×。上行少赚一点,下行少亏很多。这个默认值对齐了真实成本结构里的不对称性。
Operator escape hatch(SHANNON_FORCE_TTL=off)是为每个基础设施工程师迟早都会遇到的场景准备的:我们需要现在立刻通过一个 flag 全局关掉它,而且不能等 deploy。
教训
Prompt cache 是基础设施,不是优化。
把它当成优化,才会产生“一个例行 PR 之后成本意外飙升”的故事。隐含的心智模型是:我写我的代码,cache 自然发生;如果没发生,最坏就是付全价。这个模型两个方向都错。第一,cache 会悄悄停止生效——没有错误、没有异常、没有测试失败,只有 hit rate 的缓慢退化。第二,最坏情况不是付全价,而是付高于全价,因为你正在每次 miss 上支付 write premium(1.25× 或 2×)。
把 prompt cache 当成基础设施,意味着:
- Prompt prefix 是 API contract。修改它是版本化事件,不是一行代码编辑。
- 字节稳定性是 CI 关心的问题。漂移发生时,测试在造成漂移的 PR 里、merge 之前失败。
- TTL routing 是 policy layer。正确 TTL 取决于调用模式,不取决于工程师对复用的乐观程度。
- 可观测性是主要故事,不是事后补充。你希望在结构化日志里看到漂移,而不是在财务 dashboard 上看到。
这些都不奇怪。它和数据库 schema、公共 API surface、序列化格式上需要的纪律一样。Prompt cache 只是“字节稳定 contract”这个类别里的最新成员,而整个行业还在集体学习怎样认真对待它。
如果你今天正在上线 prompt caching,从三个原语开始:
- Tool schema freeze —— 把 serialized schema 固定到 tool name set,不受 description drift 影响。
- Deterministic tool ordering —— 永远按名字排序,不管注册顺序如何。
- Wire payload 的 byte-snapshot test —— 一个 golden-file 测试,比较精确的 serialized request body。任何字节移动,CI 就失败,早于账单。
其他东西——TTL routing、break detector、source table——都叠在这三者之上。
Shannon 里最值得先抄的测试文件是 tests/test_anthropic_cache.py。为 prompt-cache test suite 写近 1,000 行代码听起来荒唐,直到它第一次抓住一个本来会花掉四位数的漂移。
生产级多 Agent 平台——使用 Rust、Go 和 Python 构建,支持确定性执行、预算强制与企业级可观测性。