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

2
00:00:06,382 --> 00:00:14,651
这是 Codex 系列的第十五集，讲一个很实用的转变，让模型写一段程序，而不是发二十次工具调用。

3
00:00:14,651 --> 00:00:16,021
先说动机。

4
00:00:16,021 --> 00:00:21,201
读五个文件再汇总，用普通工具调用要走五次完整往返。

5
00:00:21,201 --> 00:00:29,134
每一次往返都要把整个文件内容推进上下文，模型下一轮还得把滚大的历史重读一遍。

6
00:00:29,134 --> 00:00:33,822
真正贵的地方在往返的节奏上，工具本身并不贵。

7
00:00:33,822 --> 00:00:36,394
那让模型写程序不就行了。

8
00:00:36,394 --> 00:00:41,959
麻烦在于，这段程序没有任何人审过一行，模型现写现交。

9
00:00:41,959 --> 00:00:49,639
如果给它一个能读文件、能发网络请求的运行时，等于把宿主的全部能力直接交出去。

10
00:00:49,639 --> 00:00:52,716
所以这一集其实在讲两个问题。

11
00:00:52,716 --> 00:01:01,430
第一个是给模型的运行时该削到多薄；第二个是这段程序跑不完的时候，到底算失败还是算还在跑。

12
00:01:01,430 --> 00:01:03,389
一句话先放在这儿。

13
00:01:03,389 --> 00:01:07,500
往返贵、批处理便宜，这是几十年的老账。

14
00:01:07,500 --> 00:01:13,533
而给不受信任的代码一个尽量小的环境，是安全设计的通用形状。

15
00:01:13,533 --> 00:01:15,781
两节的分工也很清楚。

16
00:01:15,781 --> 00:01:28,353
第一节讲运行时本身，它有多薄，以及跑不完的时候怎么收场；第二节讲挂点，这段程序到底跑在哪个进程里，挂点换了之后故障域和状态归属怎么变。

17
00:01:28,353 --> 00:01:31,394
前者是语义，后者是拓扑。

18
00:01:31,540 --> 00:01:35,300
Codex 给模型两个工具，exec 和 wait。

19
00:01:35,250 --> 00:01:41,620
exec 收一段 JavaScript 源码，扔进一个全新的 V8 isolate 当异步模块求值。

20
00:01:41,620 --> 00:01:50,899
所有工具挂在全局的 tools 对象上，名字被规范化成合法的标识符，写起来就是等待 tools 点 exec_command 这样一句。

21
00:01:50,899 --> 00:01:59,312
程序里想循环就循环、想分支就分支，中间值留在变量里，只有主动交出去的那部分才回到模型。

22
00:01:59,312 --> 00:02:02,714
关键在于这个运行时被削得很薄。

23
00:02:02,714 --> 00:02:06,476
工具说明书里对模型直说了它没有什么。

24
00:02:06,476 --> 00:02:11,596
没有 Node，没有文件系统，没有网络访问，连 console 都没有。

25
00:02:11,596 --> 00:02:14,805
这些能力不是忘了加，是特意不给。

26
00:02:14,805 --> 00:02:16,343
结果是什么。

27
00:02:16,343 --> 00:02:20,851
程序想产生任何副作用，只剩一条路，走 tools。

28
00:02:20,851 --> 00:02:26,752
而那条路上审批和沙箱一样不少，该弹的窗照弹，该拦的照拦。

29
00:02:26,752 --> 00:02:31,800
出处是 code-mode-protocol 下 description.rs 的第二十到二十五行。

30
00:02:31,800 --> 00:02:33,591
还有个附带好处。

31
00:02:33,591 --> 00:02:35,827
要审查的面积变小了。

32
00:02:35,827 --> 00:02:46,668
isolate 里如果能直接读文件，这一层就得自己再做一套文件权限；现在它什么都做不了，权限判断留在下一层就够，代码不用写两遍。

33
00:02:46,668 --> 00:02:49,276
再补一句说明书里的细节。

34
00:02:49,276 --> 00:02:55,514
输入要是纯源码文本，不是 JSON 字符串，也不是带围栏的 markdown。

35
00:02:55,514 --> 00:03:03,903
还有一句很关键，脚本求值结束时，isolate 的生命周期就结束了，没被等待的 promise 会被静默丢弃。

36
00:03:04,060 --> 00:03:07,267
现在看第二个问题，跑不完怎么办。

37
00:03:07,217 --> 00:03:09,621
设超时的两难很明显。

38
00:03:09,621 --> 00:03:14,512
设成三分钟，用户三分钟看不到动静，也没地方喊停。

39
00:03:14,512 --> 00:03:25,210
设成十秒，长任务永远做不完，更难受的是前面九秒的成果跟着一起丢，模型只收到一条超时消息，只能从头再来。

40
00:03:25,210 --> 00:03:29,741
两个方向都不对，问题出在把还没跑完当成了失败。

41
00:03:29,741 --> 00:03:35,390
Codex 的做法是把正在跑的脚本做成一个有身份的东西，叫 cell。

42
00:03:35,390 --> 00:03:43,094
让出时间到点，cell 不死，它把这段时间攒下的输出整包交出去，然后清空缓冲继续跑。

43
00:03:43,094 --> 00:03:48,539
exec 这时返回一句话，告诉模型脚本还在跑，编号是多少。

44
00:03:48,539 --> 00:03:51,868
模型拿到编号，手上就有三个选择。

45
00:03:51,868 --> 00:03:57,854
调 wait 再买一段时间；带终止标记把它停掉；或者干脆先去干别的。

46
00:03:57,854 --> 00:04:07,469
而且 wait 只返回上次让出之后的新输出，因为交出去的时候缓冲就被清空了，同一段内容不会重复占两次上下文。

47
00:04:07,469 --> 00:04:12,697
模型可以在首行写一句 pragma，声明这次给多少让出时间。

48
00:04:12,697 --> 00:04:17,806
执行入口把这个毫秒数换成跑到点就观察一次的模式。

49
00:04:17,806 --> 00:04:19,945
默认是一万毫秒。

50
00:04:19,945 --> 00:04:26,051
出处是 service.rs 的第七十七行，以及 description.rs 的第二十二行。

51
00:04:26,190 --> 00:04:29,902
有两个细节特别能说明设计者在想什么。

52
00:04:29,852 --> 00:04:31,474
第一个是宽限。

53
00:04:31,474 --> 00:04:38,001
让出时间超过十秒时，Codex 会额外再送一秒宽限，然后才真的去观察。

54
00:04:38,001 --> 00:04:43,553
出处是 code-mode-runtime 下 service.rs 的第一百九十八到二百一十行。

55
00:04:43,553 --> 00:04:50,801
这样做的好处是，刚好卡在边界上完成的脚本，不会因为差几毫秒白白多走一次往返。

56
00:04:50,801 --> 00:04:52,580
第二个是不对称。

57
00:04:52,580 --> 00:05:00,188
发给模型的说明书里，wait 有四个参数，cell 编号、让出时间、返回长度上限、要不要终止。

58
00:05:00,188 --> 00:05:05,957
可协议层的请求结构体只带前两个，后两个停在处理器那一层。

59
00:05:05,957 --> 00:05:11,126
终止走的是另一条路径，长度上限是拿到结果之后才截断的。

60
00:05:11,126 --> 00:05:14,779
读源码的时候这两层很容易混成一层。

61
00:05:14,779 --> 00:05:20,104
定时器到点，把攒下的输出整包交出去，缓冲同时清空。

62
00:05:20,104 --> 00:05:25,248
让出这件事被翻译成一句模型读得懂的话，带上 cell 编号。

63
00:05:25,248 --> 00:05:28,710
脚本跑完，返回结果并关闭这个 cell。

64
00:05:28,710 --> 00:05:31,294
为什么这个形状长期成立。

65
00:05:31,294 --> 00:05:36,811
把还没结束做成一等状态，是长任务接口的通用形状。

66
00:05:36,811 --> 00:05:45,176
HTTP 有 202 加轮询，任务队列有任务编号加轮询，导出大文件的后台任务也是先给你一个编号。

67
00:05:45,176 --> 00:05:52,676
共同点是不让调用方在一直等和当作失败之间二选一，而是给一个可以再问一次的把手。

68
00:05:52,676 --> 00:05:56,150
放到 agent 上，这个把手还多一层价值。

69
00:05:56,150 --> 00:06:04,527
模型拿到中间输出之后可以改主意，发现前四步的结果不对，直接终止，不用陪着跑完剩下八分钟。

70
00:06:04,527 --> 00:06:07,712
控制权回到了会思考的那一方手里。

71
00:06:07,712 --> 00:06:14,948
这也是为什么让出间隔不宜设得太大，间隔越长，模型改主意的机会就越少。

72
00:06:15,080 --> 00:06:19,585
第二部分问另一个问题，这段 JavaScript 到底挂在谁身上。

73
00:06:19,535 --> 00:06:21,314
为什么要拆出去。

74
00:06:21,314 --> 00:06:27,035
模型写一段死循环，上一集见过，要靠 isolate 的终止才能打断。

75
00:06:27,035 --> 00:06:34,559
这段程序如果和 Codex 的事件循环抢同一个进程，卡死会从单个 cell 扩大到整条会话。

76
00:06:34,559 --> 00:06:43,393
V8 的堆、即时编译、没有条目上限的 store 表，任意一项在主进程里爆炸，都会带走整个界面或者服务端。

77
00:06:43,393 --> 00:06:51,386
早期资料常把默认路径写成主进程里直接跑 isolate，当前特性注释已经把这句话改掉了。

78
00:06:51,386 --> 00:06:56,049
出处是 features 下 lib.rs 的第一百零四到一百一十一行。

79
00:06:56,049 --> 00:06:58,092
现在的默认是什么。

80
00:06:58,092 --> 00:07:01,746
CodeModeHost 已经是稳定特性，默认打开。

81
00:07:01,746 --> 00:07:05,160
用户看见的默认已经是本地子进程。

82
00:07:05,160 --> 00:07:12,948
进程内求值还在，只是沉到了宿主进程内部，isolate 活在小屋里，房东换成了一个独立二进制。

83
00:07:12,948 --> 00:07:18,056
出处是 features 下 lib.rs 的第九百二十一到九百二十五行。

84
00:07:18,056 --> 00:07:26,410
拉起进程的时候，标准输入输出和错误全管道化，Unix 上单独一个进程组，环境变量先清理一遍。

85
00:07:26,410 --> 00:07:33,092
找不到可执行文件，可用性检查直接失败，不会改去主进程里新建 isolate。

86
00:07:33,250 --> 00:07:35,892
这里有个很容易误解的地方。

87
00:07:35,842 --> 00:07:45,421
你把 app-server 指到一台远端机器的 code mode host，容易以为整段 agent 都搬家了，连命令执行的审批弹窗都该出现在远端。

88
00:07:45,421 --> 00:07:47,224
当前源码对不上。

89
00:07:47,224 --> 00:07:54,400
远端宿主只搬走了求值，嵌套工具的审批、execpolicy 和 Guardian 仍然在本机会话上。

90
00:07:54,400 --> 00:07:56,130
机制是这样的。

91
00:07:56,130 --> 00:08:02,717
宿主把会话委托做成一个远程委托，经进程间通信打回 Codex 主进程。

92
00:08:02,717 --> 00:08:06,515
主进程上的分发中介才去走嵌套工具。

93
00:08:06,515 --> 00:08:11,311
出处是 code-mode-host 下 delegate.rs 的第二十六到五十行。

94
00:08:11,311 --> 00:08:15,277
一句话概括，isolate 搬家了，策略没有搬家。

95
00:08:15,277 --> 00:08:19,135
JavaScript 在别处跑，副作用要绕回来问你。

96
00:08:19,135 --> 00:08:20,974
为什么必须这样。

97
00:08:20,974 --> 00:08:25,133
不可信的是 JavaScript 世界，可信的是审批和策略。

98
00:08:25,133 --> 00:08:28,750
把前者搬走，后者留在有界面的那边。

99
00:08:28,750 --> 00:08:33,955
没有这条回路，远端宿主就必须复制你的整套权限系统。

100
00:08:33,955 --> 00:08:36,034
还有一处安全细节。

101
00:08:36,034 --> 00:08:42,741
WebSocket 监听器拒绝带 Origin 头的请求，挡住浏览器页面跨源连到本机宿主。

102
00:08:42,741 --> 00:08:50,301
而 app-server 的参数认 http 和 https 为 gRPC，认 ws 和 wss 为 WebSocket。

103
00:08:50,301 --> 00:08:53,041
gRPC 那条线上还有一个细节。

104
00:08:53,041 --> 00:09:01,984
它把工具订阅、完成、执行流拆开，丢掉建立会话的那条租约流，会话就关闭，正在跑的 cell 一并终止。

105
00:09:01,984 --> 00:09:06,887
所以租约不是心跳那么简单，它是会话寿命的载体。

106
00:09:07,020 --> 00:09:10,804
最后讲两种常见预期，源码都不认。

107
00:09:10,754 --> 00:09:16,811
第一种预期是你按了中断，V8 一起掐掉，下一轮 wait 立刻看到终止。

108
00:09:16,811 --> 00:09:28,662
实际是，本轮被标成中断时，任务取消令牌一定会取消，但会不会再去终止还在跑的 cell，要看一个特性开关，而这个开关还在开发，默认关。

109
00:09:28,662 --> 00:09:35,032
关掉时，中断只取消本轮工具调用和审批，宿主上的 isolate 可以继续跑。

110
00:09:35,032 --> 00:09:38,807
用户按 Ctrl-C，并不自动等于那条终止。

111
00:09:38,807 --> 00:09:43,746
出处是 tasks 下 mod.rs 的第八百八十八到八百九十九行。

112
00:09:43,746 --> 00:09:49,095
第二种预期是重连之后，刚才那段脚本还在原来的 cell 里接着跑。

113
00:09:49,095 --> 00:09:50,201
也不对。

114
00:09:50,201 --> 00:09:52,929
gRPC 丢掉租约就关会话。

115
00:09:52,929 --> 00:09:56,835
客户端可以再开一条租约，世代从一往上加。

116
00:09:56,835 --> 00:10:02,196
第一代对外仍用原始 cell 编号，第二代会给编号加上世代前缀。

117
00:10:02,196 --> 00:10:06,920
模型拿着第一代的编号去 wait，会收到世代过期的回应。

118
00:10:06,920 --> 00:10:13,975
本地子进程路径没有这套前缀，只是把状态机打回新建，再分配一个新的会话号。

119
00:10:13,975 --> 00:10:17,737
两种重连都丢运行中的 cell 和那份 store。

120
00:10:17,737 --> 00:10:24,371
store 表跟着宿主侧的运行时，没有落盘，没有跨进程共享，也没有过期时间。

121
00:10:24,371 --> 00:10:27,148
远端机器重启，表就没了。

122
00:10:27,148 --> 00:10:31,595
重连恢复的是还能再执行，不是刚才那段脚本。

123
00:10:31,595 --> 00:10:33,482
一句话收在这里。

124
00:10:33,482 --> 00:10:37,124
cell 的寿命按会话算，不按轮次算。

125
00:10:37,124 --> 00:10:40,826
中断轮次和杀掉程序是两件事。

126
00:10:40,980 --> 00:10:43,021
横向看另外两家。

127
00:10:42,971 --> 00:10:46,901
DeepSeek 的 Harness 把程序放进进程内的工作线程。

128
00:10:46,901 --> 00:10:52,202
crate 头注释第一句把立场写死，这是 containment，不是 security boundary。

129
00:10:52,202 --> 00:10:55,158
模型代码按 bash 等价来对待。

130
00:10:55,158 --> 00:11:03,187
每次运行拉起一个新 worker，环境是空的，堆有上限，程序世界随 worker 一起死，没有跨运行状态。

131
00:11:03,187 --> 00:11:04,870
代价很清楚。

132
00:11:04,870 --> 00:11:12,262
一次运行必须在预算内结束，超时就是失败，没有同一段程序接着跑这种一等状态。

133
00:11:12,262 --> 00:11:16,901
换来的是实现简单，宿主不用维护一堆还活着的 cell。

134
00:11:16,901 --> 00:11:25,880
Codex 反过来，模型要多学一个 wait 协议，cell 会在会话里占着资源，直到跑完、被停掉或者会话结束。

135
00:11:25,880 --> 00:11:28,055
状态这块两边也不同。

136
00:11:28,055 --> 00:11:37,791
DSH 的程序所在世界会随 worker 一同终止，不做池化，需要传给下一次的东西要么写进工具结果，要么落到工作区文件。

137
00:11:37,791 --> 00:11:44,810
Codex 给了 store 和 load，同一会话里的多次执行可以共享数据，跨会话互相看不见。

138
00:11:44,810 --> 00:11:51,805
代价是清理责任落回自己身上，这个抽屉没有单条大小上限，也没有过期时间。

139
00:11:51,805 --> 00:11:53,956
另外两家没有对位物。

140
00:11:53,956 --> 00:12:00,194
Claude Code 和 Grok 都没有模型写一段程序、在独立运行时里编排工具的实现。

141
00:12:00,194 --> 00:12:06,961
它们的隔离出现在工作区和子 agent 上，那是工作区隔离，不是 JavaScript 宿主拆分。

142
00:12:06,961 --> 00:12:13,223
没有对位物本身就是结论，这两家把跑模型写的代码留给了普通 shell 工具。

143
00:12:13,223 --> 00:12:14,762
补一句提醒。

144
00:12:14,762 --> 00:12:22,202
仓库里还有 exec-server，它搬走的是 shell、伪终端和文件系统远程调用，并不跑 JavaScript。

145
00:12:22,202 --> 00:12:27,694
嵌套的命令执行仍然可以再走进去，那是下一层的执行拆分。

146
00:12:27,694 --> 00:12:30,783
两条路不要收成同一个远端。

147
00:12:30,920 --> 00:12:33,249
收尾，四条原则。

148
00:12:33,199 --> 00:12:36,048
一，把 N 次往返压成一次。

149
00:12:36,048 --> 00:12:41,961
模型采样一轮比一次网络往返贵得多，批处理收益只会更夸张。

150
00:12:41,961 --> 00:12:47,274
但前提是你得先想清楚，这段程序到底需要哪几样能力。

151
00:12:47,274 --> 00:12:51,865
二，给不受信任的代码做减法，而不是加监控。

152
00:12:51,865 --> 00:12:57,995
没有文件系统、没有网络、没有 console，它想干坏事都没有接口可用。

153
00:12:57,995 --> 00:13:04,990
这条和具体技术无关，换语言换沙箱该问的还是同一句，其余的能不能一样都不给。

154
00:13:04,990 --> 00:13:08,536
三，把还没结束做成一等状态。

155
00:13:08,536 --> 00:13:15,375
给调用方一个编号，让它可以再问一次，而不是在一直等和当作失败之间二选一。

156
00:13:15,375 --> 00:13:24,894
放到 agent 上还多一层价值，模型拿到中间输出可以改主意，发现不对直接终止，控制权回到会思考的那一方手里。

157
00:13:24,894 --> 00:13:27,995
四，可信与不可信要分开搬家。

158
00:13:27,995 --> 00:13:35,483
不可信的求值可以搬到远端甚至另一台机器，可信的审批和策略必须留在有界面的那一侧。

159
00:13:35,483 --> 00:13:39,101
否则远端就得复制整套权限系统。

160
00:13:39,101 --> 00:13:40,904
最后留一道练习。

161
00:13:40,904 --> 00:13:54,377
一段程序要跑四十秒，让出预算默认十秒，推演模型一共要发几次 wait，每次拿到的是全部输出还是新增的那一段，以及为什么把默认值改成三十秒并不总是更划算。

162
00:13:54,377 --> 00:14:05,219
进阶一问是，如果程序在第三十五秒把一个很大的对象放进了 store，随后模型决定终止，这个对象什么时候被清掉，谁来管它的大小。

163
00:14:05,219 --> 00:14:07,322
这一集就到这里。

