| 1 | # 提示缓存稳定性(固定前缀) |
| 2 | |
| 3 | > 英文原文:[CACHE.md](../CACHE.md)。 |
| 4 | > 最后与英文同步日期(last synced with English revision):2026-09-29。 |
| 5 | |
| 6 | 提供商(provider)的提示缓存(DeepSeek KV cache、Anthropic `cache_control`) |
| 7 | 只有在请求的**字节前缀**与上一次完全一致时才有收益:先是系统提示, |
| 8 | 再是工具目录,最后是 `messages[0..n-1]`。这些字节只要变了一处, |
| 9 | 第一处差异之后的所有 token 缓存都会失效。 |
| 10 | |
| 11 | ## 不变式 |
| 12 | |
| 13 | **会话开始之后,系统提示和工具目录就是冻结的字节。历史只增不减。 |
| 14 | 只有说得出原因,才允许出现缓存未命中。** |
| 15 | |
| 16 | 具体来说: |
| 17 | |
| 18 | - **头部**(系统提示 + 工具)在会话开始时组合一次,之后**只有**在显式的、 |
| 19 | 有日志记录的头部变更操作中才会重新组合。工具循环**不会**在循环中途 |
| 20 | 刷新系统提示。所以智能体(agent)写文件(这会改变项目上下文包、目录列表、技能扫描结果) |
| 21 | 时,也不会在回合中途让固定前缀发生变化。 |
| 22 | - **历史只增不减。** 模型必须看到的易变事实(LSP 诊断、中途干预输入、 |
| 23 | 子智能体(sub-agent)完成结果)一律追加到消息列表,绝不拼进冻结前缀。工作区漂移也是这么处理: |
| 24 | 在每个**新的用户回合**开始时(绝不在工具循环中途),引擎会重新组合那些易变内容。 |
| 25 | 只要有内容和模型上次看到的不一样,就在用户消息*之前*追加**一条** |
| 26 | `<context_update>` 用户角色消息,里面是有界的 `+`/`-` 行级差异 |
| 27 | (项目包里的新文件、改动过的 AGENTS.md 行、新增技能、记忆条目、目标文本)。 |
| 28 | 头部字节保持固定,这次更新只是一次普通追加,所以前缀仍在延长。 |
| 29 | 固定住的系统提示会告诉模型一次:更新是以这种方式送达的。 |
| 30 | 每条差异只送一次(`/cache stats` 会显示 `Context updates: N`)。 |
| 31 | - 每一次未命中都**说得清来由**。`PrefixStabilityManager`(`prefix_cache.rs`) |
| 32 | 会记录每次变更和原因,并通过 `/cache stats` 报告出来。 |
| 33 | |
| 34 | ## 哪些算已声明的头部变更 |
| 35 | |
| 36 | 下面这些操作会带着有日志的 `change:<what>` 原因重新固定前缀 |
| 37 | (这是预期内、只影响一次请求的未命中): |
| 38 | |
| 39 | | 操作 | 原因 | |
| 40 | | --- | --- | |
| 41 | | `/model`(SetModel) | `change:model` | |
| 42 | | 模式切换(agent/plan/operate/yolo) | `change:mode` | |
| 43 | | 目标设置 / 暂停 / 恢复 / 清除 / 状态 | `change:goal` | |
| 44 | | 回合中途的工具表面变更(延迟工具的加入/移除、工具搜索激活、运行时 MCP 工具抵达) | `change:tool_surface` | |
| 45 | | 会话同步 / 恢复(SyncSession) | `resume` | |
| 46 | | 会话构建 | `initial` | |
| 47 | |
| 48 | 历史重置会合法地让尾部(而不是头部)失效,记录为 `reset:<what>`—— |
| 49 | `reset:compaction`、`reset:clear`。 |
| 50 | |
| 51 | 除此之外,任何在**没有**声明原因的情况下改动头部字节的行为都算**漂移**: |
| 52 | 它会记为 `drift:<component>`,原来的固定点被**保留**下来。同一个未声明的前缀 |
| 53 | 会继续算作未命中,而不是悄悄变成新基线;`/cache stats` 里也会出现一条 `WARNING`。 |
| 54 | 去掉循环中途刷新之后,正常运行时漂移应保持为零;漂移计数不为零, |
| 55 | 就是需要排查的真实缺陷。 |
| 56 | |
| 57 | ## 归因与旧做法对比 |
| 58 | |
| 59 | 下面两种早先的做法已被否决,这与 DeepSeek Harness 的设计一致: |
| 60 | |
| 61 | - **检测并上报 + 漂移时重新固定。** 以前,管理器每次遇到变更都会重新固定到新前缀。 |
| 62 | 于是一个糟糕的步骤之后,提供商缓存其实已经失效,`/cache stats` |
| 63 | 却还显得“稳定”。现在遇到未声明的漂移,它会保留原来的固定点。 |
| 64 | - **每个工具步骤都从磁盘重新组合系统提示。** 以前回合循环在每次模型请求之前 |
| 65 | 都会调用 `refresh_system_prompt()`,包括在工具循环中途。这个做法已经删除, |
| 66 | 头部刷新只发生在上文声明的几个边界上。 |
| 67 | |
| 68 | 没有配置密钥时(常见情况),工具结果的脱敏(`prepare_model_bound_request`) |
| 69 | 不改动内容,所以不会挪动前缀。一旦工具结果里出现已配置的密钥,就必须脱敏—— |
| 70 | 这是安全要求。**这条消息里**,脱敏优先于缓存稳定性。 |
| 71 | |
| 72 | ## 验证这项修复 |
| 73 | |
| 74 | `/cache stats` 会报告前缀稳定性、固定原因、最近一次未命中的原因、 |
| 75 | 未声明漂移次数,以及提供商缓存的总体命中率。在一次编码会话里, |
| 76 | 预期第一个回合是写入,之后的每一步——包括智能体写完文件之后的步骤——都能命中。 |
| 77 | |
| 78 | ### 实时端到端检查(手动,需要密钥) |
| 79 | |
| 80 | 用真实的 `DEEPSEEK_API_KEY` 跑一次会话,让智能体在一个回合内至少执行三个 |
| 81 | 工具步骤,然后打开 `/cache inspect`。除第一个请求之外,每个请求都应报告 |
| 82 | `prompt_cache_hit_tokens > 0`;基础静态前缀哈希和工具目录哈希在各步骤之间 |
| 83 | 不能变。如果命中在回合中途掉下来,固定原因和漂移计数会指出问题所在。 |
| 84 | |
| 85 | ## KV 缓存影响说明(面向贡献者) |
| 86 | |
| 87 | 任何要加入会话上下文的新贡献项,都必须说明它的 **KV 缓存影响**: |
| 88 | 它该放进冻结前缀(系统 + 工具),还是放进只追加的历史?绝不要把易变事实 |
| 89 | (时间、一次指令修改、技能目录变更、项目文件变更)拼接进前缀—— |
| 90 | 要作为用户角色消息追加。后续请求必须是 `previous ⊕ suffix`, |
| 91 | 除非有带日志的头部变更或一次历史重置能解释这个差异。 |
| 92 | |
| 93 | ## 暂缓:完全可重建(Layer 3) |
| 94 | |
| 95 | DeepSeek Harness 通过一个纯函数投影 `deriveMessages()`,从只追加的会话日志 |
| 96 | 推导出每一次请求,所以前缀是自然延长的,不需要管理器去维持。 |
| 97 | Codewhale 现在固定头部,遇到漂移就追加一条 `<context_update>` 送出去; |
| 98 | 剩下的一步是让会话日志成为唯一事实来源,并配上纯投影 |
| 99 | (同时把上下文更新基线一并持久化)。那是后续的独立工作线,不属于本次变更。 |
| 100 |