跳到主要内容
  1. 文章/

一个 agent loop 的缓存账单:显式的、自动的,和你网关里那个你不知道的

·2 分钟

agent loop 每一轮都会把完整历史重发一遍:system prompt、工具 schema、之前 每一次工具调用和它的结果,一个不落。跑到第十轮,对话本身早就比前面那段静态 前缀大得多,没有缓存的话,每一轮都在全价重新 prefill 整个上下文。

Prompt caching 就是来解决这个的。但这周我学到的是:“prompt caching"在不同 的服务方那里是三种完全不同的东西,而且只有其中一种在乎你的框架做了什么。 下面是我改了什么、测到了什么,以及哪里让我意外。

改动:缓存整个对话,而不只是前缀 #

我的 Rust agent 框架(harness-rs) 对 Anthropic API 已经做了教科书式的事情:在静态前缀(system prompt + 工具 schema)末尾打一个 cache_control 断点。这些字节每次调用都一样,缓存一次, 之后一直读折扣价。

但对话本身没有标记。Anthropic 的缓存是严格按块 opt-in 的:不标,就不缓存。 于是每一轮都在全价重刷整个不断增长的历史——就在我们精心变便宜的前缀后面。

修复就是再打一个断点,落在最后一条消息的最后一个可标块上:

/// agent loop 只会追加历史然后整体重发;没有这个断点,每一轮都在全价
/// 重读整个对话。标记最后一个块,这一轮的请求就成了下一轮的缓存命中:
/// Anthropic 会匹配最长的已缓存前缀,只有新追加的块按写入价付费。
fn mark_history_breakpoint(messages: &mut [AnthropicMessage]) {
    for msg in messages.iter_mut().rev() {
        for block in msg.content.iter_mut().rev() {
            let slot = match block {
                AnthropicBlock::Text { cache_control, .. }
                | AnthropicBlock::ToolUse { cache_control, .. }
                | AnthropicBlock::ToolResult { cache_control, .. }
                | AnthropicBlock::Image { cache_control, .. } => cache_control,
                // thinking 块不能带 cache_control,向前回退。
                _ => continue,
            };
            *slot = Some(CacheControl::ephemeral());
            return;
        }
    }
}

每一轮的完整请求变成下一轮的缓存前缀。只有新追加的部分——模型上一条回复 和新的工具结果——按写入价(Anthropic 是 1.25x)付费,更早的一切都按 0.1x 读。

顺手我还把缓存写入量提成了正式的 usage 字段。读取量之前就有,但买来 这些读取的写入费只躺在 debug 日志里。账本缺一半,“缓存到底省了多少钱"就 没法回答。

实测:三个后端,三种语义 #

我写了一条脚本化的 agent trace——固定的 system prompt、三个工具 schema、 八轮工具调用和结果,跨运行字节级一致——然后把够得着的后端挨个测了一遍。

DeepSeek:全自动,而且很好 #

DeepSeek 自动缓存前缀。不用标记、不用配置,命中量在 usage 里回报,按大约 四分之一的价格计费。八轮 trace:

call0: prompt=1010  hit=0     miss=1010     ← 冷启动
call1: prompt=1528  hit=896   miss=632
call2: prompt=2046  hit=1408  miss=638
...
call8: prompt=5154  hit=4608  miss=546
合计:命中率 79.8%,input 成本降 59.1%

第一轮之后,每次调用几乎命中全部先前历史(按 64-token 块向下取整),只为 新追加的一轮付全价。这正是我的 Anthropic 断点显式买到的行为——DeepSeek 直接帮你做了。

两个值得知道的坑。缓存是异步写入的:请求连得太紧,偶尔会踩空一拍,在上一 次调用本应播种的地方读到零。另外 DeepSeek 还有一个 Anthropic 兼容端点 (api.deepseek.com/anthropic),usage 是正宗的 Anthropic 语义—— input_tokens 只算未缓存的部分——但它无视显式的 cache_control 标记。我在那里跑了 A/B:带不带 history 断点,结果逐字节相同。底下是同一套 自动缓存。

这个端点还有一个意外:它严格校验 thinking 模式下的 assistant 历史必须带 thinking 块。我第一版脚本 trace 用的是伪造的 assistant 消息,直接被 400。 真实的 agent loop 能过,因为框架会把 thinking 块原样回传。一次意外的集成 测试,我的框架的 thinking 回传路径通过了。

池化中转:你没点的缓存 #

我又把 trace 打到自己日常在用的 LLM 网关上——那种前面挡着一池上游账号的 中转服务。结果看起来像缓存,但不对劲:没打任何标记也有命中,cache_creation 恒为零,而且在一条单调增长的对话上,命中量来回跳——2213、1809、2823。

解释是:后端在自动缓存,但中转把每个请求路由到池里空闲的实例,每个实例有 自己的缓存。命不命中取决于你有没有落回上次服务你的那台。没有会话亲和, 命中率纯随机——我的 trace 平均 60%,而且完全不受控。

这是我见过的对 inference gateway 做 cache-aware 路由(llm-d 和 Kubernetes Gateway API Inference Extension 正在做的事)最有力的论据:缓存的价值, 取决于路由器能不能把你送回它身边。

Anthropic 直连:唯一在乎你框架的地方 #

在 Anthropic 真正的 API 上,这一切都不自动。history 上没断点,就意味着它 上面的命中永远是零,每一轮如此。按 Sonnet 价格给我的 trace 建模:只标前缀 的策略九次调用 input 侧约 $0.079;加上 history 断点是 $0.037——扣掉 1.25x 写入费后仍省 54%,而且轮数越多省得越多。

所以这个改动恰好在缓存需要显式声明的地方起作用,在其他地方无害。框架默认 行为就该长这样。

端到端验证 #

脚本 trace 干净,框架不干净。我拿真实的 harness-rs 适配器——新断点、新 usage 管道——对 DeepSeek 的 Anthropic 端点跑了一个活的工具循环:真实的 模型轮次、真实回传的 thinking 块、喂给它工具调用的假文件内容。七步,缓存 命中率 71.5%,每一步的读写都能在 loop 的用量汇总里看到。

然后我给 Go 框架(agent-go)做了 同样的事。它的问题不一样:usage 在 SDK 边界就被整个丢掉了,token 数靠 分词器估算。缓存字段其实一直躺在响应里,只是没人读。把 usage 接通之后 (流式还要 opt-in stream_options.include_usage——最后那个 usage chunk 的 choices 是空的,老代码的流式循环正好把它跳过了),同样的增长对话测试 端到端报出 71.2% 命中。两个框架、两种语言、同形 trace,数字对上了。

给你的建议 #

如果你的 agent loop 直连 Anthropic:除了前缀,把对话尾巴也标上。几行代码, 换来 history 命中率从 0% 到 ~80% 的差距。

如果你打 DeepSeek 或 OpenAI:你已经在被缓存了——但前提是上下文字节稳定。 只追加的历史、固定的工具顺序、system prompt 里别放时间戳。缓存不原谅重写。

如果你走中转:先测再假设。你可能已经有了不知道的缓存,命中率由池路由的 运气决定,而不是你能控制的任何东西。如果中转是你自己的,会话亲和是你能 做的最便宜的缓存优化。

无论哪种情况:把缓存字段接进你的用量统计,读取和写入都要。测不到的数字, 在成本评审上就是守不住的数字。