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

2
00:00:06,382 --> 00:00:11,069
今天这一集聊一个很具体、也很容易被忽略的工程问题。

3
00:00:11,069 --> 00:00:16,526
多 Agent 系统里，主会话昨天派出去的那个孩子，今天还在不在。

4
00:00:16,526 --> 00:00:18,449
先把场景摆出来。

5
00:00:18,449 --> 00:00:22,536
你让主会话派一个探索者去查认证模块。

6
00:00:22,536 --> 00:00:32,235
模型调用派生工具，工具回给你一个绝对路径，后面跟着一级一级拼出来的名字，同时给了这个孩子一个外号，叫希帕蒂娅。

7
00:00:32,235 --> 00:00:37,271
过了几分钟你调用等待接口，信箱里躺着一份最终答案。

8
00:00:37,271 --> 00:00:40,048
一切正常，你关电脑下班。

9
00:00:40,048 --> 00:00:44,507
第二天你打开同一条线程，还想跟这个孩子说话。

10
00:00:44,507 --> 00:00:48,197
这时候子会话的运行时早就卸掉了。

11
00:00:48,197 --> 00:00:52,644
进程没了，内存里的东西没了，上下文也没了。

12
00:00:52,644 --> 00:00:58,437
你再发一条消息过去，控制面回你一句，活着的 agent 路径找不到。

13
00:00:58,437 --> 00:01:00,733
这件事为什么会发生。

14
00:01:00,733 --> 00:01:04,375
因为系统只记了昨天发生过一次派生调用。

15
00:01:04,375 --> 00:01:07,067
它记的是事件，不是关系。

16
00:01:07,067 --> 00:01:13,629
一次调用结束，事件就归档，这条路径在系统眼里等于从来没有存在过。

17
00:01:13,629 --> 00:01:16,406
做玩具演示，这不算问题。

18
00:01:16,406 --> 00:01:22,139
但凡你想做一个能跨天跑的编排系统，这就是最核心的那个问题。

19
00:01:22,139 --> 00:01:33,918
因为多 Agent 真正的价值，从来不是在一个会话里同时派几个孩子出去显得热闹，而是这条任务链能不能跨天、跨进程、跨重启地活下去。

20
00:01:33,918 --> 00:01:37,079
OpenAI Codex 给的答案其实很朴素。

21
00:01:37,079 --> 00:01:40,781
把父子关系做成一张要持久化的图。

22
00:01:40,781 --> 00:01:44,326
派出去的是节点，边一出生就是 Open。

23
00:01:44,326 --> 00:01:47,007
信先入队，followup 才叫醒。

24
00:01:47,007 --> 00:01:49,447
关掉的是边，历史还在。

25
00:01:49,447 --> 00:01:52,187
这一集我们就把这三句话拆开。

26
00:01:52,187 --> 00:02:00,132
先看它在源码里怎么落地，再看这里面有哪些取舍，是你自己做多 Agent 编排时能直接拿走的。

27
00:02:00,270 --> 00:02:02,515
我们先从根会话讲起。

28
00:02:02,465 --> 00:02:09,448
根会话的路径就是 /root，一开始它空闲，可以派孩子，尚无边，图上也没有别的节点。

29
00:02:09,448 --> 00:02:12,766
点一下派生，图上会发生四件事。

30
00:02:12,766 --> 00:02:18,739
第一步，系统算出下一层的深度，把这个深度写进线程派生注册表。

31
00:02:18,739 --> 00:02:25,722
第二步，用任务名拼出绝对路径，根下面接一级，变成 /root 加 explore_auth。

32
00:02:25,722 --> 00:02:31,395
第三步，从一张科学家名单里抽出一个外号，固定抽到希帕蒂娅。

33
00:02:31,395 --> 00:02:37,417
第四步，只要不是临时会话，立刻往 SQLite 里写一条状态为 Open 的边。

34
00:02:37,417 --> 00:02:42,778
注意这个顺序，节点一出生，它就有路径、有外号、有一条边。

35
00:02:42,778 --> 00:02:46,852
运行时还没开始跑，图上的关系已经落库了。

36
00:02:46,852 --> 00:02:48,547
接下来是通信。

37
00:02:48,547 --> 00:02:55,374
根给孩子发一条消息，这条消息按只入队的方式组包，进的是会话级队列。

38
00:02:55,374 --> 00:02:58,932
入队之后，系统发一个邮箱活动通知。

39
00:02:58,932 --> 00:03:01,299
这时候孩子不一定醒。

40
00:03:01,299 --> 00:03:09,196
真正把它叫醒的是 followup_task，它把 trigger_turn 这个开关打开，这一步才叫醒。

41
00:03:09,196 --> 00:03:12,766
孩子跑完一个子回合，给父发一封结果信。

42
00:03:12,766 --> 00:03:17,345
这封信不叫醒，它默认不抢父当前正在说的这一轮。

43
00:03:17,345 --> 00:03:18,799
最后收尾。

44
00:03:18,799 --> 00:03:23,643
如果选择关机，卸掉的只是运行时，边仍然是 Open。

45
00:03:23,643 --> 00:03:27,381
只有选择关边，这条边才会被写成 Closed。

46
00:03:27,381 --> 00:03:32,020
你切换一下再重放一遍，重启之后的名单会不一样。

47
00:03:32,020 --> 00:03:34,364
图上有三个东西要分清。

48
00:03:34,364 --> 00:03:38,571
MESSAGE 是信，FOLLOWUP 是叫醒，RESULT 是结果回流。

49
00:03:38,571 --> 00:03:44,593
注册表是内存里的东西，恢复的时候只沿着 Open 边把身份贴回来。

50
00:03:44,740 --> 00:03:46,841
现在看第一层设计。

51
00:03:46,791 --> 00:03:52,716
父子关系不是一次函数调用的痕迹，是一条存在数据库里的有向边。

52
00:03:52,716 --> 00:03:54,664
这条边只有两个值。

53
00:03:54,664 --> 00:03:59,243
Open 表示这个孩子还能当作打开的派生 agent 恢复。

54
00:03:59,243 --> 00:04:02,548
Closed 表示从图的视角它已经关掉了。

55
00:04:02,548 --> 00:04:06,947
落到存储里，序列化就是 open 和 closed 两个字符串。

56
00:04:06,947 --> 00:04:10,361
边存在 SQLite 的一张专门的边表里。

57
00:04:10,361 --> 00:04:12,464
子线程 id 是主键。

58
00:04:12,464 --> 00:04:14,447
这一个决定很关键。

59
00:04:14,447 --> 00:04:17,452
一个孩子不能同时挂两个父亲。

60
00:04:17,452 --> 00:04:22,512
同一个孩子再派生一次，父亲和状态都会被新值盖住。

61
00:04:22,512 --> 00:04:28,390
所以这张图永远是一棵树，不是一个有向无环图，也不是乱网。

62
00:04:28,390 --> 00:04:30,877
还有一条分界线很重要。

63
00:04:30,877 --> 00:04:33,341
会话正文不进这张表。

64
00:04:33,341 --> 00:04:37,296
子 agent 的模型上下文仍然走它自己的 rollout 记录。

65
00:04:37,296 --> 00:04:42,584
这张边表只回答两个问题，谁生了谁，这条边现在开还是关。

66
00:04:42,584 --> 00:04:44,411
别的它一概不管。

67
00:04:44,411 --> 00:04:46,310
职责切得很干净。

68
00:04:46,310 --> 00:04:48,390
写入时机也有讲究。

69
00:04:48,390 --> 00:04:53,702
非临时会话在线程刚建出来之后，立刻 upsert 一条 Open 边。

70
00:04:53,702 --> 00:04:59,099
如果写入失败，只打一条警告日志，不让派生流程失败。

71
00:04:59,099 --> 00:05:03,029
理由是子线程已经在跑了，图可以稍后补。

72
00:05:03,029 --> 00:05:10,926
补写的时候用的是冲突就不做的语义，所以一条后来被明确关掉的边，不会被这次补写改回 Open。

73
00:05:10,926 --> 00:05:12,404
最后是遍历。

74
00:05:12,404 --> 00:05:19,039
列后代的时候，过滤条件作用在走过的每一条边上，而不是只作用在起点。

75
00:05:19,039 --> 00:05:28,365
所以当你用只取 Open 的过滤条件去列，父边已经是 Closed 的那一棵子树，就算孙子的边还写着 Open，也不会被列出来。

76
00:05:28,365 --> 00:05:32,308
这是很多人自己实现的时候会写错的地方。

77
00:05:32,308 --> 00:05:36,226
正确的写法是，边边都要过一遍过滤器。

78
00:05:36,360 --> 00:05:37,812
再说寻址。

79
00:05:37,762 --> 00:05:41,560
Codex 里真正的寻址键是路径，不是外号。

80
00:05:41,560 --> 00:05:45,058
外号是给人认的，路径才是机器用的。

81
00:05:45,058 --> 00:05:46,644
根固定是 /root。

82
00:05:46,644 --> 00:05:49,601
相对名接到当前路径后面。

83
00:05:49,601 --> 00:05:52,341
这里有一个很细的安全设计。

84
00:05:52,341 --> 00:05:56,921
上级目录和当前目录这两个符号是被明确拒绝的。

85
00:05:56,921 --> 00:06:01,848
也就是说你不能靠相对路径往上爬，爬到兄弟节点去。

86
00:06:01,848 --> 00:06:04,913
想跨到兄弟，必须写绝对路径。

87
00:06:04,913 --> 00:06:08,086
这条规则看着小，实际是边界。

88
00:06:08,086 --> 00:06:14,733
没有它，一个孩子在相对路径里写两个点加点号就能摸到兄弟的命名空间。

89
00:06:14,733 --> 00:06:19,781
有了它，横向移动必须是显式的、可被审计的绝对路径。

90
00:06:19,781 --> 00:06:22,029
角色这一层也有取舍。

91
00:06:22,029 --> 00:06:26,560
内置活角色有三个，默认、探索者、工人。

92
00:06:26,560 --> 00:06:31,524
有意思的是探索者这个角色的配置文件是个空文件。

93
00:06:31,524 --> 00:06:37,798
也就是说它不额外塞系统提示，它的能力差异不在提示词里，在别处。

94
00:06:37,798 --> 00:06:42,089
角色的作用是收能力，不是替换父会话的权威。

95
00:06:42,089 --> 00:06:47,702
孩子可以比父亲能力更强，但它的权限边界不能反向决定父亲。

96
00:06:47,702 --> 00:06:55,334
这一点在多 Agent 系统里尤其重要，因为一旦子 agent 能改写父的权限，整个权限模型就塌了。

97
00:06:55,334 --> 00:06:58,711
一次派生之后，东西落在三个地方。

98
00:06:58,711 --> 00:07:03,519
身份进注册表，边进 SQLite，会话正文进 rollout。

99
00:07:03,519 --> 00:07:07,341
这三处就是这一讲的全部数据结构。

100
00:07:07,490 --> 00:07:11,935
第二层设计是这一集里我认为最值得抄走的一条。

101
00:07:11,885 --> 00:07:15,406
投递和叫醒是两件事，必须分开。

102
00:07:15,406 --> 00:07:17,522
先看它要解决什么。

103
00:07:17,522 --> 00:07:21,308
子 agent 跑完了，要回一份最终答案给父亲。

104
00:07:21,308 --> 00:07:25,118
如果这封信自带叫醒语义，会发生什么。

105
00:07:25,118 --> 00:07:31,777
父亲可能正在写用户能看见的最终回答，写到一半，被子结果强行开了一轮。

106
00:07:31,777 --> 00:07:37,137
用户看到的就是一段半截话，后面突然插进来一份完成通知。

107
00:07:37,137 --> 00:07:38,748
体验非常糟。

108
00:07:38,748 --> 00:07:48,411
Codex 的做法是，在协议侧只保留一份跨 agent 通信结构，用一个叫 trigger_turn 的布尔字段来区分要不要叫醒。

109
00:07:48,411 --> 00:07:53,183
通信种类有四个标签，派生、消息、跟进、结果。

110
00:07:53,183 --> 00:07:57,053
这四个是给可观测性链路打标签用的。

111
00:07:57,053 --> 00:08:00,851
协议侧不关心标签，只关心那根布尔。

112
00:08:00,851 --> 00:08:07,161
落到工具上，发送消息是只入队语义，跟进任务才是触发轮次语义。

113
00:08:07,161 --> 00:08:09,048
空消息直接拒。

114
00:08:09,048 --> 00:08:15,274
跟进任务还不能打根节点，因为根没有父亲去叫醒它，这个操作没有意义。

115
00:08:15,274 --> 00:08:18,183
处理函数里的顺序也值得学。

116
00:08:18,183 --> 00:08:20,803
先入队，再决定要不要开工。

117
00:08:20,803 --> 00:08:24,277
开关为假的时候，信就继续躺着。

118
00:08:24,277 --> 00:08:30,731
只有开关为真，或者这个会话还有没走完的持久化休眠，才真的去开工。

119
00:08:30,731 --> 00:08:36,680
V2 里的完成通知走的是结果这个种类，它的叫醒开关默认就是假的。

120
00:08:36,680 --> 00:08:42,450
所以父正在说话的时候，这封信只按邮箱相位排队，不打断。

121
00:08:42,450 --> 00:08:44,733
为什么这条长期成立。

122
00:08:44,733 --> 00:08:47,185
因为叫醒权是稀缺资源。

123
00:08:47,185 --> 00:08:50,274
谁能开一轮，谁就不能随便开。

124
00:08:50,274 --> 00:08:59,072
把投递和开工拆开之后，完成通知默认不叫醒，叫醒权就只剩两个来源，显式跟进，和用户自己的插话。

125
00:08:59,072 --> 00:09:05,935
将来你换一套消息总线，这根布尔还是用得上，投递还是入队，唤醒是另一回事。

126
00:09:06,070 --> 00:09:07,570
再看信箱。

127
00:09:07,520 --> 00:09:10,909
它是一个会话级队列，里面有两条槽。

128
00:09:10,909 --> 00:09:17,340
用户插话走的是待处理输入这一条，子邮件走的是待处理邮件这一条。

129
00:09:17,340 --> 00:09:23,902
分槽的好处是用户的优先级天然排在孩子前面，不需要额外的调度器去排序。

130
00:09:23,902 --> 00:09:26,859
兄弟之间能不能互相发消息。

131
00:09:26,859 --> 00:09:29,503
能，前提是用绝对路径。

132
00:09:29,503 --> 00:09:35,104
你要发给兄弟，就得写全，从根写起，比如根下面接工人二。

133
00:09:35,104 --> 00:09:45,032
如果你懒，只写了个相对名，系统会把它接到你自己的路径后面，于是这封信发给了你自己的孩子，而不是你的兄弟。

134
00:09:45,032 --> 00:09:54,491
这是一个非常容易踩的坑，而且它不报错，消息会安静地进错信箱，等你想起来查的时候，那个孩子已经跑偏了好几轮。

135
00:09:54,491 --> 00:09:56,823
信箱还有一层叫相位。

136
00:09:56,823 --> 00:10:02,171
同一个会话里，信不是谁先到谁先处理，而是按相位走。

137
00:10:02,171 --> 00:10:08,542
父正在对用户说话的时候，子结果就是排在后面，等这一轮说完再处理。

138
00:10:08,542 --> 00:10:15,032
这就是为什么前面说的完成通知不叫醒能落地，因为信箱自己有一套排队次序。

139
00:10:15,032 --> 00:10:17,664
所以这一段的经验很直接。

140
00:10:17,664 --> 00:10:21,871
在多 agent 的地址空间里，任何省略都是危险的。

141
00:10:21,871 --> 00:10:26,390
要么显式写绝对路径，要么接受它变成你的子节点。

142
00:10:26,390 --> 00:10:34,143
另外别指望消息的顺序替你保证正确性，顺序是信箱说了算的，不是你发信的时刻说了算的。

143
00:10:34,290 --> 00:10:39,047
第三层设计，关机和关边，是两个完全独立的动作。

144
00:10:38,997 --> 00:10:45,127
这也是我最喜欢的一处，因为它把两件容易混为一谈的事情彻底分了账。

145
00:10:45,127 --> 00:10:46,690
先看反例。

146
00:10:46,690 --> 00:10:52,543
驻留名额满了，系统按最近最少使用策略卸掉一个孩子的运行时。

147
00:10:52,543 --> 00:10:58,697
如果卸载的时候顺手把边标成 Closed，这个孩子就从 Open 子树里消失了。

148
00:10:58,697 --> 00:11:00,776
下次恢复找不到它。

149
00:11:00,776 --> 00:11:05,007
可是用户从来没关过它，系统自己把它除名了。

150
00:11:05,007 --> 00:11:07,038
这在语义上是错的。

151
00:11:07,038 --> 00:11:09,346
正确的做法分两个接口。

152
00:11:09,346 --> 00:11:11,738
第一个叫关闭活着的对象。

153
00:11:11,738 --> 00:11:18,745
它关掉正在运行的 agent，刷新 rollout，发一条关闭通知，把这个线程从管理器里摘掉。

154
00:11:18,745 --> 00:11:21,101
它不动边，边还是 Open。

155
00:11:21,101 --> 00:11:25,644
所以下次恢复，它仍然会被当成活着的子树成员。

156
00:11:25,644 --> 00:11:27,254
第二个叫关闭。

157
00:11:27,254 --> 00:11:31,281
它先把目标自己的入边标成 Closed，然后再关机。

158
00:11:31,281 --> 00:11:35,680
注意一句限定，后代的边不会在这一步被标成 Closed。

159
00:11:35,680 --> 00:11:38,733
关闭只影响被指向的这一条入边。

160
00:11:38,733 --> 00:11:41,161
还有一个很实用的细节。

161
00:11:41,161 --> 00:11:46,365
父回合正常结束的时候，走的是回合完成，不调用关闭。

162
00:11:46,365 --> 00:11:50,740
所以父亲说完话了，孩子还在跑，边保持 Open。

163
00:11:50,740 --> 00:11:55,355
这就是为什么父回合结束之后孩子还能继续干活。

164
00:11:55,355 --> 00:11:57,050
恢复分两步走。

165
00:11:57,050 --> 00:12:02,411
第一步把 Open 后代的身份装回注册表，这一步不重开运行时。

166
00:12:02,411 --> 00:12:08,589
第二步，等真的有人发消息或者发跟进的时候，才按 rollout 把线程挂回来。

167
00:12:08,589 --> 00:12:11,149
这个两步走很省资源。

168
00:12:11,149 --> 00:12:16,665
重启之后如果只是看看有哪些孩子，不需要把一堆运行时拉起来。

169
00:12:16,820 --> 00:12:20,507
放到横向对比里看，取舍会更清楚。

170
00:12:20,457 --> 00:12:22,176
先看 DSH。

171
00:12:22,176 --> 00:12:24,736
它走的是接缝优先的路子。

172
00:12:24,736 --> 00:12:33,715
子 agent 被做成一个可替换的提供者接口，接口上有名字、能力、是否继承父上下文、启动这几个字段。

173
00:12:33,715 --> 00:12:39,917
进程内派生，Claude Code，Codex，ACP，都是这张接口上的不同实现。

174
00:12:39,917 --> 00:12:47,681
它也能列出孩子和后代，靠的是扫描活着的会话存储和可选的持久化层，只读枚举。

175
00:12:47,681 --> 00:12:52,332
它没有一张独立的边表，没有 Open 和 Closed 这样的状态。

176
00:12:52,332 --> 00:12:56,828
拓扑是会话头里的来源字段，加上事后折出来的。

177
00:12:56,828 --> 00:13:02,777
好处是换实现非常便宜，代价是按边去做恢复要另起炉灶。

178
00:13:02,777 --> 00:13:04,580
再看 Claude Code。

179
00:13:04,580 --> 00:13:13,018
模型面对的工具现在叫 Agent，旧线名仍然保留，用来给权限规则、hook、恢复中的会话做兼容。

180
00:13:13,018 --> 00:13:16,864
里面的一次性类型，父不会再去续跑。

181
00:13:16,864 --> 00:13:21,034
运行时给每个孩子发一个 id，没有单独的边表。

182
00:13:21,034 --> 00:13:24,448
恢复靠读这个 id 对应的那份对话记录。

183
00:13:24,448 --> 00:13:29,556
父亲要列活孩子就得扫那条侧链，没有按边过滤这件事。

184
00:13:29,556 --> 00:13:32,128
所以对比的结论很清楚。

185
00:13:32,128 --> 00:13:38,558
Codex 多了一张表，多了一套状态机，换来的是重启之后仍然能按图说话。

186
00:13:38,558 --> 00:13:41,972
这是一笔明确的交易，不是白拿的优势。

187
00:13:41,972 --> 00:13:46,683
你要按跨天的持久化去恢复一棵树，就得维护一张图。

188
00:13:46,683 --> 00:13:50,818
你只想换实现便宜，那 DSH 的接缝更合适。

189
00:13:50,970 --> 00:13:54,970
最后把这一集能直接拿走的东西收成五条。

190
00:13:54,920 --> 00:14:00,269
第一条，先想清楚第二天怎么恢复，再写派生的那个函数。

191
00:14:00,269 --> 00:14:06,579
很多人一上来就写怎么起子进程，写完了才发现重启后找不到孩子。

192
00:14:06,579 --> 00:14:08,670
顺序应该是反的。

193
00:14:08,670 --> 00:14:11,855
先定图的结构，再定运行时。

194
00:14:11,855 --> 00:14:16,627
第二条，关系状态和运行时状态必须放在两个地方。

195
00:14:16,627 --> 00:14:23,165
运行时会因为你控制不了的原因消失，进程崩了，名额被挤了，机器重启了。

196
00:14:23,165 --> 00:14:26,519
运行时可以消失，边不能跟着消失。

197
00:14:26,519 --> 00:14:29,764
只有编排者明确说关，才写 Closed。

198
00:14:29,764 --> 00:14:34,692
这条分账不依赖任何语言，你用 Go 写用 Python 写都一样。

199
00:14:34,692 --> 00:14:37,636
第三条，投递和唤醒分开。

200
00:14:37,636 --> 00:14:45,172
任何一次跨 agent 的消息，都要自己回答一个问题，这封信要不要打断对方现在正在做的事。

201
00:14:45,172 --> 00:14:47,504
默认答案应该是不要。

202
00:14:47,504 --> 00:14:52,420
完成通知、状态回流、日志这类东西，一律不醒。

203
00:14:52,420 --> 00:14:55,257
显式跟进和用户输入才醒。

204
00:14:55,257 --> 00:14:59,055
这个默认值选对了，能省掉一大类竞态。

205
00:14:59,055 --> 00:15:02,720
第四条，主键选谁，决定了你的形状。

206
00:15:02,720 --> 00:15:10,497
拿子 id 当主键，图就永远是树，一个孩子一个父亲，遍历可以按深度做广度优先，很简单。

207
00:15:10,497 --> 00:15:17,456
如果你需要一个孩子挂多个父亲，那是另一种需求，要重新设计，不要在树上打补丁。

208
00:15:17,456 --> 00:15:23,045
第五条，过滤条件要作用在每一条边上，不是只作用在起点。

209
00:15:23,045 --> 00:15:25,845
这是我见过最多的实现错误。

210
00:15:25,845 --> 00:15:32,660
父边关了，下面的整棵子树就不再出现在 Open 列表里，哪怕孙边还开着。

211
00:15:32,660 --> 00:15:38,466
写遍历的时候用一个循环检查走过的每条边，别偷懒只查一个。

212
00:15:38,466 --> 00:15:40,533
再补一句踩坑点。

213
00:15:40,533 --> 00:15:44,824
多 agent 的地址空间里不要支持相对路径往上爬。

214
00:15:44,824 --> 00:15:49,067
上级目录和当前目录这两个符号直接拒掉。

215
00:15:49,067 --> 00:15:53,357
留出这个口子，横向移动就没人审计得到了。

216
00:15:53,357 --> 00:15:54,980
一句话收尾。

217
00:15:54,980 --> 00:16:00,665
子 agent 是图上的节点，边只有 Open 和 Closed，会话正文走 rollout。

218
00:16:00,665 --> 00:16:05,617
发送只入队，跟进才叫醒，完成通知不抢当前轮。

219
00:16:05,617 --> 00:16:09,295
关机卸运行时，关边才从图里除名。

220
00:16:09,295 --> 00:16:14,223
第二天要不要跟这个孩子说话，先看边，别先看进程。

