← 返回 关于

Agent 中的 Prompt 缓存

2026-07-23 · 原文链接

人们常常把大语言模型看作函数:输入一些文本,得到一些文本。这个抽象很有用,但它忽略了运行编程 agent 时最重要的一点:绝大部分输入与上一次相同。换句话说,我们通常只是在后面追加内容。

编程 agent 会向模型发送系统提示词、工具定义、项目指令、对话历史、工具调用及其结果。下一轮它会再次发送其中几乎全部内容,只多出少量新材料。一个会话增长到数万乃至数十万 token 后,每一轮都重新计算整个 prompt,会又慢又贵。

Prompt 缓存让这一过程在经济上多少变得可行,但它也相当脆弱。工具定义的变化、模型切换,或提供商的路由决策,都可能让本应廉价的增量请求变成整段上下文的完全重放。

因此,对编程 agent 而言,缓存行为并不只是实现细节或优化项。它会影响延迟、成本、工具设计、会话设计,甚至哪些产品功能值得提供。

KV 缓存中存放了什么

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

在每一层注意力机制中,每个已处理的 token 都会产生一个键和一个值。它们并不等同于哈希表中的键值查找:两者都是数值数组,通常是浮点数或低精度量化值。处理新 token 时,模型会将该 token 的**查询(query)与先前的键(keys)比较,以确定每个先前 token 的相关性;然后用这些相关性分数,对应的值(values)**形成加权混合。从这个意义上说,键是模型拿来匹配的东西,值是它取回的信息(不过这是模糊查找,而非像字典查找那样“返回一个单一的精确匹配”)。

这些键和值会被保留,使得下一个生成的 token 能关注此前所有内容,而无需重新计算先前的 token。这份保留的状态就是KV 缓存

从概念上说,一次请求如下:

request 1:

[system][tools][user][assistant][tool result][user]
<--------------------- prefill -------------------->
                       |
                       K and V tensors per token and layer

request 2:

[system][tools][user][assistant][tool result][user][new]
<---------------- reusable prefix ----------------><--->
                                                    |
                                                    new work

真实的表示更复杂、与具体模型相关,而且“相当”庞大。重要的是,它们对应于某个特定的 token 前缀。两段 prompt 即使语义相同,只要分词方式不同,也不能共享 KV 缓存。若中间有一个 token 改变,那个 token 之后的一切都会成为不同的续写。

Prompt 缓存将这份状态的生命周期延伸到一次生成之外。当编程 agent 的下一次 API 请求以相同 token 开头时,推理系统就能复用匹配前缀已存储的工作,只对新增后缀做预填充。理论部分到此为止。

缓存在哪里

要让缓存生效,它需要存放在某个地方,并且能够被寻址。推理系统大致有两种方式,让 KV 缓存在后续请求中可用。

较简单的方法是会话亲和性(session affinity)。它把 KV 缓存保留在计算它的 GPU 上或其附近,并将下一次请求路由回同一个 worker。会话 ID 或 prompt-cache key 会成为简单的路由提示,因此甚至可以在 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 具有某种形式的回退也很常见。

                             +-- E -- F  another branch
                             |
session S: root -- A -- B -- C -- D  current branch
                   |
                   +-- Z  branch near the start

三条分支可以拥有同一个 Pi 会话 ID。对路由器而言,它们是同一个会话;对 prompt 缓存而言,它们是三条只有部分前缀重叠的 token 序列。

如果缓存保留了可复用的前缀块,从 D 跳到 F 仍可能复用 root -> C。但如果它只保留最热的续写、共享块已被逐出,或请求被路由到别处,命中就可能小得多。从 A 跳到 Z 时,即便分支从 A 开始,也可能只保留系统提示词和初始工具定义。这里精确的缓存管理行为高度依赖提供商。

反过来也可能发生。/fork 或新会话可能会产生新的会话 ID,同时带上大量完全相同的上下文。若路由系统按会话键隔离缓存,就可能无法发现这些有用的重叠。

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

显式与自动前缀缓存

提供商 API 主要以两种风格暴露缓存功能。

Anthropic 的传统接口使用显式的 cache_control 点。客户端会在请求的稳定部分之后标记边界,例如系统提示词、工具定义或最新可缓存的对话内容。服务器可以在这些点写入或查找以前缀结尾的缓存。边界是显式的,但复用仍要求此前内容完全匹配。不只是缓存点显式,定价也同样明确:你要为缓存写入付费,并可以选择保留多久,时长不同价格也不同。

其他 API 使用自动前缀缓存。客户端照常发送请求,提供商自行寻找可复用前缀。prompt-cache key 或会话 header 可能改善路由或分组,但不会让不同的前缀变成相同的前缀。

为什么工具集会摧毁缓存

工具定义通常出现在对话之前,并会在内部被“折叠”进系统提示词。它们的名称、描述和 JSON schema 与其他文本一样,都是模型输入。新增一个工具、删除一个工具、修改其 schema,甚至只是以不同顺序序列化工具,都可能把首次不匹配点移到 prompt 的很前面。

turn 1: [system][read][write][bash][conversation...........]
turn 2: [system][read][write][bash][deploy][conversation...]
                                   |
                                   old conversation is now
                                   after a mismatch

这在插件系统和 MCP 风格工具目录中是常见的意外。只在工具变得相关时才加载它,听起来很高效,因为初始时发送的 schema 更少。但对大多数模型而言,新扩展的工具集会使其后的已缓存对话失效。省下少量工具 schema token,可能导致数万对话 token 被再次处理。

一些较新的模型 API 支持增量加载工具(additive tool loading)。工具可以在转录中的某个特定工具结果处变得可用,而不是插回原始工具列表;旧前缀于是保持不变:

[system][initial tools][conversation][new tool][next turn]
<--------- cached prefix ----------->

Pi 现已针对具备原生延迟工具机制的模型支持此功能。当扩展通过 setActiveTools() 作出纯粹的增量变更时,Pi 会在工具结果上记录新增的名称。对于受支持的 Anthropic 模型,它会使用延迟定义和 tool_reference;对于受支持的 OpenAI 模型,它会发出相应的 tool-search 项。其他模型会走安全回退路径:Pi 在下一次请求时发送完整的活动工具列表,功能上没有问题,但可能清空 prompt 缓存。

增量”一词很重要:删除工具、用另一套工具集替换当前工具集,或修改 prompt 片段,都会改变更早的输入。某个扩展若重建系统提示词、打乱工具顺序、注入时间戳,或每一轮都改变活动工具,都可能意外击败整个会话的缓存。

可扩展性意味着 Pi 无法代表每个扩展保证缓存稳定性。我们可以提供缓存友好的机制;扩展仍需正确使用它们。而据我们所见,对许多扩展而言,缓存效率只是事后才会考虑的事。这部分是因为在固定订阅付费时,缓存未命中的相关成本并不那么显眼。

中断与 TTL

一些重要的 prompt 缓存默认存活时间很短。Anthropic 默认的五分钟缓存尤其重要,因为它比很多正常的编程活动都短。使用 Fable 时,你去喝杯咖啡,10 分钟后回来发一条“say hi”,花的钱可能比预想得更多。

这是因为用户可能认为编程会话是持续活跃的,但推理提供商看到的是一系列彼此孤立的请求:

model request --> run tests for 7 minutes --> model request
                  no cache traffic here

长时间构建、测试套件、午饭、会议,或者只是停下来审阅 diff,都可能超过缓存存活期。下一次请求包含相同的 prompt,但存储的 KV 状态已经消失,前缀会再次按输入计费。

由于 Pi 目前不是 Anthropic 订阅中获许可的 harness,我们遵循 Anthropic 面向 API 用户推荐的 5 分钟默认值。不过,从 Claude Code 的代码库可以得知:对于他们自己的订阅用户,他们正将缓存超时增加到一小时。但在需要按 API token 价格付费时,这增加的成本往往并不划算。

不过,你可以选择启用更长的保留期。一些提供商(如 Anthropic)提供更长的保留控制。对于受支持的直连 API,Pi 用户可以设置 PI_CACHE_RETENTION=long 来请求它们。但这仍然只是请求:Pi 无法强制 gateway 保留缓存项,无法阻止内存压力下的逐出,也无法在没有模型请求流量时让缓存保持存活。

未命中的代价

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

想象一个编程会话有 100,000 token 的历史,之后接上一条很短的新请求,就像上面的 Fable 示例。当缓存生效时,几乎全部历史都会按较低的缓存读取价格收费;只有少量新内容需要按常规输入价格处理,并可能写入缓存。

当缓存未命中时,提供商必须以常规输入价格重新处理整段 100,000 token 历史,还可能对将这些历史重新写回缓存收费。这就是为什么在缓存过期后,像 continue 这样很短的请求也会出奇地昂贵。在长编程会话中,重新读取旧输入的成本可能远高于生成下一个回答。

缓存还可能创造不那么显然的激励。

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

Gateway 或转售商的激励则可能不同。如果它按未缓存费率的输入 token 获取收入,一次未命中就可能带来更大的客户账单。它是否也会获得更多利润,取决于上游成本、合同以及谁在运营缓存。在利益错配的栈中,负责路由的一方可能不承担未命中的全部成本,而向用户收费的一方却会在未命中发生时赚得更多。

这并不表示提供商会蓄意破坏缓存,但它说明缓存性能应当可观测。用户不应只能从异常高昂的账单中推断这一点。理解缓存中是否有异常情况,可能是重要的洞察。

严格遵守缓存也意味着,gateway 在回合之间把你路由到最佳选项的灵活性会更低。你可能希望为了继续使用另一种模型而选择缓存命中,因为从那一刻起它可能更经济;也可能更适合在不同提供商之间进行负载均衡。

为什么 Pi 不激进地裁剪

读到这里,你大概已经明白为什么 Pi 不会裁剪工具调用。持续删除旧工具结果或重写历史,来控制成本很有诱惑力;有时这也确有必要,尤其接近上下文窗口极限时。但正如我们所了解的,裁剪本身也有缓存成本。

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

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

one-time rewrite cost
    ~= surviving tokens after the edit * (uncached price - cache-read price)

future savings per turn
    ~= pruned tokens * cache-read price

这不仅是账务问题,因为旧工具结果通常含有模型作出后续决策时依赖的证据。即使摘要保留了大意,移除它们也可能降低行为质量。

因此,Pi 偏好稳定、仅追加的转录,并不把每个旧 token 都视为浪费。当上下文压力足以证明有损重写的合理性时,可以使用压缩。由于压缩是有意创建新的上下文,而非意外地对未变的 prompt 重复计费,Pi 会在会话统计中将其视为缓存重置,而非缓存失败。

目标并不是让 prompt 尽可能小,而是在模型上下文、缓存复用、延迟与价格之间取得最佳权衡。

同时,裁剪也可能有理由。如果你使用的提供商不会因良好的缓存使用给予折扣,或由于任何原因无法获得高缓存率,那么裁剪或许更好。它确实能提升路由器在不同后端之间进行负载均衡的空间,因为缓存无法迁移。

Pi 能做什么,不能做什么

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

Pi 无法控制请求离开本机后发生的每一层。它无法选择提供商的逐出策略、把缓存延长到 API 允许范围之外、让某块特定 GPU 持续存活,或保证 gateway 遵从亲和性。它也无法保留被扩展更改过的前缀。

它能做的是让缓存健康状况可见。

交互式页脚会显示累计缓存读取和写入,分别记为 RW,并以 CH 表示最新请求的缓存命中率。/session 命令提供更完整的视图:缓存与未缓存输入总量、累计命中率、成本,以及由显著缓存未命中重复计费的 token 和美元金额估算。

Messages
Total: 178
User: 6
Assistant: 58
Tools: 114 calls, 114 results

Tokens
Input: 7,129,883
  Cached: 6,776,832 (95.0%)
  Uncached: 353,051
Output: 30,013
Total: 7,159,896

Cost
Total: $6.054
Cache Re-billed: $0.728 (161,744 tokens, 2 misses)

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

缓存表现变差的常见原因

当一个会话的缓存命中率看起来不对劲时,通常原因如下:

  1. 空闲。 一条命令、代码审阅或对话暂停超过提供商的保留窗口。
  2. 模型或提供商切换。 KV 状态与模型绑定,通常不会在提供商之间转移。
  3. 分支导航。 即使会话 ID 不变,/tree、回退、fork 与替代分支仍可能改变活动 token 序列。
  4. 压缩或手动重写历史。 它们会有意替换 prompt 的一部分,并建立新的前缀。
  5. 工具与推理等级变更。 除非模型支持消息锚定式加载且变更纯属增量,否则新增、删除、重排或编辑工具定义都会改变请求的早期部分。推理等级变更通常也有同样效果。
  6. 动态系统提示词。 时间戳、随机值、变化的项目上下文和扩展提供的 prompt 片段,都会使其后的所有内容失效。
  7. 扩展上下文转换。 一个修改旧消息或提供商 payload 的扩展,可能让表面稳定的 Pi 转录在传输层变得不稳定。
  8. 提供商路由与逐出。 Prompt 可以完全相同,却仍会未命中,因为相关 KV 块已不在请求所抵达的位置可用。