Atlarix 的 QA 对比同一会话的请求后发现,Build 模式后续消息持续重写约 1.9 万个缓存 token。文章解释了 Claude 按精确前缀匹配缓存的机制,以及缓存断点前内容变化为何会导致失效并增加费用。
Prompt caching 是 AI 编程 Agent 中最容易见效、成本最低的优化。每次请求中的 system prompt 和工具定义都相同,因此服务商可以缓存这些内容,并在后续读取时只收取原价的一小部分。Atlarix 很早就为 prompt 设置了缓存标记,日志看起来也一切正常。直到一次 QA 检查对同一聊天中的两条消息进行了逐字节比较,才发现:在 Build 模式下,从第二条消息开始,每条消息都会重写整个缓存,而不是读取缓存。
Claude 的缓存依赖完全一致的前缀匹配:依次是工具、system prompt、对话内容,一直到标记的断点。如果断点之前的任何一个字节发生变化,从那个字节开始的所有内容都会缓存未命中,并被重新写入缓存。按照 Anthropic 的定价,缓存写入比普通输入更贵:有效期五分钟的缓存条目收费为普通输入的 1.25 倍,一小时的则为 2 倍;读取价格约为普通输入的十分之一。因此,每次都要重新写入的缓存,比完全不用缓存还贵。
Atlarix 将 system prompt 分成两部分构建。稳定的头部包含 Agent 的身份、工具使用规则,以及当前模式的指令;这些内容在整个聊天过程中保持不变,因此缓存断点设在这里,有效期为一小时。每轮变化的尾部包含当天日期和当前消息的请求;它随每条消息变化,所以设计上应该放在断点之后,独占一个 block。
Ask 模式正是这样处理的。Build 模式却先把这两部分拼成一个字符串,再把上下文各节、指南和项目笔记追加到后面。结果,用户请求落在了携带缓存断点的那个 block 中间。
处理一条消息时,Agent 会随着工具结果的返回,多次调用模型,而这些调用使用的 system prompt 完全相同。因此,日志中的缓存读取情况很正常:处理一条消息的第二步时,从缓存读取了 19,379 个 token。缓存未命中只发生在消息之间,因为这时请求文本发生了变化。只逐条检查单条消息,检查就总能通过。
在对采用 Anthropic 格式的服务商进行 QA 时,我们捕获了同一聊天中两次连续请求的请求体,并进行了比较。一个长达 24,000 个字符的 system block,首次出现差异的位置在第 22,966 个字符:那里是存放用户请求的 <user_context>。它之后的所有内容完全相同,之前的所有内容也完全相同。全部差异就出在这一个 block 上。
现在,构建完成的 Build prompt 会在发送前重新拆成两部分:缓存部分不再包含任何随消息变化的内容;每轮变化的尾部则作为独立的 system block,放在缓存部分之后发送。prompt 中的文字一个都没改,改的只是尾部的位置。应用中其他读取 prompt 的地方,仍然读取完整内容。
用于防止问题复发的测试,采用了与这次 QA 相同的做法:运行两条消息,要求缓存 block 逐字节完全一致,并且请求位于后面的 block 中。旧代码无法通过这个测试,新代码可以。
在同一个聊天中发送三条消息,使用 Build 模式,模型为 Amazon Bedrock 上的 Claude Haiku 4.5:
从第二条消息开始,prompt 的固定部分现在会命中缓存读取,价格约为普通输入的十分之一;此前,它每次都会执行有效期一小时的缓存写入,价格是普通输入的两倍。对于每条消息的固定部分,这让 Claude 上的费用大约降至原来的十五分之一。同样的内容布局,也能让任何按前缀匹配缓存的服务商复用缓存。
我们从中总结出的规则是:任何随消息变化的内容,都必须放在最后一个缓存断点之后;唯一可靠的检查方式,是比较两次真实请求,而不是只检查一次。修复已随 Atlarix 15.9 发布。
本文最初发表于 atlarix.dev。
如需采取进一步措施,你可以考虑屏蔽此人和/或举报滥用行为。