Agent 中的提示词缓存
日期:
发件人:Earendil Engineering < rfc@earendil.com >
收件人:你
主题:
我们经常把大语言模型看作函数:输入一段文本,得到一段文本。这个抽象很有用,却忽略了运行编码 Agent 时一个极其重要的事实:每次请求的大部分输入都和上一次相同。换句话说,我们通常只是在已有输入末尾追加内容。
编码 Agent 会把系统提示词、工具定义、项目指令、对话历史、工具调用和工具结果一起发送给模型。下一轮请求又会发送几乎相同的内容,只多出少量新信息。当一次会话增长到数万甚至数十万 token 时,每轮都重新计算整段提示词既慢又贵。
提示词缓存(prompt caching)让这种调用方式在经济上勉强可行,但它也十分脆弱。工具定义发生变化、切换模型,或者服务商改变请求路由,都可能让原本廉价的增量请求变成对整个上下文的完整重放。
因此,对编码 Agent 来说,缓存行为不只是实现细节或性能优化。它会影响延迟、成本、工具设计、会话设计,甚至决定产品应该提供哪些功能。
KV 缓存中保存了什么
Transformer 处理提示词大致分为两个阶段。在**预填充(prefill)阶段,模型读取输入 token,并计算对应的注意力状态。在解码(decode)**阶段,模型每次生成一个新 token。
在每一层注意力中,每个处理过的 token 都会产生一个键(key)和一个值(value)。它们与哈希表中的键值查找并不相同:键和值都是数值数组,通常由浮点数或低精度量化值组成。模型处理新 token 时,会把这个 token 的**查询(query)与之前的键(key)进行比较,从而判断每个早期 token 与当前 token 的相关程度。随后,模型根据相关性分数,对相应的值(value)**做加权组合。从这个角度看,key 是模型用来匹配的对象,value 是模型取回的信息。但这种查找是模糊匹配,不像字典那样“返回唯一且完全匹配的结果”。
系统会保留这些 key 和 value。生成下一个 token 时,模型便能关注前面所有内容,无须重新计算早期 token。保存下来的状态就是 KV 缓存(KV cache)。
从概念上看,一组请求如下:
请求 1:
[system][tools][user][assistant][tool result][user]
<--------------------- prefill -------------------->
|
每个 token、每一层对应的 K 和 V 张量
请求 2:
[system][tools][user][assistant][tool result][user][new]
<------------------ 可复用前缀 -------------------><--->
|
新计算
实际的数据表示更复杂,具体形式取决于模型,而且体积“相当”大。这里最重要的特性是:这些状态对应一个确定的 token 前缀。两段含义相同但分词结果不同的提示词不能共享 KV 缓存。如果中间某个 token 发生变化,从该 token 之后的所有内容都会成为另一段续写。
提示词缓存把这些状态的生命周期延长到单次生成之外。编码 Agent 的下一次 API 请求如果以相同 token 开头,推理系统就可以复用匹配前缀的计算结果,只对新增后缀执行预填充。理论上就是这样。
缓存存放在哪里
缓存要发挥作用,系统必须把它存放在某处,并且能够再次找到它。推理系统主要通过两种方式,让后续请求可以使用已有 KV 缓存。
较简单的方式是会话亲和性(session affinity)。系统把 KV 缓存保留在执行计算的 GPU 上或附近,再把下一次请求路由回同一个 worker。会话 ID 或提示词缓存键可以直接充当路由提示,因此 HTTP 负载均衡器甚至不需要检查请求正文就能处理这个问题。
request(session-42) --> router --> worker 7 --> GPU 7 KV cache
next(session-42) --> router --> worker 7 --> GPU 7 KV cache
这种方式无须通过网络搬运庞大的缓存。运行正常时速度很快,但会限制调度。指定 worker 可能负载过高、发生重启或淘汰缓存项。路由器也可能认为整个集群的负载均衡比保留某个会话的缓存更重要。尽管如此,这仍是一种很有吸引力的方案,因为它几乎不需要额外部署基础设施和硬件。
另一种方式是分布式缓存。系统可以把 KV 块存放在另一层存储中,或者让多个 worker 都能访问它们,这样请求就不必与某块 GPU 严格绑定。
+--------------------+
request --> scheduler -->| worker 3 / GPU 3 |
| +--------------------+
|
+----------> distributed KV blocks
|
+----------> worker 9 / GPU 9
这种方式提高了调度和故障恢复的灵活性,但搬运、索引和保留 KV 块本身就是一项系统工程。不同实现会组合使用 GPU 显存、主机内存、本地存储、远程存储、前缀感知路由和缓存淘汰策略。
为了更准确地理解 KV 缓存的规模:它们可能很大,但有时比直觉估计的要小。借助各种优化,即使面对很长的对话,KV 缓存也可以缩减到数 GB。
缓存与前缀
Pi 的会话是一棵树,而不是一张列表。/tree 可以让当前对话回到早期节点,再沿着另一条分支继续。回退操作可以丢弃当前使用的后缀,但不会从会话文件中删除它。新分支可能与旧上下文共享大部分内容,也可能只共享少量内容,甚至几乎没有重合。Pi 并非唯一采用这种设计的编码 Agent。不少编码 Agent 至少在概念上提供了类似能力。即使会话本身不是树形结构,Agent 中也常见某种形式的回退功能。
+-- E -- F 另一条分支
|
会话 S:root -- A -- B -- C -- D 当前分支
|
+-- Z 靠近起点的分支
三条分支可以拥有相同的 Pi 会话 ID。在路由器看来,它们属于同一个会话;在提示词缓存看来,它们是三段只共享部分前缀的 token 序列。
如果缓存保留了可复用的前缀块,从 D 跳到 F 后,系统仍可能复用 root -> C。如果系统只保留最热的续写、共享块已经被淘汰,或者请求被路由到了其他节点,命中比例就会低得多。跳转到 Z 时,即使它从 A 处分叉,也可能只保留系统提示词和最初的工具定义。这里具体采用什么缓存管理行为,很大程度上取决于服务商。
反过来的情况也会发生。/fork 或新会话可以生成新的会话 ID,同时继承大量完全相同的上下文。如果路由系统按照会话键隔离缓存,它可能无法发现这些可利用的重合内容。
可复用前缀决定哪些计算能够被缓存。会话身份只是帮助基础设施寻找可能相关的内容。在一些系统中,路由键对缓存管理至关重要;在另一些系统中,它只是一个优化项。
显式前缀缓存与自动前缀缓存
服务商 API 主要通过两种方式提供缓存能力。
Anthropic 的传统接口使用显式 cache_control 节点。客户端在请求中的稳定部分之后标记边界,例如系统提示词、工具定义,或者最近一段可缓存的对话内容。服务端可以写入或查找截至这些节点的前缀。边界虽然由客户端显式指定,但要实现复用,边界之前的内容仍然必须匹配。除了缓存节点,定价也是显式的:写入缓存需要付费,用户可以选择缓存保留时长,不同保留时长对应不同价格。
其他 API 使用自动前缀缓存。客户端照常发送请求,服务商在没有客户端指定断点的情况下查找可复用前缀。提示词缓存键或会话请求头可能有助于路由和分组,但它们无法让不同前缀变成相同前缀。
为什么工具配置会破坏缓存
工具定义通常位于对话之前,并且会在模型内部“折叠”进系统提示词。工具名称、描述和 JSON Schema 与其他文本一样,都是模型输入。新增或删除工具、修改工具 Schema,甚至改变工具序列化顺序,都可能让第一次不匹配出现在提示词的前部。
第 1 轮:[system][read][write][bash][conversation...........]
第 2 轮:[system][read][write][bash][deploy][conversation...]
|
旧对话现在位于
不匹配位置之后
插件系统和 MCP 风格的工具目录经常带来这种意外。只有在工具变得相关时才加载它,听起来很高效,因为初始请求发送的 Schema 更少。然而在大多数模型中,扩展后的工具配置会让位于其后的对话缓存失效。省下几百个工具 Schema token,可能导致系统重新处理数万个对话 token。
一些较新的模型 API 支持增量工具加载(additive tool loading)。系统可以让某个工具在对话记录中的特定工具结果处生效,而不是将它插入最初的工具列表。这样,原有前缀不会改变:
[system][initial tools][conversation][new tool][next turn]
<---------------- 已缓存前缀 ----------------->
现在,Pi 会为具备原生延迟工具机制的模型提供这种能力。当扩展通过 setActiveTools() 完成纯增量变更时,Pi 会把新增的工具名称记录到工具结果中。对于支持该能力的 Anthropic 模型,Pi 使用延迟定义和 tool_reference;对于支持该能力的 OpenAI 模型,Pi 会生成相应的工具搜索项。其他模型使用安全的降级方案:Pi 在下一次请求中发送完整的活动工具列表。功能仍能正常工作,但提示词缓存可能会被清空。
这里的关键是增量。删除工具、用另一组工具替换当前配置,或者修改提示词片段,仍然会改变早期输入。扩展如果在每一轮都重建系统提示词、打乱工具顺序、注入时间戳或切换活动工具,就可能无意中让整场会话无法使用缓存。
Pi 提供扩展能力,也就无法替每个扩展保证缓存稳定。Pi 可以提供对缓存友好的机制,但扩展仍需要正确使用它们。根据我们的观察,许多扩展并不重视缓存效率。部分原因是,用户按固定订阅付费时,很难直接感受到缓存未命中的成本。
中断与 TTL
一些重要的提示词缓存默认保留时间很短。Anthropic 默认的五分钟缓存尤其值得关注,因为许多正常的编码活动都会超过五分钟。如果你在使用 Fable 时去喝杯咖啡,十分钟后回来只发一句“打个招呼”,产生的费用也会高于预期。
用户会把一段编码会话理解为连续活动,但推理服务商看到的是一系列彼此独立的请求:
模型请求 --> 运行 7 分钟测试 --> 模型请求
期间没有缓存流量
耗时较长的构建、测试套件、午餐、会议,或者停下来审查 diff,都可能超过缓存生命周期。下一次请求虽然包含完全相同的提示词,但系统保存的 KV 状态已经消失,这段前缀会被重新计为输入。
Anthropic 目前不允许 Pi 使用其订阅服务,因此我们遵循 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 都当作浪费。上下文压力足以抵消有损重写的代价时,用户可以执行压缩。压缩会有意生成新上下文,而不是意外地让一段未改变的提示词再次计费,所以 Pi 在会话统计中把它视为缓存重置,而不是缓存故障。
目标不是让提示词尽可能短,而是在模型上下文、缓存复用、延迟和价格之间取得最合适的平衡。
在某些情况下,裁剪也有价值。如果服务商没有因良好的缓存使用情况提供折扣,或者由于某种原因无法获得较高的缓存命中率,裁剪可能更合适。更短的上下文也确实让路由器更容易在不同后端间进行负载均衡,因为缓存通常无法迁移。
Pi 能做什么,不能做什么
Pi 会尽力维持稳定输入。它传递一致的会话 ID 和服务商专用缓存提示,为需要显式缓存节点的 API 设置相应节点,记录缓存读取量和写入量,并在模型支持时提供基于消息锚点的增量工具加载。Pi 默认也不会无故重写旧对话内容。
请求离开本机之后,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,它也会说明原因。对于其他未命中,Pi 只报告事实,不会假装知道服务商内部发生了什么。
缓存性能下降的常见原因
一场会话的缓存命中率出现异常时,通常可以从以下方面排查:
- 长时间空闲。 命令执行、代码审查或对话暂停的时间超过了服务商的缓存保留窗口。
- 切换模型或服务商。 KV 状态与模型绑定,通常无法跨服务商迁移。
- 分支导航。
/tree、回退、fork 和其他分支会改变当前 token 序列,即使会话 ID 保持不变。 - 上下文压缩或手动重写历史。 这些操作会有意替换部分提示词,并建立新的前缀。
- 改变工具或推理级别。 除非模型支持基于消息锚点的工具加载,且变更为纯增量,否则添加、删除、重新排序或修改工具定义都会改变请求前部。改变推理级别通常也会产生相同影响。
- 动态系统提示词。 时间戳、随机值、不断变化的项目上下文和扩展提供的提示词片段,都会让后续内容的缓存失效。
- 扩展的上下文转换。 扩展如果修改旧消息或服务商请求载荷,就可能让 Pi 中看似稳定的对话记录在线路上变得不稳定。
- 服务商路由与缓存淘汰。 即使提示词完全相同,只要请求落到无法访问相关 KV 块的位置,缓存仍然会未命中。