大型语言模型常被当作“函数”使用:输入一段文本,输出一段文本。这个抽象很实用,但它忽略了运行编码代理时最关键的一点:大部分输入与上一次几乎一样。换句话说,我们主要是在不断追加内容。
编码代理会发送系统提示词、工具定义、项目说明、对话历史、工具调用以及工具结果。到了下一轮,它几乎把这些全部再发一遍,只加了很少的新内容。一旦会话增长到几万甚至十几万 token,每一轮都重算整个 prompt 会变得又慢又贵。
提示词缓存使得这类工作具备一定经济性,但它也很脆弱。一点工具定义变化、模型切换,或路由决策上的调整,都可能让原本该是“增量请求”的场景退化为完整上下文重放。
对于编码代理来说,缓存行为不是一个实现细节或纯优化,而是会直接影响延迟、成本、工具设计、会话设计,甚至决定该向用户开放哪些功能。
KV 缓存到底包含什么
Transformer 大致分两阶段处理 prompt:在prefill阶段,它读取输入 token 并为其计算注意力状态;在decode阶段,它一次生成一个新 token。
在每一层注意力里,每个已处理 token 会生成一个 key 和一个 value。它们并不完全像哈希表里的 key-value 查询:它们是浮点数或低精度量化后的数字数组。处理新 token 时,模型会把这个 token 的query与之前的keys比较,以判断每个历史 token 的相关性,再用相关性权重去组合对应的values。从这个角度看,key 是“匹配对象”,value 是要取回的信息(与字典“返回唯一精确匹配”不同,它是模糊匹配)。
这些 key 和 value 会被保留,这样下一个生成 token 时就能回看之前全部上下文,而不必把之前部分全部重算。这个保留状态就是 KV cache。
从概念上看,一次请求像这样:
request 1:
[system][tools][user][assistant][tool result][user]
<--------------------- prefill -------------------->
|
每个 token 与每层的 K 与 V 张量
request 2:
[system][tools][user][assistant][tool result][user][new]
<---------------- reusable prefix ----------------><--->
|
新增处理部分
真实表示比这个更复杂、也更大,且依赖具体模型。关键点是它对应的是某个具体 token 前缀。两个意思相同但分词不同的 prompt 不会共享 KV 缓存。如果中间某个 token 发生变化,后面的全部内容都会变成不同的延续。
提示词缓存让这种状态能够跨一次生成持续存在。下一次来自编码代理的 API 请求以同样的 tokens 开头时,推理系统就能复用匹配前缀的已有计算,只需对新后缀做 prefill。理论上如此。
缓存存在哪里
要让缓存可用,必须有存储且可寻址。推理系统一般有两种让后续请求复用 KV 缓存的方法。
更简单的方式是会话黏性(session affinity)。它把 KV 缓存保留在计算它的 GPU 上或附近,再把下一次请求路由回同一 worker。一个 session 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 独有,很多编码代理在概念上都有类似机制;即使你没用树结构,很多代理也会有某种回溯能力。
+-- E -- F another branch
|
session S: root -- A -- B -- C -- D current branch
|
+-- Z branch near the start
这三条分支都可以是同一个 Pi 会话 ID。对路由器来说它们是一会话;对提示词缓存来说,它们是三条 token 序列,仅有部分前缀重叠。
如果缓存保留了可复用前缀块,从 D 跳到 F 仍可能复用 root -> C。如果只保留“最热续写”块、共享块已被逐出,或请求被路由到其他位置,命中率就可能很低。跳到 Z 可能只保留系统提示词和初始工具定义,即使它从 A 开始也一样。这里具体的缓存管理行为在不同 provider 之间差异很大。
反向也会发生。/fork 或新建会话可能产生新 session ID,却携带大量相同上下文。若路由系统按会话键隔离缓存,它可能没法识别这类重叠。
可复用前缀决定了哪些工作能被缓存;会话身份只是在基础设施层提供“可能匹配”的线索。在一些系统,路由键对管理缓存至关重要;在另一些系统,它只是优化项。
显式 vs 自动前缀缓存
Provider 的 API 主要有两种提供方式。
Anthropic 的传统接口使用显式 cache_control 标记。客户端会在请求中的稳定部分(如系统提示词、工具定义、最新可缓存对话)后打标,服务器据此写入或查找到该边界为止的前缀。边界是显式的,但复用仍要求边界前内容完全一致。不仅边界显式,计费也显式:你需要为缓存写入付费,并可选择不同有效期,价格也不同。
其他 API 使用自动前缀缓存。客户端按正常方式发起请求,provider 自行寻找可复用前缀,无需客户端放置断点。prompt-cache key 或 session header 可能帮助路由/分组,但不能使不同前缀“变相等”。
为什么工具载入会“毁”缓存
工具定义通常位于对话之前,并且会在内部被“折叠”进系统提示词。它们的名字、描述、JSON schema 都像普通文本一样成为模型输入。新增工具、移除工具、修改 schema,甚至只是改变工具顺序,都可能让首次不匹配发生得更靠前。
turn 1: [system][read][write][bash][conversation...........]
turn 2: [system][read][write][bash][deploy][conversation...]
|
旧对话现在位于
不匹配之后
这是插件系统和 MCP 风格工具目录中的常见坑。按需加载工具看似高效,因为起始时少发一些 schema。但在多数模型上,新扩展的工具载入会让后续缓存失效,节省的那点工具 token 可能换来几万条对话 token 的重新计算。
一些较新的模型 API 支持增量式工具载入。工具可以在 transcript 的某个具体工具返回点才变为可用,而不是一开始就插入全部工具列表。旧前缀不变:
[system][initial tools][conversation][new tool][next turn]
<--------- cached prefix ----------->
Pi 目前对支持原生 deferred-tool 机制的模型提供了这个能力。当扩展通过 setActiveTools() 做纯增量变更时,Pi 会在工具结果上记录新增名称。对支持 Anthropic 的模型,使用 deferred definitions 和 tool_reference;对支持 OpenAI 的模型,发送相应的 tool-search 条目。其它模型会退回到安全兜底:Pi 在下一次请求发送完整活跃工具列表,功能上可用,但可能会清空提示词缓存。
增量很关键:移除工具、替换载入清单、修改 prompt 片段,甚至每轮都插入时间戳或换工具顺序,都会改变前面输入,仍会破坏缓存。扩展性意味着 Pi 不能为每个扩展保证缓存稳定性。我们可以提供缓存友好的机制,但扩展仍需按它使用;从观察看,对许多扩展开发者而言,缓存效率常被当作附带考虑。部分原因是按订阅计费时,缓存 miss 带来的成本不那么直观。
中断与 TTL
一些重要的 prompt 缓存有较短的默认寿命。Anthropic 的默认五分钟 cache 非常关键,因为它比很多正常编码活动都要短。你在用 Fable 时去喝杯咖啡,十分钟后回来发一句“嗨”,这单次请求可能比你预期贵得多。
因为用户可能认为编程会话是持续活动的,provider 其实看到的是一串隔离请求:
model request --> run tests for 7 minutes --> model request
no cache traffic here
长时间编译、测试、午休、开会,或只是在 review 一段 diff,都可能超出缓存窗口。下一次请求虽然携带同一套 prompt,但 KV 状态已丢失,前缀又会按普通输入计费。
目前 Pi 尚未被 Anthropic 的 subscription 接口开通,因此我们按 Anthropic 给 API 用户建议的五分钟默认值执行。不过从 Claude Code 的代码库看,Anthropic 对其自有订阅用户把缓存超时延长到了一个小时。把这个窗口拉长通常是要付出 API token 成本的,且不一定划算。
但这可以选择开启。Anthropic 等部分 provider 提供更长保留时间控制。对支持的直接 API,Pi 用户可设置 PI_CACHE_RETENTION=long 来请求。它仍只是请求:Pi 不能强制网关保留条目,也不能阻止在内存压力下逐出,或者在无模型请求时维持缓存。
缓存 Miss 的代价
Provider 通常对未缓存输入、缓存写入、缓存读取按不同价格计费。缓存读取常有折扣,因为昂贵的 prefill 工作已完成;缓存写入可能有溢价,因为 provider 承诺后续复用该状态。
想象一个有 100,000 token 历史的编码会话,接着来了一个很短的新请求(和上面的 Fable 场景类似)。若缓存命中,几乎全部历史都按低价缓存读取计费,只有新增小部分按常规输入价格处理并可能写入缓存。
而一旦 miss,provider 要再次按常规输入价处理整段 100,000 token 历史,并且可能还会对写回缓存再次收费。这就是为什么会发生“继续生成”等短输入时,缓存过期后账单会突然偏高:在长会话里,重新阅读旧输入的成本可能远超下一个答案本身。
缓存也会带来一些不太直观的激励结构。
用户当然希望高命中率,因为它降低延迟并降低成本。GPU 运营方也有同样动机:prefill 更少,意味着同样硬件可服务更多请求。设计良好的缓存折扣可以让双方都受益,同时维护更好的利润空间。
但网关或转售方的激励可能不同。如果它按未缓存输入向用户收费,每次 miss 可能产生更高账单。它是否更赚钱还取决于上游成本、合同条款及谁在实际操作缓存。若链路对齐不当,负责路由的那一层可能不承担 miss 的全部成本,而负责计费面向用户的那一层却在 miss 时拿到更高收入。
这并不代表 provider 会故意破坏缓存,但它意味着缓存性能应当可观测。用户不该只凭一张异常偏高的账单去猜测问题所在;能看清缓存是否异常,是一项重要能力。
严格命中也会减少网关在轮次间在不同后端间灵活路由的空间。你或许希望用命中继续走另一个更省钱的模型,或者为均衡负载切到别的 provider。
为什么 Pi 不会激进地修剪
看到这里,你大概也明白为什么 Pi 不激进修剪工具调用。不断删掉旧工具结果或重写历史确实可控成本,尤其在临近上下文窗口时很常见,但我们刚看到,修剪本身也有缓存代价。
从中间删除内容会改变删除点之后的前缀。删除点之后所有存活的对话都可能需要重新处理。一次重写长期缓存上下文的即刻成本,可能超过未来因删除少量“已缓存” token 节省的开销。
一个粗略的盈亏平衡可表示为:
一次性重写成本
~= 编辑后保留 token 数 * (未缓存价格 - 缓存读取价格)
每轮未来节省
~= 被修剪 token 数 * 缓存读取价格
这不仅是核算问题。旧工具结果往往包含模型后续决策的证据。即使摘要保留了要点,直接删除也可能削弱模型行为。
Pi 因而倾向于稳定的追加式 transcript,不把每个旧 token 都当废纸。只有当上下文压力足以证明有必要进行有损压缩时才进行 compact。因为 compact 有意创建新上下文,不是“偶然重复计费未变更 prompt”的故障,所以 Pi 将其视为一次“会话统计中的缓存重置”而非“缓存失败”。
目标不是把 prompt 压到最小,而是在模型上下文、缓存复用、延迟与成本之间取最佳平衡。
同时,修剪也有其合理场景。如果你使用不对缓存有折扣的 provider,或无论如何都拿不到高命中率,修剪可能更优;它还能提升路由器在不同后端间均衡机会,因为缓存通常不可跨提供方迁移。
Pi 能做什么、不能做什么
Pi 尝试保持输入稳定:它传递一致的 session ID 和 provider 特定的缓存提示,在需要的 API 里设置显式 cache point,记录缓存读取/写入,支持模型允许时进行按消息锚定的增量工具加载。它默认的 transcript 行为也避免了对旧上下文的无意义重写。
但 Pi 不能控制请求离开本机后的每一层:它不能决定 provider 的逐出策略,不能把缓存延长到 API 允许之外,不能让某块 GPU 常驻,也不能保证网关一定尊重会话黏性。它也无法保住 extension 修改过的前缀。
Pi 能做的是让缓存健康状况可见。
交互页脚会显示累计的缓存读取/写入为 R 与 W,以及最近一次请求的缓存命中率 CH。/session 命令可看到更完整视图:累计缓存输入、未缓存输入、累计命中率、成本,以及由显著缓存 miss 带来的 token 和美元重计费估计(见 significant cache misses)。
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)
如果你想在 miss 发生时立刻知道,可在 /settings 打开Show cache miss notices(对应 settings.json 的 showCacheMissNotices)。Pi 会在显著 miss 时插入告警,包含估算的重计费 token 与成本。当它侦测到模型切换或空闲超过常见 TTL 时会说明;对其它 miss,它只报事实,不臆测 provider 内部具体原因。
常见缓存命中率下降原因
当会话的缓存命中率异常偏低时,常见原因是:
- 空闲。 命令、review 或对话停顿超过 provider 的保留窗口。
- 模型或 provider 切换。 KV 状态与模型相关,通常不能跨 provider 迁移。
- 分支导航。
/tree、回退、fork 和分支会改变当前 token 序列,即使 session ID 不变。 - compaction 或手动重写历史。 这类操作会有意替换一部分 prompt,从而建立新前缀。
- 工具与推理模式变化。 增删改工具定义会改变请求早期内容,除非模型支持按消息锚定加载且变更纯增量。推理等级变化通常也有同样作用。
- 动态系统提示。 时间戳、随机值、持续变化的项目信息、扩展注入的 prompt 片段都会让后续失效。
- 扩展上下文改写。 扩展若修改旧消息或 provider 负载内容,会让看似稳定的 Pi transcript 在传输层变得不稳定。
- provider 路由与逐出。 即便 prompt 完全一致,命中也可能是因为相关 KV 块在请求落点不再可用。