跳转到正文

Earendil 官方授权中文译文 · 03

Agent 中的提示缓存 ​

  • 原文标题 Prompt Caching In Agents
  • 作者 Earendil Engineering <rfc@earendil.com>
  • 发布日期 2026-07-22
  • 原文地址 earendil.com/posts/prompt-caching
  • 授权说明 经 Earendil 授权改编与翻译(Adapted and translated with permission from Earendil.)
  • 译文许可 CC BY 4.0

人们常常把大语言模型想象成函数:发送一些文本,接收一些文本。这是一个有用的抽象,但它忽略了运行编程 Agent 时最重要的事实之一:绝大部分输入都与上一次相同。换句话说,我们通常只是在末尾追加内容。

编程 Agent 会把系统提示、工具定义、项目说明、对话历史、工具调用和工具结果发送给模型。下一轮,它会再次发送几乎所有这些内容,只多出少量新材料。当会话增长到数万甚至数十万 token 时,每轮都重新计算整段提示会既慢又昂贵。

提示缓存让这种工作方式在经济上勉强可行,但它也十分脆弱。工具定义发生变化、切换模型或提供商改变路由,都可能让原本应该很便宜的增量请求变成对整个上下文的完整重放。

因此,对编程 Agent 来说,缓存行为不只是实现细节或性能优化。它会影响延迟、成本、工具设计、会话设计,甚至影响哪些产品功能应该提供给用户。

KV 缓存里有什么 ​

Transformer 处理提示大致分为两个阶段。在 prefill(预填充)阶段,它读取输入 token,并为它们计算注意力状态;在 decode(解码)阶段,它每次生成一个新 token。

在每一层注意力中,每个处理过的 token 都会产生一个 key 和一个 value。它们与哈希表里的键值查找并不完全相同:两者都是数字数组,通常由浮点数或低精度量化值组成。处理一个新 token 时,模型会将这个 token 的 query 与此前的 key 比较,以判断每个早期 token 与当前 token 的相关程度。随后,它根据这些相关性分数,对相应的 value 进行加权混合。从这个意义上说,key 是模型用来匹配的内容,value 是模型取回的信息;不过这种查找是模糊的,并不像字典那样“只返回一个完全匹配的结果”。

这些 key 和 value 会被保留下来,让下一个生成的 token 无需重新计算早期 token,就能关注此前的全部内容。这个保留下来的状态就是 KV 缓存。

从概念上看,一次请求是这样的:

text
请求 1:

[系统][工具][用户][助手][工具结果][用户]
<-------------------- 预填充 -------------------->
                       |
                 每个 token、每一层的 K 和 V 张量

请求 2:

[系统][工具][用户][助手][工具结果][用户][新增]
<------------------ 可复用前缀 ----------------><-->
                                                  |
                                                新工作

真实的表示更加复杂,会因模型而异,而且体积“相当”大。最重要的特性是:这些表示对应某一个特定的 token 前缀。两段意思相同但 token 化结果不同的提示,无法共享 KV 缓存。如果中间有一个 token 发生变化,从它之后的所有内容都会成为不同的延续。

提示缓存把这种状态的生命周期延长到一次生成之外。当编程 Agent 的下一次 API 请求以相同 token 开头时,推理系统可以复用与该前缀匹配的既有计算,只对新增后缀执行预填充。理论部分到此为止。

缓存存在哪里 ​

缓存要发挥作用,就必须存储在某个地方,并且能被再次寻址。推理系统大致有两种方式,让后续请求能够使用 KV 缓存。

比较简单的方式是会话亲和性(session affinity)。它把 KV 缓存保留在最初执行计算的 GPU 上或其附近,并将下一次请求重新路由到同一个工作节点。会话 ID 或提示缓存 key 可以成为一个简单的路由提示,所以这个问题甚至可能只在 HTTP 负载均衡层就得到处理,无需检查请求内容。

text
请求(session-42) --> 路由器 --> 工作节点 7 --> GPU 7 的 KV 缓存
下次(session-42) --> 路由器 --> 工作节点 7 --> GPU 7 的 KV 缓存

这样可以避免通过网络移动体积很大的缓存。它在正常工作时速度很快,但也限制了调度方式。选中的工作节点可能过载、重启或淘汰对应缓存。路由器也可能认为平衡整个集群的负载比保留某个会话的缓存更重要。不过,这种方案依然很有吸引力,因为它几乎不需要额外的部署基础设施和硬件。

另一种方式是分布式缓存。KV 块可以存放在另一层内存中,或在多个工作节点之间共享,让请求不必过度绑定到某一块 GPU。

text
                         +--------------------+
请求 --> 调度器 -------->| 工作节点 3 / GPU 3 |
             |           +--------------------+
             |
             +----------> 分布式 KV 块
             |
             +----------> 工作节点 9 / GPU 9

这样能提高调度的灵活性和故障恢复能力,但移动、索引和保留 KV 块本身就是一个系统工程问题。不同实现会以各自的方式组合 GPU 内存、主机内存、本地存储、远程存储、前缀感知路由和淘汰策略。

客观来看,KV 缓存虽然很大,但在某些方面又没有想象中那么大。借助各种技巧,即使是很长的对话,也可以把 KV 缓存压缩到几 GB 的量级。

缓存与前缀 ​

Pi 的会话是树,而不是列表。/tree 可以把当前对话移回较早的节点,然后沿着另一条分支继续。回退操作可以丢弃当前活动后缀,却不把它从会话文件中删除。新分支可能与旧上下文共享大部分内容、只共享少量内容,也可能实际上完全不共享。这种设计并非 Pi 独有;不少编程 Agent 至少在概念上有类似机制。即使会话不以树的形式表示,Agent 具有某种回退功能也并不少见。

text
                             +-- E -- F  另一条分支
                             |
会话 S:根 -- A -- B -- C -- D  当前分支
                   |
                   +-- Z  靠近起点的分支

这三条分支可以拥有相同的 Pi 会话 ID。从路由器的角度看,它们属于同一个会话;从提示缓存的角度看,它们是三段只共享部分前缀的 token 序列。

如果缓存保留了可复用的前缀块,从 D 跳到 F 时,仍可能复用 根 -> C。但如果缓存只保留最热门的延续、共享块已被淘汰,或请求被路由到了别处,缓存命中就可能大幅减少。跳到 Z 时,即使它从 A 处分叉,也可能只保留系统提示和最初的工具定义。具体缓存管理行为在很大程度上取决于提供商。

反过来的情况也可能发生。/fork 或新会话可以生成新的会话 ID,却带上大量完全相同的上下文。如果路由系统按会话 key 隔离缓存,它可能无法发现这种有用的重叠。

真正决定哪些工作可以缓存的是可复用前缀。会话身份只是在帮助基础设施找到可能相关的内容。在某些系统中,路由 key 对缓存管理至关重要;在另一些系统中,它只是一项优化。

显式前缀缓存与自动前缀缓存 ​

提供商 API 主要以两种方式开放缓存。

Anthropic 的传统接口使用显式的 cache_control 节点。客户端在系统提示、工具定义或最新可缓存对话内容等稳定部分之后标记边界。服务器随后可以写入或查找截止到这些节点的前缀。边界是显式的,但复用仍然要求它之前的内容完全一致。不仅缓存节点是显式的,价格也是显式的:缓存写入需要付费,你还可以选择不同价格的保留时长。

其他 API 使用自动前缀缓存。客户端按平常方式发送请求,提供商会自行寻找可复用前缀,不需要客户端设置断点。提示缓存 key 或会话标头可能改善路由或分组,但它无法让不同的前缀变成相同。

为什么工具配置会摧毁缓存 ​

工具定义通常出现在对话之前,并在模型内部被“折叠”进系统提示。它们的名称、描述和 JSON Schema 与其他文本一样,都是模型输入。增加一个工具、移除一个工具、改变其 Schema,甚至只是换一种顺序序列化工具,都可能让第一次不匹配出现在提示开头附近。

text
轮次 1:[系统][读取][写入][Shell][对话...........]
轮次 2:[系统][读取][写入][Shell][部署][对话...]
                                  |
                                  旧对话现在位于
                                  不匹配位置之后

在插件系统和 MCP 风格的工具目录中,这种意外很常见。只在工具变得相关时再加载它,听起来很高效,因为一开始需要发送的 Schema 更少。然而,对大多数模型来说,后来扩展工具配置会让它之后的已缓存对话全部失效。节省几个工具 Schema token,可能导致数万个对话 token 被重新处理。

一些较新的模型 API 支持增量工具加载。工具可以在转录记录中的某个工具结果位置变为可用,而不是被插入最初的工具列表。这样,原有前缀就不会变化:

text
[系统][初始工具][对话][新工具][下一轮]
<---------- 已缓存前缀 ---------->

现在,Pi 已经在具备原生延迟工具机制的模型上支持这种方式。当 Extension 通过 setActiveTools() 做出纯增量修改时,Pi 会把新加入的工具名称记录在工具结果中。对于支持此功能的 Anthropic 模型,它使用延迟定义和 tool_reference;对于支持此功能的 OpenAI 模型,它会发出相应的工具搜索项。其他模型会采用安全的回退方案:Pi 在下一次请求中发送完整的活动工具列表。功能上仍然正常,但可能清空提示缓存。

“增量”二字十分重要,因为删除工具、用一套工具替换另一套工具,或修改提示片段,仍然会改变早期输入。如果 Extension 重建系统提示、打乱工具顺序、注入时间戳,或每轮都改变活动工具,就可能意外破坏整个会话的缓存。

Pi 的可扩展性意味着,它无法替每个 Extension 保证缓存稳定。Pi 可以提供对缓存友好的机制,但 Extension 仍需正确使用它们。根据我们的观察,许多 Extension 对缓存效率的重视都不够。其中一部分原因是,使用固定订阅时,缓存未命中所带来的成本并不明显。

中断与 TTL ​

一些重要的提示缓存默认生命周期很短。Anthropic 默认的五分钟缓存尤其值得注意,因为它短于许多正常的编程活动。如果你使用 Fable 时去喝杯咖啡,十分钟后回来,只发送一句“say hi”,花费可能比预期高得多。

这是因为用户可能认为一个编程会话始终连续活跃,而推理提供商看到的却是一系列相互分离的请求:

text
模型请求 --> 运行测试 7 分钟 --> 模型请求
              此处没有缓存流量

一次很长的构建、一套测试、午餐、会议,甚至只是停下来检查 diff,都可能超过缓存生命周期。下一次请求虽然包含相同提示,但已保存的 KV 状态已经消失,整个前缀会再次按输入计费。

由于目前 Anthropic 不允许 Pi 作为其订阅服务的使用工具,我们采用 Anthropic 为 API 用户推荐的五分钟默认值。不过,从 Claude Code 的代码库可以看出,Anthropic 为自己的订阅用户把缓存时长延长到了一小时。但如果你按 API token 价格付费,延长缓存的额外成本往往并不值得。

当然,你也可以主动启用更长的保留时长。Anthropic 等提供商提供了更长的保留控制。使用受支持的直连 API 时,Pi 用户可以设置 PI_CACHE_RETENTION=long 来提出这一请求。但它终究只是一项请求:Pi 无法强制网关保留缓存项,无法阻止内存压力下的淘汰,也无法在没有模型请求时让缓存一直保持活跃。

一次缓存未命中的代价 ​

提供商通常会对未缓存输入、缓存写入和缓存读取采用不同价格。缓存读取往往有折扣,因为昂贵的预填充工作已经完成。缓存写入可能有溢价,因为提供商承诺为后续使用保留状态。

仍以上面的 Fable 场景为例:设想一次编程会话已经积累了 10 万 token 的历史,之后只发出一条很短的新请求。缓存正常时,几乎全部历史都会按更低的缓存读取价计费,只有少量新材料需要按正常输入价处理,并可能写入缓存。

缓存未命中时,提供商必须按正常输入价重新处理完整的 10 万 token 历史,还可能对把它们重新写入缓存另行收费。因此,缓存过期后,一句 continue 这样的短请求也可能出奇昂贵。在长会话里,重新读取旧输入的成本可能远高于生成下一条回复。

缓存也可能造成不那么直观的激励关系。

用户希望命中率高,因为这样能降低延迟和价格。拥有 GPU 的推理运营商也应该希望命中率高:更少的预填充计算意味着相同硬件能服务更多请求。设计合理的缓存 token 折扣,可以让双方利益一致,同时让运营商获得更好的利润率。

网关或转售商的激励可能不同。如果它从按未缓存费率计费的输入 token 中获得收入,那么缓存未命中可能让客户账单更高。是否也能带来更多利润,则取决于它的上游成本、合同以及由谁运营缓存。在利益没有对齐的技术栈中,负责路由的一方可能不必承担缓存未命中的全部成本,而向用户收费的一方却会在未命中时获得更多收入。

这并不意味着提供商会故意破坏缓存,但它说明缓存表现应该是可观察的。用户不应只能从一张出乎意料的高额账单中推测出了问题。了解缓存是否出现异常,可能是一条重要线索。

严格遵循缓存也意味着,网关在两个轮次之间无法灵活地把请求路由到最佳选项。你可能愿意牺牲一次缓存命中,换用另一个从此以后更经济的模型;也可能最好把负载均衡到另一家提供商。

为什么 Pi 不会激进地剪枝 ​

读到这里,你大概已经明白 Pi 为什么不会剪除工具调用。人们很容易想到通过不断删除旧工具结果或重写历史来控制成本;有时这样做确实必要,尤其是在接近上下文窗口上限时。但正如前面所说,剪枝本身也有缓存成本。

从中间删除内容,会在删除位置改变前缀。它之后仍保留的全部对话都可能需要重新处理。重写一段很长的已缓存上下文所带来的一次性成本,可能超过删除少量低价缓存 token 所节省的未来成本。

粗略的盈亏平衡比较如下:

text
一次性重写成本
    ≈ 编辑点之后保留的 token ×(未缓存价格 - 缓存读取价格)

每轮未来节省
    ≈ 被剪除的 token × 缓存读取价格

这还不只是一个计费问题。旧工具结果经常包含模型后来据以做出决定的证据。删除它们可能让模型表现变差,即使摘要保留了大意也一样。

因此,Pi 偏好稳定、以追加为主的转录记录,不会把每个旧 token 都视为浪费。当上下文压力足以证明有必要进行有损重写时,可以执行压缩。由于压缩是在有意建立新上下文,而不是意外地对未改变的提示重复计费,因此 Pi 在会话统计中把它视为一次缓存重置,而不是缓存失败。

目标不是让提示尽可能短,而是在模型上下文、缓存复用、延迟和价格之间取得最佳平衡。

与此同时,剪枝有时也有价值。如果你使用的提供商不会因为良好缓存表现而给出折扣,或由于某种原因无法获得高命中率,那么剪枝可能更合适。它也确实能让路由器更容易在不同后端之间均衡负载,因为缓存无法转移。

Pi 能做什么、不能做什么 ​

Pi 会努力让稳定的输入保持稳定。它会传递一致的会话 ID 和提供商特定的缓存提示,在 API 需要时设置显式缓存节点,记录缓存读取和写入用量,并在模型允许时支持锚定在消息位置的增量工具加载。它默认的转录行为也会避免无端重写旧上下文。

请求离开本机以后,Pi 无法控制之后的每一层。它不能决定提供商的淘汰策略,不能把缓存延长到 API 允许的期限之外,不能保证某块 GPU 一直存活,也不能确保网关遵守亲和性路由。它同样无法为被 Extension 改变的前缀保留缓存。

Pi 能做的是让缓存健康状况变得可见。

交互界面的底部状态栏会用 R 和 W 显示累计缓存读取量和写入量,并用 CH 显示最近一次请求的缓存命中率。/session 命令会显示更完整的信息:缓存和未缓存输入总量、累计命中率、成本,以及由显著缓存未命中导致的重复计费 token 和金额估算。

text
消息
总计:178
用户:6
助手:58
工具:114 次调用,114 个结果

Token
输入:7,129,883
  已缓存:6,776,832(95.0%)
  未缓存:353,051
输出:30,013
总计:7,159,896

成本
总计:$6.054
缓存重复计费:$0.728(161,744 token,2 次未命中)

希望在缓存未命中发生时收到提示的用户,可以在 /settings 中启用 Show cache miss notices(显示缓存未命中通知),对应 settings.json 中的 showCacheMissNotices。之后,Pi 会在发生显著未命中时插入一条警告,其中包含重复计费 token 数量和成本估算。当它能够观察到模型切换,或空闲时间超过通常的短 TTL 时,也会说明原因。对于其他未命中,它只报告事实,不会假装知道提供商内部发生了什么。

缓存表现变差的常见原因 ​

当一个会话的缓存命中率看起来不对时,常见原因包括:

  1. 空闲。 某个命令、代码审查或对话暂停超过了提供商的保留窗口。
  2. 切换模型或提供商。 KV 状态与模型绑定,通常不能跨提供商移动。
  3. 分支导航。 /tree、回退、fork 和替代分支可能改变当前 token 序列,即使会话 ID 没变。
  4. 压缩或手动重写历史。 这些操作会有意替换部分提示,并建立新的前缀。
  5. 工具与推理级别变化。 增加、移除、重新排序或编辑工具定义,都会改变请求较早的部分,除非模型支持锚定在消息位置的加载,而且变化是纯增量的。改变推理级别通常也有同样影响。
  6. 动态系统提示。 时间戳、随机值、不断变化的项目上下文和 Extension 提供的提示片段,可能让它们之后的一切失效。
  7. Extension 上下文转换。 修改旧消息或提供商请求载荷的 Extension,可能让表面稳定的 Pi 转录记录在实际传输时发生变化。
  8. 提供商路由与淘汰。 即使提示完全一致,如果请求到达的位置已无法取得对应 KV 块,缓存仍会未命中。

译者说明

本文为 Earendil Engineering 原文的完整中文译文。中文译文及适配部分经授权按 CC BY 4.0 发布;英文原文版权归 Earendil 所有。如中文表述与原文存在歧义,请以英文原文为准。

继续阅读 ​