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

2
00:00:06,382 --> 00:00:10,649
这一集讲两件看着没关系、其实是一体两面的事。

3
00:00:10,649 --> 00:00:14,963
第一件，模型看见的工具清单到底怎么算出来的。

4
00:00:14,963 --> 00:00:20,180
第二件，你告诉模型可以并行之后，底下到底是谁在管秩序。

5
00:00:20,180 --> 00:00:22,656
先说前半场的核心事实。

6
00:00:22,656 --> 00:00:32,536
同一段对话、同一份配置，下一轮采样却可能多出几个 MCP 名字、多一个协作入口，或者多一个 tool_search。

7
00:00:32,536 --> 00:00:37,487
变的不是配置，而是这一次采样允许广告哪些 handler。

8
00:00:37,487 --> 00:00:40,757
工具清单有三个容易混淆的集合。

9
00:00:40,757 --> 00:00:42,812
第一，注册了什么。

10
00:00:42,812 --> 00:00:46,009
第二，模型这一轮看得见什么。

11
00:00:46,009 --> 00:00:48,545
第三，真正能 dispatch 什么。

12
00:00:48,545 --> 00:00:53,389
这三份集合不是一回事，而且它们由三个不同的东西决定。

13
00:00:53,389 --> 00:01:01,983
规划函数决定广告什么，注册表决定能 dispatch 什么，暴露态决定是直接给、延迟发现，还是只可执行。

14
00:01:01,983 --> 00:01:03,834
后半场讲并行。

15
00:01:03,834 --> 00:01:10,360
发给模型的那面并行旗，只表示模型可以在一次响应里发出多个工具调用。

16
00:01:10,360 --> 00:01:18,449
至于这些调用能不能真的叠着跑，模型根本看不到，由运行时的一张本地表和一把读写锁说了算。

17
00:01:18,449 --> 00:01:20,853
最后还有一个反直觉的点。

18
00:01:20,853 --> 00:01:28,112
模型以为的顺序、锁上实际发生的顺序、历史里写下的顺序，三者可以完全不一致。

19
00:01:28,112 --> 00:01:29,423
开始。

20
00:01:29,570 --> 00:01:31,683
先看冻结这件事。

21
00:01:31,633 --> 00:01:34,337
为什么不实时更新工具清单。

22
00:01:34,337 --> 00:01:38,808
假设你刚接上一个 MCP 服务，模型这一轮还在跑。

23
00:01:38,808 --> 00:01:46,789
如果按整轮用户对话冻结工具表，新连上的服务要等下一条用户消息才能进菜单，反应太慢。

24
00:01:46,789 --> 00:01:53,929
如果每次调用工具冻结一次，那么同一次采样里并行的两次调用可能看见两份不同的表。

25
00:01:53,929 --> 00:02:01,152
广告一份、执行另一份，或者执行到一半表变了，是工具清单最常见的翻车方式。

26
00:02:01,152 --> 00:02:10,828
Codex 的答案是把这一次采样的模型、审批、环境、MCP 绑定和已经算完的 tool_router 一起放进一个 StepContext。

27
00:02:10,828 --> 00:02:14,097
源码注释写明它是请求作用域的。

28
00:02:14,097 --> 00:02:17,763
采样请求之间可以变，同一次采样里不变。

29
00:02:17,763 --> 00:02:20,888
所以冻结单位是 step，不是 turn。

30
00:02:20,888 --> 00:02:25,263
一个 turn 里可以有多次采样，每次重建一份上下文。

31
00:02:25,263 --> 00:02:31,693
这点很多人会搞错，turn 是用户轮次，step 是一次采样，中间隔着工具调用。

32
00:02:31,693 --> 00:02:37,775
采样进行期间，模型看见的名字和能 dispatch 的运行时都读这份快照。

33
00:02:37,775 --> 00:02:42,570
MCP 服务器在这次采样中途掉线，改不了已经算完的表。

34
00:02:42,570 --> 00:02:49,542
下一次采样会重新算绑定，可能复用，也可能换成空绑定，那些名字才会消失。

35
00:02:49,542 --> 00:02:52,270
演化的顺序也值得记一下。

36
00:02:52,270 --> 00:03:13,776
先是本轮的 MCP 绑定被解析出来，然后采集 MCP 目录交给规划函数，接着按身份决定要不要走完整来源，再登记 shell、资源、实用、协作这几组，之后追加 MCP 并套上暴露策略，有延迟项才挂搜索工具，最后只把标成直接的那些 spec 发给模型，其余冻进 StepContext。

37
00:03:13,776 --> 00:03:16,144
最后说这样做的好处。

38
00:03:16,144 --> 00:03:20,795
可见和可执行共用一份快照，下一轮再吸收新连接。

39
00:03:20,795 --> 00:03:31,997
换个语言重写，最小形态仍是一个函数，输入开关、MCP 目录、身份，输出可见表和可执行表，请求开始时算一次，算完就冻。

40
00:03:32,140 --> 00:03:36,585
第二个重点，规划函数按身份决定走哪些来源。

41
00:03:36,535 --> 00:03:39,167
这个设计的动机非常实际。

42
00:03:39,167 --> 00:03:49,708
如果审批模型能看见工作模型那整张 MCP 和协作工具表，它就能自己去 spawn_agent、去读外部资源，审批本身就被绕开了。

43
00:03:49,708 --> 00:03:54,936
所以必须是按身份裁剪来源，而不是先注册全表再过滤。

44
00:03:54,936 --> 00:03:59,419
后者只要漏一条分支，就会把不该给的名字送出去。

45
00:03:59,419 --> 00:04:01,475
看几条具体规则。

46
00:04:01,475 --> 00:04:05,092
Guardian 评审员，标签必须恰好是 guardian。

47
00:04:05,092 --> 00:04:10,465
权限档案如果不是 Managed，函数直接返回，注册表是空的。

48
00:04:10,465 --> 00:04:19,407
Managed 并且有环境的时候，最多只有三件，exec_command、write_stdin，加上可选的 view_image。

49
00:04:19,407 --> 00:04:27,604
普通会话才走 shell、MCP 资源、实用工具、协作这四组，再追加 MCP、扩展和动态工具。

50
00:04:27,604 --> 00:04:30,165
这里有个细节很有意思。

51
00:04:30,165 --> 00:04:35,008
handlers 目录里根本没有 ReadFileHandler，读盘没有一等入口。

52
00:04:35,008 --> 00:04:42,989
模型要读文件，只能走 exec_command、MCP 的 filesystem，或者 view_image 内部的接口。

53
00:04:42,989 --> 00:04:47,328
这种设计的好处是身份一变，整组来源都不走。

54
00:04:47,328 --> 00:04:56,282
默认关上门这件事发生在评审员、环境缺失、特性关闭这几处，而不是散落在每个工具的 if 里面。

55
00:04:56,430 --> 00:04:58,110
再看暴露态。

56
00:04:58,060 --> 00:04:59,683
为什么需要它。

57
00:04:59,683 --> 00:05:06,065
MCP 工具的 schema 很贵，全挂上去，初始请求的 token 会被描述文本吃光。

58
00:05:06,065 --> 00:05:11,161
而且两个服务都提供同名工具的时候，不消歧就会撞名。

59
00:05:11,161 --> 00:05:14,455
所以注册表被拆成三条可见面。

60
00:05:14,455 --> 00:05:16,245
Direct 进初始表。

61
00:05:16,245 --> 00:05:18,890
Deferred 只留给 tool_search。

62
00:05:18,890 --> 00:05:23,000
Hidden 只留给 dispatch，模型看不见但能执行。

63
00:05:23,000 --> 00:05:28,866
搜索开关打开的时候，MCP 工具默认变成 Deferred，不进初始可见表。

64
00:05:28,866 --> 00:05:33,613
tool_search 这个工具的挂载条件值得单独记一下。

65
00:05:33,613 --> 00:05:43,421
要两件事同时成立才挂，一是模型支持 search tool，二是注册表里至少还有一个 deferred 并且带 search_info 的工具。

66
00:05:43,421 --> 00:05:49,442
没有 deferred 的东西，tool_search 根本不会进表，因为加了也没东西可搜。

67
00:05:49,442 --> 00:05:51,498
然后是撞名怎么办。

68
00:05:51,498 --> 00:06:00,488
两个 MCP 服务的同名工具，在规范化阶段消歧，默认加 mcp 前缀，清洗之后还撞车就再追十二位哈希后缀。

69
00:06:00,488 --> 00:06:05,873
反过来，外部工具如果撞到已被占用的核心名，直接跳过。

70
00:06:05,873 --> 00:06:08,541
核心名保留，外部让路。

71
00:06:08,541 --> 00:06:13,277
所以记住这句话，注册了，还不等于这一轮发给了模型。

72
00:06:13,277 --> 00:06:17,147
可见、可检索、可执行是三份集合。

73
00:06:17,290 --> 00:06:20,821
横向对比一下同一道题的另外两种答法。

74
00:06:20,771 --> 00:06:23,680
第一个 harness 没有集中规划函数。

75
00:06:23,680 --> 00:06:30,555
每个工具包在装配时投递自己的 schema，装配器再用部署配置里的工具顺序排座位。

76
00:06:30,555 --> 00:06:35,567
未点名的工具插在一个保留标记处，按名字字典序排。

77
00:06:35,567 --> 00:06:38,788
漏写这个标记，装配期直接失败。

78
00:06:38,788 --> 00:06:44,918
装上它的文件工具包，模型就看见一等工具 read，描述还要求用它读文本。

79
00:06:44,918 --> 00:06:52,586
Codex 正好相反，把读盘留给 shell、MCP 和内部接口，规划函数里根本没有那个入口。

80
00:06:52,586 --> 00:06:58,415
第二个对比对象是一张写死的数组，文件读取工具是一等成员。

81
00:06:58,415 --> 00:07:06,637
请求时先过滤 deny 规则，再把内置和 MCP 各自按名字排序后拼接，内置在前，撞名时内置赢。

82
00:07:06,637 --> 00:07:13,968
注释写明这么排是为了提示缓存，因为 MCP 插到内置中间，后面的 cache key 会全失效。

83
00:07:13,968 --> 00:07:21,829
差别在于，那边的基表打开一个文件就能数完，Codex 要数清单必须走完整个规划流程。

84
00:07:21,829 --> 00:07:26,348
代价换来的是同一套规划能按身份和预算裁剪。

85
00:07:26,348 --> 00:07:29,726
这里给一个可以直接用的判断标准。

86
00:07:29,726 --> 00:07:36,613
工具来源相对固定、数量不大，写死一张表更好维护，还能顺手保住提示缓存。

87
00:07:36,613 --> 00:07:46,901
反过来，工具来源会随外部连接动态变化，还要按审批身份裁剪，那就必须有一个中心规划函数，在每次请求开始时算一遍。

88
00:07:46,901 --> 00:07:54,221
两种做法没有绝对的优劣，只有一个决策点，你的工具来源到底是静态的还是动态的。

89
00:07:54,221 --> 00:07:58,524
答错这一题，后面每一行代码都会别扭。

90
00:07:58,670 --> 00:08:01,432
后半场换话题，讲并行。

91
00:08:01,382 --> 00:08:03,654
先看一个常见现象。

92
00:08:03,654 --> 00:08:07,752
你让模型同时读两个源文件，再打一处补丁。

93
00:08:07,752 --> 00:08:13,053
屏幕上两份读几乎同时出进度，打补丁那一下却停了一拍。

94
00:08:13,053 --> 00:08:14,195
为什么。

95
00:08:14,195 --> 00:08:22,872
如果运行时把可以并行理解成这些调用叠着跑，两个 apply_patch 会一起改共享的 diff 记账，账就乱了。

96
00:08:22,872 --> 00:08:28,750
如果这些调用首尾相接，两个读文件也要排队，白白多耗一轮墙钟。

97
00:08:28,750 --> 00:08:34,267
请求侧那面旗只回答一个问题，模型愿不愿意一次发多个盒子。

98
00:08:34,267 --> 00:08:37,704
它回答不了盒子落地时谁能重叠。

99
00:08:37,704 --> 00:08:40,973
所以 Codex 把两件事彻底拆开。

100
00:08:40,973 --> 00:08:43,786
发给模型的旗写在 Prompt 上。

101
00:08:43,786 --> 00:08:47,860
字段默认是 false，采样主路径把它写成 true。

102
00:08:47,860 --> 00:08:54,074
编成请求体时，再和模型是不是 Lite 版本做一次与运算，Lite 上这面旗关掉。

103
00:08:54,074 --> 00:09:03,630
压缩那两条组包路径也写死 true，为的是和主采样请求的形状对齐，因为多路复用会逐字段对比，包括这面旗。

104
00:09:03,630 --> 00:09:05,757
执行侧另有一张表。

105
00:09:05,757 --> 00:09:10,024
执行器默认返回 false，漏写覆盖就走写锁。

106
00:09:10,024 --> 00:09:18,005
exec_command、view_image、tool_search 覆盖成 true，apply_patch 不覆盖。

107
00:09:18,005 --> 00:09:21,262
路由先查注册表，查不到就当 false。

108
00:09:21,262 --> 00:09:25,553
暴露态是 Hidden 的，handler 自己返回 true 也没用。

109
00:09:25,553 --> 00:09:30,288
MCP 还要服务器开关或只读提示，缺省仍是串行。

110
00:09:30,288 --> 00:09:33,570
模型只看见这面旗是真还是假。

111
00:09:33,570 --> 00:09:40,060
它看不见谁能并行，工具清单也不会因为某个工具其实要拿写锁而少一项。

112
00:09:40,210 --> 00:09:43,116
分类分对了，还得有人看门。

113
00:09:43,066 --> 00:09:50,783
一次采样共用一把 RwLock，锁保护的值是单元类型，里面没有任何业务数据，只当闸门用。

114
00:09:50,783 --> 00:09:52,850
执行顺序有讲究。

115
00:09:52,850 --> 00:09:56,816
任务先 spawn，可选地等就绪，最后才拿锁。

116
00:09:56,816 --> 00:09:59,857
能并行就拿读锁，否则拿写锁。

117
00:09:59,857 --> 00:10:03,174
关键在于，就绪等待放在锁外面。

118
00:10:03,174 --> 00:10:09,773
一个还没连上的 MCP 服务，绝不能占着写锁让旁边已经就绪的 shell 也跟着卡住。

119
00:10:09,773 --> 00:10:12,357
这是这条规矩的全部意义。

120
00:10:12,357 --> 00:10:14,953
再看公平性为什么重要。

121
00:10:14,953 --> 00:10:20,867
如果后来的读者能从写者头顶上翻进去，补丁可能永远拿不到锁。

122
00:10:20,867 --> 00:10:27,597
换成标准库那把读写锁，优先级就依赖操作系统，写者有机会被饿死。

123
00:10:27,597 --> 00:10:34,677
所以这里选的是公平策略，宁可牺牲一部分读的并发，也不能让写操作无限期等待。

124
00:10:34,677 --> 00:10:38,126
这把锁的优先级是公平，也叫写优先。

125
00:10:38,126 --> 00:10:43,860
已经排队的写请求没拿到、没释放之前，后面的读锁不会发下去。

126
00:10:43,860 --> 00:10:45,566
举个具体例子。

127
00:10:45,566 --> 00:10:57,177
view_image 还在跑，apply_patch 已经在门外排队，这时再来的 exec_command 明明可以和 view_image 重叠，也必须排在补丁后面。

128
00:10:57,177 --> 00:11:03,018
公平换来的是写者不会饿死，代价是后来的读者被写者隔开。

129
00:11:03,018 --> 00:11:05,362
还有一个容易忽略的边界。

130
00:11:05,362 --> 00:11:13,018
把锁的关注点说清楚，它保护的值是单元类型，里面没有任何业务数据，纯粹是个闸门。

131
00:11:13,018 --> 00:11:18,944
业务数据的一致性由别处负责，这把锁只回答此刻有没有独占者。

132
00:11:18,944 --> 00:11:28,775
把这个理解偏了，就会想在锁里塞状态，然后锁的生命周期和业务的生命周期就缠在一起，死锁排查会变成噩梦。

133
00:11:28,775 --> 00:11:30,242
再看粒度。

134
00:11:30,242 --> 00:11:34,376
分类粒度是工具实例，看不到这一次的参数。

135
00:11:34,376 --> 00:11:40,218
exec_command 无论跑的是列表命令还是删除命令，都走读锁。

136
00:11:40,218 --> 00:11:44,100
apply_patch 无论补丁多大，都走写锁。

137
00:11:44,100 --> 00:11:48,054
两个毫不相关的串行工具也会互相挡住。

138
00:11:48,054 --> 00:11:52,285
还有一点，检索业务代码，没有容量上限。

139
00:11:52,285 --> 00:11:54,388
十个 shell 可以一起进。

140
00:11:54,388 --> 00:11:58,739
容量交给进程、沙箱和操作系统去管。

141
00:11:58,739 --> 00:12:00,710
餐厅类比很贴切。

142
00:12:00,710 --> 00:12:04,809
多人可以同时看菜单，但后厨只准一人进。

143
00:12:04,809 --> 00:12:09,220
业务代码只问一个问题，这一刻有没有独占者。

144
00:12:09,350 --> 00:12:14,155
最后一个反直觉的点，跑完的顺序不决定入账的顺序。

145
00:12:14,105 --> 00:12:16,100
如果不处理会怎样。

146
00:12:16,100 --> 00:12:19,947
两个命令叠着跑，后发出的可能先跑完。

147
00:12:19,947 --> 00:12:27,194
要是按完成顺序写历史，模型下一轮看到的结果顺序就和它当初发出的调用对不上。

148
00:12:27,194 --> 00:12:32,326
模型会以为先跑完的那个是它先叫的，账就错了。

149
00:12:32,326 --> 00:12:39,021
Codex 的做法是，采样循环把每个任务的 future 按到达顺序推进一个保序队列。

150
00:12:39,021 --> 00:12:42,855
出队时按插入顺序排，再写入会话。

151
00:12:42,855 --> 00:12:46,942
读锁让两个 shell 叠着跑，入账仍然按发出顺序。

152
00:12:46,942 --> 00:12:50,716
先发出的先入账，哪怕它其实更晚跑完。

153
00:12:50,716 --> 00:12:53,420
所以三套顺序可以不一致。

154
00:12:53,420 --> 00:13:00,560
模型发出的顺序、锁上实际执行的顺序、历史里写下的顺序，各自有自己的规则。

155
00:13:00,560 --> 00:13:03,216
这里要区分清楚两个概念。

156
00:13:03,216 --> 00:13:09,237
流内每收到一条完成事件就建立一个 future，那一套管的是何时开工。

157
00:13:09,237 --> 00:13:14,406
这里讲的是开工之后谁能重叠，以及结果按什么顺序入账。

158
00:13:14,406 --> 00:13:16,389
别把它们混为一谈。

159
00:13:16,389 --> 00:13:19,754
再补一句为什么这样长期成立。

160
00:13:19,754 --> 00:13:25,512
观测顺序和执行重叠从结构上分开，并行只改墙钟，不改账本。

161
00:13:25,512 --> 00:13:29,778
自己做 Agent 的时候，至少要把这两条队列分开写。

162
00:13:29,778 --> 00:13:37,026
对模型说可以并行，跑工具时再看本地表，结果仍然按调用列表的原顺序收下。

163
00:13:37,170 --> 00:13:40,593
最后收一下，六条能直接用的东西。

164
00:13:40,543 --> 00:13:44,930
第一，工具清单要在请求开始时算一次然后冻住。

165
00:13:44,930 --> 00:13:50,074
可见和可执行共用同一份快照，下一次请求再吸收新连接。

166
00:13:50,074 --> 00:13:52,827
别让它在执行过程中变化。

167
00:13:52,827 --> 00:13:57,358
第二，按身份裁剪来源，不要先注册全表再过滤。

168
00:13:57,358 --> 00:14:02,094
默认关门要写在入口，不要散落在每个工具的判断里。

169
00:14:02,094 --> 00:14:06,925
第三，把可见、可检索、可执行拆成三份集合。

170
00:14:06,925 --> 00:14:11,565
token 预算紧就推迟发现，只给搜索工具留一个入口。

171
00:14:11,565 --> 00:14:15,964
第四，对模型的许可以和宿主调度彻底分开。

172
00:14:15,964 --> 00:14:22,983
请求侧只回答愿不愿意一次发多个调用，执行侧只回答这一次能不能和别人重叠。

173
00:14:22,983 --> 00:14:26,445
第五，等待就绪必须放在锁外面。

174
00:14:26,445 --> 00:14:29,425
还没准备好的人，不要占着门口。

175
00:14:29,425 --> 00:14:32,851
第六，并行只改墙钟，不改账本。

176
00:14:32,851 --> 00:14:35,964
结果按调用列表的原顺序收下。

177
00:14:35,964 --> 00:14:39,209
把这六条放在一起，主线是这样。

178
00:14:39,209 --> 00:14:46,889
前半场讲的是同一份注册表可以有多个观察面，后半场讲的是同一批调用可以有多个顺序。

179
00:14:46,889 --> 00:14:53,920
两者共同的思路是，把外部承诺和内部实现拆开，各自用最合适的结构表达。

180
00:14:53,920 --> 00:14:56,024
这一集就到这里。

