返回首页

缓存、心跳与 tokens 经济学

5149 字 26 分钟
缓存、心跳与 tokens 经济学
目录

在面向相对长程任务的 agent 设计开发中,我们大概是绕不开一个问题的:

用户暂时离开时,我们应该让 agent 的缓存自然过期,还是使用某些类似于心跳机制的东西把缓存持续留存?

在短上下文时代,缓存是一个锦上添花的东西:命中了可以省一点钱,没命中也无伤大雅。但当上下文长度持续达到百 K 量级时,一次 cache miss 的代价就是命中价格的十几倍乃至几十倍,缓存的维持与失效之间横亘着数量级的成本差异。由此,缓存策略变成了一个动态的经济决策

  • 缓存失效意味着下一次请求要支付整段上下文的输入费用 + 缓存写入费用
  • 维持缓存意味着持续支付缓存命中的成本

所以,为了避免未来一次昂贵的 cache miss,我现在最多值得花多少钱去维持 cache 状态?

我们可以在这篇博客中尝试去计算,建模并深入讨论这个问题。

如果你不想看各种原理和数学公式,可以直接跳到 2.1 末尾看各家的缓存甜品点曲线。

一、缓存是什么,以及各家的定价策略#

1.缓存的数学与物理本质#

在讨论各家(尤其是 DeepSeek)的独特优化之前,我们可以先从最基础的标准 Transformer 开始。

已知 Transformer 自注意力机制的定义为:

Attention(Q,K,V)=softmax(QKTdk)V\text{Attention}(Q, K, V) = \text{softmax}\left(\frac{Q K^T}{\sqrt{d_k}}\right) V

在每一次前向传播的过程中,自注意力层都要做两件事:先把输入序列里的每个 token 通过线性投影变成 Q、K、V 三个向量,然后让所有 query 去跟所有 key 做点积、算注意力得分、最后加权求和得到输出。

当我们只是在序列末尾追加新的 token(如用户多打了一句话,返回了一个工具结果)时,前面所有历史 token 的 K 和 V 投影,跟上一次算出来的结果完全一样,一个浮点数都不会变。但在没有缓存的情况下,系统仍然会老老实实地把整个序列从头到尾重新投影一遍、重新算一遍所有注意力对。

所以,KV Cache 做的就是把这些「确定不会变的中间结果」显式地记下来。

当上下文长度为 NN、新增生成/输入为 MM 时:

  • 无 Cache 时(全量前向传播):每次都要重新投影并计算所有序列对的注意力。线性投影的计算量正比于 (N+M)dmodel(N+M) \cdot d_{\text{model}},注意力得分的计算量正比于 (N+M)2dk(N+M)^2 \cdot d_k,整体 FLOPs 与序列长度呈二次关系。
  • 有 Cache 时(增量解码与前缀缓存):此时历史前缀序列 1N1 \dots N 的投影矩阵 K1:N,V1:NK_{1:N}, V_{1:N} 已计算并驻留在显存中。新增 MM 个 token 时,只需计算:
    • 新 token 的线性投影:QN+1:N+M,KN+1:N+M,VN+1:N+MQ_{N+1:N+M}, K_{N+1:N+M}, V_{N+1:N+M},复杂度仅为 O(Mdmodel)\mathcal{O}(M \cdot d_{\text{model}})
    • 新 query 与全量 key 的交互:QN+1:N+M×[K1:N;KN+1:N+M]TQ_{N+1:N+M} \times [K_{1:N}; K_{N+1:N+M}]^T,复杂度为 O(M(N+M)dk)\mathcal{O}(M \cdot (N+M) \cdot d_k)

MNM \ll N 时——比如解码阶段每次只生成 1 个 token,而上下文已经有 100k,此时的计算量从 O(N2)\mathcal{O}(N^2) 直接坍缩到了 O(N)\mathcal{O}(N),差了整整一个维度。

因此,我们或许可以认为Cache 的数学本质是记忆化(Memoization),Transformer 的前向传播是一张巨大的有向无环图(DAG),每一层的 K/V 投影都是其中一个子图。当输入只是前缀追加而非整体修改时,历史序列对应的那部分子图的输入没变,输出也必然不变。Cache 把这些子图的结果存下来,下次走到同一个节点时直接读取,彻底消除重复计算。

但这个「存」也是有其物理代价的。

算力(FLOPs)可以被视为一种即时消耗品,而内存/显存则是一种容量-时间的积分。在标准情况下,维持一段大小为 NN 个 token 的 KV Cache,所占用的显存空间为:

S(N)=2×L×H×dk×N×B(Bytes)S(N) = 2 \times L \times H \times d_k \times N \times B \quad \text{(Bytes)}

(其中 LL 为层数,HH 为注意力头数,dkd_k 为头维度,BB 为每个参数的字节数,如 FP16 为 2,FP8 为 1)

如果在显存中维持该 Cache 持续时间为 tt,那么系统付出的物理代价就是显存/内存时

Footprint=S(N)t\text{Footprint} = S(N) \cdot t

而云服务商的矛盾就在于:GPU 显存是绝对的刚性稀缺资源。如果用户占着 S(N)S(N) 不释放却不发请求,显存时就会被极大浪费,系统的并发承载能力会线性下降。

那退而求其次,把缓存挪到便宜的硬盘上行不行?在普通架构(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)的读取速度大约在 714 GB/s7 \sim 14 \text{ GB/s},如果每次请求都要从硬盘把几十 GB 的缓存读回显存,光是 I/O 延迟就需要好几秒,重新算一遍的前向时间甚至比从硬盘读出来的时间还要快。所以把传统模型的 KV Cache 放硬盘,在工程上是不划算的。

到这里,整条链路就被封死了:算力虽便宜,却要每次重算;硬盘虽便宜,却读得太慢;真正能用的,只剩那一小块既昂贵又稀缺的显存。缓存被死死锁在显存里,也就解释了为什么各大厂商会围绕它推出截然不同的定价方案。

2.各家的缓存都是如何定价的?#

我们大概可以把整个市场划分成两种类型:租约型仓储型

租约型#

租约型缓存是目前大模型 API 最主流的形态(包括 A\、OpenAI、DeepSeek 等)。它的核心特征是请求驱动、命中续约

  1. 首次创建 cache 时,可能需要支付高于普通 input token 的费用(有的慷慨的 Provider 可能完全不需要我们支付额外的 cache 创建费用,这些计算起来相对简单所以此处暂且不表);
  2. cache hit 的价格会大幅降低
  3. cache 有一个相对明确的有效期,命中后可以刷新或者延长生命周期

比较典型的定价结构大概是:

Pwrite1.25PinputP_{write} ≈ 1.25P_{input}Phit0.1PinputP_{hit} ≈ 0.1P_{input}

像 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. 第 1 次提问:需要首次写入缓存,支付:

    C1=0.5M×$1.25/M=$0.625C_1 = 0.5\text{M} \times \$1.25/\text{M} = \$0.625

    此时缓存被激活,并获得初始的 5 分钟寿命。

  2. 第 2 ~ 5 次提问:每一次提问都在租期内到达,不仅享受 1 折读取优惠,还会自动把租约再往后延 5 分钟。4 次命中只需支付:

    C25=4×0.5M×$0.1/M=$0.20C_{2\sim5} = 4 \times 0.5\text{M} \times \$0.1/\text{M} = \$0.20

5 次对话的总上下文成本为:

Clease=$0.625+$0.20=$0.825C_{\text{lease}} = \$0.625 + \$0.20 = \$0.825

如果完全不用缓存,5 次全量输入需 5 x $0.5 = $2.5 ,直接省下了近 67% 的费用。

场景 B:思考卡壳或摸鱼

租约型坏就坏在它极度敏感于我们的请求间隔,假设我们在第二次提问后突然停下来仔细阅读文档,或者去冲了杯咖啡,过了 10 分钟 才发回第三次提问:

  • 超过了 5 分钟 TTL,之前的缓存已经被服务商无情清理;
  • 第 3 次提问到达时,不仅享受不到 $0.05 的命中优惠,还必须重新掏 $0.625 写入费用去重建缓存

如果这 5 次提问每次都隔了 10 分钟,那么总成本就会变成:

5×$0.625=$3.1255 \times \$0.625 = \$3.125

这种情况下非但没有省钱,反而比从不使用缓存($2.5)还要多付 25% 的溢价。

仓储型#

第二类则可以被称为仓储型缓存,Google 的 Explicit Context Caching 是其中最典型的例子。

它与租约型最大的区别在于:开发者并不需要通过不断发送请求来维持 Cache,而是直接为缓存占用的时间付费。其成本可以分成两部分:

Ccache=Cread+CstorageC_{\text{cache}} = C_{\text{read}} + C_{\text{storage}}

其中:

Cread=NhitPcachedC_{\text{read}} = N_{\text{hit}}\cdot P_{\text{cached}}Cstorage=NcachedtPstorageC_{\text{storage}} = N_{\text{cached}}\cdot t\cdot P_{\text{storage}}

Google 当前的 Context Cache 就直接按照 每百万 Token 每小时 收取存储费用,同时缓存命中的 Token 再按照较低的 Cached Token 单价收费。

依然以刚才那段 500k tokens 的上下文为例。假设我们要把它保留 2 个小时,仓储单价假设为 $0.5 / M Tokens / Hour(即 500k tokens 存 1 小时需 $0.25):

首先需要支付两小时的仓租:

Cstorage=0.5M×2h×$0.5/(Mh)=$0.5C_{\text{storage}} = 0.5\text{M} \times 2\text{h} \times \$0.5/(\text{M}\cdot\text{h}) = \$0.5

这一笔费用与我们期间有没有实际调用完全无关

假设在这 2 个小时内,我们同样进行了 5 次调用,并完整复用了该缓存:

Cread=5×0.5M×$0.1/M=$0.25C_{\text{read}} = 5 \times 0.5\text{M} \times \$0.1/\text{M} = \$0.25

两小时总成本为:

Ccache=$0.5+$0.25=$0.75C_{\text{cache}} = \$0.5 + \$0.25 = \$0.75

(对比不加缓存的 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 的计费。这就引出了一个工程架构中必须算明白的问题:我们到底值得花多少钱,去维持一个可能无人问津的缓存?

为了理清这个问题,我们可以建立一个成本函数。设当前上下文共 NN 个 token,每 token 的原始 input 单价为 PP

  • 重建一次缓存(Write)花费 Cwrite=αNPC_{\text{write}} = \alpha N P,其中 α=Pwrite/P\alpha = P_{\text{write}} / P 是写入溢价倍数;
  • 命中一次缓存(Hit)花费 Chit=βNPC_{\text{hit}} = \beta N P,其中 β=Phit/P\beta = P_{\text{hit}} / P 是读取折扣倍数,同时换来一个新的 TTL ;
  • 要把缓存强行续满时间 TT,大约需要 TTTL\frac{T}{\text{TTL}} 次心跳,累计的「纯续命成本」是 TTTLβNP\frac{T}{\text{TTL}}\,\beta N P;等用户回来时,那次真实请求还要再付一次命中费 βNP\beta N P

所谓「值不值得续」,就是问:从用户回来的那一刻往回看,「续命钱 + 回来那次的命中费」什么时候追平一次彻底推倒重来的重建费用?令两者相等:

TTTLβNP+βNP=αNP\frac{T}{\text{TTL}}\,\beta N P + \beta N P = \alpha N P

NNPP 可以消掉,答案是:

T=(αβ1)TTLT^* = \left(\frac{\alpha}{\beta} - 1\right)\cdot\text{TTL}

根据这个公式,我们可以得出三个结论:

第一,TT^* 和我们的上下文有多长、单价有多贵,一点关系都没有。 无论是50k token 还是500k token,无论单价是贵是廉价,答案都只由 Provider 自己的 α\alphaβ\beta 和 TTL 决定。心跳该不该发出去,是 Provider 的定价模型说了算,和业务用量无关。

第二,αβ1\frac{\alpha}{\beta} - 1 的物理含义是「最多可容忍的心跳次数」。 减掉的那个 1 对应用户回来时的那次请求:续着缓存的话,它还要付一次命中费,而重建路径的写入费里已经包含了这次读取。拿上一节的通用例子来算:α=1.25\alpha = 1.25β=0.1\beta = 0.1αβ1=11.5\frac{\alpha}{\beta} - 1 = 11.5——意味着我们最多只能发出 11.511.5 次心跳。心跳次数只能取整,所以从第 12 次起,续命钱加上回来那次的命中费就已经超过从头重建一次了。再乘上 5 分钟的 TTL,T=57.5T^* = 57.5 分钟。

这就是心跳策略的绝对止损线:如果我们的 Agent 两次真实动作之间的沉默时间预期会超过 TT^*,那么任何心跳尝试都是在做亏本生意,不如坦然让它过期、下次重建;而如果是在 TT^* 之内,心跳就是保护核心资产的极佳策略。

为了让这个“止损线”更直观,我们不妨算一笔具体的经济账,看看不同厂商的定价策略会造成多大的工程差异:

Claude Opus 5 为例(500k tokens 上下文),如果我们使用的是 5 分钟档的 TTL,其心跳成本的阶梯十分陡峭。第 12 次心跳(约 57 分钟)时,心跳续命的沉没成本就已经超过了推倒重建的 $3.13,所以一般 Claude 官方推荐给 Agent 主线程选择 1 小时档的 TTL,subagent 线程可以选择 5 分钟档。 1 小时档的 TTL 虽然单次价格更高,但它把 TTTL\frac{T}{\text{TTL}} 的除数从 5 分钟拉长到 60 分钟,心跳次数直接压缩了 12 倍,在长 agent 交互场景下反而更划算(最起码不会因为你走个神泡杯咖啡就导致缓存全部过期)。

反观 DeepSeek V4.1 Flash,由于其极其低廉的 Cache Hit 命中价,它的心跳阶梯就极其平缓。我们需要连续发出 50 次心跳、维持近 12 天的空转,其累积心跳成本才会摸到推倒重建的盈亏平衡点。

注意: TT^* 算的是「纯心跳」的上界——假设这些请求纯粹是为了续约、本身毫无用处。但在真实业务中,用户在持续对话,本来就要发请求,这时候的 Cache Hit 是白捡的。所以 TT^* 只是「空转定时器」的分界线,不是「该不该用缓存」的分界线。

第三,理论上限与工程折损(The Engineering Discount)。
上述公式推导的是一个理想状态下的绝对理论上限。但在真实的工程架构中,我们绝对不可能贴着 100% 的死线去发心跳——网络会有延迟、API 会偶发 502/504 需要重试。为了保证续约的成功率,我们在写定时器时,通常会在 95% * TTL 的水位线提前触发。

这意味着,心跳带来的有效续命时间被打了一个折扣,实际我们需要打心跳的频率会比理论值更高。因此,真实工程中的绝对止损线 TrealT^*_{\text{real}} 必须引入一个安全系数(例如 k=0.95k=0.95):

Treal=(αβ1)(kTTL)T^*_{\text{real}} = \left(\frac{\alpha}{\beta} - 1\right)\cdot (k \cdot \text{TTL})

我们可以看一下市面上主流玩家的缓存/心跳的价格边际图:

2.心跳机制应该如何设计?#

一个优秀的心跳机制,大概应该是极其廉价无副作用的。

最朴素的实现可以是:假设 Provider 的 TTL 是 5 分钟,那就每隔 4 分 45 秒自动发一个请求,把缓存不断续下去。

当然现实里肯定没人这么做,因为真实的 Agent 并不是从创建开始就进入纯空转状态。只要用户还在正常聊天、工具还在返回结果、Subagent 还在工作,这些真实请求本身就会产生 Cache Hit,并顺手刷新 TTL。此时额外发送 heartbeat 不但毫无意义,反而会白白多付一次完整 Cached Context 的读取费用。

既然真实请求已经在替我们续命,心跳的计时起点就该锚定在最后一次成功刷新 Cache TTL 的真实请求上。假设安全系数为 kk,那么每一次真实请求完成后,我们都重新设置:

tnext=tlast hit+kTTLt_{\text{next}} = t_{\text{last hit}} + k\cdot TTL

只要在 tnextt_{\text{next}} 之前又发生了一次真实请求,原来的 heartbeat timer 就直接取消,并从新的 Cache Hit 开始重新计时。

这样一来,心跳机制或许可以被视为一个不断被真实调用所推迟的倒计时器:只有当整个 Agent 在接近一个完整 TTL 的时间里都没有产生任何真实请求时,我们才真正发出一次 heartbeat。

至于这一次 heartbeat 长什么样,还是得回到它的初衷——既然它唯一的目的只是制造一次 Cache Hit,那就应当尽可能接近一个 No-op。我们并不需要让模型重新思考任务,更不该让它调用工具、修改 Memory、更新计划,或者产出一大段自然语言。理想情况下,heartbeat 只需要在原有 Cached Prefix 后追加一个极短的、语义上无副作用的输入,再把输出长度压到最低。

因为大模型的对话是基于历史消息数组(Messages Array)追加的。如果我们为了续命发了 5 次无意义的心跳请求,这 5 次对话就会被固化进当前的上下文里。等用户真的回来发消息时,他面对的模型已经被前 5 次心跳「污染」了,可能导致回复质量下降,甚至模型会开始回答那些心跳指令。

所以心跳必须是「阅后即焚」的幽灵请求:客户端发送心跳后,绝对不能将心跳的回包追加到本地维护的 History 数组中。下一次用户发起真实请求时,必须从最后一个真实节点的树杈上继续生长。

不过最后还有一个很重要的问题,心跳当然不是那种一旦启动就应该永远持续下去的东西。前一节计算出的 TrealT^*_{\text{real}},本质上就是不同 Provider 为我们划出的经济边界。超过这个边界以后,或者说我们认为这个对话已经低概率继续下去了,那与其继续为了保住 Cache 而支付 Hit 成本,已经不如干脆让它过期、等下一次真实请求到来时重新建立缓存。

由于 DeepSeek Cache Hit 的价格和 cache 持续的时长都非常惊人,其理论平衡点甚至可以达到数天乃至十余天。在这种定价结构下,“这个 Session 本周内还有可能重新打开”本身就足以成为持续温缓存的理由,长期 Heartbeat 反而可能是一个完全合理的工程选择。

而什么时候选择建立心跳,什么时候选择停止心跳,就需要各位开发者根据自己的场景来进行权衡了。