在面向相对长程任务的 agent 设计开发中,我们大概是绕不开一个问题的:
用户暂时离开时,我们应该让 agent 的缓存自然过期,还是使用某些类似于心跳机制的东西把缓存持续留存?
在短上下文时代,缓存是一个锦上添花的东西:命中了可以省一点钱,没命中也无伤大雅。但当上下文长度持续达到百 K 量级时,一次 cache miss 的代价就是命中价格的十几倍乃至几十倍,缓存的维持与失效之间横亘着数量级的成本差异。由此,缓存策略变成了一个动态的经济决策:
- 缓存失效意味着下一次请求要支付整段上下文的输入费用 + 缓存写入费用
- 维持缓存意味着持续支付缓存命中的成本
所以,为了避免未来一次昂贵的 cache miss,我现在最多值得花多少钱去维持 cache 状态?
我们可以在这篇博客中尝试去计算,建模并深入讨论这个问题。
如果你不想看各种原理和数学公式,可以直接跳到 2.1 末尾看各家的缓存甜品点曲线。
一、缓存是什么,以及各家的定价策略
1.缓存的数学与物理本质
在讨论各家(尤其是 DeepSeek)的独特优化之前,我们可以先从最基础的标准 Transformer 开始。
已知 Transformer 自注意力机制的定义为:
在每一次前向传播的过程中,自注意力层都要做两件事:先把输入序列里的每个 token 通过线性投影变成 Q、K、V 三个向量,然后让所有 query 去跟所有 key 做点积、算注意力得分、最后加权求和得到输出。
当我们只是在序列末尾追加新的 token(如用户多打了一句话,返回了一个工具结果)时,前面所有历史 token 的 K 和 V 投影,跟上一次算出来的结果完全一样,一个浮点数都不会变。但在没有缓存的情况下,系统仍然会老老实实地把整个序列从头到尾重新投影一遍、重新算一遍所有注意力对。
所以,KV Cache 做的就是把这些「确定不会变的中间结果」显式地记下来。
当上下文长度为 、新增生成/输入为 时:
- 无 Cache 时(全量前向传播):每次都要重新投影并计算所有序列对的注意力。线性投影的计算量正比于 ,注意力得分的计算量正比于 ,整体 FLOPs 与序列长度呈二次关系。
- 有 Cache 时(增量解码与前缀缓存):此时历史前缀序列 的投影矩阵 已计算并驻留在显存中。新增 个 token 时,只需计算:
- 新 token 的线性投影:,复杂度仅为
- 新 query 与全量 key 的交互:,复杂度为
当 时——比如解码阶段每次只生成 1 个 token,而上下文已经有 100k,此时的计算量从 直接坍缩到了 ,差了整整一个维度。
因此,我们或许可以认为Cache 的数学本质是记忆化(Memoization),Transformer 的前向传播是一张巨大的有向无环图(DAG),每一层的 K/V 投影都是其中一个子图。当输入只是前缀追加而非整体修改时,历史序列对应的那部分子图的输入没变,输出也必然不变。Cache 把这些子图的结果存下来,下次走到同一个节点时直接读取,彻底消除重复计算。
但这个「存」也是有其物理代价的。
算力(FLOPs)可以被视为一种即时消耗品,而内存/显存则是一种容量-时间的积分。在标准情况下,维持一段大小为 个 token 的 KV Cache,所占用的显存空间为:
(其中 为层数, 为注意力头数, 为头维度, 为每个参数的字节数,如 FP16 为 2,FP8 为 1)
如果在显存中维持该 Cache 持续时间为 ,那么系统付出的物理代价就是显存/内存时:
而云服务商的矛盾就在于:GPU 显存是绝对的刚性稀缺资源。如果用户占着 不释放却不发请求,显存时就会被极大浪费,系统的并发承载能力会线性下降。
那退而求其次,把缓存挪到便宜的硬盘上行不行?在普通架构(MHA/GQA)下,这条路也走不通——因为上下文缓存实在太庞大、太吃带宽了。
梁圣 DeepSeek 的缓存优化就是另一个话题了,因为要搜集的资料论文太多所以本文按住不表
一个 400B 级的稠密大模型(如 Llama-3.1-405B),每 1k tokens 的 KV Cache 大概需要数百 MB。如果一个用户的长上下文有 100k tokens,其 KV Cache 可能会达到 几十 GB。而硬盘(哪怕是顶级 PCIe 4.0/5.0 NVMe SSD)的读取速度大约在 ,如果每次请求都要从硬盘把几十 GB 的缓存读回显存,光是 I/O 延迟就需要好几秒,重新算一遍的前向时间甚至比从硬盘读出来的时间还要快。所以把传统模型的 KV Cache 放硬盘,在工程上是不划算的。
到这里,整条链路就被封死了:算力虽便宜,却要每次重算;硬盘虽便宜,却读得太慢;真正能用的,只剩那一小块既昂贵又稀缺的显存。缓存被死死锁在显存里,也就解释了为什么各大厂商会围绕它推出截然不同的定价方案。
2.各家的缓存都是如何定价的?
我们大概可以把整个市场划分成两种类型:租约型和仓储型。
租约型
租约型缓存是目前大模型 API 最主流的形态(包括 A\、OpenAI、DeepSeek 等)。它的核心特征是请求驱动、命中续约:
- 首次创建 cache 时,可能需要支付高于普通 input token 的费用(有的慷慨的 Provider 可能完全不需要我们支付额外的 cache 创建费用,这些计算起来相对简单所以此处暂且不表);
- cache hit 的价格会大幅降低
- cache 有一个相对明确的有效期,命中后可以刷新或者延长生命周期
比较典型的定价结构大概是:
像 Anthropic 还额外提供更长生命周期的缓存,例如以更高的 Write Premium(2x 的 input 价格)换取 1 小时的初始缓存寿命,其默认缓存则会在每次成功命中后重新刷新生命周期;OpenAI 在创建缓存后则至少提供 30 分钟的缓存寿命。
听起来可能有些抽象,我们直接以一段 500k tokens 的上下文为例。为了方便直观计算,假设普通 Input 的价格为 $1 / M Tokens(即 500k tokens 单次输入为 $0.5)。那么租约型的单价为:
- Cache Write(首次写入):$1.25 / M Tokens(500k tokens 需 $0.625)
- Cache Hit(命中读取):$0.1 / M Tokens(500k tokens 仅需 $0.05)
假设默认租约的生命周期(TTL)为 5 分钟。现在来看看两种不同的场景:
场景 A:连续对话(保持心跳,租约不断刷新)
假设我们在 15 分钟内连续发起了 5 次提问,每次思考提问的间隔为 2~3 分钟(始终没超过 5 分钟):
-
第 1 次提问:需要首次写入缓存,支付:
此时缓存被激活,并获得初始的 5 分钟寿命。
-
第 2 ~ 5 次提问:每一次提问都在租期内到达,不仅享受 1 折读取优惠,还会自动把租约再往后延 5 分钟。4 次命中只需支付:
5 次对话的总上下文成本为:
如果完全不用缓存,5 次全量输入需 5 x $0.5 = $2.5 ,直接省下了近 67% 的费用。
场景 B:思考卡壳或摸鱼
租约型坏就坏在它极度敏感于我们的请求间隔,假设我们在第二次提问后突然停下来仔细阅读文档,或者去冲了杯咖啡,过了 10 分钟 才发回第三次提问:
- 超过了 5 分钟 TTL,之前的缓存已经被服务商无情清理;
- 第 3 次提问到达时,不仅享受不到 $0.05 的命中优惠,还必须重新掏 $0.625 写入费用去重建缓存。
如果这 5 次提问每次都隔了 10 分钟,那么总成本就会变成:
这种情况下非但没有省钱,反而比从不使用缓存($2.5)还要多付 25% 的溢价。
仓储型
第二类则可以被称为仓储型缓存,Google 的 Explicit Context Caching 是其中最典型的例子。
它与租约型最大的区别在于:开发者并不需要通过不断发送请求来维持 Cache,而是直接为缓存占用的时间付费。其成本可以分成两部分:
其中:
Google 当前的 Context Cache 就直接按照 每百万 Token 每小时 收取存储费用,同时缓存命中的 Token 再按照较低的 Cached Token 单价收费。
依然以刚才那段 500k tokens 的上下文为例。假设我们要把它保留 2 个小时,仓储单价假设为 $0.5 / M Tokens / Hour(即 500k tokens 存 1 小时需 $0.25):
首先需要支付两小时的仓租:
这一笔费用与我们期间有没有实际调用完全无关。
假设在这 2 个小时内,我们同样进行了 5 次调用,并完整复用了该缓存:
两小时总成本为:
(对比不加缓存的 5 次全量输入:5 x 0.5M x $1 = $2.5)
所以 Google 的逻辑其实很像租一个仓库:500k Token 的上下文就是我们存进去的一批货物,Storage Price 是每小时的仓储租金,而 Cached Input Price 则是每次把这批货物拿出来使用时支付的读取费用。
二、工程视角下的 tokens 经济曲线
1.什么是心跳
因为仓储型缓存是显式按时长付费,完全是算力的静态租赁,不需要开发者在调用侧花心思,因此,本节只聚焦于策略空间更大的租约型缓存。
对于租约型缓存来说,TTL(存活时间)通常很短(比如 5 分钟)。如果用户的两次操作间隔超过了 TTL,缓存就会被销毁。为了保住这笔来之不易的缓存资产,工程上衍生出了心跳(Heartbeat):在真实用户请求缺席时,由程序自动向大模型发送无意义的极微小请求,以此来强行触发一次 Cache Hit,从而将缓存的生命周期再往后推一个 TTL。
但心跳当然不是没有代价的,每一次心跳,都在消耗 Cache Hit 的计费。这就引出了一个工程架构中必须算明白的问题:我们到底值得花多少钱,去维持一个可能无人问津的缓存?
为了理清这个问题,我们可以建立一个成本函数。设当前上下文共 个 token,每 token 的原始 input 单价为 :
- 重建一次缓存(Write)花费 ,其中 是写入溢价倍数;
- 命中一次缓存(Hit)花费 ,其中 是读取折扣倍数,同时换来一个新的 TTL ;
- 要把缓存强行续满时间 ,大约需要 次心跳,累计的「纯续命成本」是 ;等用户回来时,那次真实请求还要再付一次命中费 。
所谓「值不值得续」,就是问:从用户回来的那一刻往回看,「续命钱 + 回来那次的命中费」什么时候追平一次彻底推倒重来的重建费用?令两者相等:
和 可以消掉,答案是:
根据这个公式,我们可以得出三个结论:
第一, 和我们的上下文有多长、单价有多贵,一点关系都没有。 无论是50k token 还是500k token,无论单价是贵是廉价,答案都只由 Provider 自己的 、 和 TTL 决定。心跳该不该发出去,是 Provider 的定价模型说了算,和业务用量无关。
第二, 的物理含义是「最多可容忍的心跳次数」。 减掉的那个 1 对应用户回来时的那次请求:续着缓存的话,它还要付一次命中费,而重建路径的写入费里已经包含了这次读取。拿上一节的通用例子来算:、,——意味着我们最多只能发出 次心跳。心跳次数只能取整,所以从第 12 次起,续命钱加上回来那次的命中费就已经超过从头重建一次了。再乘上 5 分钟的 TTL, 分钟。
这就是心跳策略的绝对止损线:如果我们的 Agent 两次真实动作之间的沉默时间预期会超过 ,那么任何心跳尝试都是在做亏本生意,不如坦然让它过期、下次重建;而如果是在 之内,心跳就是保护核心资产的极佳策略。
为了让这个“止损线”更直观,我们不妨算一笔具体的经济账,看看不同厂商的定价策略会造成多大的工程差异:
以 Claude Opus 5 为例(500k tokens 上下文),如果我们使用的是 5 分钟档的 TTL,其心跳成本的阶梯十分陡峭。第 12 次心跳(约 57 分钟)时,心跳续命的沉没成本就已经超过了推倒重建的 $3.13,所以一般 Claude 官方推荐给 Agent 主线程选择 1 小时档的 TTL,subagent 线程可以选择 5 分钟档。 1 小时档的 TTL 虽然单次价格更高,但它把 的除数从 5 分钟拉长到 60 分钟,心跳次数直接压缩了 12 倍,在长 agent 交互场景下反而更划算(最起码不会因为你走个神泡杯咖啡就导致缓存全部过期)。
反观 DeepSeek V4.1 Flash,由于其极其低廉的 Cache Hit 命中价,它的心跳阶梯就极其平缓。我们需要连续发出 50 次心跳、维持近 12 天的空转,其累积心跳成本才会摸到推倒重建的盈亏平衡点。
注意: 算的是「纯心跳」的上界——假设这些请求纯粹是为了续约、本身毫无用处。但在真实业务中,用户在持续对话,本来就要发请求,这时候的 Cache Hit 是白捡的。所以 只是「空转定时器」的分界线,不是「该不该用缓存」的分界线。
第三,理论上限与工程折损(The Engineering Discount)。
上述公式推导的是一个理想状态下的绝对理论上限。但在真实的工程架构中,我们绝对不可能贴着 100% 的死线去发心跳——网络会有延迟、API 会偶发 502/504 需要重试。为了保证续约的成功率,我们在写定时器时,通常会在 95% * TTL 的水位线提前触发。
这意味着,心跳带来的有效续命时间被打了一个折扣,实际我们需要打心跳的频率会比理论值更高。因此,真实工程中的绝对止损线 必须引入一个安全系数(例如 ):
我们可以看一下市面上主流玩家的缓存/心跳的价格边际图:
2.心跳机制应该如何设计?
一个优秀的心跳机制,大概应该是极其廉价且无副作用的。
最朴素的实现可以是:假设 Provider 的 TTL 是 5 分钟,那就每隔 4 分 45 秒自动发一个请求,把缓存不断续下去。
当然现实里肯定没人这么做,因为真实的 Agent 并不是从创建开始就进入纯空转状态。只要用户还在正常聊天、工具还在返回结果、Subagent 还在工作,这些真实请求本身就会产生 Cache Hit,并顺手刷新 TTL。此时额外发送 heartbeat 不但毫无意义,反而会白白多付一次完整 Cached Context 的读取费用。
既然真实请求已经在替我们续命,心跳的计时起点就该锚定在最后一次成功刷新 Cache TTL 的真实请求上。假设安全系数为 ,那么每一次真实请求完成后,我们都重新设置:
只要在 之前又发生了一次真实请求,原来的 heartbeat timer 就直接取消,并从新的 Cache Hit 开始重新计时。
这样一来,心跳机制或许可以被视为一个不断被真实调用所推迟的倒计时器:只有当整个 Agent 在接近一个完整 TTL 的时间里都没有产生任何真实请求时,我们才真正发出一次 heartbeat。
至于这一次 heartbeat 长什么样,还是得回到它的初衷——既然它唯一的目的只是制造一次 Cache Hit,那就应当尽可能接近一个 No-op。我们并不需要让模型重新思考任务,更不该让它调用工具、修改 Memory、更新计划,或者产出一大段自然语言。理想情况下,heartbeat 只需要在原有 Cached Prefix 后追加一个极短的、语义上无副作用的输入,再把输出长度压到最低。
因为大模型的对话是基于历史消息数组(Messages Array)追加的。如果我们为了续命发了 5 次无意义的心跳请求,这 5 次对话就会被固化进当前的上下文里。等用户真的回来发消息时,他面对的模型已经被前 5 次心跳「污染」了,可能导致回复质量下降,甚至模型会开始回答那些心跳指令。
所以心跳必须是「阅后即焚」的幽灵请求:客户端发送心跳后,绝对不能将心跳的回包追加到本地维护的
History数组中。下一次用户发起真实请求时,必须从最后一个真实节点的树杈上继续生长。
不过最后还有一个很重要的问题,心跳当然不是那种一旦启动就应该永远持续下去的东西。前一节计算出的 ,本质上就是不同 Provider 为我们划出的经济边界。超过这个边界以后,或者说我们认为这个对话已经低概率继续下去了,那与其继续为了保住 Cache 而支付 Hit 成本,已经不如干脆让它过期、等下一次真实请求到来时重新建立缓存。
由于 DeepSeek Cache Hit 的价格和 cache 持续的时长都非常惊人,其理论平衡点甚至可以达到数天乃至十余天。在这种定价结构下,“这个 Session 本周内还有可能重新打开”本身就足以成为持续温缓存的理由,长期 Heartbeat 反而可能是一个完全合理的工程选择。
而什么时候选择建立心跳,什么时候选择停止心跳,就需要各位开发者根据自己的场景来进行权衡了。