博客

Prompt Caching 的字节稳定性测试

2026年4月16日

English

打开 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 要求这些字节和之前某次写入完全一致。

这里“完全”两个字,分量很重。看两个在人类眼里一模一样的请求:

same prefix · one byte different · cache treats them as unrelatedsystem: "You are a helpful assistant."tools: [calculator, web_search]model: "claude-sonnet-4-6"4,200 tokens in prefixsystem: "You are a helpful assistant.tools: [calculator, web_search]model: "claude-sonnet-4-6"4,200 tokens + 1 byte·"→ 1 byte diff (trailing space)HIT0.1× input on readMISS1.25× input (write premium)

两个请求,只差一个字节。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):

Agent runtimeAnalysis & verificationContext & retrievalEdge surfacesagent_executeagent_execute_streamagent_loopinterpretation · stub_cleanupcomplexity_analyzecomplexity_taskevaluateverify_batch · verify_claimverify_extract_claimsdecompose · research_planmemory_extractcontext_summarytool_selectweb_fetch_summaryweb_fetch_extracttuiwebhookcompletions_proxy · _stream_build_api_request()the only place prompts serialize≈ the only place to defendAnthropicmessages API20 call sites · 1 serialization boundary

真正的威胁模型不是恶意 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

三种路由结果,每一种都有原则:

① operator override (SHANNON_FORCE_TTL)takes precedence — short-circuits source-routing② cache_source (per-request label)if no override, route by call-site identity1h TTL · 2× write premiumslack · line · feishu · larktelegram · tui · shanclaw5m TTL · 1.25× write premiumdefault — everything else"fail cheap" if not amortizedknown humanchannelunknown /automated(also: cache off)

未知 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,从三个原语开始:

  1. Tool schema freeze —— 把 serialized schema 固定到 tool name set,不受 description drift 影响。
  2. Deterministic tool ordering —— 永远按名字排序,不管注册顺序如何。
  3. 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 行代码听起来荒唐,直到它第一次抓住一个本来会花掉四位数的漂移。

Kocoro-lab/Shannon

生产级多 Agent 平台——使用 Rust、Go 和 Python 构建,支持确定性执行、预算强制与企业级可观测性。

View on GitHub