Skip to content

[Feature] Anthropic 适配器 prompt caching 只覆盖 system,消息历史每轮全额重算 #9388

Description

@YuanZHAO321

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 渲染顺序是 toolssystemmessages,缓存按前缀匹配、断点定义前缀的终点。因此这一个断点缓存的是 tools + systemmessages 整体落在缓存范围之外,每轮请求全额重算。而消息历史恰好是唯一会持续增长的部分。

观察连续多轮请求的 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 的账目里既不计费也不可见 —— 没有任何途径判断断点是否生效。

建议的修改方向

  1. 消息尾部滚动断点。 在 system 断点之外,从消息列表尾部往前再打若干断点。建议标记最后 3 条消息:一轮请求通常只往历史追加 2 条(assistant + user/tool_result),标记 3 条可保证上一次请求的写入位置与本次某个断点逐字节重合,拿到精确命中,而不依赖 API「向前回看 20 个 block」的模糊匹配 —— 后者在多工具并发(一轮新增 4-8 个 block)时不一定够用。API 硬上限是 4 个断点,1 + 3 正好用满。
  2. 保留 system 断点。 它跨会话共享,新会话 / 会话标题生成 / 上下文压缩之后仍能命中,不能被消息断点替代。
  3. 无条件规范化字符串 content 为单个 text block(见问题 3)。
  4. 把断点放置移到 _sanitize_assistant_messages 之后。
  5. TTL 可配。 建议仅对 api.anthropic.com 默认 1h,第三方兼容端点保持现状。
  6. 写时复制,并且在调用方(插件)已自行放置 cache_control 时让路 —— 这也给「插件向 system 注入内容」提供了一个逃生口,插件可以自己决定切分位置。
  7. 跳过过小的请求。 会话标题生成这类请求 system 只有几十 token,前缀低于模型最小可缓存长度(512~4096,随模型而异),缓存会被静默忽略;而刚好过线的一次性请求只会白付 1.25x 写入费。
  8. 至少把 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_remindersastrbot/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 / 使用场景

两类都会出现在同一个会话里:

  1. IM 长对话闲聊。 单轮消息很短,但聊久了历史累积到很大,每轮都在全价重算这段历史。会话间隔经常超过 5 分钟,连 system 缓存也保不住。
  2. 工具调用干活。 短时间内多轮往返,每轮产生体积较大的 tool_result,一轮可能新增多个 content block(多工具并发时更多)。历史增长最快,浪费也最集中。

两种场景下未命中的输入部分都会随轮次持续增长,而缓存命中量恒定不变,成本几乎全花在重复输入上。

Willing to Submit PR? / 是否愿意提交 PR?

  • Yes, I am willing to submit a 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?

  • Yes, I am willing to submit a PR. / 是的,我愿意提交 PR。

Code of Conduct

Metadata

Metadata

Assignees

No one assigned

    Labels

    area:providerThe bug / feature is about AI Provider, Models, LLM Agent, LLM Agent Runner.enhancementNew feature or request

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions