通过PinnedPrefix/FrozenPrefix/AppendLog三区设计保证system prompt字节级稳定,解决对话历史修改导致的cache失效和Token重计费问题。
源码版本:CodeSmith v0.5.0(commit 3a74c82f)。所有路径均相对于仓库根目录;行号指此版本。目标读者:已读过第 4 篇、且以为三区模型只是理论的人。
第 4 篇做过计算:只要把 system prompt 漂移一个字节,之后的每个 token 都要按原价重算——前缀缓存的基石是"字节级前缀不动"。彼时三区模型还基本停留在理论与观察层面:fingerprint 可以做尸检,/cache stats 可以显示命中率,但会话历史是谁重写的、怎么重写的,全靠代码自己"表现良好"。
这段历史分三步走来,中间那段值得讲讲。第一步是观察:/cache stats 率先上线,让大家能看到缓存命中率。第二步是 Phase 1:构建了六种类型——PinnedPrefix、FrozenPrefix、PrefixDrift、AppendLog、TurnScratch、ThreeZoneRequest——但没有一种接入引擎;全部标记为 #[allow(dead_code)],只是作为脚手架立在那里。
Phase 1 时代,/cache zones 的输出里有一段坦白:三区契约"尚未接入"。到了 Phase 2,这段坦白被一个测试钉进了坟墓——cache_zones_reports_wired_contract(位于 crates/tui/src/commands/debug.rs:928)断言输出中不再包含"not yet wired"或"Phase 1 foundation",测试内部一行注释给这件事画上了句号:"Phase 1 的坦白已经消失。"
为什么要分两步?因为把六种类型一下子焊进一万六千行的 executor 主循环,任何行为漂移都会埋进一个没法阅读的 diff 里。Phase 2 的解法:先埋一块字节级回归测试的界碑,再去动接线(见下文"界碑"一节)。
三区划分,直接引用模块文档的原图(crates/agent-runtime/src/prompt_zones.rs:1-16):
┌─────────────────────────────────────────┐
│ PinnedPrefix (construction 后 frozen) │ ← system prompt + tool catalog
│ combined_sha256 在 freeze() 时计算 │ cache hit candidate
├─────────────────────────────────────────┤
│ AppendLog (append-only) │ ← conversation history
│ 仅 push(),无 insert / remove / edit │ 保留前序轮次的 prefix
├─────────────────────────────────────────┤
│ TurnScratch (ephemeral) │ ← 每轮 composition staging,
│ 每轮边界清空 │ 请求前提交到 log
└─────────────────────────────────────────┘
中间区承担了核心负载。AppendLog 现在就是会话历史本身的存储类型(prompt_zones.rs:277-294),其 rustdoc 中包含了整个设计里最精彩的一句话:
"There is deliberately no way to insert, remove, truncate, or index-write: the append-only prefix that DeepSeek's KV cache matches against is a property of the type, not of discipline."
翻译过来就是:类型在设计上刻意不提供 insert、remove、truncate 或按下标写入的能力——DeepSeek 的 KV cache 赖以匹配的 append-only prefix 是类型的属性,而非团队的纪律。纪律会在某个深夜加班时崩塌,编译错误不会。
读取通过共享的 Deref<Target = [Message]> 发出——DerefMut 被刻意留作未实现,否则 swap、sort 这类就地重写操作又会重新能够编译。所以 &log、log[i]、log.to_vec() 都正常工作,而 Vec 的所有变更方法在这里一律无法编译。
append-only 有许可例外:压缩、context 溢出恢复、/edit 回滚、会话恢复、cycle 重新播种。例外本身不可怕,可怕的是匿名例外。Phase 2 把所有例外收敛到一个入口——AppendLog::rebuild——其参数是一个枚举,强制调用方陈述原因(prompt_zones.rs:212-244):
pub enum RebuildReason {
ManualCompaction,
AutoCompaction,
Purge,
MicroCompaction,
ContextOverflowRecovery,
FrontTrim,
VerifyAndReplanReset,
EditRollback,
SessionSync,
CycleReset,
Runtime(&'static str),
//......
}
换言之:每次会话历史被整体重写,调用方必须在类型层面署名,而原因——连同重写前后各自的消息条数——会落入审计记录。/cache zones 现在能回答"我的缓存为什么被重置",而不是留作一个谜。
配套的那句话值得装框挂墙(prompt_zones.rs:44-45):这些位点按设计穿透 KV prefix cache;类型系统的职责是让这种事不可能"偶然"发生。
在 executor 侧,所有 9 个旧的 clear-and-repush 位点都迁移到了这个通道(用 commit 信息自己的话说:"The 9 runtime clear+repush sites migrate to it"),host 侧的重写入口也紧接着被收拢——这同时治愈了一个老毛病:在旧模式下,事件要么从不触发,要么连续触发 N 次;现在是原子输血,一次只会触发恰好一个 TranscriptRebuilt。审计记录在 TUI 中保留最近 8 条(crates/tui/src/tui/app.rs:666)。
Phase 2 还终结了 fingerprint 的双轨安排。之前,请求路径冻结一个 fingerprint,而稳定性观察器计算另一个;现在 PrefixStabilityManager 消费的是请求路径在每一步冻结的同一个 FrozenPrefix(crates/agent-runtime/src/prefix_cache.rs:32-38)——"一个 fingerprint 实现,一个真实来源"。
fingerprint 的算法也得到了升级:tool-catalog 的摘要从"名称列表的 hash"变为"完整 JSON(排序后)的 hash"。动机有一个测试注释佐证(约在 prefix_cache.rs:292):旧的 name-hash 漏掉了一整类漂移——一个 tool 保留了名称但改了描述或 schema,所以前缀实际已经变了而 fingerprint 什么都没发现。测试 verify_detects_schema_change(prompt_zones.rs:592)精准地锁定了这一类情况。
最后,最关键的保障:接线必须证明零行为差异。
测试 three_zone_assembly_sends_verbatim_log_snapshot(crates/agent-runtime/src/engine/host_executor.rs:4683)将旧组件和新组件序列化后逐字节比较;失败信息是一行裁断:prompt 构建是非确定性的——第一个差异在第 N 字节。
注释同样是一份设计宣言:分区接线改变的是请求的表达方式,而不是上线传输的字节。因此透明重试是缓存安全的——相同的 zone 输入总是序列化为相同的字节。
同一 feature 分支还合入了一份文档(commit 56503762,通过 next PR 合并),把散落各处的配置优先级规则收拢成一张有序表格(docs/CONFIGURATION.md:46-57):CLI 标志 > 配置文件(profile 覆盖顶层,项目 overlay 进一步覆盖其上)> 密钥回退(config > keyring > env)> 托管配置 > 启动时的 requirements 验证。
这个附录和第 0 篇的预设层级是一枚硬币的两面:预设是填空题的基准线,这张表格是同名 key 冲突时的裁决顺序。项目 overlay 仅在 workspace 通过启动信任边界后才读取,可允许的 key 被收窄到一份白名单,approval_policy 和 sandbox_mode 只能收紧、不能放松——项目级配置想换 provider 或 base_url?文档引用了 #417 并断然拒绝:否则恶意项目文件就能把用户的凭证导向一个仿冒端点。
把各项机制串成一条链:
AppendLog:push 是唯一的日常变更;Vec 风格的重写不再能够编译;rebuild(RebuildReason);原因落入审计,可通过 /cache zones 查询;ThreeZoneRequest 组装:消息只能是 log 的一个切片加上一段 scratch 尾巴;三个特性及其代价:
特性而非纪律:编译器代替团队守护 append-only——代价是新增一种合法的重写场景,首先意味着添加一个枚举变体并在审计中留下名字;
实名拆除:缓存失效从事故变成记账条目——代价是美好的零失效状态只存在于从未触碰历史的会话中;
零干预观察:漂移检测只报告不拦截——代价是当发现漂移时,那一步的钱已经花出去了。
契约已签,简约主线收束:缓存的经济学写下了法律(第 4 篇),类型系统来执行它(本篇)。下一个问题转到另一块地盘——模型的主上下文既昂贵又拥挤,大块素材如何进去、如何参与计算:下一篇先让 Agent 在自己的私人厨房里烹饪。