原文:Prompt Caching In Agents — Earendil Engineering

人们经常把大语言模型理解成函数:输入一些文本,得到一些文本。这是一种有用的抽象,但它忽略了运行编程智能体时最重要的事实之一:绝大部分输入都和上一次相同。换句话说,我们大多数时候只是在已有内容后面继续追加。
编程智能体会把系统提示词、工具定义、项目指令、对话历史、工具调用和工具结果发送给模型。到了下一轮,它会再次发送其中几乎全部内容,只额外增加少量新材料。当一次会话增长到数万乃至数十万个 token 时,每轮都重新计算整段提示词既慢又昂贵。
提示词缓存让这种运行方式在经济上勉强可行,但它也相当脆弱。一次工具定义变更、模型切换或服务商的路由决策,都可能把原本应当很便宜的增量请求,变成对全部上下文的完整重放。
因此,对编程智能体来说,缓存行为不只是实现细节或性能优化。它会影响延迟、成本、工具设计、会话设计,甚至决定哪些产品功能适合开放给用户。
KV 缓存里有什么
Transformer 处理提示词大致分为两个阶段。在**预填充(prefill)阶段,它读取输入 token 并计算相应的注意力状态;在解码(decode)**阶段,它每次生成一个新 token。
在每一层注意力中,每个已经处理的 token 都会产生一个键(key)和一个值(value)。它们并不像哈希表里的键值查询:两者都是数字数组,通常由浮点数或经过低精度量化的数值组成。处理新 token 时,模型会把这个 token 的**查询向量(query)与此前的键(keys)比较,以判断每个早期 token 与当前 token 的相关程度。随后,它利用这些相关性分数,对相应的值(values)**进行加权组合。从这个意义上说,键是模型用来匹配的对象,值则是模型取回的信息;不过这种查询是模糊匹配,并不像字典查询那样“返回唯一且完全匹配的结果”。
这些键和值会被保留下来,让下一个生成的 token 可以关注此前的全部内容,而不必重新计算早期 token。被保留的这部分状态就是 KV 缓存。
从概念上看,请求大致如下:
请求 1:
[系统][工具][用户][助手][工具结果][用户]
<--------------------- 预填充 -------------------->
|
每个 token、每一层对应的 K 和 V 张量
请求 2:
[系统][工具][用户][助手][工具结果][用户][新增内容]
<---------------- 可复用的前缀 ----------------><------->
|
新增计算
真实的表示形式更加复杂,因模型而异,而且体积“相当”大。关键属性在于:它们对应的是某一个确定的 token 前缀。两个提示词即使语义相同,只要分词结果不同,就无法共享同一份 KV 缓存。如果中间某个 token 发生变化,那么从这个 token 往后的所有内容都会成为不同的续写序列。
提示词缓存会把这部分状态的生命周期延长到单次生成之外。当编程智能体发出的下一次 API 请求以完全相同的 token 开头时,推理系统就能复用匹配前缀的既有计算,只对新增的后缀执行预填充。以上是理论部分。
缓存存在哪里
缓存要发挥作用,首先必须存放在某处,并且后续请求能够寻址到它。推理系统大体有两种方式,让后来的请求能够使用此前的 KV 缓存。
更简单的方式是会话亲和性(session affinity):把 KV 缓存保留在最初计算它的 GPU 上或附近,再把下一次请求路由回同一个工作节点。会话 ID 或提示词缓存键会成为一个简单的路由提示,因此甚至可能只在 HTTP 负载均衡器这一层解决问题,而无须查看请求载荷。
请求(session-42) --> 路由器 --> 工作节点 7 --> GPU 7 KV 缓存
后续(session-42) --> 路由器 --> 工作节点 7 --> GPU 7 KV 缓存
这种方式避免了通过网络搬运体积很大的缓存。运行顺利时速度很快,但它也限制了调度。被选中的工作节点可能过载、重启或淘汰该缓存条目;路由器也可能认为整个集群的负载均衡比保住某个会话的缓存更重要。尽管如此,它仍然很有吸引力,因为只需增加很少的基础设施和硬件就能落地。
另一种方式是分布式缓存。KV 块可以存放在其他内存层级中,或者在多个工作节点之间共享,使请求不必紧密绑定到某一块 GPU。
+--------------------+
请求 --> 调度器 --------------->| 工作节点 3 / GPU 3 |
| +--------------------+
|
+-----------------> 分布式 KV 块
|
+-----------------> 工作节点 9 / GPU 9
这会提高调度灵活性和故障恢复能力,但 KV 块的搬运、索引和保留本身也是一个系统工程问题。不同实现会以不同方式组合 GPU 显存、主机内存、本地存储、远程存储、前缀感知路由和淘汰策略。
把 KV 缓存的体积放到实际背景中看:它们确实可能很大,但某些方面又没有人们想象得那么夸张。借助各种技术,即使是很长的对话,KV 缓存也可以压缩到几 GB 的量级。
缓存与前缀
Pi 的会话是树,而不是列表。/tree 可以把当前对话退回到更早的节点,再沿另一条分支继续。回退操作可以放弃当前使用的后缀,却不必从会话文件中删除它。新分支可能与旧上下文共享绝大部分内容,也可能只共享一点,甚至几乎完全不共享。这样的设计并非 Pi 独有,不少编程智能体至少在概念上采用了类似机制。即使会话没有被表示成树,智能体具备某种形式的回退功能也很常见。
+-- E -- F 另一条分支
|
会话 S:根节点 -- A -- B -- C -- D 当前分支
|
+-- Z 靠近起点的分支
这三条分支可以拥有同一个 Pi 会话 ID。从路由器的视角看,它们属于同一个会话;从提示词缓存的视角看,它们却是三条只在部分前缀上重叠的 token 序列。
如果缓存保留了可复用的前缀块,那么从 D 跳到 F 时,仍有可能复用 根节点 -> C。但如果缓存只保留了最热的那条续写路径、共享块已被淘汰,或者请求被路由到了其他位置,实际命中的部分就会小得多。跳到 Z 时,即使它从 A 开始,也可能只保住系统提示词和最初的工具定义。这里具体采用什么缓存管理行为,很大程度上取决于服务商。
相反的情况同样会发生。/fork 或新会话可能生成新的会话 ID,同时继承大量完全相同的上下文。如果路由系统按照会话键隔离缓存,就可能察觉不到这部分有价值的重叠。
真正决定哪些计算能够缓存的是可复用前缀。会话身份只是帮助基础设施找到可能匹配的内容。在一些系统中,路由键对缓存管理至关重要;在另一些系统中,它只是一项优化。
显式与自动前缀缓存
服务商 API 主要以两种方式提供缓存能力。
Anthropic 的传统接口使用显式的 cache_control 缓存点。客户端会在请求中相对稳定的部分之后标记边界,例如系统提示词、工具定义,或最近一段可缓存的对话内容。服务器随后可以写入或查找截至这些边界的前缀。边界是显式的,但要实现复用,边界之前的内容仍然必须完全匹配。不只是缓存点明确,定价方式也很明确:缓存写入需要付费,而且可以选择保留时长,不同时长对应不同价格。
其他 API 采用自动前缀缓存。客户端像平常一样发送请求,由服务商自行找到可复用的前缀,无须客户端设置断点。提示词缓存键或会话请求头可能有助于路由和分组,但它不会让原本不同的前缀变成相同前缀。
为什么工具配置会让缓存失效
工具定义通常出现在对话之前,并会在内部被“折叠”进系统提示词。工具名称、描述和 JSON Schema 与其他文本一样,都会成为模型输入。增加一个工具、移除一个工具、修改工具的 Schema,甚至只是用不同顺序序列化工具,都可能让第一次不匹配出现在提示词非常靠前的位置。
第 1 轮:[系统][读取][写入][bash][对话...................]
第 2 轮:[系统][读取][写入][bash][部署][对话.............]
|
旧对话现在位于
不匹配内容之后
插件系统和 MCP 风格的工具目录经常带来这种出乎意料的情况。只在工具真正相关时才加载它,听上去很高效,因为初始请求需要发送的 Schema 更少。然而在大多数模型上,新扩展的工具配置会使它后面的已缓存对话失效。省下几个工具 Schema 的 token,可能导致数万个对话 token 被重新处理。
一些较新的模型 API 支持增量式工具加载(additive tool loading)。工具可以在会话记录中某个特定的工具结果之后变为可用,而不是被插入最初的工具列表。这样,旧前缀就不会改变:
[系统][初始工具][对话][新工具][下一轮]
<--------- 已缓存前缀 --------->
如今,Pi 已经能在具备原生延迟工具机制的模型上支持这种能力。当扩展通过 setActiveTools() 进行纯粹的“只增不减”变更时,Pi 会把新增的工具名称记录在工具结果上。对于受支持的 Anthropic 模型,它会使用延迟定义和 tool_reference;对于受支持的 OpenAI 模型,则会发出相应的工具搜索条目。其他模型会采用安全回退方案:Pi 在下一次请求中发送完整的当前工具列表。这样在功能上没有问题,但可能清空提示词缓存。
增量式这个词很重要,因为移除工具、把一套工具配置替换成另一套,或修改提示词片段,仍然会改变此前的输入。如果某个扩展重建系统提示词、打乱工具顺序、注入时间戳,或每轮都改变启用的工具,就可能在无意中让整个会话都无法有效使用缓存。
可扩展性意味着 Pi 无法替每一个扩展保证缓存稳定。我们可以提供对缓存友好的机制,但扩展仍然需要正确使用它们。就我们观察到的情况而言,很多扩展只把缓存效率当成事后考虑。这在一定程度上是因为:当你支付的是固定费用订阅时,缓存未命中带来的额外成本并不那么直观。
中断与 TTL
一些重要的提示词缓存默认生存时间很短。Anthropic 默认的五分钟缓存尤其值得注意,因为它比许多正常的编程活动都短。使用 Fable 时,如果你起身去喝杯咖啡,十分钟后回来,仅仅发送一句“say hi”,花费就可能比预期高得多。
原因在于:用户可能把编程会话视为一段持续进行的活动,但推理服务商看到的是一连串彼此独立的请求:
模型请求 --> 运行测试 7 分钟 --> 模型请求
此期间没有缓存流量
一次较长的构建、一套测试、午餐、会议,或只是停下来审阅 diff,都可能超过缓存的生存时间。下一次请求包含的提示词完全相同,但此前保存的 KV 状态已经消失,整个前缀会再次作为输入计费。
由于 Pi 目前并不是 Anthropic 订阅允许使用的客户端框架(harness),我们采用 Anthropic 为 API 用户建议的五分钟默认值。不过,通过查看 Claude Code 的代码库可以知道,Anthropic 为自家订阅用户把缓存超时时间延长到了一小时。但如果需要按照 API token 价格付费,延长保留期所增加的成本往往并不划算。
不过,用户可以主动选择更长的保留时间。Anthropic 等服务商提供了更长的缓存保留控制。对于受支持的直连 API,Pi 用户可以设置 PI_CACHE_RETENTION=long 来提出这一请求。但它依然只是请求:Pi 无法强制网关保留缓存条目,无法阻止内存压力下的缓存淘汰,也无法在没有模型请求时让缓存一直保持活跃。
缓存未命中的代价
服务商通常会分别定价未缓存输入、缓存写入和缓存读取。缓存读取通常有折扣,因为昂贵的预填充计算已经完成;缓存写入则可能有溢价,因为服务商承诺把这份状态保留到后续使用。
仍以前面的 Fable 场景为例:假设一次编程会话已经积累了 100,000 个 token 的历史,接下来只发出一个很短的新请求。缓存正常工作时,几乎全部历史都会按更低的缓存读取价格计费。只有少量新增内容需要按常规输入价格处理,并可能写入缓存。
缓存未命中时,服务商必须按常规输入价格重新处理整段 100,000 token 的历史,而且还可能对把这段历史重新写回缓存收费。这就是为什么缓存过期后,像 continue 这样很短的请求也可能异常昂贵。在长时间的编程会话中,重新读取旧输入的成本可能远高于生成下一条回答。
缓存还有可能产生一些不容易察觉的激励关系。
用户当然希望有较高的命中率,因为它能降低延迟和价格。拥有 GPU 的推理服务运营方也应该希望如此:减少预填充计算,意味着同样的硬件可以处理更多请求。设计合理的缓存 token 折扣能够让双方利益一致,同时让运营方获得更好的利润率。
网关或转售商的激励却可能不同。如果它通过按未缓存价格计费的输入 token 获得收入,那么一次缓存未命中带来的客户账单可能高于缓存命中。它是否也能获得更多利润,取决于上游成本、合同安排以及由谁运营缓存。在利益没有对齐的技术栈里,负责路由的一方可能不必承担未命中的全部成本,而向用户收费的一方却会在未命中时获得更多收入。
这并不意味着服务商会故意破坏缓存,但它说明缓存性能必须具备可观测性。用户不应该只能从一张异常高昂的账单中推测缓存出了问题。能够发现缓存中是否存在异常,是一项重要洞察。
严格保持缓存亲和性,也会降低网关在两轮请求之间把你路由到最佳选项的灵活性。你可能愿意牺牲一次缓存命中、切换到另一个模型,因为从那一刻起新模型也许更加经济;也有可能,把请求负载均衡到另一家服务商反而更合适。
为什么 Pi 不会激进裁剪
读到这里,你大概已经知道 Pi 为什么不会主动裁剪工具调用。通过持续删除旧的工具结果或重写历史来控制成本很有吸引力,而且有时确实有必要,尤其是在接近上下文窗口上限时。但正如前文所述,裁剪本身也有缓存成本。
从中间删除内容,会在删除点改变前缀。位于它后面的所有保留对话可能都需要重新处理。重写一段很长的已缓存上下文所产生的即时成本,可能高于未来通过删掉少量低价缓存 token 所节省的费用。
粗略的盈亏平衡比较如下:
一次性重写成本
~= 编辑点之后仍保留的 token *(未缓存价格 - 缓存读取价格)
每轮未来节省
~= 被裁剪的 token * 缓存读取价格
这也不只是记账问题。旧工具结果往往包含模型后来做出决策时使用的证据。即使摘要保留了大意,删除这些结果仍可能降低模型表现。
因此,Pi 更偏好稳定、以追加为主的会话记录,并不会把每个旧 token 都视为浪费。当上下文压力足以证明有损重写是合理的时,用户可以执行压缩(compaction)。由于压缩会有意创建新的上下文,而不是意外地对未改变的提示词重复计费,Pi 在会话统计中把它视为一次缓存重置,而不是缓存故障。
目标并不是让提示词尽可能短,而是在模型上下文、缓存复用、延迟和价格之间取得最佳平衡。
与此同时,某些情况下裁剪也有其价值。如果所使用的服务商不会因为良好的缓存利用率给你折扣,或者无论出于什么原因都无法获得较高的缓存命中率,那么裁剪可能更合适。由于缓存无法在不同后端之间转移,它确实能为路由器在多个后端之间进行负载均衡创造更多空间。
Pi 能做什么、不能做什么
Pi 会尽力让稳定的输入保持稳定。它传递一致的会话 ID 和服务商专用缓存提示,为需要显式缓存点的 API 设置缓存点,记录缓存读取和写入用量,并在模型允许的情况下支持以消息为锚点的增量式工具加载。它默认处理会话记录的方式,也会避免无谓地重写旧上下文。
请求离开本机以后,Pi 无法控制之后的每一层。它不能决定服务商的淘汰策略,不能让缓存突破 API 允许的保留时长,不能保证某一块 GPU 始终在线,也无法保证网关遵循会话亲和性。同样,如果某个扩展修改了前缀,Pi 也无法把这个前缀保持原样。
Pi 能做的是让缓存健康状态变得可见。
交互界面底部会用 R 和 W 显示累计缓存读取量与写入量,并用 CH 显示最近一次请求的缓存命中率。/session 命令提供更完整的视图:缓存和未缓存输入的总量、累计命中率、成本,以及因显著缓存未命中而被重新计费的 token 数和金额估算。
消息
总计: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 数和成本估算。如果 Pi 能够观察到模型切换,或者空闲时间超过常见的短 TTL,它会明确指出;对于其他未命中,它只报告事实,不会假装知道服务商内部究竟发生了什么。
缓存性能变差的常见原因
当一次会话的缓存命中率看起来不正常时,常见原因包括:
- 空闲时间过长。 一条命令、一次代码审阅或对话暂停超过了服务商的缓存保留窗口。
- 切换模型或服务商。 KV 状态与具体模型绑定,通常无法跨服务商迁移。
- 切换会话分支。
/tree、回退、fork 和其他分支可能改变当前 token 序列,即使会话 ID 没有变化。 - 压缩或手动重写历史。 这些操作会有意替换提示词的一部分,并建立新的前缀。
- 工具或推理级别变化。 增加、移除、重新排序或编辑工具定义,都会改变请求中靠前的部分;除非模型支持以消息为锚点的工具加载,而且变更是纯粹的“只增不减”。推理级别变化通常也会产生同样的影响。
- 动态系统提示词。 时间戳、随机值、不断变化的项目上下文,以及扩展提供的提示词片段,都可能使它们之后的所有内容失效。
- 扩展对上下文的变换。 修改旧消息或服务商载荷的扩展,可能让 Pi 中看似稳定的会话记录,在实际发往服务商的请求中变得不稳定。
- 服务商路由与缓存淘汰。 即使提示词完全相同,如果相关 KV 块在请求落到的节点上已经不可用,仍然会发生缓存未命中。