1
00:00:00,100 --> 00:00:06,432
本内容改编自小山学堂《学 AI 产品，从入门到精通》，为二次演绎配音版。

2
00:00:06,382 --> 00:00:12,956
这一集讲一个听起来特别枯燥、但一旦出事就是大事故的话题，持久化治理。

3
00:00:12,956 --> 00:00:23,293
具体说三件事，日志格式怎么演进，分叉会话的边界怎么定，还有为什么在这套系统眼里，读不懂的数据宁可整个拒绝。

4
00:00:23,293 --> 00:00:25,168
先把背景说清楚。

5
00:00:25,168 --> 00:00:30,877
会话日志是唯一真源，恢复、分叉、回放全从它派生。

6
00:00:30,877 --> 00:00:36,310
真源这个词有分量，意味着它要活得比任何一个版本的程序都久。

7
00:00:36,310 --> 00:00:39,843
今天写的日志，明年的程序要能读。

8
00:00:39,843 --> 00:00:48,629
反过来，新版本写的日志落到老版本手里，老版本得知道自己读不了，而且得说得清楚是哪种读不了。

9
00:00:48,629 --> 00:00:55,072
这三件事串起来其实是同一条原则，宁可吵闹地失败，不要安静地读错。

10
00:00:55,072 --> 00:01:04,891
听着简单，往下看你会发现它渗透到了每一个细节，从版本号用几个数字，到分叉边界要不要写两份，全都由它决定。

11
00:01:04,891 --> 00:01:11,466
为了让你更直观地感受到这条原则的分量，课程里有个对比演示值得先讲一下。

12
00:01:11,466 --> 00:01:15,288
同一份有问题的日志，同时喂给两个加载器。

13
00:01:15,288 --> 00:01:20,829
左边是这套系统的做法，读不懂就报错，整个会话拒绝打开。

14
00:01:20,829 --> 00:01:26,959
右边是很多系统的惯用做法，尽最大努力解析，读不懂就跳过接着读。

15
00:01:26,959 --> 00:01:37,175
三个场景跑下来，左边每次都明确告诉你哪里不行、该怎么办，右边每次都成功加载，但加载出来的东西一次比一次离谱。

16
00:01:37,175 --> 00:01:48,786
这里要说清楚，右边那个加载器是教学用的反面教材，真实系统里并不存在，它存在的唯一目的就是让你看见静默跳过的后果有多安静。

17
00:01:48,940 --> 00:01:50,704
先看版本号。

18
00:01:50,654 --> 00:01:59,404
这套系统的版本方案朴素到只有一个数字，会话格式版本，当前是零，定义在核心会话包的类型文件里。

19
00:01:59,404 --> 00:02:02,277
没有一点二点三这种大小版本。

20
00:02:02,277 --> 00:02:05,077
你可能会觉得这也太糙了。

21
00:02:05,077 --> 00:02:17,710
但设计笔记里的理由很扎实，某一步升级能不能自动转换，由那一步的升级器写不写得出来决定，两级编号等于提前承诺了一件你在设计时根本不知道的事。

22
00:02:17,710 --> 00:02:26,820
你写一个大版本二点零，等于对外宣称这里有不兼容变更，可实际上兼容性是由升级器决定的，不由编号决定。

23
00:02:26,820 --> 00:02:29,320
编号方案最好别撒谎。

24
00:02:29,320 --> 00:02:32,974
那什么时候必须升版本，标准也很明确。

25
00:02:32,974 --> 00:02:39,729
当且仅当老版本运行时无法在语义上完全正确地处理新日志时，才必须升。

26
00:02:39,729 --> 00:02:43,936
注意这里有个容易踩的坑，解析不报错不算数。

27
00:02:43,936 --> 00:02:53,575
能读完、不抛异常，但重建出来的是个错误的会话，这就是读错了，比直接报错还危险，因为它骗过了所有检查。

28
00:02:53,575 --> 00:02:56,784
拿不准的时候怎么办，答案是升。

29
00:02:56,784 --> 00:03:07,013
因为一个近似恒等的升级器成本几乎为零，写个什么都不改的转换函数而已，而漏升一次的代价是老版本静默读坏数据。

30
00:03:07,013 --> 00:03:12,662
这两件事的成本完全不在一个量级上，所以天平没有任何悬念。

31
00:03:12,662 --> 00:03:16,400
再补一句为什么真源这个概念这么关键。

32
00:03:16,400 --> 00:03:22,361
如果日志只是个缓存，读错了重建一份就行，大不了丢点东西。

33
00:03:22,361 --> 00:03:31,207
但它是唯一真源，恢复、分叉、回放全都从它派生，读错一次，错误就会沿着这三条路径复制出去。

34
00:03:31,207 --> 00:03:37,481
派生出去的东西不会带着错误标记，它们看起来跟正确的数据一模一样。

35
00:03:37,481 --> 00:03:46,075
所以真源上的读取规则必须比普通数据严格一个量级，这不是洁癖，是它承担不起读错的代价。

36
00:03:46,230 --> 00:03:52,538
打开一份存储的日志，先比版本号，三种结果对应三种完全不同的处理。

37
00:03:52,488 --> 00:03:55,084
版本号相等，正常读。

38
00:03:55,084 --> 00:03:58,714
日志比你旧，走升级器链逐级转换。

39
00:03:58,714 --> 00:04:02,488
日志比你新，明确拒绝，并且指路。

40
00:04:02,488 --> 00:04:05,432
最值得咂摸的是拒绝这一格。

41
00:04:05,432 --> 00:04:12,392
早先的实现对任何版本不匹配都抛同一条含糊的错误，用户根本不知道该怎么办。

42
00:04:12,392 --> 00:04:18,377
改动之后报错分方向，这个改动很小，只有五行，但价值极大。

43
00:04:18,377 --> 00:04:26,117
日志比你新，明说这条日志由更新的 harness 写入，请升级，还附上原始日志文件的路径。

44
00:04:26,117 --> 00:04:31,298
日志比你旧但升级链断了，就说本构建没有它的升级路径。

45
00:04:31,298 --> 00:04:37,848
这两种文案的差别在于，用户看到的永远是「该升级了」，绝不是「文件损坏」。

46
00:04:37,848 --> 00:04:43,005
这个区分特别重要，因为数据明明没坏，报损坏是冤枉它。

47
00:04:43,005 --> 00:04:50,973
而冤枉数据的后果是用户会去修数据，去找备份，去做一系列基于错误前提的破坏性操作。

48
00:04:50,973 --> 00:04:57,716
报错文案不只是措辞问题，它是给用户指的第一条路，指错了后面全错。

49
00:04:57,716 --> 00:05:07,127
这个函数被协调器的加载检查和各个存储后端共用，后端在解码任何结构之前就先用它拒绝外来版本。

50
00:05:07,127 --> 00:05:11,971
注意这个顺序，先拒绝再解码，不是先试着解再报错。

51
00:05:11,971 --> 00:05:17,524
因为一份结构你都不认识的数据，去解码它本身就是危险动作。

52
00:05:17,670 --> 00:05:22,848
往旧读的方向还有个细节，特别能体现这套系统的克制。

53
00:05:22,798 --> 00:05:29,589
旧日志被新版本打开，升级器链只在内存里逐级转换，看一眼，不落盘。

54
00:05:29,589 --> 00:05:38,279
只有用户真的继续这个会话，真的往里写东西了，转换结果才原子替换写回磁盘，而且原文件留备份。

55
00:05:38,279 --> 00:05:43,531
设计笔记明确否决过另一种方案，查看时自动迁移落盘。

56
00:05:43,531 --> 00:05:50,538
否决的理由一句话就说清了，打开即改写，等于把读操作变成了破坏性写操作。

57
00:05:50,538 --> 00:05:55,406
转换器要是有 bug，你浏览一下日志就把日志损坏了。

58
00:05:55,406 --> 00:06:03,495
想想这个场景有多荒谬，你只是想看看上周的对话记录，结果因为一个转换器的问题，记录没了。

59
00:06:03,495 --> 00:06:09,336
这个原则其实很通用，凡是读操作可能触发写入的地方都要警惕。

60
00:06:09,336 --> 00:06:15,634
缓存、迁移、惰性修复，看起来都是优化，本质上都是把读变成了写。

61
00:06:15,634 --> 00:06:24,853
而写是有风险的，风险应该在用户明确表达意图之后再承担，而不是在他只是想看看的时候替他承担。

62
00:06:25,010 --> 00:06:28,938
版本号管的是结构变更，管不了词汇增长。

63
00:06:28,888 --> 00:06:33,960
事件的种类由挂了哪些插件决定，一个整数描述不了它。

64
00:06:33,960 --> 00:06:37,410
所以还有第二道防线，逐事件标记。

65
00:06:37,410 --> 00:06:46,833
读取器遇到不认识的事件类型，默认整个会话拒绝恢复，除非那条事件的信封上带着写入方声明的可忽略标记。

66
00:06:46,833 --> 00:07:00,366
已知词汇清单不是手写的，由脚本从全仓库所有事件声明合并生成，一共四十四个类型，还有一份九百四十六行的持久化事件目录，配专门的校验脚本保证清单不过期。

67
00:07:00,366 --> 00:07:07,494
手写的清单一定会过期，会过期的清单等于没有清单，所以这里必须用生成加校验。

68
00:07:07,494 --> 00:07:12,325
那为什么默认必需，为什么忘写标记宁可拒绝过头。

69
00:07:12,325 --> 00:07:15,378
设计笔记把这笔账算得很清楚。

70
00:07:15,378 --> 00:07:22,974
忘写可忽略标记，后果是一个本来可以恢复的会话被拒绝打开，用户不爽，这是体验问题。

71
00:07:22,974 --> 00:07:33,840
反过来，如果默认可忽略，同样的疏忽会静默恢复出一个内容残缺的会话，模型接着在错误的历史上继续工作，这是安全事故。

72
00:07:33,840 --> 00:07:37,157
有个演示场景就是这个事故的现场。

73
00:07:37,157 --> 00:07:44,621
跳过一条装着用户消息的未知事件，恢复出来的对话里，助手正在回答一个不存在的问题。

74
00:07:44,621 --> 00:07:52,674
用户看到的是一段完全正常的对话，只是它缺了一环，而缺掉的那一环恰好是理解的起点。

75
00:07:52,674 --> 00:07:58,852
这种错误不会报错，不会崩溃，不会有任何痕迹，它只是安静地错着。

76
00:07:58,852 --> 00:08:03,587
两种失败严重不对称，防线自然偏向吵闹的那边。

77
00:08:03,587 --> 00:08:08,984
吵闹的失败你会立刻知道，安静的失败你可能永远不知道。

78
00:08:08,984 --> 00:08:13,395
咱们把这笔账再往前推一步，推到你自己的插件上。

79
00:08:13,395 --> 00:08:20,486
假设你写了个插件，往会话日志里追加自定义事件，记录每次工具调用的审计信息。

80
00:08:20,486 --> 00:08:28,623
先说你不标可忽略的情况，用户把这份日志拷到一台没装你插件的同版本机器上打开，会怎么样。

81
00:08:28,623 --> 00:08:38,431
清单是从仓库内的声明生成的，仓库外插件的事件按构造就在清单之外，所以会直接拒绝，整个会话打不开。

82
00:08:38,431 --> 00:08:44,609
很吵，但用户立刻知道原因，装上插件或者确认可以丢弃就能解决。

83
00:08:44,609 --> 00:08:47,289
再说你标了可忽略的情况。

84
00:08:47,289 --> 00:08:55,907
同一台没装插件的机器，会话正常打开，你那条审计事件被安静地丢掉，对话历史看起来完整无缺。

85
00:08:55,907 --> 00:09:03,960
听起来更好，但你的审计信息在重建中彻底消失了，而且没有任何地方记录它曾经存在过。

86
00:09:03,960 --> 00:09:11,051
那到底该选哪个，判断的尺子是一句话，丢了它会不会改变日志其余部分的解读。

87
00:09:11,051 --> 00:09:17,241
如果这条事件只是旁证，丢了不影响理解其余内容，标可忽略是对的。

88
00:09:17,241 --> 00:09:24,092
如果它是理解后续对话的前提，那必须默认必需，哪怕代价是会话打不开。

89
00:09:24,240 --> 00:09:25,836
再看分叉。

90
00:09:25,786 --> 00:09:33,947
分叉一个会话，就是把源会话到某个稳定位置为止的事件深拷贝一份，当作子会话的种子。

91
00:09:33,947 --> 00:09:43,490
麻烦在于边界，子会话日志的前半段是继承来的种子，后半段才是自己写的，这两段在字节层面长得一模一样。

92
00:09:43,490 --> 00:09:45,173
哪里是分界线？

93
00:09:45,173 --> 00:09:51,291
直觉答案是数一数构造时种子有几条，也就是种子的长度属性。

94
00:09:51,291 --> 00:09:53,406
这个答案错得很隐蔽。

95
00:09:53,406 --> 00:10:03,106
恢复的会话拿完整存储日志当构造种子，这个长度算出来的边界会随着每次重新打开往后跑，越跑越远。

96
00:10:03,106 --> 00:10:08,370
而 header 里的种子长度字段才一直保留着最初分叉时的值。

97
00:10:08,370 --> 00:10:10,474
所以边界写了两份。

98
00:10:10,474 --> 00:10:17,168
第一份在 header，创建子会话时把父会话标识和种子长度写进创建元数据。

99
00:10:17,168 --> 00:10:28,527
第二份在日志里，带种子的会话把种子结束事件作为自己的第一次实时写入，追加在种子之后，专门服务那些只拿得到存储字节的消费方。

100
00:10:28,527 --> 00:10:34,272
这两个谁也替代不了，一个管内存里的语义，一个管磁盘上的字节。

101
00:10:34,272 --> 00:10:37,469
这条边界事件解决的问题很具体。

102
00:10:37,469 --> 00:10:47,865
种子历史里可能有一个没配对的压缩开始标记，它到底是上个生命周期崩在压缩中途，还是此刻正在压缩，光看字节分不出来。

103
00:10:47,865 --> 00:10:55,966
有了种子结束事件，在它之前的未配对开启标记一律属于已结束的生命周期，判断立刻确定。

104
00:10:55,966 --> 00:11:08,226
类型定义的文档注释里还有一句狠话，会话构造函数是唯一合法写入方，插件擅自追加一条，等于把它之前的所有实时工作静默归类成种子历史。

105
00:11:08,226 --> 00:11:13,599
这句话听着严厉，但它防的就是前面说的那种安静的错误。

106
00:11:13,599 --> 00:11:16,796
顺带说一句大日志的恢复成本。

107
00:11:16,796 --> 00:11:29,079
恢复一个一百三十万事件、六十二兆压缩数据的会话，全程不物化整份明文，这轮优化把恢复准入从大约六百毫秒压到二百六十三毫秒。

108
00:11:29,079 --> 00:11:34,536
这个数字的意义不只是快，而是让严格校验变得负担得起。

109
00:11:34,536 --> 00:11:41,567
很多人放弃校验的真实原因是它慢，一旦你把校验的成本压下去，就没有理由再省它了。

110
00:11:41,567 --> 00:11:45,582
而这里有一项始终没省，校验和冻结。

111
00:11:45,582 --> 00:11:51,027
原因说得很清楚，持久存储属于运行时边界，防线本身不动。

112
00:11:51,027 --> 00:12:00,534
性能优化可以砍掉一切中间层，但边界上的那道校验是例外，因为它是你判断数据有没有被动过手脚的唯一依据。

113
00:12:00,670 --> 00:12:05,836
把别家的做法摆过来看，更能理解这套规则为什么长这样。

114
00:12:05,786 --> 00:12:10,245
Grok Build 的会话摘要读取走的是标准反序列化路径。

115
00:12:10,245 --> 00:12:17,457
在恢复预读循环里，读不出或解析不了的摘要文件直接跳过，不报错，不留痕。

116
00:12:17,457 --> 00:12:24,524
会话相关的反序列化结构体也没有一处标记拒绝未知字段，默认静默丢弃。

117
00:12:24,524 --> 00:12:29,380
新版本加的字段，被旧版本读一遍再写回，就没了。

118
00:12:29,380 --> 00:12:37,637
这是快速迭代产品的常见取舍，只是它把格式演进的正确性交给了新老版本别混用这个假设。

119
00:12:37,637 --> 00:12:43,466
Claude Code 的会话以行式 JSON 存在用户目录下，支持恢复和查看。

120
00:12:43,466 --> 00:12:53,658
书稿材料覆盖了启动、上下文管理和可观测性，但没有出现会话日志格式版本协商或未知记录拒绝机制的还原代码。

121
00:12:53,658 --> 00:12:59,776
这里要诚实地说，基于已公开证据，它读到不认识的数据时行为未知。

122
00:12:59,776 --> 00:13:03,070
差距的根源其实不是能力，是处境。

123
00:13:03,070 --> 00:13:14,043
闭源产品可以靠客户端总是最新版来兜底，服务端发新版，客户端跟着升，读取端和写入端基本同代，格式演进的风险被部署节奏吃掉了。

124
00:13:14,043 --> 00:13:27,336
而开源基建完全不同，各版本会长期共存，有人用上个月编的，有人用半年前的，还有人从源码自己改了一版，你根本不知道一份日志会在什么版本手里被打开。

125
00:13:27,336 --> 00:13:34,271
兜底的假设根本不成立，所以拒绝规则必须写进读取器，而不是写在部署文档里。

126
00:13:34,271 --> 00:13:38,070
这也解释了为什么这套规则看起来偏执。

127
00:13:38,070 --> 00:13:48,502
它不是在设计一个理想世界里的读取器，它是在设计一个必须承认自己会被各种版本、各种插件、各种改过的分支读取的读取器。

128
00:13:48,502 --> 00:13:55,990
你没法控制谁会来读你的数据，你能控制的只有一件事，当他读不了的时候，你告诉他。

129
00:13:56,130 --> 00:13:59,637
最后收一下，几条能直接拿走的原则。

130
00:13:59,587 --> 00:14:05,429
第一，版本号用单调整数，不要用语义化版本去承诺你不知道的事。

131
00:14:05,429 --> 00:14:10,164
能不能升级由升级器是否存在决定，不由编号决定。

132
00:14:10,164 --> 00:14:17,808
拿不准就升版本，因为一个近似恒等的升级器几乎零成本，漏升一次却会静默读坏数据。

133
00:14:17,808 --> 00:14:20,837
第二，报错要分方向，要指路。

134
00:14:20,837 --> 00:14:27,039
数据比你新，说请升级，数据比你旧但没有升级路径，说本构建不支持。

135
00:14:27,039 --> 00:14:35,356
永远不要把版本不匹配报成文件损坏，那是在冤枉数据，而且会引导用户去做破坏性的修复。

136
00:14:35,356 --> 00:14:38,830
第三，读操作不要偷偷变成写操作。

137
00:14:38,830 --> 00:14:48,157
迁移、缓存、惰性修复都是好事，但都应该在用户明确表达继续的意图之后才落盘，而且落盘前留备份。

138
00:14:48,157 --> 00:14:53,133
打开即改写，会把一个只读动作变成破坏性动作。

139
00:14:53,133 --> 00:14:57,520
第四，未知数据默认拒绝，不要默认跳过。

140
00:14:57,520 --> 00:15:04,503
判断的尺子是两种失败对不对称，拒绝过头是体验问题，静默跳过是安全事故。

141
00:15:04,503 --> 00:15:10,080
防线永远偏向吵闹的那一边，因为吵闹的失败你会立刻知道。

142
00:15:10,080 --> 00:15:15,176
第五，边界要写两份，别指望一个字段覆盖所有读者。

143
00:15:15,176 --> 00:15:22,568
内存里的语义边界和磁盘上的字节边界是两回事，服务不同的消费方，谁也替代不了谁。

144
00:15:22,568 --> 00:15:28,289
顺带一句，清单一定要生成加校验，手写的清单一定会过期。

