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

2
00:00:06,382 --> 00:00:15,324
这是 Codex 系列的第二十一集，也是一次三节合辑，素材有一万两千多字，我会做取舍，挑出最能带走的部分。

3
00:00:15,324 --> 00:00:17,367
三节分别讲什么。

4
00:00:17,367 --> 00:00:28,653
第一节讲终端界面，模型按 token 往外推，终端却是一个写出去就改不了的字符网格，哪些行该立刻固化，哪些行必须继续留在可变区。

5
00:00:28,653 --> 00:00:35,576
第二节讲架构决策，写在文档里的规则会腐坏，写成 lint 的规则会在当天亮红。

6
00:00:35,576 --> 00:00:43,930
第三节讲安全，可回放和可拒绝是两套视角，同一条命令上它们有时一致，有时给出相反结论。

7
00:00:43,930 --> 00:00:49,903
把它们放在一起的理由是，三节都在处理同一件事，边界划在哪里。

8
00:00:49,903 --> 00:00:57,680
第一节划的是可变与不可变的边界，第二节划的是人读与机器查的边界，第三节划的是事前与事后的边界。

9
00:00:57,680 --> 00:01:00,805
划错了不报错，只是悄悄失效。

10
00:01:00,805 --> 00:01:07,680
所以这一集你只要记住一句，凡是能局部检查的东西，都不要只靠人记得。

11
00:01:07,830 --> 00:01:09,402
先讲终端。

12
00:01:09,352 --> 00:01:11,936
你盯着屏幕看模型写答案。

13
00:01:11,936 --> 00:01:14,701
散文还好，一行一行往下长。

14
00:01:14,701 --> 00:01:20,338
接着它开始吐一张表，先出表头，再出分隔行，再出第一行数据。

15
00:01:20,338 --> 00:01:22,657
列宽每来一行就变一次。

16
00:01:22,657 --> 00:01:28,415
刚才对齐好的那一列被挤到下一列，上一帧的竖线还印在屏幕上。

17
00:01:28,415 --> 00:01:34,641
你往上滚想看刚才那句结论，滚轮动了，历史和正在写的尾巴叠在一起。

18
00:01:34,641 --> 00:01:38,811
网页换节点的时候浏览器会保住滚动位置。

19
00:01:38,811 --> 00:01:44,821
终端往标准输出写一个字，光标就往前走一格，没有回头改的余地。

20
00:01:44,821 --> 00:01:53,054
想留住旧答案，又想让表跟着新行改列宽，就得先决定哪些格子属于过去，哪些还属于现在。

21
00:01:53,054 --> 00:01:56,119
做法是把渲染结果切成两区。

22
00:01:56,119 --> 00:02:05,145
能确定不再变的行进动画队列，等提交节拍写成历史格子，用转义序列塞进终端自己的回滚缓冲区。

23
00:02:05,145 --> 00:02:10,518
还可能变的行只活在活动格子里面，下一帧可以整段换掉。

24
00:02:10,518 --> 00:02:15,518
出处是 tui 下 streaming 目录里 controller.rs 的第一到三十六行。

25
00:02:15,518 --> 00:02:18,210
控制器同时记两套长度。

26
00:02:18,210 --> 00:02:23,102
一套是交给队列的行数，一套是写进回滚区的行数。

27
00:02:23,102 --> 00:02:27,657
活动尾巴从入队边界算起，不从写出边界算起。

28
00:02:27,657 --> 00:02:33,427
按写出边界切的话，排队还没写出的行会在活动区里再出现一次。

29
00:02:33,427 --> 00:02:38,355
三个指针同向移动，已经写出的那一截不许回头改。

30
00:02:38,355 --> 00:02:40,722
这个合同的本质是什么。

31
00:02:40,722 --> 00:02:45,109
已经交给终端回滚区的字，进程里没有可写副本。

32
00:02:45,109 --> 00:02:52,898
用户用滚轮、搜索、复制，用的是终端自己的能力，界面层不必再做一份完整历史视口。

33
00:02:52,898 --> 00:02:55,434
代价是提交之后不能改。

34
00:02:55,434 --> 00:03:00,963
换个语言重写，只要界面还是终端字符网格，这道题就还在。

35
00:03:00,963 --> 00:03:05,434
这个合同往下走一层，是最容易被忽略的一条。

36
00:03:05,434 --> 00:03:08,811
没换行的增量连活动尾巴都不更新。

37
00:03:08,811 --> 00:03:12,982
用户看见的最小时间单位是一行 Markdown 源码。

38
00:03:12,982 --> 00:03:19,124
半行表格如果先画出来，下一秒结构一对，列会立刻消失再长出来。

39
00:03:19,124 --> 00:03:23,487
所以未结束的源码进缓冲，不能改可见尾巴。

40
00:03:23,487 --> 00:03:28,859
出处是 chatwidget 下 streaming.rs 的第四百八十九到四百九十二行。

41
00:03:28,859 --> 00:03:31,407
这一条能解释很多现象。

42
00:03:31,407 --> 00:03:37,177
模型在一个超长段落中间一直不吐换行，屏幕上就会停在那儿不动。

43
00:03:37,177 --> 00:03:40,518
不是卡了，是还没到可以提交的边界。

44
00:03:40,518 --> 00:03:46,311
同理，一条特别长的半截表格行，在闭合之前你什么都看不到。

45
00:03:46,311 --> 00:03:52,261
所以排查「流式输出卡住」的时候，先问一句，这一帧里有没有换行。

46
00:03:52,261 --> 00:03:55,770
没有换行就是设计内的行为，不是 bug。

47
00:03:55,770 --> 00:04:05,782
真正的问题是另一端，如果模型长时间不吐换行，用户会以为界面死了，这时候需要的是别的心跳提示，不是改渲染逻辑。

48
00:04:05,910 --> 00:04:07,735
表格为什么特殊。

49
00:04:07,685 --> 00:04:11,483
因为 Markdown 表加一行就能改所有列宽。

50
00:04:11,483 --> 00:04:17,648
表头如果已经按窄列冻进回滚区，后面来的长单元格没法回去改它。

51
00:04:17,648 --> 00:04:20,545
竖线对不齐，残影留在原处。

52
00:04:20,545 --> 00:04:24,055
扫描器的做法只认表头加分隔行。

53
00:04:24,055 --> 00:04:29,956
上一行像表头、下一行还没到，先乐观扣住，状态叫待定表头。

54
00:04:29,956 --> 00:04:36,062
后面来的如果是普通散文，状态回到空，那一行再进稳定队列。

55
00:04:36,062 --> 00:04:43,117
两行对上了，进入已确认，从表头起整张表留在尾巴里，直到流结束才定稿。

56
00:04:43,117 --> 00:04:45,858
表前面的散文可以继续提交。

57
00:04:45,858 --> 00:04:50,617
出处是 table_holdback.rs 的第二十一到三十二行。

58
00:04:50,617 --> 00:04:52,576
这里有个误伤问题。

59
00:04:52,576 --> 00:04:57,865
一句看起来像表头的散文，中间带竖线，其实只是一句话。

60
00:04:57,865 --> 00:05:02,901
扣得太狠，这段散文会被卡住，直到流结束才放行。

61
00:05:02,901 --> 00:05:10,064
所以扫描器要求两行对上才确认，只有一行的时候保持待定而不是直接判成表。

62
00:05:10,064 --> 00:05:12,937
尾巴预算由扫描状态决定。

63
00:05:12,937 --> 00:05:16,999
空状态预算是零，行直接进稳定队列。

64
00:05:16,999 --> 00:05:21,182
待定和已确认则从表头起点起整段扣住。

65
00:05:21,182 --> 00:05:28,141
原始模式预算也是零，表格当纯文本流走，列宽问题交给用户自己的终端选区。

66
00:05:28,141 --> 00:05:32,805
出处是 controller.rs 的第三百八十四到四百一十二行。

67
00:05:32,805 --> 00:05:34,475
还有两个细节。

68
00:05:34,475 --> 00:05:38,189
sh 围栏里的竖线当代码，不触发扣留。

69
00:05:38,189 --> 00:05:46,855
连续多张表时，第一张没结束，后面的表也不提前定稿，超长多表答案会在活动区堆到流结束。

70
00:05:46,855 --> 00:05:48,754
为什么长期成立。

71
00:05:48,754 --> 00:05:53,177
列宽是全局量，局部追加会改已经画过的行。

72
00:05:53,177 --> 00:05:58,249
把整张未闭合的表留在可变区，是这条约束的最小解。

73
00:05:58,249 --> 00:06:04,403
网页里对应的做法是，表格节点在闭合之前不要拆进不可变的节点片段。

74
00:06:04,540 --> 00:06:08,372
第一节最后讲两个工程细节，都很实用。

75
00:06:08,322 --> 00:06:10,341
第一个是排水速度。

76
00:06:10,341 --> 00:06:13,959
稳定行入队之后立刻全部写出去也不对。

77
00:06:13,959 --> 00:06:19,259
慢流希望一行一行长出来，像打字；快流希望队列赶得上模型。

78
00:06:19,259 --> 00:06:22,180
只有一档速度，两端都难受。

79
00:06:22,180 --> 00:06:27,841
做法是分两档，平滑档每个节拍出一行，追赶档一次抽空当前队列。

80
00:06:27,841 --> 00:06:33,526
深度到八行，或者最老一行超过一百二十毫秒，就能进追赶。

81
00:06:33,526 --> 00:06:39,548
退出要深度降到二、年龄降到四十毫秒，并且保持二百五十毫秒。

82
00:06:39,548 --> 00:06:45,401
退出后再挡二百五十毫秒，除非堆到六十四行或三百毫秒。

83
00:06:45,401 --> 00:06:50,533
策略不看这段文本是标题还是表格，只看队列深度和年龄。

84
00:06:50,533 --> 00:06:54,716
出处是 chunking.rs 的第八十二到一百二十五行。

85
00:06:54,716 --> 00:06:57,757
第二个是一帧里的写入要原子。

86
00:06:57,757 --> 00:07:04,043
历史行用转义序列写到视口上方，界面库再画输入框和活动尾巴。

87
00:07:04,043 --> 00:07:10,137
拆成两次刷新，用户会先看见历史往上跳一截，输入框还停在旧位置。

88
00:07:10,137 --> 00:07:18,442
所以写屏时回滚区插入和视口绘制包进同一次同步更新，双缓冲只把变过的格子写出去。

89
00:07:18,442 --> 00:07:24,367
画回调必须画满整帧，少画一块，终端会留下上一帧的残字。

90
00:07:24,367 --> 00:07:28,995
出处是 tui.rs 的第九百五十四到九百七十三行。

91
00:07:28,995 --> 00:07:37,312
两处设计的共同点是，不要用平均值对付两种压力，也不要把同一帧的多次写入拆开提交。

92
00:07:37,470 --> 00:07:40,665
第二节换个话题，讲规则和文档。

93
00:07:40,615 --> 00:07:43,668
新人接到任务，改一个工具调用。

94
00:07:43,668 --> 00:07:48,067
他打开仓库规范文档，抄下第三十五行的路径。

95
00:07:48,067 --> 00:07:49,653
文件不存在。

96
00:07:49,653 --> 00:07:53,656
真实文件叫连接管理器，就在同一个目录。

97
00:07:53,656 --> 00:07:59,184
文档里那个带前缀的名字，是一次重命名之后没改干净的残留。

98
00:07:59,184 --> 00:08:05,651
出处是规范文档的第三十二到三十六行，以及那个真实文件的第一到十五行。

99
00:08:05,651 --> 00:08:09,293
有意思的是同一份文件里的另一条规则。

100
00:08:09,293 --> 00:08:13,043
位置参数少了一个注释，本地命令会红。

101
00:08:13,043 --> 00:08:17,586
改了依赖清单忘刷新锁文件，持续集成会红。

102
00:08:17,586 --> 00:08:20,230
唯独那条路径没有检查器。

103
00:08:20,230 --> 00:08:23,307
Markdown 不会自己核对文件在不在。

104
00:08:23,307 --> 00:08:26,684
再看那条会红的规则是怎么做的。

105
00:08:26,684 --> 00:08:33,355
调用点写裸的空值或者布尔值，读者必须跳到定义才知道这个参数管什么。

106
00:08:33,355 --> 00:08:39,401
首选是改接口让调用点自己能读；改不了接口，才允许写行内注释。

107
00:08:39,401 --> 00:08:44,966
这条检查住在一个独立的静态分析库里，当一次编译器插件跑。

108
00:08:44,966 --> 00:08:54,076
类型解析完成之后才能拿到被调方的参数名，入口只看函数调用和方法调用，宏展开出来的直接跳过。

109
00:08:54,076 --> 00:09:01,937
出处是 argument-comment-lint 下 lib.rs 的第一百六十五到一百八十行、第二百六十一到二百七十四行。

110
00:09:01,937 --> 00:09:03,872
检查顺序值得记。

111
00:09:03,872 --> 00:09:24,633
只查本仓库的 crate，标准库和第三方直接放过；注释从参数前的空隙、前六十四字节、参数文本自身三处找；名字不对报不匹配；没写时，方法名等于唯一参数名就豁免；剩下的只拦匿名字面量，空值、布尔、数字要写，字符串和字符放过。

112
00:09:24,633 --> 00:09:28,876
仓库入口把默认允许的那条抬成拒绝。

113
00:09:28,876 --> 00:09:35,510
持续集成在三个操作系统上各跑一次，一台失败另外两台继续跑完。

114
00:09:35,510 --> 00:09:41,772
人在苹果机器上绿了，另两台上多出一处裸空值，第三台仍会拦住。

115
00:09:41,772 --> 00:09:46,857
出处是持续集成配置的第一百六十四到一百八十七行。

116
00:09:46,857 --> 00:09:49,561
为什么值得养一台编译器插件。

117
00:09:49,561 --> 00:09:55,018
因为调用点是局部的、参数名是可解析的、误报能用豁免收住。

118
00:09:55,018 --> 00:10:00,102
换个语言形状一样，TypeScript 用 ESLint，Python 用 ruff。

119
00:10:00,250 --> 00:10:02,784
同一种腐坏还有第二处。

120
00:10:02,734 --> 00:10:10,618
规范文档里写着某个协议文件的单文件路径，当前已经是一个目录，下面拆成三十多个文件。

121
00:10:10,618 --> 00:10:13,070
文件靠近八百行就要拆。

122
00:10:13,070 --> 00:10:16,904
拆了之后，指南里的单文件路径没人改。

123
00:10:16,904 --> 00:10:21,339
出处是规范文档的第二百六十到二百六十六行。

124
00:10:21,339 --> 00:10:27,445
模块行数规则点名五个高频文件，四个已经越过八百，一个贴着九百。

125
00:10:27,445 --> 00:10:31,327
其中一个文件按行计有一万两千多行。

126
00:10:31,327 --> 00:10:34,104
仓库里没有数行数的命令。

127
00:10:34,104 --> 00:10:36,964
行数能数，持续集成不数。

128
00:10:36,964 --> 00:10:43,287
一次改动是不是机械拆分，机器做不好，所以八百行上限停在评审环节。

129
00:10:43,287 --> 00:10:49,008
出处是规范文档的第四十九到六十一行、第一百二十五到一百三十一行。

130
00:10:49,008 --> 00:10:51,748
所以把规则分成两套来读。

131
00:10:51,748 --> 00:10:55,450
一套有命令或编译器，合并前会亮红。

132
00:10:55,450 --> 00:10:59,116
一套只能被人和评审读，漏看就过。

133
00:10:59,116 --> 00:11:08,238
路径是否存在本来是最容易检查的，抽出反引号里的路径，对仓库根做存在性判断就行，二十行脚本的事。

134
00:11:08,238 --> 00:11:09,741
仓库没做。

135
00:11:09,741 --> 00:11:14,392
预算花在调用点可读性上，没有花在路径存在性上。

136
00:11:14,392 --> 00:11:19,717
这是个很典型的取舍失误，难的检查做了，便宜的检查没做。

137
00:11:19,717 --> 00:11:21,760
横向看一眼别家。

138
00:11:21,760 --> 00:11:30,414
有团队把每个包必须拥有一份不变量说明同时写成散文和门禁，门禁是二十一行脚本，失败就退出非零。

139
00:11:30,414 --> 00:11:35,150
空安装器必须带固定前缀，空是显式的架构结论。

140
00:11:35,150 --> 00:11:40,450
两边缺一，就会回到那种文字还在、对象已经搬家的状态。

141
00:11:40,450 --> 00:11:42,445
最小形态是什么。

142
00:11:42,445 --> 00:11:46,736
二十行脚本核对路径存在性，不需要编译器插件。

143
00:11:46,736 --> 00:11:51,784
小团队先抄这个，比抄复杂的静态分析便宜得多。

144
00:11:51,920 --> 00:11:55,283
第三节讲安全，先把两个视角分开。

145
00:11:55,233 --> 00:11:59,716
可回放问的是，出事之后，现场还能不能拼回来。

146
00:11:59,716 --> 00:12:04,055
可拒绝问的是，出事之前，有没有一道门能说不。

147
00:12:04,055 --> 00:12:09,524
同一条危险命令上，这两套视角会一致，也会给出相反结论。

148
00:12:09,524 --> 00:12:11,231
先看可回放。

149
00:12:11,231 --> 00:12:12,997
历史写成两份。

150
00:12:12,997 --> 00:12:18,454
JSONL 是原文，只追加已经定稿的条目，不从内容里推断元数据。

151
00:12:18,454 --> 00:12:21,699
SQLite 是镜像，给列表和检索用。

152
00:12:21,699 --> 00:12:26,375
元数据丢了可以再抽，JSONL 丢了，恢复必须读文件。

153
00:12:26,375 --> 00:12:30,654
出处是 thread-store 下 README 的第二十二到二十八行。

154
00:12:30,654 --> 00:12:32,240
还有一道筛选。

155
00:12:32,240 --> 00:12:39,091
持久化策略会丢掉流式增量、审批弹窗、警告和启动进度，只留下轮次开始。

156
00:12:39,091 --> 00:12:44,704
你能回放轮次边界和完成态，回放不了当时屏幕上闪过的审批文案。

157
00:12:44,704 --> 00:12:49,295
出处是 rollout 下 policy.rs 的第八十六到一百零五行。

158
00:12:49,295 --> 00:12:52,264
分叉认的也是这条物理边界。

159
00:12:52,264 --> 00:13:02,132
目标轮次必须在有效历史里，文件里真有一条轮次开始，进行中的轮次直接拒绝，投影出来的合成编号不能当切点。

160
00:13:02,132 --> 00:13:08,646
出处是 thread_rollout_truncation.rs 的第一百八十七到一百九十ー行。

161
00:13:08,646 --> 00:13:10,954
所以真相也经过筛选。

162
00:13:10,954 --> 00:13:18,238
列表能告诉你有过这个线程，只有 JSONL 能告诉你模型当时看见了哪几条消息。

163
00:13:18,390 --> 00:13:20,046
再看可拒绝。

164
00:13:19,996 --> 00:13:26,402
一条命令从模型提议走到进程启动，至少经过四道可以拒绝的门。

165
00:13:26,402 --> 00:13:28,037
第一道是策略。

166
00:13:28,037 --> 00:13:34,047
判定结果只有允许、询问、禁止三态，用序取最严的那一档。

167
00:13:34,047 --> 00:13:39,948
规则文件里的反例加载器真的跑一遍，反例被命中，会话开不起来。

168
00:13:39,948 --> 00:13:47,809
出处是 execpolicy 下 decision.rs 的第九到十六行、rule.rs 的第二百八十一到三百零六行。

169
00:13:47,809 --> 00:13:49,540
第二道是审批。

170
00:13:49,540 --> 00:13:56,030
会话缓存认精确键，带上规范化后的完整参数、工作目录、权限。

171
00:13:56,030 --> 00:14:03,987
早期常把记住同类理解成前缀缓存，当前源码里，跑测试和跑检查是两把不同的键。

172
00:14:03,987 --> 00:14:08,157
这道门挡住同一条精确命令的重复弹窗。

173
00:14:08,157 --> 00:14:13,145
出处是 unified_exec.rs 的第八十六到九十七行。

174
00:14:13,145 --> 00:14:15,765
第三道用模型换弹窗。

175
00:14:15,765 --> 00:14:21,931
安全审查器超时或者输出坏了就关闸，只认明确的允许或拒绝。

176
00:14:21,931 --> 00:14:23,830
它挡住审批疲劳。

177
00:14:23,830 --> 00:14:27,172
用户本人点批准，这道门会让路。

178
00:14:27,172 --> 00:14:30,850
出处是 guardian 下 mod.rs 的第一到十二行。

179
00:14:30,850 --> 00:14:33,205
第四道才是操作系统。

180
00:14:33,205 --> 00:14:41,402
苹果机器上拼的是系统级沙箱描述语言，Linux 默认用容器加系统调用过滤，失败不回退到旧方案。

181
00:14:41,402 --> 00:14:44,972
Windows 走受限令牌，开关关着就返回空。

182
00:14:44,972 --> 00:14:53,482
进程启动之后，出站再过代理，代理拒绝时把带错误头的四百零三回给命令进程，循环继续。

183
00:14:53,482 --> 00:15:02,797
出处是 sandboxing 下 manager.rs 的第三十六到四十二行，以及 network-proxy 下 responses.rs 的第七十六到八十三行。

184
00:15:02,797 --> 00:15:05,429
最后补一条特别反直觉的。

185
00:15:05,429 --> 00:15:09,011
命令退出码非零，按直觉像错误。

186
00:15:09,011 --> 00:15:15,886
可如果把沙箱拒绝升级成引擎错误，模型看不到退出码，只会换一条更绕的命令。

187
00:15:15,886 --> 00:15:21,475
所以工具层只允许两种失败，回喂模型或者打断引擎。

188
00:15:21,475 --> 00:15:30,429
沙箱拒绝走的是成功的工具输出，进程号被清掉，退出码留在正文里，记日志时成功位恒为真。

189
00:15:30,429 --> 00:15:38,818
出处是 function_call_error.rs 的第一到十行，以及 context.rs 的第三百四十到三百五十三行。

190
00:15:38,818 --> 00:15:41,186
每一层看的东西不一样。

191
00:15:41,186 --> 00:15:47,496
策略看参数，审批看人，内核看路径和系统调用，代理看域名。

192
00:15:47,496 --> 00:15:50,753
一层看不清，就停在这一层。

193
00:15:50,890 --> 00:15:52,522
最后收五条。

194
00:15:52,472 --> 00:15:55,994
第一，可变与不可变的边界要显式划。

195
00:15:55,994 --> 00:16:02,172
终端里已经写进回滚区的字改不了，所以还没闭合的东西必须留在活动区。

196
00:16:02,172 --> 00:16:09,119
表格扣留是这条约束的最小解，网页上对应的是表格闭合之前不要拆进不可变节点。

197
00:16:09,119 --> 00:16:11,955
第二，提交的最小单位选对。

198
00:16:11,955 --> 00:16:16,727
没有换行就不更新可见内容，这不是卡住，是设计。

199
00:16:16,727 --> 00:16:22,328
用户以为界面死了的时候，需要的是心跳提示，不是改渲染逻辑。

200
00:16:22,328 --> 00:16:26,451
第三，能局部检查的决策不要只写在文档里。

201
00:16:26,451 --> 00:16:32,725
文档不会自己复查，重命名和拆文件的那天，文字还在，对象已经搬家。

202
00:16:32,725 --> 00:16:39,527
先做便宜的检查，路径存在性是二十行脚本的事，比养一台编译器插件便宜得多。

203
00:16:39,527 --> 00:16:42,773
第四，事前和事后是两套视角。

204
00:16:42,773 --> 00:16:48,398
可回放回答出事之后能不能复原，可拒绝回答出事之前有没有门。

205
00:16:48,398 --> 00:16:55,405
四道门加一道出站漏斗，每一道只锁住一类风险，一层看不清就停在这一层。

206
00:16:55,405 --> 00:16:59,479
第五，不要把工具失败升级成引擎错误。

207
00:16:59,479 --> 00:17:07,388
沙箱拒绝、非零退出码、代理拒绝，默认都回喂模型，让模型自己决定下一步。

208
00:17:07,388 --> 00:17:10,946
只有编排本身坏了，才停整轮对话。

209
00:17:10,946 --> 00:17:15,597
一句话收尾，边界划错不会报错，只会悄悄失效。

210
00:17:15,597 --> 00:17:21,931
所以每一条边界，都要问一句，它有没有一个机器能在当天检查的地方。

