Description / 描述
astrbot/core/provider/sources/anthropic_source.py 目前只放置了一个 prompt cache 断点,位于 system 的最后一个 block(v4.26.7,_apply_explicit_prompt_cache_breakpoints,约 484-492 行):
@classmethod
def _apply_explicit_prompt_cache_breakpoints(cls, payloads: dict) -> None:
system_blocks = payloads.get("system")
if not isinstance(system_blocks, list) or not system_blocks:
return
last_block = system_blocks[-1]
if isinstance(last_block, dict) and "cache_control" not in last_block:
last_block["cache_control"] = dict(cls._PROMPT_CACHE_CONTROL)
Anthropic 的 prompt 渲染顺序是 tools → system → messages,缓存按前缀匹配、断点定义前缀的终点。因此这一个断点缓存的是 tools + system,messages 整体落在缓存范围之外,每轮请求全额重算。而消息历史恰好是唯一会持续增长的部分。
观察连续多轮请求的 usage 可以直接看出这个形态:cache_read_input_tokens 从第二轮起就固定不动,数值等于 tools + system 的规模,之后无论对话怎么涨都不再变化;与此同时未命中的输入部分随轮次单调上升。也就是说缓存命中量是个常数,而全价重算的量在持续增长——对话越长,浪费越大。
具体问题
1. 消息历史没有断点(主要成本来源)
长对话和工具调用循环里增长的都是 messages,却完全不缓存。
2. 断点在 _sanitize_assistant_messages 之前放置(_query 512/514 行、_query_stream 614/616 行)
_sanitize_assistant_messages 会合并相邻同角色消息、把 tool_result block 重排到前面。断点位置只有在最终消息列表上才有意义,当前顺序下即使将来往 messages 加断点也会错位。
3. 纯字符串 content 无法承载 cache_control
_prepare_payload 只把 assistant 的字符串 content 转成 block 列表,user 的纯文本消息(assemble_context 的向后兼容分支)仍是字符串。要给消息打断点,必须先规范化成 block —— 且必须对所有消息无条件规范化:如果只转换「需要打断点的那条」,同一条历史消息会因为在不同请求里是否被标记而呈现两种形状,前缀字节抖动,缓存必然落空。(这一点我第一版实现时写错了,靠前缀对拍才发现,见下文。)
4. 默认 5 分钟 TTL 对 IM 场景偏短
IM 闲聊相邻两轮经常间隔超过 5 分钟,回来时连 tools + system 也整段失效重写。1h TTL 写入费从 1.25x 涨到 2x,三次读取即回本,对长会话是划算的。但第三方 Anthropic 兼容端点(ProviderKimiCode / ProviderMiniMaxTokenPlan / ProviderXiaomiTokenPlan 都继承自本类)不一定接受 ttl 字段,不宜无差别开启。
5. 原地修改了调用方持有的 block
last_block["cache_control"] = ... 是原地写。payloads["system"] 在 system_prompt 为字符串时是新建的 dict,但调用方传入 block 列表时是共享对象;_prepare_payload 中消息 block 也与调用方历史共享同一批 dict。将来给消息打断点时,原地写会把 cache_control 泄漏进持久化的对话历史。
6. cache_creation_input_tokens 完全丢失
_extract_usage 只取 input_tokens / cache_read_input_tokens / output_tokens。Anthropic 的 input_tokens 不含写入部分,所以缓存写入的 token 在 AstrBot 的账目里既不计费也不可见 —— 没有任何途径判断断点是否生效。
建议的修改方向
- 消息尾部滚动断点。 在 system 断点之外,从消息列表尾部往前再打若干断点。建议标记最后 3 条消息:一轮请求通常只往历史追加 2 条(assistant + user/tool_result),标记 3 条可保证上一次请求的写入位置与本次某个断点逐字节重合,拿到精确命中,而不依赖 API「向前回看 20 个 block」的模糊匹配 —— 后者在多工具并发(一轮新增 4-8 个 block)时不一定够用。API 硬上限是 4 个断点,1 + 3 正好用满。
- 保留 system 断点。 它跨会话共享,新会话 / 会话标题生成 / 上下文压缩之后仍能命中,不能被消息断点替代。
- 无条件规范化字符串 content 为单个 text block(见问题 3)。
- 把断点放置移到
_sanitize_assistant_messages 之后。
- TTL 可配。 建议仅对
api.anthropic.com 默认 1h,第三方兼容端点保持现状。
- 写时复制,并且在调用方(插件)已自行放置
cache_control 时让路 —— 这也给「插件向 system 注入内容」提供了一个逃生口,插件可以自己决定切分位置。
- 跳过过小的请求。 会话标题生成这类请求 system 只有几十 token,前缀低于模型最小可缓存长度(512~4096,随模型而异),缓存会被静默忽略;而刚好过线的一次性请求只会白付 1.25x 写入费。
- 至少把
cache_creation_input_tokens 打进 debug 日志(更彻底的做法是给 TokenUsage 加字段,但那会牵动 entities.py 和统计口径、影响其他适配器,可以另开)。
附:一份可参考的实现
我按上述方向改了一版自用,附在本 Issue 里(anthropic_source.py 全文 / diff),维护者可以直接取用或作为参考重写,不必顾虑我的写法。
改动全部集中在 anthropic_source.py,没有触碰 entities.py、config schema 或其他适配器。本地全量测试 1783 条通过,ruff check / format 干净,另补了 11 条回归测试。其中最关键的一条是前缀对拍:把第 N 次请求断点处的前缀字节(剔除 cache_control 元数据)序列化,断言它出现在第 N+1 次请求的断点前缀集合里 —— 闲聊和工具循环两个场景都成立。上面问题 3 就是这条断言报错才暴露出来的。
需要说明的是,这份实现只做了本地前缀对拍与单元测试验证,没有附上改动前后的线上账单对比。我是自用场景,没有做受控的成本测量,所以不宜把任何收益数字写成结论。上面的问题定位依据是 Anthropic 前缀缓存的机制本身,以及 usage 字段呈现出的形态(命中量恒定、未命中量随轮次增长),这部分不依赖具体数值。
补充说明
- 我确认过
_append_system_reminders(astrbot/core/astr_main_agent.py)注入的 <system_reminder>Current datetime: ...> 只写入当轮 user turn 并随该轮固化,历史不会每分钟重渲染。如果它被改成每次请求重新渲染整段历史,上面所有优化都会立刻失效,值得在这一块加个注释提醒后来者。
- 三个子类(Kimi / MiniMax / 小米)只覆写
__init__ 和 get_models,会直接继承本类的缓存行为,改动需要考虑它们连的是第三方端点。
anthropic_source.py
anthropic_source-prompt-cache-tests.patch
anthropic_source-prompt-cache.patch
Use Case / 使用场景
两类都会出现在同一个会话里:
- IM 长对话闲聊。 单轮消息很短,但聊久了历史累积到很大,每轮都在全价重算这段历史。会话间隔经常超过 5 分钟,连 system 缓存也保不住。
- 工具调用干活。 短时间内多轮往返,每轮产生体积较大的
tool_result,一轮可能新增多个 content block(多工具并发时更多)。历史增长最快,浪费也最集中。
两种场景下未命中的输入部分都会随轮次持续增长,而缓存命中量恒定不变,成本几乎全花在重复输入上。
Willing to Submit PR? / 是否愿意提交 PR?
暂不勾选:我这份改动是按自用需求写的,取舍(默认 1h TTL、域名白名单、跳过小请求的阈值)未必符合项目的通用口味,直接提 PR 可能反而增加维护者的取舍负担。附件可以直接拿去用。如果维护者认可这个方向并希望我整理成 PR,请在此 Issue 下告知,我可以按项目要求调整后提交。
说明 / Disclosure
本 Issue 的问题定位、修改方向与附带的实现均由 Claude Code 完成。分工如下:
- Claude Code:阅读适配器与其调用链(
tool_loop_agent_runner / astr_main_agent / entities)、定位缓存失效原因、设计断点放置方案、实现改动、编写回归测试(含前缀对拍)、确认子类与现有测试的影响面。
- 我:提出需求与使用场景约束、提供改动前的 usage 观测、审阅方案、自用验证。
- 附(全文唯一一段人写的): 基本上就是部署了之后用了一段时间然后发现账单有些异常,Cache率之前OpenClaw的对不上。最早用的是AIHubMix,但是所有Claude的都没有Cache,和Claude提出并给他一堆数据后他说用的是OpenAI兼容格式,Claude需要自己打Cache标。于是我用了Anthropic模版,魔改了一下BaseURL到AIHubMix,然后发现Cache率还是有一定的异常,就Fork了AstrBot项目后把问题描述给了Claude Code,让它来自己排查问题并设计修改,他查出来是目前只在System那里打Cache标,但我一般大头来源于User和Assistant。后续基本上全是他自己设计和修改的,我只给他提出了我自己通常的使用场景(长对话,主打陪伴,和ToolCall干活)。所有的Cache的打法和规则全是他设计的。代码也是他实现的,连这份Issue稿子也是他撰写的。这次修改主要是AstrBot在一些独有的插件的加持下太好用了,太像真人了,所以决定一直用,但是目前因为这个Cache的架构和设计的原因又很烧钱,所以魔改一下给自己省钱的。此法不一定适合所有人的Use Case,它好像设计了带1h的Cache的标,可能大部分人5Min的那个就够了,1h反而更贵。仅供参考。期待早日优化(不然我只能每个版本更新后手动覆盖一下对应文件😂)(Claude 模型全程为最新出的Opus 5)(所有此文中的“我”基本都是Opus5他自己)(以及他写的版本里面打了一些中文注释。Claude的习惯就是这样,管不住。正是使用可能需要清除掉)
Generated by Claude Code
Willing to Submit PR? / 是否愿意提交PR?
Code of Conduct
Description / 描述
astrbot/core/provider/sources/anthropic_source.py目前只放置了一个 prompt cache 断点,位于system的最后一个 block(v4.26.7,_apply_explicit_prompt_cache_breakpoints,约 484-492 行):Anthropic 的 prompt 渲染顺序是
tools→system→messages,缓存按前缀匹配、断点定义前缀的终点。因此这一个断点缓存的是tools + system,messages整体落在缓存范围之外,每轮请求全额重算。而消息历史恰好是唯一会持续增长的部分。观察连续多轮请求的 usage 可以直接看出这个形态:
cache_read_input_tokens从第二轮起就固定不动,数值等于tools + system的规模,之后无论对话怎么涨都不再变化;与此同时未命中的输入部分随轮次单调上升。也就是说缓存命中量是个常数,而全价重算的量在持续增长——对话越长,浪费越大。具体问题
1. 消息历史没有断点(主要成本来源)
长对话和工具调用循环里增长的都是
messages,却完全不缓存。2. 断点在
_sanitize_assistant_messages之前放置(_query512/514 行、_query_stream614/616 行)_sanitize_assistant_messages会合并相邻同角色消息、把tool_resultblock 重排到前面。断点位置只有在最终消息列表上才有意义,当前顺序下即使将来往messages加断点也会错位。3. 纯字符串
content无法承载cache_control_prepare_payload只把 assistant 的字符串 content 转成 block 列表,user 的纯文本消息(assemble_context的向后兼容分支)仍是字符串。要给消息打断点,必须先规范化成 block —— 且必须对所有消息无条件规范化:如果只转换「需要打断点的那条」,同一条历史消息会因为在不同请求里是否被标记而呈现两种形状,前缀字节抖动,缓存必然落空。(这一点我第一版实现时写错了,靠前缀对拍才发现,见下文。)4. 默认 5 分钟 TTL 对 IM 场景偏短
IM 闲聊相邻两轮经常间隔超过 5 分钟,回来时连
tools + system也整段失效重写。1h TTL 写入费从 1.25x 涨到 2x,三次读取即回本,对长会话是划算的。但第三方 Anthropic 兼容端点(ProviderKimiCode/ProviderMiniMaxTokenPlan/ProviderXiaomiTokenPlan都继承自本类)不一定接受ttl字段,不宜无差别开启。5. 原地修改了调用方持有的 block
last_block["cache_control"] = ...是原地写。payloads["system"]在system_prompt为字符串时是新建的 dict,但调用方传入 block 列表时是共享对象;_prepare_payload中消息 block 也与调用方历史共享同一批 dict。将来给消息打断点时,原地写会把cache_control泄漏进持久化的对话历史。6.
cache_creation_input_tokens完全丢失_extract_usage只取input_tokens/cache_read_input_tokens/output_tokens。Anthropic 的input_tokens不含写入部分,所以缓存写入的 token 在 AstrBot 的账目里既不计费也不可见 —— 没有任何途径判断断点是否生效。建议的修改方向
_sanitize_assistant_messages之后。api.anthropic.com默认 1h,第三方兼容端点保持现状。cache_control时让路 —— 这也给「插件向 system 注入内容」提供了一个逃生口,插件可以自己决定切分位置。cache_creation_input_tokens打进 debug 日志(更彻底的做法是给TokenUsage加字段,但那会牵动entities.py和统计口径、影响其他适配器,可以另开)。附:一份可参考的实现
我按上述方向改了一版自用,附在本 Issue 里(
anthropic_source.py全文 / diff),维护者可以直接取用或作为参考重写,不必顾虑我的写法。改动全部集中在
anthropic_source.py,没有触碰entities.py、config schema 或其他适配器。本地全量测试 1783 条通过,ruff check / format 干净,另补了 11 条回归测试。其中最关键的一条是前缀对拍:把第 N 次请求断点处的前缀字节(剔除cache_control元数据)序列化,断言它出现在第 N+1 次请求的断点前缀集合里 —— 闲聊和工具循环两个场景都成立。上面问题 3 就是这条断言报错才暴露出来的。需要说明的是,这份实现只做了本地前缀对拍与单元测试验证,没有附上改动前后的线上账单对比。我是自用场景,没有做受控的成本测量,所以不宜把任何收益数字写成结论。上面的问题定位依据是 Anthropic 前缀缓存的机制本身,以及 usage 字段呈现出的形态(命中量恒定、未命中量随轮次增长),这部分不依赖具体数值。
补充说明
_append_system_reminders(astrbot/core/astr_main_agent.py)注入的<system_reminder>Current datetime: ...>只写入当轮 user turn 并随该轮固化,历史不会每分钟重渲染。如果它被改成每次请求重新渲染整段历史,上面所有优化都会立刻失效,值得在这一块加个注释提醒后来者。__init__和get_models,会直接继承本类的缓存行为,改动需要考虑它们连的是第三方端点。anthropic_source.py
anthropic_source-prompt-cache-tests.patch
anthropic_source-prompt-cache.patch
Use Case / 使用场景
两类都会出现在同一个会话里:
tool_result,一轮可能新增多个 content block(多工具并发时更多)。历史增长最快,浪费也最集中。两种场景下未命中的输入部分都会随轮次持续增长,而缓存命中量恒定不变,成本几乎全花在重复输入上。
Willing to Submit PR? / 是否愿意提交 PR?
暂不勾选:我这份改动是按自用需求写的,取舍(默认 1h TTL、域名白名单、跳过小请求的阈值)未必符合项目的通用口味,直接提 PR 可能反而增加维护者的取舍负担。附件可以直接拿去用。如果维护者认可这个方向并希望我整理成 PR,请在此 Issue 下告知,我可以按项目要求调整后提交。
说明 / Disclosure
本 Issue 的问题定位、修改方向与附带的实现均由 Claude Code 完成。分工如下:
tool_loop_agent_runner/astr_main_agent/entities)、定位缓存失效原因、设计断点放置方案、实现改动、编写回归测试(含前缀对拍)、确认子类与现有测试的影响面。Generated by Claude Code
Willing to Submit PR? / 是否愿意提交PR?
Code of Conduct