本集对应 DeepSeek Harness 模块 T4 的一节课:「持久化治理:版本、fork 边界与拒绝解读」。
来源:xueai.miyang.cn(小山学堂 · 洛小山《学 AI 产品,从入门到精通》)
会话日志是唯一真源,恢复、分叉、回放全从它派生。真源意味着它要活得比任何一个版本的程序都久。于是三个问题无法回避:格式怎么演进、分叉边界怎么定、读不懂的数据怎么办。
本集的答案串起来是同一条原则——宁可吵闹地失败,不要安静地读错。这条原则渗透到了每一个细节,从版本号用几个数字,到分叉边界要不要写两份,全都由它决定。
| 能力 | 落点 | 一句话 |
|---|---|---|
| 单调整数版本 | SESSION_FORMAT_VERSION | 当前为 0,不分大小版本 |
| 方向性拒绝 | sessionFormatVersionRefusal | 五行函数,先拒绝再解码 |
| 内存升级链 | 升级器链 | 查看不落盘,继续才原子写回 |
| 未知事件守卫 | assertEventsSupported | 默认拒绝,除非 ignorable: true |
| 词汇清单生成 | KNOWN_SESSION_EVENT_TYPES | 脚本从全仓库声明合并,44 个类型 |
| 目录校验 | 持久化事件目录 | 946 行,配校验脚本防过期 |
| fork 语义边界 | header seedLength | 保留最初 fork 时的值 |
| fork 字节边界 | session/end-seed 事件 | 服务只拿到存储字节的消费方 |
| 大日志恢复 | 恢复优化 | 130 万事件 / 62 MiB,600ms 压到 263ms |
版本方案朴素到只有一个数字:SESSION_FORMAT_VERSION,当前是 0,定义在 packages/core/session/src/types.ts 第 56 行。没有 1.2.3 这种大小版本。
设计笔记的理由:某一步升级能不能自动转换,由那一步的升级器写不写得出来决定,两级编号等于提前承诺了一件设计时根本不知道的事。写一个大版本 2.0 等于对外宣称有不兼容变更,可兼容性实际由升级器决定,不由编号决定——编号方案最好别撒谎。
升版本的标准:当且仅当老版本运行时无法在语义上完全正确地处理新日志时,才必须升。「解析不报错」不算数——能读完但重建出错误的会话,这就是读错了,比直接报错更危险,因为它骗过了所有检查。
拿不准就升:一个近似恒等的升级器几乎零成本,漏升一次却会让老版本静默读坏数据。两者成本不在一个量级。
| 版本关系 | 处理 | 用户看到的 |
|---|---|---|
| 相等 | 正常读 | — |
| 日志更旧 | 走升级器链逐级转换 | — |
| 日志更新 | 明确拒绝并指路 | 「由更新的 harness 写入,请升级」 |
| 更旧但升级链断 | 明确拒绝 | 「本构建没有它的升级路径」 |
早先的 assertVersion 对任何不匹配都抛同一条含糊错误。改动后报错分方向——这个改动只有五行,但价值极大。用户看到的永远是「该升级了」,绝不是「文件损坏」。 数据明明没坏,报损坏是冤枉它,而冤枉数据的后果是用户会去做一系列基于错误前提的破坏性操作。
源码(packages/session/session-persistence/src/coordinator.ts 第 77 至 81 行)被协调器加载检查与各存储后端共用,后端在解码任何结构之前就先用它拒绝外来版本——先拒绝再解码,不是先试着解再报错。
版本不匹配的两种文案差别不在措辞,在后果。说「请升级」用户去升级;说「文件损坏」用户去找备份、去修数据。后者会引导用户做破坏性操作。写拒绝逻辑时,把方向性和下一步动作一起写进文案。
旧日志被新版本打开,升级器链只在内存里逐级转换,看一眼不落盘。只有用户真的继续这个会话,转换结果才原子替换写回磁盘,原文件留备份。
设计笔记明确否决过「查看时自动迁移落盘」:打开即改写等于把读操作变成破坏性写操作,转换器有 bug 会在浏览时损坏日志。
这条原则很通用:凡是读操作可能触发写入的地方都要警惕——缓存、迁移、惰性修复,看起来是优化,本质都是把读变成了写。风险应该在用户明确表达意图之后再承担。
版本号管结构变更,管不了词汇增长——事件种类由挂了哪些插件决定,一个整数描述不了。第二道防线是逐事件标记。
源码(coordinator.ts 第 1061 至 1066 行)整段就一个循环:
private assertEventsSupported(meta: SessionHeader, events: readonly SessionEvent[]): void {
for (const event of events) {
if (KNOWN_SESSION_EVENT_TYPES.has(event.type) || event.ignorable === true) continue
throw this.unsupported(meta, `session "${meta.id}" contains event type "${event.type}" (seq ${event.seq}) unknown to this harness and not marked ignorable; refusing to interpret the log — it was likely written by a newer harness`)
KNOWN_SESSION_EVENT_TYPES 不是手写的,由脚本从全仓库所有事件声明合并生成,共 44 个类型,连同 946 行的 docs/persistence-catalog.zh.md 一起,有专门校验脚本保证不过期。手写的清单一定会过期,会过期的清单等于没有清单。
为什么默认必需:设计笔记算得很清楚——
ignorable → 一个本可恢复的会话被拒绝打开,用户不爽,体验问题;演示场景 A 就是后者现场:跳过一条装着用户消息的未知事件,恢复出的对话里助手在回答一个不存在的问题。这种错误不会报错、不会崩溃、不会有任何痕迹,它只是安静地错着。
凡是要给某个开关选默认值,先问:选错了两种方向,后果是不是对称的?如果不对称,防线永远偏向吵闹的那一边——吵闹的失败你会立刻知道,安静的失败你可能永远不知道。
分叉 = 把源会话到某个稳定位置为止的事件深拷贝一份当种子。麻烦在于:子会话日志前半段是继承来的种子,后半段才是自己写的,两段在字节层面长得一模一样。
直觉答案是错的:数构造时种子有几条(seed.length)。恢复的会话拿完整存储日志当构造种子,这个长度算出的边界会随每次重新打开往后跑。header 里的 seedLength 才一直保留最初 fork 时的值。
所以边界写两份:
seedLength — fork() 创建子会话时把 parentSession 和 seedLength 写进创建元数据;session/end-seed 事件 — 带种子的会话把它作为第一次实时写入追加在种子之后,服务只拿到存储字节的消费方。一个管内存里的语义,一个管磁盘上的字节,谁也替代不了。
解决的问题很具体:种子历史里可能有一个没配对的 compaction/start,它到底是「上个生命周期崩在压缩中途」还是「此刻正在压缩」?光看字节分不出来。有了 session/end-seed,在它之前的未配对开启标记一律属于已结束的生命周期。
类型定义 JSDoc 里有句狠话:Session 的构造函数是唯一合法写入方,插件擅自追加一条,等于把它之前的所有实时工作静默归类成种子历史。
两条容易漏的工程纪律。其一,任何「已知类型清单」如果靠手维护,它一定会在某次重构后过期,而过期的清单比没有清单更危险——它给你虚假的安全感。其二,一个跨层的边界字段往往服务不同消费方,内存语义与磁盘字节是两回事,别指望一个字段全覆盖。
恢复一个 130 万事件、62 MiB 压缩数据的会话,全程不物化整份明文,这轮优化把恢复准入从约 600ms 压到 263ms(Agent Note 2026-08-05)。但校验和冻结一项没省:持久存储属于运行时边界,防线本身不动。
性能优化可以砍一切,但运行时边界上的校验是例外。
| Grok Build | Claude Code | DeepSeek Harness | |
|---|---|---|---|
| 读不懂时 | continue 跳过,不报错不留痕 | 基于已公开证据,行为未知 | 整个会话拒绝恢复 |
| 未知字段 | serde 未标 deny_unknown_fields,静默丢弃 | 未知 | ignorable 显式声明才放行 |
| 版本协商 | 未见 | 未见还原代码 | 单调整数 + 方向性拒绝 |
Grok 的做法是快速迭代产品的常见取舍,只是它把格式演进的正确性交给了「新老版本别混用」这个假设。
差距的根源不是能力,是处境。 闭源产品可以靠「客户端总是最新版」兜底;DSH 是开源基建,各版本会长期共存,兜底假设不成立,所以拒绝规则必须写进读取器,而不是写在部署文档里。
假设你写了个 DSH 插件,往会话日志里追加自定义事件 myplugin/audit,记录每次工具调用的审计信息。推演两种情况:
不标 ignorable:用户把日志拷到一台没装你插件的同版本 harness 上打开。由于 KNOWN_SESSION_EVENT_TYPES 由仓库内声明生成,仓库外插件的事件按构造就在清单之外 → 直接拒绝,整个会话打不开。很吵,但用户立刻知道原因,装上插件或确认可丢弃即可解决。
标了 ignorable: true:同一台机器上会话正常打开,你的审计事件被安静丢弃,对话历史看起来完整无缺。但审计信息在重建中彻底消失,且没有任何地方记录它曾经存在过。
判断尺子:丢了它会不会改变日志其余部分的解读?只是旁证 → 标可忽略;是理解后续对话的前提 → 必须默认必需,哪怕代价是会话打不开。
KNOWN_SESSION_EVENT_TYPES 由仓库内声明生成,仓库外插件的事件按构造就在清单之外。第三方插件作者必须显式标 ignorable,否则会在其他环境造成拒绝。deny_unknown_fields、Claude Code 拒绝机制未知的结论,均基于已公开的还原/书稿材料,标注「基于已公开证据」。dsh17-播客.mp3dsh17-播客.srtscript.txt本内容改编自小山学堂《学 AI 产品,从入门到精通》,为二次演绎配音版。
来源:xueai.miyang.cn(小山学堂 · 洛小山)