Prompt Caching 的核心不在于“是不是同一个会话”,而在于:新请求的 token 序列,是否与已缓存内容拥有足够长、足够稳定的共同前缀。
LLM 推理框架缓存机制设计视角:树状会话与前缀缓存的矛盾#
会话的真实结构通常是树,而非一条线:
+-- E -- F (另一分支) |会话 S: 根节点 -- A -- B -- C -- D (当前主分支) | +-- Z (早期分支)这就给缓存机制带来了两个层面的复杂性:
同一会话内切换分支#
用户从 D 回到 C,再走向 E -> F:
根 -> A -> B -> C -> E -> F从 Router 角度看,这仍是 session S,理论上应具备会话亲和性;但从 Cache 角度看,新路径只与旧路径 根 -> A -> B -> C -> D 共享到 C。
因此,实际收益取决于:
- 缓存是否以足够细的块保存了
根 -> C; - 该共享块是否仍未被淘汰;
- 请求是否被路由到持有该缓存副本的节点;
- 缓存系统能否在分叉结构中找到最长公共前缀。
如果系统只保留了完整的“根到 D”热路径,或 根 -> C 的共享块已经失效,那么去往 E -> F 的请求只能复用较少内容,甚至完全重新预填充。
从节点 Fork 出新会话#
用户从 B 分叉出新会话:
根 -> A -> B -> Z即使它使用全新的 Session ID,其文本前缀仍与原会话高度重合。若缓存系统只按 Session ID 隔离,就会把它错误地当作完全新请求,浪费原本可以复用的 根 -> A -> B 计算结果。
结论:缓存复用应以实际 token 前缀和缓存块为依据,而不是仅凭会话 ID。Session ID、会话亲和性和路由策略只能提高命中概率,不能替代对真实 token 前缀的精细管理。
以上揭示了为什么一些顶级的推理框架(如 SGLang 的 RadixAttention)要专门设计更精细的缓存寻址机制。这些问题是无法由 Agent 消费侧的设计来解决的,必须由服务侧解决。
Agent 提示词设计视角#
缓存命中与未命中的成本模型#
一般可概括为:
未缓存输入价 > 缓存写入价 > 缓存读取价- 缓存命中:历史 token 按缓存读取价格计费,通常更便宜、速度也更快。
- 首次写入:可能按缓存写入价计费。
- 缓存未命中:整段历史按普通输入价重新计算,必要时还要再次写入缓存。
因此,缓存过期、早期前缀变动时,一条很短的后续指令也可能异常昂贵。具体情形如下:
中断与缓存生存时间(TTL)— 缓存过期#
提供商的缓存通常有生命周期限制。例如 ClaudeCode API 默认缓存 TTL 只有约 5 分钟。
用户认为自己处于连续会话中:去喝杯咖啡、等待测试跑完、回来发送一句“继续”。
服务商看到的却是两次独立请求,中间可能已有 7–10 分钟无流量。为了释放资源,底层缓存可能已被回收。
当一个长上下文,例如 10 万 token,缓存过期后,即使用户只发送“继续”两个字,也需要重新处理整段前缀。于是成本和延迟会突然显著上升。
为什么“工具加载”容易毁掉缓存?— 早期前缀变动#
工具定义通常会被展开并放入系统提示词或请求前部。此处一旦发生变化:
- 增加或删除一个工具;
- 修改函数描述或 JSON Schema;
- 调整工具的排列顺序;
- 改变工具相关配置。
第一个不匹配 token 往往会非常靠前。由于 KV Cache 遵循前缀匹配,早期的一点变化会使后面数万 token 的对话历史都无法复用。
这是一种典型的“局部节省、全局亏损”:
- 节省的可能只是几十到几百个工具定义 token;
- 代价却是重算数万 token 的历史上下文。
解决方向:可加性工具加载#
工具不应总是一次性注入到稳定前缀中,而应支持按需追加:
- 使用
defer_loading: true:延迟工具的完整定义加载,避免污染初始前缀; - 提供工具发现机制,例如
ToolSearch工具; - 模型需要工具时,生成
tool_reference等特殊引用块; - 工具引用被追加到消息流中的某个位置,而不是回写到请求开头。
这样,tool_reference 之前的系统提示、历史对话和已有缓存前缀都保持不变,工具扩展不会破坏已有缓存。
为什么不应对“已发送给LLM并已缓存的数据”进行事后剪枝?— 前缀变动#
为了控制上下文长度和成本,系统常尝试删除旧工具结果、重写历史或压缩中间消息。但如果在历史中间删除或修改内容,删除点之后的所有 token 位置都会改变,后续缓存前缀随之失效。
这会产生一个反直觉结果:
- 删除 1,000 个旧 token,未来可能节省一些上下文费用;
- 但为了删除它们,当前请求可能需要重算后面数万甚至十万 token。
因此,短期内重写长缓存上下文的代价,可能远远高于剪去少量内容所带来的收益。
Pi 的工程策略#
基于“位置即缓存”的原则,Pi 这类应用的工程策略:
-
让历史尽可能保持不变
- 不修改旧消息;
- 不删除旧工具结果;
- 不重排历史内容;
- Pi 会在应用层为同一棵会话持续使用同一个稳定 session-id 标识,并在提供商或网关支持时把它作为请求元数据传递;
-
把变化尽量追加到末尾
- 尽量保持消息 append-only;
- 支持可加性、按需工具加载;
-
记录缓存读取、写入和命中情况,在界面中展示缓存指标,例如 R/W/CH
只有看得到缓存读取、写入和命中情况,用户和开发者才能判断某项优化是否真的有效。
- R(Reads):累计从缓存中读取的输入 token 数。越高,说明历史上下文被复用得越多。
- W(Writes):累计写入缓存的 token 数。通常代表首次预填充或新追加内容被缓存。
- CH(Cache Hit):最近一次请求的缓存命中率,通常可理解为“本次输入中有多少比例按缓存读取”。
-
必要时才压缩,并明确提示用户这是一次缓存重置
当会话接近模型上下文窗口上限,例如 128K 或 200K token,继续追加已不可行,才执行 Compaction:
- 提炼旧会话的核心事实、任务状态和决策;
- 创建新的会话或新上下文;
- 用简短摘要作为新的起点继续工作。
这应被视为一次主动的“缓存重置”,不是缓存系统故障。它是为了换取新的上下文空间,主动放弃旧缓存。
Pi 无法控制:
- 提供商底层缓存的实际淘汰策略;
- 缓存的固定 TTL 或是否允许延长;
- 网关是否始终将请求路由到持有缓存的节点;
- 不同模型、不同提供商之间的缓存兼容性;
- 第三方扩展是否改写最终发送的消息载荷。
缓存命中率突然下降时的检查清单#
- 空闲超时:两次请求间隔超过提供商的缓存 TTL。
- 模型或提供商切换:KV Cache 通常与具体模型和服务端实现绑定。
- 分支导航:回退、
/tree、切换分支或 Fork 改变了实际 token 路径。 - 压缩或手动重写历史:主动建立了新的前缀。
- 工具或推理等级变化:工具定义、顺序、Schema,或 reasoning 配置改变了请求前部。
- 动态系统提示词:时间戳、随机值、动态项目上下文等使每次请求前缀不同。
- 扩展的上下文转换:第三方插件修改了消息或网络载荷。
- 提供商路由或缓存淘汰:请求到达未持有缓存副本的机器,或副本已被回收。