学 AI 产品 · 专业 AI 产品经理播客第 4 章 · T4 解剖 DeepSeek Harness:一切皆插件的 Agent 底座 · EP 17
第 4 章 · EP 17

持久化治理:版本、fork 边界与拒绝解读

时长 15:28音色 云健 · 男声

同步字幕

章节导航(点击跳转)

0:00开场 · 一份要活过所有版本的日志1:48
1:48一个整数管版本1:57
3:46三种命运 · 按方向区分的读取规则1:31
5:17往旧读 · 打开即改写是破坏性操作1:07
6:24为什么静默跳过是安全事故2:59
9:24fork 边界为什么写两份2:36
12:00横向对比 · 别家怎么对待读不懂的数据1:55
13:56可带走的设计原则与踩坑点1:32
解读全文

持久化治理:版本、fork 边界与拒绝解读 · 解读与音频稿件

本集对应 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 行)被协调器加载检查与各存储后端共用,后端在解码任何结构之前就先用它拒绝外来版本——先拒绝再解码,不是先试着解再报错。

提示1 · 报错文案是给用户指的第一条路,指错了后面全错

版本不匹配的两种文案差别不在措辞,在后果。说「请升级」用户去升级;说「文件损坏」用户去找备份、去修数据。后者会引导用户做破坏性操作。写拒绝逻辑时,把方向性和下一步动作一起写进文案。

三、往旧读 · 打开即改写是破坏性操作

旧日志被新版本打开,升级器链只在内存里逐级转换,看一眼不落盘。只有用户真的继续这个会话,转换结果才原子替换写回磁盘,原文件留备份。

设计笔记明确否决过「查看时自动迁移落盘」:打开即改写等于把读操作变成破坏性写操作,转换器有 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 就是后者现场:跳过一条装着用户消息的未知事件,恢复出的对话里助手在回答一个不存在的问题。这种错误不会报错、不会崩溃、不会有任何痕迹,它只是安静地错着。

提示2 · 用「两种失败对不对称」来判断默认值

凡是要给某个开关选默认值,先问:选错了两种方向,后果是不是对称的?如果不对称,防线永远偏向吵闹的那一边——吵闹的失败你会立刻知道,安静的失败你可能永远不知道。

五、fork 边界为什么写两份

分叉 = 把源会话到某个稳定位置为止的事件深拷贝一份当种子。麻烦在于:子会话日志前半段是继承来的种子,后半段才是自己写的,两段在字节层面长得一模一样。

直觉答案是错的:数构造时种子有几条(seed.length)。恢复的会话拿完整存储日志当构造种子,这个长度算出的边界会随每次重新打开往后跑。header 里的 seedLength 才一直保留最初 fork 时的值。

所以边界写两份:

  1. header seedLength — fork() 创建子会话时把 parentSession 和 seedLength 写进创建元数据;
  2. 日志里 session/end-seed 事件 — 带种子的会话把它作为第一次实时写入追加在种子之后,服务只拿到存储字节的消费方。

一个管内存里的语义,一个管磁盘上的字节,谁也替代不了。

解决的问题很具体:种子历史里可能有一个没配对的 compaction/start,它到底是「上个生命周期崩在压缩中途」还是「此刻正在压缩」?光看字节分不出来。有了 session/end-seed,在它之前的未配对开启标记一律属于已结束的生命周期。

类型定义 JSDoc 里有句狠话:Session 的构造函数是唯一合法写入方,插件擅自追加一条,等于把它之前的所有实时工作静默归类成种子历史。

提示3 · 清单必须生成加校验,边界必须写两份

两条容易漏的工程纪律。其一,任何「已知类型清单」如果靠手维护,它一定会在某次重构后过期,而过期的清单比没有清单更危险——它给你虚假的安全感。其二,一个跨层的边界字段往往服务不同消费方,内存语义与磁盘字节是两回事,别指望一个字段全覆盖。

六、大日志恢复与防线不动

恢复一个 130 万事件、62 MiB 压缩数据的会话,全程不物化整份明文,这轮优化把恢复准入从约 600ms 压到 263ms(Agent Note 2026-08-05)。但校验和冻结一项没省:持久存储属于运行时边界,防线本身不动。

性能优化可以砍一切,但运行时边界上的校验是例外。

横向对比 · 别家怎么对待读不懂的数据

Grok BuildClaude CodeDeepSeek Harness
读不懂时continue 跳过,不报错不留痕基于已公开证据,行为未知整个会话拒绝恢复
未知字段serde 未标 deny_unknown_fields,静默丢弃未知ignorable 显式声明才放行
版本协商未见未见还原代码单调整数 + 方向性拒绝

Grok 的做法是快速迭代产品的常见取舍,只是它把格式演进的正确性交给了「新老版本别混用」这个假设。

差距的根源不是能力,是处境。 闭源产品可以靠「客户端总是最新版」兜底;DSH 是开源基建,各版本会长期共存,兜底假设不成立,所以拒绝规则必须写进读取器,而不是写在部署文档里。

课堂练习 · 给你的插件事件选默认值

假设你写了个 DSH 插件,往会话日志里追加自定义事件 myplugin/audit,记录每次工具调用的审计信息。推演两种情况:

不标 ignorable:用户把日志拷到一台没装你插件的同版本 harness 上打开。由于 KNOWN_SESSION_EVENT_TYPES 由仓库内声明生成,仓库外插件的事件按构造就在清单之外 → 直接拒绝,整个会话打不开。很吵,但用户立刻知道原因,装上插件或确认可丢弃即可解决。

标了 ignorable: true:同一台机器上会话正常打开,你的审计事件被安静丢弃,对话历史看起来完整无缺。但审计信息在重建中彻底消失,且没有任何地方记录它曾经存在过。

判断尺子:丢了它会不会改变日志其余部分的解读?只是旁证 → 标可忽略;是理解后续对话的前提 → 必须默认必需,哪怕代价是会话打不开。

审查清单

  1. 版本号是不是单调整数?有没有用语义化版本承诺了升级器无法保证的事?
  2. 升版本的判断标准是不是「语义上无法正确处理」而非「解析报错」?
  3. 拒绝逻辑是不是分方向?有没有把版本不匹配误报成文件损坏?
  4. 后端是不是在解码之前就拒绝外来版本?
  5. 升级器链是不是只在内存转换?有没有「打开即改写」的路径?
  6. 未知类型清单是不是脚本生成 + 校验脚本防过期?
  7. 新事件的默认值是不是「必需」?有没有人图省事默认可忽略?
  8. fork 边界是不是 header 与日志事件两份都写了?

约束说明

  • 版本升级判断约束:「拿不准就升」是有前提的——升级器必须存在。若某步升级器写不出来,编号再怎么调整也无济于事,此时应直接从源头避免该格式变更。
  • 拒绝粒度约束:未知事件拒绝是整个会话级的,不是单条事件级。这意味着任何一个插件写了未标记的事件,都会导致会话在没装该插件的环境上完全打不开。这是刻意的不便利。
  • 仓库外插件约束:KNOWN_SESSION_EVENT_TYPES 由仓库内声明生成,仓库外插件的事件按构造就在清单之外。第三方插件作者必须显式标 ignorable,否则会在其他环境造成拒绝。
  • 性能数字约束:600ms 压到 263ms 是当前测量值(Agent Note 2026-08-05),依赖具体数据规模(130 万事件 / 62 MiB),不保证在其他规模下成立。
  • 对比证据约束:关于 Grok 无 deny_unknown_fields、Claude Code 拒绝机制未知的结论,均基于已公开的还原/书稿材料,标注「基于已公开证据」。
  • 源码核对约束:引用行号依据本地仓库 deepseek-harness-master,核对日期 2026-08-13,仓库演进后行号可能漂移,以语义为准。
  • 取材约束:本集只使用本集素材内资料,不跨集引用,不虚构源文档未出现的数据、案例与引文。

可带走的设计原则

  1. 版本号用单调整数,别用语义化版本承诺你不知道的事。拿不准就升。
  2. 报错要分方向、要指路。永远不要把版本不匹配报成文件损坏。
  3. 读操作不要偷偷变成写操作。迁移留备份,落盘等用户明确意图。
  4. 未知数据默认拒绝,不默认跳过。防线偏向吵闹的那一边。
  5. 边界写两份,清单用生成加校验。

音频与稿件

  • 音频:dsh17-播客.mp3
  • 字幕:dsh17-播客.srt
  • 口播稿:script.txt
  • 集页:https://xueai-podcast.pages.dev/t/dsh17/

本内容改编自小山学堂《学 AI 产品,从入门到精通》,为二次演绎配音版。

来源:xueai.miyang.cn(小山学堂 · 洛小山)