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

2
00:00:06,382 --> 00:00:09,891
这一集从一个很小的联调事故讲起。

3
00:00:09,891 --> 00:00:14,675
你给侧栏写代码，等一个 type 等于 turn_started 的事件。

4
00:00:14,675 --> 00:00:19,855
联调那天字段都对得上，type 却写成了 task_started。

5
00:00:19,855 --> 00:00:24,759
你把它改成新名，结果旧测试夹具里的旧名还能解出来。

6
00:00:24,759 --> 00:00:27,403
然后你加了一个自己的事件。

7
00:00:27,403 --> 00:00:30,456
本地编译和内核一起编，过了。

8
00:00:30,456 --> 00:00:33,834
隔壁的旧版 MCP 客户端解不出来。

9
00:00:33,834 --> 00:00:45,540
再过一周，新版写下的会话落盘文件，拿到旧版里去恢复，那一行被跳过去了，解析错误计数加一，会话还能开，只是少了一段生命周期。

10
00:00:45,540 --> 00:00:49,387
同一件事，在三个地方有三种失败方式。

11
00:00:49,387 --> 00:00:53,137
这不是运气差，是设计时没把话说清楚。

12
00:00:53,137 --> 00:00:55,949
Codex 的答案写在四行注释里。

13
00:00:55,949 --> 00:01:01,382
一次会话中，客户端和 agent 通过提交队列和事件队列异步通信。

14
00:01:01,382 --> 00:01:05,000
就这四行，是整个模块的模式声明。

15
00:01:05,000 --> 00:01:09,447
注意它的位置，它写在模块头，不是写在文档里。

16
00:01:09,447 --> 00:01:15,060
把说话方式写死在代码最前面，是这一讲最先能拿走的一条。

17
00:01:15,060 --> 00:01:18,088
今天要讲的就是这条双通道。

18
00:01:18,088 --> 00:01:22,475
命令从提交队列进内核，事件从事件队列出来。

19
00:01:22,475 --> 00:01:29,651
同一条生命周期，代码里叫 TurnStarted，写到 JSON 上却仍然是 task_started。

20
00:01:29,651 --> 00:01:34,699
以及最关键的，当你加了一个别人不认识的 type，系统该怎么做。

21
00:01:34,699 --> 00:01:38,822
这三件事看起来是三件事，其实是一件事。

22
00:01:38,822 --> 00:01:46,093
它们都在回答同一个问题，同一件事在进的这一侧和出的这一侧，应该长什么样。

23
00:01:46,230 --> 00:01:49,100
我们把画面想象成一条流水线。

24
00:01:49,050 --> 00:01:56,358
左边是下行通道，右边是上行通道，你在左边投进去一条命令，看它在右边变成什么。

25
00:01:56,358 --> 00:02:00,336
左信封上只有两个字段，一个 id，一个 op。

26
00:02:00,336 --> 00:02:03,365
没有 JSON，没有 type 字符串。

27
00:02:03,365 --> 00:02:05,697
它就是一封进程内的信。

28
00:02:05,697 --> 00:02:08,858
右边的事件盒子长得很不一样。

29
00:02:08,858 --> 00:02:11,995
它有一个 id，对上左边那条提交。

30
00:02:11,995 --> 00:02:14,928
它还有一个盖子，盖子上写着 type。

31
00:02:14,928 --> 00:02:17,211
这个 type 才是对外的词。

32
00:02:17,211 --> 00:02:19,170
现在做三个实验。

33
00:02:19,170 --> 00:02:22,788
第一个，盖子上写 task_started。

34
00:02:22,788 --> 00:02:25,685
第二个，写 turn_started。

35
00:02:25,685 --> 00:02:30,853
你会发现这两个在 Codex 里其实是同一个变体，都能解出来。

36
00:02:30,853 --> 00:02:35,096
这就是下一节要讲的，线上名和代码名分开。

37
00:02:35,096 --> 00:02:40,685
第三个实验，写 future_event，一个谁都不认识的未来事件。

38
00:02:40,685 --> 00:02:43,605
这个时候有意思的事情发生了。

39
00:02:43,605 --> 00:02:47,776
MCP 那一侧原样序列化，解不出来，直接摔碎。

40
00:02:47,776 --> 00:02:55,276
而恢复落盘文件那一侧，把这一行丢进解析错误计数，然后跳过，会话照常打开。

41
00:02:55,276 --> 00:03:03,281
同样的三行，在 DSH 里会让整份日志被拒绝，在 Grok 里会被收成 Unknown 然后静默忽略。

42
00:03:03,281 --> 00:03:05,673
这一节的观察点就一句。

43
00:03:05,673 --> 00:03:10,769
进的形状是进程内命令，出的形状是能写成 JSON 的事件。

44
00:03:10,769 --> 00:03:16,923
它们靠同一根 id 对上，但长得完全不一样，失败的默认方向也不一样。

45
00:03:17,070 --> 00:03:19,027
先看进的这一侧。

46
00:03:18,977 --> 00:03:21,236
下行条目叫 Submission。

47
00:03:21,236 --> 00:03:26,200
它有两个东西，一个是关联用的 id，一个是要执行的操作。

48
00:03:26,200 --> 00:03:29,602
操作是内核动词，当前有二十八种。

49
00:03:29,602 --> 00:03:31,957
这里有个很关键的性质。

50
00:03:31,957 --> 00:03:35,912
Submission 只派生调试输出，没有序列化能力。

51
00:03:35,912 --> 00:03:37,054
为什么。

52
00:03:37,054 --> 00:03:43,027
因为命令里会带一次性回调，会带审批决定，甚至会带实时音频帧。

53
00:03:43,027 --> 00:03:48,099
这些东西根本没法变成 JSON，也不该变成 JSON。

54
00:03:48,099 --> 00:03:54,133
所以整封 Submission 不做序列化，这是设计约束推出来的结果，不是偷懒。

55
00:03:54,133 --> 00:03:57,859
命令是进程内的东西，它不需要过网。

56
00:03:57,859 --> 00:03:59,710
再看通道容量。

57
00:03:59,710 --> 00:04:03,412
会话启动的时候会同时建两条通道。

58
00:04:03,412 --> 00:04:07,222
下行这一条是有界的，容量五百一十二。

59
00:04:07,222 --> 00:04:09,482
上行那一条是无界的。

60
00:04:09,482 --> 00:04:11,933
这个不对称很有意思。

61
00:04:11,933 --> 00:04:17,390
客户端连打五百一十二条还没被循环收走，下一次发送就会等待。

62
00:04:17,390 --> 00:04:19,421
命令通道会反压。

63
00:04:19,421 --> 00:04:21,609
为什么不反压就危险。

64
00:04:21,609 --> 00:04:24,842
因为命令是人发的，频率天然低。

65
00:04:24,842 --> 00:04:27,570
人点一下按钮，一次提交。

66
00:04:27,570 --> 00:04:30,840
堵住了让人等一下，代价很小。

67
00:04:30,840 --> 00:04:37,414
而事件是模型和工具喷出来的，一条工具调用后面可能跟着几十条输出。

68
00:04:37,414 --> 00:04:41,152
事件通道如果反压，这一轮就被卡住了。

69
00:04:41,152 --> 00:04:43,568
还有一个很容易忽略的点。

70
00:04:43,568 --> 00:04:48,292
命令为什么要带回调，而不是自己也走事件队列回来。

71
00:04:48,292 --> 00:04:55,371
因为命令需要回答的是这条提交有没有被接住，这是一个同步的问题，一个一次性的答案。

72
00:04:55,371 --> 00:04:59,698
它不需要排队，不需要重放，也不需要写进历史。

73
00:04:59,698 --> 00:05:04,662
混进事件队列里，反而要给它发明一种表示終态的方式。

74
00:05:04,662 --> 00:05:06,561
为什么长期成立。

75
00:05:06,561 --> 00:05:10,455
命令是人发的，频率低，堵住可以反压。

76
00:05:10,455 --> 00:05:15,034
事件是模型和工具喷出来的，堵住会把这一轮卡住。

77
00:05:15,034 --> 00:05:23,712
换语言重写也没用，只要命令带回调、事件要落盘，这两条队列还是得分开，容量策略还是得相反。

78
00:05:23,860 --> 00:05:25,733
再看出的这一侧。

79
00:05:25,683 --> 00:05:27,870
上行条目叫 Event。

80
00:05:27,870 --> 00:05:29,697
它有序列化能力。

81
00:05:29,697 --> 00:05:33,639
id 对上当初那条提交，msg 才是事件本体。

82
00:05:33,639 --> 00:05:36,885
事件这条链路上有几步值得记。

83
00:05:36,885 --> 00:05:41,788
第一步，内核生成提交 id，实现里用的是 UUID 第七版。

84
00:05:41,788 --> 00:05:44,445
第二步，把操作包成提交。

85
00:05:44,445 --> 00:05:47,149
第三步，送进有界队列。

86
00:05:47,149 --> 00:05:50,214
第四步，提交循环按变体分发。

87
00:05:50,214 --> 00:05:54,817
第五步，发事件的时候，用那个提交 id 作为事件的 id。

88
00:05:54,817 --> 00:05:59,132
第六步，需要的时候再补发一份旧名字的副本。

89
00:05:59,132 --> 00:06:03,183
第七步，按白名单决定要不要写进落盘文件。

90
00:06:03,183 --> 00:06:06,127
第八步，送进无界事件队列。

91
00:06:06,127 --> 00:06:07,666
注意第五步。

92
00:06:07,666 --> 00:06:11,163
事件的 id 就是当初那条提交的 id。

93
00:06:11,163 --> 00:06:14,793
这一根 id 是两条通道唯一的对账凭证。

94
00:06:14,793 --> 00:06:20,767
你在侧栏上看到一条事件，想知道它是响应你哪一次操作，靠的就是它。

95
00:06:20,767 --> 00:06:23,543
最后一步的白名单也值得说。

96
00:06:23,543 --> 00:06:26,452
不是所有事件都进落盘文件。

97
00:06:26,452 --> 00:06:31,620
瞬时事件，比如命令输出、审批提示，喷一次就过去了。

98
00:06:31,620 --> 00:06:36,404
这些东西如果全部写进真源文件，文件会按 token 涨。

99
00:06:36,404 --> 00:06:39,997
所以要不要落盘，是白名单说了算的。

100
00:06:39,997 --> 00:06:42,173
还有一条容易搞混的。

101
00:06:42,173 --> 00:06:46,716
操作结果的路由走一次性通道，不走事件队列。

102
00:06:46,716 --> 00:06:51,368
一次性通道只回答一个问题，这条提交有没有被接住。

103
00:06:51,368 --> 00:06:55,118
事件消息描述的才是这一轮发生了什么。

104
00:06:55,118 --> 00:06:57,702
两者的语义完全不同。

105
00:06:57,840 --> 00:07:01,047
现在讲这一集最实用的一条经验。

106
00:07:00,997 --> 00:07:03,521
改标识符不必改磁盘。

107
00:07:03,521 --> 00:07:07,102
Rust 里的事件变体已经改名叫 TurnStarted。

108
00:07:07,102 --> 00:07:15,107
如果 JSON 上的字符串跟着一起改，旧的落盘文件和旧客户端都会在反序列化边界上断掉。

109
00:07:15,107 --> 00:07:19,771
而按标识符名去猜线上的名字，是一定会猜错的。

110
00:07:19,771 --> 00:07:29,242
Codex 的做法是，序列化的时候写出 task_started 这个旧名，读入的时候同时认 turn_started 这个新名。

111
00:07:29,242 --> 00:07:31,465
显示和指标走新名。

112
00:07:31,465 --> 00:07:35,083
于是同一个变体身上挂了两套字符串。

113
00:07:35,083 --> 00:07:38,449
磁盘保住旧名，代码用新名。

114
00:07:38,449 --> 00:07:43,004
代价是你要同时记住两套，收益是老数据一行不丢。

115
00:07:43,004 --> 00:07:46,057
这里要强调一个很容易搞错的方向。

116
00:07:46,057 --> 00:07:50,780
很多人以为改名就是从旧名换成新名，一步到位。

117
00:07:50,780 --> 00:07:53,124
实际上顺序是反的。

118
00:07:53,124 --> 00:08:01,465
先在代码里改成新标识符，同时用重命名把旧字符串钉死在序列化层，然后才能说迁移完成。

119
00:08:01,465 --> 00:08:07,992
如果顺序反过来，先把字符串改了，那已经落盘的旧文件就再也读不回来了。

120
00:08:07,992 --> 00:08:10,444
这一条为什么长期成立。

121
00:08:10,444 --> 00:08:14,494
因为标识符可以改，已经落盘的字符串改不起。

122
00:08:14,494 --> 00:08:21,297
改名的时候用重命名加别名保住旧字符串，是给磁盘留后门的通用做法。

123
00:08:21,297 --> 00:08:29,110
另外提醒一句，指标用哪一套字符串要单独测，不要假设它和序列化用的是同一套。

124
00:08:29,260 --> 00:08:32,791
顺着这个话题再看一个更细的现象。

125
00:08:32,741 --> 00:08:36,924
item 的生命周期事件，会再喷一份旧名字的副本。

126
00:08:36,924 --> 00:08:39,544
新前端看到的是 ItemStarted。

127
00:08:39,544 --> 00:08:43,402
旧前端看到的是命令开始，或者 agent 消息。

128
00:08:43,402 --> 00:08:49,929
也就是说同一条生命周期，在事件队列上会出现两条语义重复的条目。

129
00:08:49,929 --> 00:08:54,772
这是迁移动线，是给那些还没迁到新结构的消费者留的。

130
00:08:54,772 --> 00:08:58,510
等所有人都迁完，这份旧副本才会退场。

131
00:08:58,510 --> 00:09:00,866
这件事给我们两个提醒。

132
00:09:00,866 --> 00:09:06,131
第一，队列上出现重复语义不一定是 bug，可能是有意的兼容期。

133
00:09:06,131 --> 00:09:11,647
看代码的时候要先确认它是不是迁移动线的一部分，再动手删。

134
00:09:11,647 --> 00:09:14,724
第二，兼容副本要有退场计划。

135
00:09:14,724 --> 00:09:23,234
喷两份事件意味着每个消费者都会多收一条，多收的这条如果不处理，指标会翻倍，日志会翻倍。

136
00:09:23,234 --> 00:09:31,071
所以这类副本要么写在白名单里不落盘，要么在文档里明确标注退役时间和判断标准。

137
00:09:31,071 --> 00:09:38,751
判断标准建议写成一句话，当某一版客户端的市场占比低于某个阈值，就删掉对应副本。

138
00:09:38,751 --> 00:09:42,621
没有这句话，兼容副本会永远留着。

139
00:09:42,770 --> 00:09:45,688
第三节最后讲最重要的部分。

140
00:09:45,638 --> 00:09:53,066
事件消息是内部事件词表，有八十一个变体，既没有兜底变体，也没有标记不可穷尽。

141
00:09:53,066 --> 00:09:56,648
那么加一个新 type，旧读取器该怎么办。

142
00:09:56,648 --> 00:10:00,554
这件事不能靠看情况，必须有明确答案。

143
00:10:00,554 --> 00:10:04,629
Codex 给了三条路径，答案全部写在代码里。

144
00:10:04,629 --> 00:10:07,297
第一条，同进程同版本。

145
00:10:07,297 --> 00:10:12,309
终端界面、命令行、MCP 和内核链到同一份类型定义。

146
00:10:12,309 --> 00:10:16,444
你加了一个变体，别人的穷尽匹配就编译不过。

147
00:10:16,444 --> 00:10:19,088
这条路径是编译期拦住的。

148
00:10:19,088 --> 00:10:26,744
旧客户端如果还没升级，它根本不会和这份新内核链在一起，所以不存在旧客户端的问题。

149
00:10:26,744 --> 00:10:29,460
第二条，跨版本 JSON。

150
00:10:29,460 --> 00:10:33,775
MCP 会把整个事件序列化成一条通知发出去。

151
00:10:33,775 --> 00:10:39,088
旧客户端拿着旧词表去解，未知的 type 让反序列化直接失败。

152
00:10:39,088 --> 00:10:44,508
注意失败发生的位置，内核早就发出去了，失败在客户端这一侧。

153
00:10:44,508 --> 00:10:47,261
内核不会因为你解不出来就不发。

154
00:10:47,261 --> 00:10:49,713
第三条，恢复旧文件。

155
00:10:49,713 --> 00:10:54,653
坏的那一行让解析错误计数加一，然后继续往下走。

156
00:10:54,653 --> 00:10:59,004
未知的 type 不会让整个会话打不开，它只是少一行。

157
00:10:59,004 --> 00:11:03,307
函数仍然返回已经成功解析出来的那些条目。

158
00:11:03,307 --> 00:11:06,792
三条路径，三个不同的默认方向。

159
00:11:06,792 --> 00:11:10,782
编译期拒绝，客户端失败，恢复跳行。

160
00:11:10,782 --> 00:11:15,097
把这三条放在一起看，会发现一个很清晰的规律。

161
00:11:15,097 --> 00:11:18,078
越靠近开发者，态度越强硬。

162
00:11:18,078 --> 00:11:21,371
编译期直接编不过，逼你把匹配补上。

163
00:11:21,371 --> 00:11:24,749
越靠近用户数据，态度越宽容。

164
00:11:24,749 --> 00:11:28,198
恢复的时候跳一行，让会话还能开。

165
00:11:28,198 --> 00:11:35,879
中间那一层是跨版本的网络边界，它最无奈，因为内核已经发出去了，只能让对方失败。

166
00:11:35,879 --> 00:11:38,234
这个梯度不是随便定的。

167
00:11:38,234 --> 00:11:40,855
它对应的是三件事的代价。

168
00:11:40,855 --> 00:11:44,244
开发者改代码成本最低，所以最严。

169
00:11:44,244 --> 00:11:47,465
用户的数据丢了就没了，所以最松。

170
00:11:47,465 --> 00:11:51,143
网络对面你控制不了，所以只能如实失败。

171
00:11:51,143 --> 00:11:53,667
操作这一侧正好反过来。

172
00:11:53,667 --> 00:12:02,742
操作标记了不可穷尽，提交循环末尾是一个捕获一切的分支，返回假，未知命令被直接丢掉，循环不崩。

173
00:12:02,742 --> 00:12:04,557
为什么两边相反。

174
00:12:04,557 --> 00:12:09,112
因为事件是对外词表，漏一个变体必须在编译期被看见。

175
00:12:09,112 --> 00:12:13,042
命令面向内部扩展，丢掉比崩掉更安全。

176
00:12:13,042 --> 00:12:15,350
这个对比非常值得记。

177
00:12:15,480 --> 00:12:20,790
放到横向对比里看，这三种默认方向的取舍会更清楚。

178
00:12:20,740 --> 00:12:22,459
先看 DSH。

179
00:12:22,459 --> 00:12:25,259
它把事件日志当成真源。

180
00:12:25,259 --> 00:12:28,036
信封上有一个可忽略标记。

181
00:12:28,036 --> 00:12:34,718
缺这个标记的时候，读取器碰到不认识的 type，必须拒绝重建，不能悄悄丢掉。

182
00:12:34,718 --> 00:12:36,401
代价很清楚。

183
00:12:36,401 --> 00:12:38,589
旧版本打不开新日志。

184
00:12:38,589 --> 00:12:42,459
换来的是一句承诺，能打开就一定完整。

185
00:12:42,459 --> 00:12:50,307
忘了打标记，结果是过分拒绝，但过分拒绝比静默恢复一份被掏空的会话要安全得多。

186
00:12:50,307 --> 00:12:51,666
再看 Grok。

187
00:12:51,666 --> 00:12:57,952
它的会话事件协议只有六个变体，里面有一个 Unknown 变体，带兜底属性。

188
00:12:57,952 --> 00:13:04,334
模块头写明，旧消费者碰到新的事件类型，解析成 Unknown，不要失败。

189
00:13:04,334 --> 00:13:07,315
而且消费者必须静默忽略。

190
00:13:07,315 --> 00:13:09,887
原始类型名不会被保留。

191
00:13:09,887 --> 00:13:12,158
这个设计适合通知流。

192
00:13:12,158 --> 00:13:15,824
通知丢了，会话还能靠别的状态活着。

193
00:13:15,824 --> 00:13:21,762
但 Codex 的 TurnStarted 是落盘文件的截断边界，真源事件不能静默丢。

194
00:13:21,762 --> 00:13:28,769
所以 Codex 恢复路径选择跳行，比 Grok 更接近打开，比 DSH 更接近尽量打开。

195
00:13:28,769 --> 00:13:31,365
它是三条路里的中间位置。

196
00:13:31,365 --> 00:13:34,274
为什么三家会做出不同选择。

197
00:13:34,274 --> 00:13:37,194
因为他们对事件日志的定位不同。

198
00:13:37,194 --> 00:13:43,997
DSH 把日志当成唯一真源，所以完整性高于一切，宁可打不开也不能是残缺的。

199
00:13:43,997 --> 00:13:49,190
Grok 把事件当成通知流，所以可用性高于一切，丢了也无妨。

200
00:13:49,190 --> 00:13:55,980
Codex 的落盘文件既要当真源，又要保证老文件能开，所以它只能选中间那条路。

201
00:13:55,980 --> 00:13:59,971
任何一个设计决定，追到底都是定位问题。

202
00:13:59,971 --> 00:14:04,767
你先回答这份日志是什么，默认值自然就出来了。

203
00:14:04,920 --> 00:14:06,732
最后收成五条。

204
00:14:06,682 --> 00:14:12,512
第一条，命令和事件必须分成两条通道，而且容量策略要相反。

205
00:14:12,512 --> 00:14:14,844
命令有界，可以反压。

206
00:14:14,844 --> 00:14:17,404
事件无界，不能反压。

207
00:14:17,404 --> 00:14:21,478
因为命令频率低是人发的，事件是模型喷的。

208
00:14:21,478 --> 00:14:24,795
混在一条队列上，迟早互相拖累。

209
00:14:24,795 --> 00:14:28,570
第二条，一次性回调和事件消息要分清。

210
00:14:28,570 --> 00:14:34,495
前者只回答这条提交有没有被接住，后者描述这一轮发生了什么。

211
00:14:34,495 --> 00:14:39,231
语义混在一起，后面做幂等和重试会非常痛苦。

212
00:14:39,231 --> 00:14:44,795
第三条，改标识符用重命名加别名保住已经落盘的字符串。

213
00:14:44,795 --> 00:14:49,074
磁盘上的字符串是已经发出去的承诺，改不起。

214
00:14:49,074 --> 00:14:53,666
同一个变体挂两套名字是合理的成本，不是技术债。

215
00:14:53,666 --> 00:14:57,680
第四条，未知 type 的默认方向要提前写下来。

216
00:14:57,680 --> 00:15:03,570
三条边界各有一个落点，编译期拒绝、客户端失败、恢复跳行。

217
00:15:03,570 --> 00:15:08,942
三条都能抄，但不要让三条路径各做一套却不写进文档。

218
00:15:08,942 --> 00:15:14,459
真源事件和通知流可以给不同的默认值，但必须写在信封上。

219
00:15:14,459 --> 00:15:18,822
第五条，对外词表和内部扩展的默认方向相反。

220
00:15:18,822 --> 00:15:24,050
对外词表漏一个变体要在编译期被看见，所以它不标不可穷尽。

221
00:15:24,050 --> 00:15:30,949
内部命令面向扩展，丢掉比崩掉安全，所以它标不可穷尽并用捕获分支兜住。

222
00:15:30,949 --> 00:15:33,017
再补一句踩坑点。

223
00:15:33,017 --> 00:15:37,993
指标用哪套字符串要单独测，不要假设它和序列化一致。

224
00:15:37,993 --> 00:15:44,447
以及瞬时事件进不进落盘文件，必须是白名单说了算，否则文件会按 token 涨。

225
00:15:44,447 --> 00:15:46,070
一句话收尾。

226
00:15:46,070 --> 00:15:51,382
命令从提交队列进，事件从事件队列出，靠同一根 id 对账。

227
00:15:51,382 --> 00:15:55,312
代码里的名字可以改，磁盘上的名字改不起。

228
00:15:55,312 --> 00:16:03,305
碰到不认识的 type，先想清楚你要拒绝、要跳行，还是收成未知，然后把答案写进代码。

