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

2
00:00:06,382 --> 00:00:11,983
这一集只讲一件事，按下取消键之后，一个 agent 系统怎么收手。

3
00:00:11,983 --> 00:00:22,175
听起来是个小功能，实际上它是整个架构里最能暴露设计水平的地方，因为取消要穿过每一层，而每一层的答案都不一样。

4
00:00:22,175 --> 00:00:24,387
先说它为什么值得关心。

5
00:00:24,387 --> 00:00:32,331
第一，工具失败和用户取消是两件完全不同的事，把它们混为一谈是最常见的实现错误。

6
00:00:32,331 --> 00:00:39,663
工具跑失败了，正确反应是把错误喂回模型让它改，而不是让整轮对话死掉。

7
00:00:39,663 --> 00:00:46,141
第二，取消一旦发生，就必须处理一个很脏的问题，已经写进磁盘的东西怎么办。

8
00:00:46,141 --> 00:00:55,841
第三，收手有顺序，顺序错了，轻则历史记录自相矛盾，重则模型下一轮基于错误的上下文继续改代码。

9
00:00:55,841 --> 00:00:57,596
这一集分三问。

10
00:00:57,596 --> 00:01:00,240
工具失败为什么不停对话。

11
00:01:00,240 --> 00:01:05,288
用户按 Esc 之后，任务、采样、工具按什么顺序收手。

12
00:01:05,288 --> 00:01:08,425
哪一层的收手一旦落下就回不去。

13
00:01:08,425 --> 00:01:18,353
素材来自 OpenAI 的 Codex 仓库，我会顺着源码的调用链讲，但你可以把结论直接搬进自己的系统，因为这三个问题跟语言无关。

14
00:01:18,353 --> 00:01:20,733
还有一句提醒放在前面。

15
00:01:20,733 --> 00:01:27,608
取消功能之所以经常被做糟，是因为它平时不露面，只有出事故那天才被想起。

16
00:01:27,608 --> 00:01:34,579
而那一天往往同时来了三件事，用户在等、磁盘在写、模型在等回执。

17
00:01:34,579 --> 00:01:41,610
所以正确的做法不是事后补，而是把它当成一条贯穿每一层的主线来设计。

18
00:01:41,760 --> 00:01:43,524
先看一个场景。

19
00:01:43,474 --> 00:01:50,674
你让 Codex 改一个测试文件，模型先跑一遍测试命令，编译器吐了两屏报错，退出码是一。

20
00:01:50,674 --> 00:01:56,371
很多第一次写 agent 的人会这么干，工具返回错误，整轮跟着死。

21
00:01:56,371 --> 00:02:00,986
结果是模型还没看见那两屏报错，会话已经结束。

22
00:02:00,986 --> 00:02:04,436
它连自己错在哪都不知道，更别说改。

23
00:02:04,436 --> 00:02:06,431
正确的反应是什么。

24
00:02:06,431 --> 00:02:11,539
把报错原文喂回模型，让它读，让它改，让它再跑一次。

25
00:02:11,539 --> 00:02:17,176
编译器报错对模型来说是最有价值的一种反馈，你居然把它扔了。

26
00:02:17,176 --> 00:02:20,566
这就是这一节要立住的第一条分诊线。

27
00:02:20,566 --> 00:02:26,936
命令失败是工具层的事，对话失败是引擎层的事，两者之间隔着一道门。

28
00:02:26,936 --> 00:02:30,361
默认答案是，命令失败不停对话。

29
00:02:30,361 --> 00:02:32,837
为什么这个默认答案是对的。

30
00:02:32,837 --> 00:02:36,419
因为模型的能力恰恰体现在读错误上。

31
00:02:36,419 --> 00:02:41,407
你把错误藏起来，等于把模型降级成一个只会重试的傻子。

32
00:02:41,407 --> 00:02:42,861
再补一句。

33
00:02:42,861 --> 00:02:45,410
这条线不只适用于命令。

34
00:02:45,410 --> 00:02:48,739
沙箱拒绝执行，走同一条路。

35
00:02:48,739 --> 00:02:56,599
而且你会发现一个有意思的现象，模型处理编译器报错的能力，往往比处理你给的模糊描述更强。

36
00:02:56,599 --> 00:02:59,881
错误越具体，它下一轮的动作越准。

37
00:02:59,881 --> 00:03:05,049
所以把原始报错原封不动地喂回去，是一种很划算的投入。

38
00:03:05,049 --> 00:03:14,772
就连代理把请求拦下来、给命令进程返回一个四百零三，也还是走同一条路，它只是命令没拿到数据，不是引擎出问题。

39
00:03:14,772 --> 00:03:20,446
但模型接口那边返回的四百零三就完全不同了，那是引擎错误。

40
00:03:20,446 --> 00:03:26,527
两个四百零三，一个在最浅一层，一个在最深一层，别把它们并成一档。

41
00:03:26,527 --> 00:03:33,559
这种看起来一样的错误码其实分属两层的例子，在做错误处理的时候特别容易踩。

42
00:03:33,700 --> 00:03:36,270
那这道门具体怎么分诊。

43
00:03:36,220 --> 00:03:39,429
素材里给了三层门槛，由浅到深。

44
00:03:39,429 --> 00:03:42,181
最浅一层，进程已经跑过。

45
00:03:42,181 --> 00:03:47,926
退出码非零、命令超时、沙箱拒绝，全部收成工具回执。

46
00:03:47,926 --> 00:03:55,895
注意这里的成功标记写的是真，它表示的不是命令成功，而是处理器跑完了，回执可以喂给模型。

47
00:03:55,895 --> 00:03:59,729
命令到底成没成功，写在回执正文里。

48
00:03:59,729 --> 00:04:05,535
这个语义区分很小，但很关键，它把执行成功和调度成功分开了。

49
00:04:05,535 --> 00:04:08,287
中间一层，调用没做成。

50
00:04:08,287 --> 00:04:16,821
参数坏了、进程没拉起来、打补丁时上下文对不上，走的是另一个分支，这时候回执的成功标记才是假。

51
00:04:16,821 --> 00:04:22,362
模型读到这段文案，知道是自己这边的调用有问题，自己改再试。

52
00:04:22,362 --> 00:04:25,979
最深一层，只有两种情况下才会用到。

53
00:04:25,979 --> 00:04:30,535
一种是返回的数据结构对不上，一种是任务合并失败。

54
00:04:30,535 --> 00:04:35,042
到这一层才把它升级成引擎级错误，整轮停掉。

55
00:04:35,042 --> 00:04:39,369
三层门槛的共同点是，默认停在最浅那一层。

56
00:04:39,369 --> 00:04:43,828
想让对话停下来，你得明确写出一个终止意图。

57
00:04:43,828 --> 00:04:48,563
这个默认值的选择，决定了系统在面对意外时的性格。

58
00:04:48,563 --> 00:04:51,712
默认回灌，系统倾向于自愈。

59
00:04:51,712 --> 00:04:54,934
默认中断，系统倾向于放弃。

60
00:04:54,934 --> 00:04:58,888
前者更适合 agent，因为模型能读错误。

61
00:04:59,040 --> 00:05:06,646
Codex 把这套分诊写成了一个只有两档的枚举，我认为这是整个设计里最漂亮的一笔。

62
00:05:06,596 --> 00:05:09,516
第一档是把字符串喂回模型。

63
00:05:09,516 --> 00:05:12,894
第二档是终止，升级成引擎错误。

64
00:05:12,894 --> 00:05:16,620
没有第三档，没有警告档，没有重试档。

65
00:05:16,620 --> 00:05:18,399
为什么敢这么省。

66
00:05:18,399 --> 00:05:22,197
因为类型系统在这里承担了合同的角色。

67
00:05:22,197 --> 00:05:29,444
任何一处工具调用，只要它没显式写出终止，返回值就被折成一个正常的成功结果。

68
00:05:29,444 --> 00:05:34,853
也就是说，想让整轮对话死掉，你必须主动、显式地声明。

69
00:05:34,853 --> 00:05:36,680
沉默等于继续。

70
00:05:36,680 --> 00:05:39,192
这个默认值反过来也成立。

71
00:05:39,192 --> 00:05:45,959
任何一个底层的输入输出错误，如果它一路冒到了轮次循环，整轮就会跟着死。

72
00:05:45,959 --> 00:05:51,367
所以正确的写法是，在工具处理器里把它接住，折成回执。

73
00:05:51,367 --> 00:05:54,865
你可以把这个枚举直接抄进自己的项目。

74
00:05:54,865 --> 00:06:04,036
它的价值不在于名字，而在于它逼着每个写工具的人表态，你这次失败到底是想让模型看见，还是想让引擎停。

75
00:06:04,036 --> 00:06:08,363
这两个意图混在一起，是错误处理混乱的根源。

76
00:06:08,363 --> 00:06:12,569
横向看另外两个项目，能看清各自的取舍。

77
00:06:12,569 --> 00:06:24,060
一个项目没有这两档枚举，它靠执行器捕获异常来分诊，工具里抛出的异常被收成一条错误回执，轮次继续，只有循环自己的失败才停。

78
00:06:24,060 --> 00:06:30,334
它甚至把一句产品合同直接写进了注释，非零退出是报告，不是错误。

79
00:06:30,334 --> 00:06:35,887
省掉枚举的代价是，漏过执行器的异常仍然会变成整轮错误。

80
00:06:35,887 --> 00:06:51,656
另一个项目更极端，整个工具错误模块都要求把详情回给模型，取消、超时、执行失败是同一种回灌，工具层根本没有终止档，引擎停不停靠另一套类型判断，不可重试才停采样。

81
00:06:51,656 --> 00:06:54,264
三条路的结论是一致的。

82
00:06:54,264 --> 00:06:57,557
工具层的失败，默认回给模型。

83
00:06:57,557 --> 00:07:05,153
差别只在用什么机制把这句话钉死，一个用类型，一个用执行器捕获，一个用模块约定。

84
00:07:05,153 --> 00:07:10,298
你选哪种都行，重要的是别让这件事变成口头约定。

85
00:07:10,440 --> 00:07:12,938
现在讲用户按 Esc。

86
00:07:12,888 --> 00:07:15,352
这是另一条完全不同的链。

87
00:07:15,352 --> 00:07:19,510
难点在于，你要停的不只是当前那次采样。

88
00:07:19,510 --> 00:07:25,712
采样可能还在读流，工具可能正在写文件，后台的终端可能已经拉起来了。

89
00:07:25,712 --> 00:07:32,743
只杀采样，工具会继续改磁盘，这是最糟糕的一种取消，你以为停了，其实它还在写。

90
00:07:32,743 --> 00:07:40,195
一刀切把所有进程杀掉，后台的长期任务又被误杀，用户明明只想停当前这一轮。

91
00:07:40,195 --> 00:07:42,755
Codex 的做法是造一棵树。

92
00:07:42,755 --> 00:07:46,229
任务启动的时候现造一张取消令牌。

93
00:07:46,229 --> 00:07:51,686
采样请求用的是它的子令牌，工具派发的时候再往下派生一次。

94
00:07:51,686 --> 00:07:55,267
父令牌一取消，所有子令牌一起取消。

95
00:07:55,267 --> 00:07:58,452
子令牌自己取消，不影响父令牌。

96
00:07:58,452 --> 00:08:00,676
这个方向性很重要。

97
00:08:00,676 --> 00:08:04,751
取消只能从上往下传，不能从下往上传。

98
00:08:04,751 --> 00:08:08,440
一次工具超时，不该把整轮对话带下去。

99
00:08:08,440 --> 00:08:13,705
而用户按 Esc，是顶层意图，必须穿透到每一个子节点。

100
00:08:13,705 --> 00:08:21,253
配套的还有一个小工具函数，它的作用是把两个异步任务的赛跑结果写成固定形状。

101
00:08:21,253 --> 00:08:25,087
任意一段未来任务配一张令牌，谁先到用谁。

102
00:08:25,087 --> 00:08:31,241
如果取消先到，返回值一律变成同一个中止错误，不保留第三种可能。

103
00:08:31,241 --> 00:08:37,479
这又是一次用类型收窄分支的操作，跟前面那个两档枚举是同一种手法。

104
00:08:37,479 --> 00:08:40,099
这里有个细节值得单独说。

105
00:08:40,099 --> 00:08:47,118
协议层在定义中断这个操作的时候就写死了合同，中止当前任务，不杀后台终端。

106
00:08:47,118 --> 00:08:53,645
也就是说，用户按 Esc 到底杀什么，是写进协议的一句明文，不是实现细节。

107
00:08:53,645 --> 00:08:59,847
这种把意图写成合同的做法，比在代码里到处判断要可靠得多。

108
00:09:00,000 --> 00:09:04,625
收手是有顺序的，而且顺序里有一步是不可逆的。

109
00:09:04,575 --> 00:09:08,998
用户按 Esc 之后，处理函数先做的是取消令牌。

110
00:09:08,998 --> 00:09:12,135
这一步只是发信号，不是硬拆。

111
00:09:12,135 --> 00:09:19,527
它给每一层一个机会，让你把手上的活收干净，把该落盘的落盘，把该回复的状态回复掉。

112
00:09:19,527 --> 00:09:21,847
然后它等一百毫秒。

113
00:09:21,847 --> 00:09:25,765
这一百毫秒是给协作式取消的宽限期。

114
00:09:25,765 --> 00:09:31,594
如果一百毫秒之后还没收完，才走最后一步，直接撤销任务句柄。

115
00:09:31,594 --> 00:09:36,077
这一步没有回切，撤销就是撤销，没有第二次机会。

116
00:09:36,077 --> 00:09:39,335
这是整个链条里唯一不可逆的一刀。

117
00:09:39,335 --> 00:09:41,294
为什么要有这一刀。

118
00:09:41,294 --> 00:09:50,597
因为协作式取消的前提是所有人都配合，而第三方工具、卡住的网络请求、死循环的子进程都不一定会配合。

119
00:09:50,597 --> 00:09:55,921
系统必须保留一个兜底的暴力手段，否则取消就只是一个建议。

120
00:09:55,921 --> 00:09:58,445
为什么又要先给一百毫秒。

121
00:09:58,445 --> 00:10:00,885
因为暴力手段会丢状态。

122
00:10:00,885 --> 00:10:08,998
写了一半的文件、还没刷出去的日志、正在回滚的事务，都会在硬拆的那一刻停在未知状态。

123
00:10:08,998 --> 00:10:16,077
给一个短窗口，让大多数正常情况能优雅收尾，只有真正卡死的才吃那一刀。

124
00:10:16,077 --> 00:10:19,214
这个形状非常通用，值得记下来。

125
00:10:19,214 --> 00:10:22,772
先发信号，给宽限期，最后才硬拆。

126
00:10:22,772 --> 00:10:31,270
你在任何并发系统里都能用，用最简单的事件对象就能做一个最小版本，不需要抄那一大套错误类型。

127
00:10:31,270 --> 00:10:34,058
再强调一次这两步的关系。

128
00:10:34,058 --> 00:10:36,979
发信号是请求，硬拆是执行。

129
00:10:36,979 --> 00:10:39,731
请求可以被忽略，执行不能。

130
00:10:39,731 --> 00:10:43,926
中间那一段等待，是把这两者分开的缓冲。

131
00:10:43,926 --> 00:10:49,154
少了它，取消就变成暴力；少了硬拆，取消就变成请求。

132
00:10:49,154 --> 00:10:52,087
两个都要有，顺序不能换。

133
00:10:52,220 --> 00:10:56,833
最后讲一个最容易被做错的地方，历史记录该怎么记。

134
00:10:56,783 --> 00:11:00,365
取消往往发生在工具已经改了文件之后。

135
00:11:00,365 --> 00:11:09,656
这时候如果你把已经完成的输出改写成被用户中止，模型下一轮会以为那次写入没做成，于是再打一遍补丁。

136
00:11:09,656 --> 00:11:15,954
文件被改两次，或者补丁打在一个它以为没改过的状态上，事故就这么来的。

137
00:11:15,954 --> 00:11:22,757
正确的做法是，工具那边的异步任务同时等两件事，派发结果和取消令牌。

138
00:11:22,757 --> 00:11:27,456
令牌先到，还要再看一眼处理器是不是已经走到终态。

139
00:11:27,456 --> 00:11:31,362
已经走完，就保留真实结果，不动它。

140
00:11:31,362 --> 00:11:39,055
没走完，才撤销，再造一条被中止的输出回执，正文写明多少秒之后被用户中止。

141
00:11:39,055 --> 00:11:43,273
然后往历史里追加一段模型可见的中止标记。

142
00:11:43,273 --> 00:11:51,579
这段文案很老实，它承认两件事，后台终端可能还在跑，被中止的工具可能已经执行了一部分。

143
00:11:51,579 --> 00:11:53,766
关键在追加这两个字。

144
00:11:53,766 --> 00:11:56,014
历史只追加，不改写。

145
00:11:56,014 --> 00:11:59,247
已经落盘的工具输出一个字都不动。

146
00:11:59,247 --> 00:12:08,718
标记写进历史之后立刻刷盘，因为有的客户端收到中止事件会同步重读这份记录，标记必须先落盘再发事件。

147
00:12:08,718 --> 00:12:14,343
顺序是这样的，先写片段，再刷盘，最后才发中止事件。

148
00:12:14,343 --> 00:12:16,230
为什么要这么较真。

149
00:12:16,230 --> 00:12:22,132
因为有的客户端收到中止事件之后会立刻同步重读那份落盘的记录。

150
00:12:22,132 --> 00:12:29,740
如果标记还没写进去，它读到的就是一个没有中止痕迹的历史，界面上会显示成正常结束。

151
00:12:29,740 --> 00:12:35,725
你以为只是顺序问题，实际上它决定了用户看到的那个状态是不是真的。

152
00:12:35,725 --> 00:12:41,603
界面上你看到的那个中止提示，其实是这条链全部走完之后才出现的。

153
00:12:41,603 --> 00:12:50,124
于是下一轮模型能同时看见三样东西，已经完成的输出、被中止工具的回执、以及中止标记。

154
00:12:50,124 --> 00:12:54,127
三样都在，它自己判断哪些改动已经生效。

155
00:12:54,127 --> 00:13:01,591
这个形状就是上下文治理的第一条原则，历史是账本，只能往后记，不能往回改。

156
00:13:01,720 --> 00:13:06,802
讲了这么多源码，最后给一个今天下午就能做完的最小版本。

157
00:13:06,752 --> 00:13:12,857
素材里有一句话我特别认同，你不必抄那三十七个变体的引擎错误类型。

158
00:13:12,857 --> 00:13:15,514
最小版本只要三样东西。

159
00:13:15,514 --> 00:13:17,954
第一样是一个取消事件。

160
00:13:17,954 --> 00:13:22,377
用最普通的事件对象就够，不需要任何框架。

161
00:13:22,377 --> 00:13:27,569
任务启动的时候造一个，采样和工具各派生一个子事件。

162
00:13:27,569 --> 00:13:31,848
父事件置位，子事件跟着置位，反过来不行。

163
00:13:31,848 --> 00:13:35,790
这就是那棵树的最小形态，十几行代码。

164
00:13:35,790 --> 00:13:38,074
第二样是一个宽限期。

165
00:13:38,074 --> 00:13:43,831
取消置位之后等一个很短的时间，然后强制结束还在跑的东西。

166
00:13:43,831 --> 00:13:49,252
这一刀必须是最后一步，而且你要在代码里写清楚它不可逆。

167
00:13:49,252 --> 00:13:52,569
顺序写反了，历史就会自相矛盾。

168
00:13:52,569 --> 00:13:55,357
第三样是一份只追加的账本。

169
00:13:55,357 --> 00:14:01,776
所有已经产生的结果，不管完成还是被中止，都往后面追加，不回头改。

170
00:14:01,776 --> 00:14:09,576
这一条收益最大，成本最低，因为它不需要任何并发控制，只需要你克制住改写历史的冲动。

171
00:14:09,576 --> 00:14:14,144
有了这三样，你的系统已经比大多数框架更可靠。

172
00:14:14,144 --> 00:14:21,800
剩下的复杂错误类型、协议层合同、后台任务生命周期，是规模上去之后才需要补的。

173
00:14:21,800 --> 00:14:26,415
顺便说一句为什么不建议一上来就抄完整的错误枚举。

174
00:14:26,415 --> 00:14:35,898
三十七个变体的价值在于穷举，穷举的收益来自长期维护的沉淀，你自己第一天的业务根本用不到那么多分支。

175
00:14:35,898 --> 00:14:39,576
先把方向做对，比把变体补全重要。

176
00:14:39,710 --> 00:14:41,811
把这一集压成五条。

177
00:14:41,761 --> 00:14:46,533
第一条，工具失败回给模型，引擎失败才停对话。

178
00:14:46,533 --> 00:14:51,929
默认的那一档要选对，默认回灌，自愈的机会交给模型。

179
00:14:51,929 --> 00:14:59,634
明天能做的动作是，去查一遍你的系统里，命令退出码非零的时候，是不是把整轮对话掐了。

180
00:14:59,634 --> 00:15:03,612
第二条，把失败分诊写成只有两档的枚举。

181
00:15:03,612 --> 00:15:07,759
想终止必须显式声明，沉默等于继续。

182
00:15:07,759 --> 00:15:11,388
这份合同能逼着每个写工具的人表态。

183
00:15:11,388 --> 00:15:16,220
第三条，取消做成一棵树，方向只能从上往下。

184
00:15:16,220 --> 00:15:19,465
父取消带子，子取消不带父。

185
00:15:19,465 --> 00:15:23,275
用户意图写进协议层，别散落在实现里。

186
00:15:23,275 --> 00:15:29,465
第四条，收手顺序是先发信号，再给一个短宽限期，最后才硬拆。

187
00:15:29,465 --> 00:15:34,297
硬拆不可逆，所以它必须是最后一步，而不是第一步。

188
00:15:34,297 --> 00:15:37,674
第五条，中止只追加，不回滚。

189
00:15:37,674 --> 00:15:43,828
已经落盘的东西一个字都别改，让下一轮模型自己看见完整事实。

190
00:15:43,828 --> 00:15:46,208
五条串起来是同一件事。

191
00:15:46,208 --> 00:15:52,482
一个可靠的系统，对失败要宽容，对取消要克制，对历史要诚实。

