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

2
00:00:06,382 --> 00:00:08,930
这是 Codex 系列的第二十集。

3
00:00:08,930 --> 00:00:19,747
这一集要解决的工程问题是，你在给编辑器写插件，调试器里明明能看见内核往外抛的事件，字段名也对，可第一包数据过来就是对不上。

4
00:00:19,747 --> 00:00:24,867
方法名中间多了个斜杠，字段从小写下划线变成了驼峰。

5
00:00:24,867 --> 00:00:26,694
为什么值得关心。

6
00:00:26,694 --> 00:00:30,480
因为这一层是客户端和内核之间唯一的合同。

7
00:00:30,480 --> 00:00:44,375
按内核枚举直接写客户端，等于把内部重构变成对外破坏性变更，每加一种内部事件就是一次客户端升级；而漏改这一层，客户端不会报错，只是功能悄悄消失。

8
00:00:44,375 --> 00:00:50,913
两种失败都不吵不闹，等到用户反馈某个按钮不亮，排查链路已经很长了。

9
00:00:50,913 --> 00:00:52,956
再具体一点说代价。

10
00:00:52,956 --> 00:00:59,771
客户端往往是第三方写的编辑器扩展、内部工具、别人的脚本，你没法统一升级。

11
00:00:59,771 --> 00:01:06,899
内核事件每加一种就要求所有客户端跟着改，这个成本会随客户端数量线性放大。

12
00:01:06,899 --> 00:01:15,576
反过来，有了一层投影，内部想怎么重构都行，只要对外那条路径不变，外面的代码一行都不用动。

13
00:01:15,576 --> 00:01:22,548
所以这一集的主线是一句话，对外协议是一张投影表，不是内核枚举的 JSON 导出。

14
00:01:22,548 --> 00:01:26,562
这里说的投影，重点在两个字，是有损的。

15
00:01:26,562 --> 00:01:32,091
内核里发生的事情，并不全部对外可见，也不该全部对外可见。

16
00:01:32,091 --> 00:01:33,665
内容分三块。

17
00:01:33,665 --> 00:01:41,129
第一块讲投影的四条规则，改名、换容器、丢掉、拆开，以及为什么未知行必须失败。

18
00:01:41,129 --> 00:01:49,278
第二块讲时序，请求立刻回、事件随后到、审批是反向请求，这三件事落在三个不同时刻。

19
00:01:49,278 --> 00:01:54,038
第三块讲两个官方 SDK 走的其实不是同一条协议面。

20
00:01:54,038 --> 00:01:57,271
末尾收五条可以带走的原则。

21
00:01:57,410 --> 00:01:59,559
先站到你的位置上。

22
00:01:59,509 --> 00:02:01,709
你在给编辑器写插件。

23
00:02:01,709 --> 00:02:09,028
调试器里能看到内核往外抛事件，turn started、exec command begin，字段是 snake_case。

24
00:02:09,028 --> 00:02:16,288
可第一包数据过来，方法名是 turn 斜杠 started，中间是斜杠，字段成了 threadId 这种驼峰。

25
00:02:16,288 --> 00:02:24,834
命令开始时，你等的那个开始事件没出现，来的是 item 斜杠 started，里面塞着一个类型为命令执行的条目。

26
00:02:24,834 --> 00:02:31,576
审批更怪，服务端反向发来一条请求，你得回一个响应，否则这一轮就卡在那儿。

27
00:02:31,576 --> 00:02:34,389
内核里有八十一种事件枚举。

28
00:02:34,389 --> 00:02:44,653
如果编辑器按八十一种写分支，每加一种内部事件都是一次客户端升级，连废弃别名都会从仓内兼容问题变成对外合同。

29
00:02:44,653 --> 00:02:46,925
所以中间必须有一层。

30
00:02:46,925 --> 00:02:52,983
这一层的调度函数吃一条内核事件，按四条规则收成对外消息。

31
00:02:52,983 --> 00:03:00,266
出处是 app-server 下 bespoke_event_handling.rs 的第一百五十九到一百八十八行。

32
00:03:00,266 --> 00:03:01,949
第一条是改名。

33
00:03:01,949 --> 00:03:10,747
内核的下划线类型变成 turn 斜杠 started、item 斜杠 agentMessage 斜杠 delta 这种资源路径，字段改成驼峰。

34
00:03:10,747 --> 00:03:12,574
第二条是换容器。

35
00:03:12,574 --> 00:03:19,749
增量文本和工具生命周期被收进线程条目，再塞进条目开始或者条目完成的通知里。

36
00:03:19,749 --> 00:03:23,776
客户端按条目的类型画卡片，不用自己拼。

37
00:03:23,776 --> 00:03:25,639
第三条是丢掉。

38
00:03:25,639 --> 00:03:32,129
命令开始、看图工具调用，以及匹配末尾的通配臂，线上都没有对应通知。

39
00:03:32,129 --> 00:03:36,348
旧事件还在给内部录制扇出，只是不再对外。

40
00:03:36,348 --> 00:03:38,187
第四条是拆开。

41
00:03:38,187 --> 00:03:45,999
一条工具调用开始的事件，既发一条通知，又发一条反向请求，等着客户端真的去执行。

42
00:03:45,999 --> 00:03:54,954
这一条最容易被忽略，因为同一个内部事实被拆成了两条对外消息，一条告诉你发生了什么，一条要求你做事。

43
00:03:54,954 --> 00:03:56,660
为什么要这么分。

44
00:03:56,660 --> 00:04:01,023
内核按发生了什么命名，对外按用户看见什么命名。

45
00:04:01,023 --> 00:04:06,877
两种命名方式的更新节奏不一样，中间没有投影层就只能互相绑架。

46
00:04:06,877 --> 00:04:11,096
要么内部不敢改，要么外部天天改，二选一。

47
00:04:11,096 --> 00:04:18,992
出处还有两处，同一个文件的第八百八十到九百一十八行、第九百九十六到一千零三十六行。

48
00:04:18,992 --> 00:04:24,040
四条规则摆在一起，能看出一件事，投影不是格式转换。

49
00:04:24,040 --> 00:04:31,660
改名只是换了写法，换容器改变了聚合粒度，丢掉减少了对面积，拆开增加了交互次数。

50
00:04:31,660 --> 00:04:37,718
后三条都在改变客户端看到的世界形状，不是把同一个东西换个编码。

51
00:04:37,718 --> 00:04:41,240
这就是为什么不把它叫做序列化层。

52
00:04:41,380 --> 00:04:44,683
这里有一个读源码时特别容易踩的坑。

53
00:04:44,633 --> 00:04:52,746
有一个函数名叫事件到服务通知的映射，看起来像总入口，其实只覆盖一对一、无状态的投影。

54
00:04:52,746 --> 00:04:55,126
真正的判断在调用点。

55
00:04:55,126 --> 00:05:04,260
同一个命令开始事件，在这个助手函数里还能变成条目开始通知，到了调度函数里却走进废弃的空分支。

56
00:05:04,260 --> 00:05:08,599
现场那张命令卡片来自后面那条条目开始事件。

57
00:05:08,599 --> 00:05:11,316
结论很简单，以调度为准。

58
00:05:11,316 --> 00:05:20,342
出处是 app-server-protocol 下 event_mapping.rs 的第二十五到三十七行，以及 item_builders.rs 的第一到十一行。

59
00:05:20,342 --> 00:05:23,840
再说一条硬规则，未知行必须失败。

60
00:05:23,840 --> 00:05:32,830
匹配里不能留空默认，空默认等于通配臂，新加的内部事件能通过编译，客户端的标准输出上什么都没有。

61
00:05:32,830 --> 00:05:37,674
这不是洁癖，是把漏改从静默故障变成编译期报错。

62
00:05:37,674 --> 00:05:44,885
出处是 bespoke_event_handling.rs 的第一千二百三十八到一千二百四十五行。

63
00:05:44,885 --> 00:05:47,457
这条规则值得多想一层。

64
00:05:47,457 --> 00:05:55,162
写代码的时候，加一个通配兜底分支是很自然的手部动作，尤其是匹配臂已经很长的时候。

65
00:05:55,162 --> 00:05:59,717
但在这里，兜底等于宣布以后所有新事件都不对外。

66
00:05:59,717 --> 00:06:06,015
功能不是被删掉的，是从来没上线过，而且没有任何一条日志会告诉你。

67
00:06:06,015 --> 00:06:11,664
所以对这一层的态度应该是，宁可编译不过，也不要静默通过。

68
00:06:11,664 --> 00:06:14,296
横向对比一下更有意思。

69
00:06:14,296 --> 00:06:25,727
另一套方案是五个入口共用同一棵插件树，内核类型就是协议类型，改一个事件字段五张脸一起变，收益是永远不会出现两种分裂写法。

70
00:06:25,727 --> 00:06:30,054
代价是内部想标废弃继续扇出去就做不到。

71
00:06:30,054 --> 00:06:40,342
Codex 选了另一边，对外合同按投影层冻结，内部可以自由演进，代价是要维护这张表，而且漏改的时候客户端看不见。

72
00:06:40,490 --> 00:06:44,923
现在讲时序，这是本集最容易写错客户端的地方。

73
00:06:44,873 --> 00:06:49,043
同事把 turn 斜杠 start 的响应当成一轮已经开始。

74
00:06:49,043 --> 00:06:52,890
响应立刻回来，里面是一份空条目的 turn。

75
00:06:52,890 --> 00:06:54,536
模型还没开口。

76
00:06:54,536 --> 00:06:58,959
真正开跑是后面那条 turn 斜杠 started 通知。

77
00:06:58,959 --> 00:07:02,709
出处是 app-server 目录下 README 的第八十一行。

78
00:07:02,709 --> 00:07:05,702
线上能解出来的对象只有四种。

79
00:07:05,702 --> 00:07:10,798
带编号的请求，不带编号的通知，成功响应，错误响应。

80
00:07:10,798 --> 00:07:17,829
看着像 JSON-RPC，结构体里却没有那个版本字段，常量还在，线上不带这个键。

81
00:07:17,829 --> 00:07:22,877
出处是 rpc.rs 的第一到十一行和第三十四到七十二行。

82
00:07:22,877 --> 00:07:25,269
这四套消息是不对称的。

83
00:07:25,269 --> 00:07:31,868
客户端请求，客户端问、等人回包，初始化和轮次开始是稳定面的主干。

84
00:07:31,868 --> 00:07:38,142
服务端通知，服务端推、不等回包，轮次已开始和条目已开始都在这里。

85
00:07:38,142 --> 00:07:44,055
服务端请求，服务端问人，第一条稳定方法是命令执行请求审批。

86
00:07:44,055 --> 00:07:48,262
客户端通知，展开之后只有已初始化这一种。

87
00:07:48,262 --> 00:07:56,375
出处是 common.rs 的第一千六百六十三到一千六百七十行、第一千九百五十四到一千九百五十六行。

88
00:07:56,375 --> 00:08:02,469
所以判断标准很清楚，回包只表示请求被接受，开跑要看通知。

89
00:08:02,469 --> 00:08:08,623
把这两件事合成一件，编辑器要么空转等开跑，要么把等待状态画错。

90
00:08:08,623 --> 00:08:11,796
顺带说一个读代码时的小陷阱。

91
00:08:11,796 --> 00:08:17,986
这套东西看着像 JSON 远程调用，你会下意识去找那个版本字段。

92
00:08:17,986 --> 00:08:22,012
结构体里没有，常量还在，线上不带这个键。

93
00:08:22,012 --> 00:08:27,216
所以不要用通用库的严格模式去校验它，会全部拒掉。

94
00:08:27,216 --> 00:08:30,365
出处是 rpc.rs 的第一到十一行。

95
00:08:30,365 --> 00:08:32,421
还有一个实操建议。

96
00:08:32,421 --> 00:08:38,238
客户端应该在收到回包之后先进入一个已提交状态，而不是已运行。

97
00:08:38,238 --> 00:08:42,577
等收到轮次已开始的通知再切到运行中的转圈。

98
00:08:42,577 --> 00:08:52,012
这两帧之间通常很短，但短不代表可以合并，尤其在网络差或者排队的时候，这段等待会长到用户能感知。

99
00:08:52,150 --> 00:08:54,732
审批是最容易做错的一块。

100
00:08:54,682 --> 00:09:00,619
在 app-server 面上，命令审批以服务端请求的形式出现，客户端必须回包。

101
00:09:00,619 --> 00:09:08,300
如果把它当成普通通知，客户端可以不理，这一轮就停在等待上，直到超时或者被中断。

102
00:09:08,300 --> 00:09:13,420
用户看着界面卡住，其实服务端一直在等一个永远不来的响应。

103
00:09:13,420 --> 00:09:15,631
为什么编号这么重要。

104
00:09:15,631 --> 00:09:23,876
请求带编号，响应对得上编号，过载的时候还能把请求失败回给调用方，避免审批悬挂在那里没人管。

105
00:09:23,876 --> 00:09:27,134
通知不带编号，天生就不可回。

106
00:09:27,134 --> 00:09:34,405
这一层的设计意图很清楚，请求要回执，通知是广播，反向请求把人拉进环里。

107
00:09:34,405 --> 00:09:40,150
三件事混成一种，编辑器要么空转等开跑，要么漏画审批按钮。

108
00:09:40,150 --> 00:09:46,989
漏画审批按钮的后果比看起来严重，用户以为模型在思考，实际是卡在等授权。

109
00:09:46,989 --> 00:09:52,638
所以写客户端的时候，先问自己一句，这条消息我要不要回包。

110
00:09:52,638 --> 00:09:57,675
要回包的走请求，不回包的走通知，不要凭方法名猜。

111
00:09:57,840 --> 00:10:01,636
再看一个很省事的设计，实验能力怎么开。

112
00:10:01,586 --> 00:10:04,699
实验方法有五十七个方法级标记。

113
00:10:04,699 --> 00:10:11,514
如果靠第二端口，稳定客户和冒险客户要连两个地方，文档和接入成本都翻倍。

114
00:10:11,514 --> 00:10:17,259
实际做法是初始化时传一个布尔，叫实验接口，缺省是假。

115
00:10:17,259 --> 00:10:20,804
再初始化一次会收到已初始化的错误。

116
00:10:20,804 --> 00:10:29,254
没开这个开关就去打诊断方法，错误码是负的三万二千六百，句子是固定的，诊断需要实验接口能力。

117
00:10:29,254 --> 00:10:34,086
有意思的是，Python 的 SDK 把这个默认值改成了真。

118
00:10:34,086 --> 00:10:42,595
也就是说官方脚本已经站在实验合同上了，稳定面和实验面的边界在客户端这一侧被悄悄移动过一次。

119
00:10:42,595 --> 00:10:51,790
这件事的含义是，你以为自己在用稳定面，其实你的依赖已经默认站到了实验面上，而实验面是不承诺兼容的。

120
00:10:51,790 --> 00:10:57,812
写生产代码的时候，最好显式把这个布尔传成假，别吃 SDK 的默认值。

121
00:10:57,812 --> 00:11:06,225
出处是 message_processor.rs 的第八百九十一到八百九十五行，以及 Python 客户端的第二百零九行。

122
00:11:06,225 --> 00:11:09,374
同样一条原则也用在进程内嵌。

123
00:11:09,374 --> 00:11:17,920
TUI 不直连内核，内嵌只换载体，套接字和标准输入输出换成内存通道，消息处理器还在。

124
00:11:17,920 --> 00:11:22,319
请求仍然是客户端请求，响应仍然走同一套信封。

125
00:11:22,319 --> 00:11:25,840
进程内是传输层局部，不是免协议。

126
00:11:25,840 --> 00:11:29,927
出处是 in_process.rs 的第一到二十四行。

127
00:11:29,927 --> 00:11:31,826
为什么长期成立。

128
00:11:31,826 --> 00:11:36,526
远程和本机的差别应该落在网络上，不落在语义上。

129
00:11:36,526 --> 00:11:41,802
用能力开关切实验面，比在文档里写一句实验要硬得多。

130
00:11:41,802 --> 00:11:48,990
一个布尔把稳定面和实验面切开，模式生成出两份，默认那份不含实验字段。

131
00:11:49,130 --> 00:11:55,714
这一段讲一个很容易被忽略的事实，两个官方 SDK 走的不是同一条协议面。

132
00:11:55,664 --> 00:11:58,993
TypeScript 的 SDK 不走刚才那条路。

133
00:11:58,993 --> 00:12:07,347
它拼的是命令行加实验 JSON 参数，事件类型是点号分隔，字段是下划线的完整枚举只有八个变体。

134
00:12:07,347 --> 00:12:10,496
没有初始化握手，没有审批请求。

135
00:12:10,496 --> 00:12:12,130
这意味着什么。

136
00:12:12,130 --> 00:12:23,657
同一轮对话里模型要跑一条需要提问的命令，Python 客户端可以弹窗并回包，TypeScript 的那个运行方法做不到，因为它压根不在有反向请求的环里。

137
00:12:23,657 --> 00:12:31,133
出处是 exec.ts 的第八十九到九十行，以及 exec_events.rs 的第八到三十七行。

138
00:12:31,133 --> 00:12:34,630
所以选 SDK 的时候不要按语言偏好选。

139
00:12:34,630 --> 00:12:42,743
需要审批交互、需要完整事件流，走 Python 那条；只是想跑一次拿结果，TypeScript 那条更轻。

140
00:12:42,743 --> 00:12:45,856
能力差在协议面，不差在语言。

141
00:12:45,856 --> 00:12:50,147
把这条当成语言差异去排查，会浪费很多时间。

142
00:12:50,147 --> 00:12:51,842
再补一句对比。

143
00:12:51,842 --> 00:13:00,640
另一家方案里能找到的只有入口判断，环境变量等于某个值时返回对应名字，没有对位的对外协议层。

144
00:13:00,640 --> 00:13:07,455
第三方编辑器靠 MCP 和进程入口嵌进来，没有一份带模式的双向远程调用可以对。

145
00:13:07,455 --> 00:13:15,147
Codex 付了投影层的维护成本，换来编辑器扩展、Python SDK 和本机 TUI 共用同一份 v2。

146
00:13:15,290 --> 00:13:16,922
最后收五条。

147
00:13:16,872 --> 00:13:20,117
第一，对外协议是投影，不是导出。

148
00:13:20,117 --> 00:13:24,480
内核按发生了什么命名，对外按用户看见什么命名。

149
00:13:24,480 --> 00:13:31,211
两者更新节奏不同，中间那层的四条规则是改名、换容器、丢掉、拆开。

150
00:13:31,211 --> 00:13:36,884
读源码时以调度为准，别被名字像总入口的助手函数带偏。

151
00:13:36,884 --> 00:13:39,456
第二，未知行必须失败。

152
00:13:39,456 --> 00:13:45,550
匹配里留空默认等于通配臂，新事件能过编译但客户端什么也收不到。

153
00:13:45,550 --> 00:13:50,514
把静默故障推到编译期，是这一层最值钱的一条纪律。

154
00:13:50,514 --> 00:13:52,906
第三，回包不是开跑。

155
00:13:52,906 --> 00:14:00,839
请求带编号要回执，通知不带编号只是广播，反向请求必须回包否则这一轮就停在等待上。

156
00:14:00,839 --> 00:14:06,211
写客户端先判断这条消息要不要回，再决定走哪条通道。

157
00:14:06,211 --> 00:14:09,769
第四，审批是反向请求，不是通知。

158
00:14:09,769 --> 00:14:16,584
用户卡住不动的时候，先排查是不是有服务端请求没人回，而不是先怀疑模型慢。

159
00:14:16,584 --> 00:14:19,757
第五，能力差异先看协议面。

160
00:14:19,757 --> 00:14:29,637
实验能力靠初始化时的一个布尔握手，进程内嵌只换传输不换合同；两个官方 SDK 走的根本不是同一条协议面。

161
00:14:29,637 --> 00:14:33,519
遇到能力对不上，先比协议面，再比语言。

162
00:14:33,519 --> 00:14:40,286
一句话收尾，把内核事件直接当成对外合同，是最便宜也最贵的一种写法。

163
00:14:40,286 --> 00:14:46,776
便宜在不用写投影层，贵在每一次内部重构都会变成对外破坏性变更。

