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

2
00:00:06,382 --> 00:00:09,242
这是 DeepSeek Harness 系列的第四集。

3
00:00:09,242 --> 00:00:18,629
这一集要解决的工程问题是，按下取消键之后到底发生了什么，以及进程被强杀之后，重启凭什么还能接着跑。

4
00:00:18,629 --> 00:00:20,456
为什么值得关心。

5
00:00:20,456 --> 00:00:24,555
取消是智能体系统里最容易被低估的一块。

6
00:00:24,555 --> 00:00:31,430
它看起来只是一个按键，实际上要同时做到两件事，停得干净，和只停该停的。

7
00:00:31,430 --> 00:00:40,144
停不干净会留下僵尸工作，在后台偷偷改文件、写状态；停错范围会取消掉不该取消的下一轮。

8
00:00:40,144 --> 00:00:44,495
这两类事故都不会当场报错，都是事后才发现。

9
00:00:44,495 --> 00:00:46,754
再补一个判断角度。

10
00:00:46,754 --> 00:00:51,514
取消的设计质量，直接决定了这个系统能不能被信任。

11
00:00:51,514 --> 00:00:59,867
用户敢不敢让智能体跑一个半小时的长任务，取决于他相不相信按下取消键之后一切会停下来。

12
00:00:59,867 --> 00:01:05,853
如果不相信，用户就不敢放权，自动化的边界就被压在很小的范围里。

13
00:01:05,853 --> 00:01:10,997
所以这不是一个边角功能，是决定自动化上限的关键一环。

14
00:01:10,997 --> 00:01:17,572
这一集其实讲了三种退出方式，有序取消、崩溃、以及修复后的重入。

15
00:01:17,572 --> 00:01:22,415
三种方式都要让日志认账，这是贯穿全集的线索。

16
00:01:22,415 --> 00:01:25,084
所以这一集的主线有两句。

17
00:01:25,084 --> 00:01:31,105
第一句是，取消权跟着轮次走，一轮一个取消控制器，绝不跨轮。

18
00:01:31,105 --> 00:01:36,069
第二句是，中断也要把账记平，崩溃恢复只补齐不截断。

19
00:01:36,069 --> 00:01:37,644
内容分三块。

20
00:01:37,644 --> 00:01:49,435
先讲取消信号怎么传遍模型流和工具执行，再讲被中断的轮次为什么也要写终态回日志，最后讲强杀之后重启怎么把半截会话救回来。

21
00:01:49,435 --> 00:01:52,668
末尾收五条可以带走的原则。

22
00:01:52,800 --> 00:01:54,793
先看反面做法。

23
00:01:54,743 --> 00:02:03,793
想象取消做成一个全局开关，智能体身上挂一个布尔标志位，谁都能设，各处代码自己抽空看一眼。

24
00:02:03,793 --> 00:02:05,680
翻车迟早发生。

25
00:02:05,680 --> 00:02:12,844
第一种翻车，上一轮注册的某个超时回调半夜苏醒，顺手把正在跑的新一轮取消了。

26
00:02:12,844 --> 00:02:22,916
第二种翻车，取消用竞速实现，输掉的那个工具调用没人善后，还在后台偷偷改文件、写状态，成了僵尸工作。

27
00:02:22,916 --> 00:02:28,805
所以取消这件事的难点是两件事，停得干净，和只停该停的。

28
00:02:28,805 --> 00:02:31,642
这两样，全局开关都给不了。

29
00:02:31,642 --> 00:02:39,478
这套系统的做法是一根显式传递的线，一头拴在轮次上，另一头拴在每个正在干活的边界上。

30
00:02:39,478 --> 00:02:47,339
驱动器每次醒来干活，新建一个取消控制器；一轮跑完、队列里还有活，就再换一个新的。

31
00:02:47,339 --> 00:02:53,745
任何时刻最多只有一个控制器有效，取消权的生命周期和轮次一样长。

32
00:02:53,745 --> 00:02:58,721
出处是 agent.ts 的第一百八十七行和第三百二十五行。

33
00:02:58,721 --> 00:03:01,558
取消键只是拉了一下这根线。

34
00:03:01,558 --> 00:03:10,416
界面把按键翻译成一次带身份的取消调用，用户按的是用户类型，子智能体被父级打断的是父级类型。

35
00:03:10,416 --> 00:03:13,433
取消自带身份，不是无名的。

36
00:03:13,433 --> 00:03:17,363
取消入口小到可以背下来，就两个动作。

37
00:03:17,363 --> 00:03:26,462
默认先把收件箱清空，排队没跑的消息全部作废；想保住排队工作就传一个保留参数，只中断当前活动。

38
00:03:26,462 --> 00:03:30,596
然后对当前控制器发出中止，并且带上原因。

39
00:03:30,596 --> 00:03:35,837
空闲时调取消是空操作，不会给未来的工作预埋取消状态。

40
00:03:35,837 --> 00:03:40,236
出处是 agent.ts 的第一百三十四到一百四十行。

41
00:03:40,236 --> 00:03:52,339
同一个信号显式地发给前置钩子、提示词组装、模型请求、流式读取、工具执行、审批这些环节，连命令行工具都能顺着它杀掉整个进程组。

42
00:03:52,339 --> 00:03:54,322
传递是协作式的。

43
00:03:54,322 --> 00:04:04,346
循环在每个等待边界前后检查中断，不用竞速半路丢弃一个还在跑的异步任务，所以不会有僵尸工作偷偷改状态。

44
00:04:04,346 --> 00:04:10,668
信号是显式参数，不靠全局状态，每个边界自己检查、协作式收手。

45
00:04:10,668 --> 00:04:13,649
这里值得对比一下竞速方案的差别。

46
00:04:13,649 --> 00:04:19,214
竞速写法很省事，把请求和一个取消承诺赛跑，谁先到用谁。

47
00:04:19,214 --> 00:04:25,704
问题是输掉的那一方并没有被停止，它还在继续跑，只是结果没人要了。

48
00:04:25,704 --> 00:04:30,788
如果那一方是一个会写文件的工具调用，它就会继续写下去。

49
00:04:30,788 --> 00:04:37,123
协作式检查则要求每一处代码自己负责收手，写起来麻烦，但停得干净。

50
00:04:37,123 --> 00:04:46,558
所以判断标准可以这样记，凡是会留下没人管的异步任务的取消实现，都不是真正的取消，只是不再等待。

51
00:04:46,700 --> 00:04:50,412
再讲一处特别讲究的细节，权力交接。

52
00:04:50,362 --> 00:04:55,277
循环在发布轮次结束之前就清掉本轮的取消持有者。

53
00:04:55,277 --> 00:05:01,443
之后哪怕持久化刷新还没结算完，谁也取消不了已经完成的轮次工作。

54
00:05:01,443 --> 00:05:07,309
下一轮拿到的是全新的信号，旧回调想越权，连把手都摸不到。

55
00:05:07,309 --> 00:05:11,864
这一句听着简单，它其实解决了一类很难查的问题。

56
00:05:11,864 --> 00:05:21,708
如果没有这一步，完成态和取消态之间会有一个窗口，在这个窗口里一个迟到的取消能把已经写完的轮次标记成中断。

57
00:05:21,708 --> 00:05:27,597
日志里于是出现一条自相矛盾的记录，内容完整、结局却是中断。

58
00:05:27,597 --> 00:05:30,554
有了这一步，窗口被关掉了。

59
00:05:30,554 --> 00:05:33,138
为什么这个形状长期成立。

60
00:05:33,138 --> 00:05:37,417
显式令牌加作用域绑定，是结构化并发的通则。

61
00:05:37,417 --> 00:05:42,200
Go 的上下文、点 NET 的取消令牌，走的都是这条路。

62
00:05:42,200 --> 00:05:48,162
令牌由创建者负责收回，活不过自己的作用域，越权自然无从谈起。

63
00:05:48,162 --> 00:05:56,587
只要系统里同时有流式输入输出和外部进程要停，换个语言重写，这根显式的线还是得有。

64
00:05:56,730 --> 00:06:00,862
现在讲第二个原理，中断的轮次也要写终态。

65
00:06:00,812 --> 00:06:04,538
假设被取消的轮次不写终态会怎样。

66
00:06:04,538 --> 00:06:17,747
日志停在半截，回放的人不知道这轮怎么结束的；界面没法如实告诉用户哪些排队的活被扔了；下游拿到日志，分不清这轮是被人有序停掉的，还是意外死掉的。

67
00:06:17,747 --> 00:06:23,000
所以结论是，中断是正常业务，不记账的中断才是事故。

68
00:06:23,000 --> 00:06:26,197
轮次的主体逻辑包在异常捕获里。

69
00:06:26,197 --> 00:06:34,935
捕获分支发现信号已中止，就把结局定为已中止；最后的收尾块里无论如何写一条轮次结束回日志。

70
00:06:34,935 --> 00:06:39,430
已经落盘的流式片段、工具输出，一个都不删。

71
00:06:39,430 --> 00:06:43,997
于是日志里的死法只有两种，各有专属签名。

72
00:06:43,997 --> 00:06:49,971
第一种是已中止，由循环亲手写下，有人调了取消，轮次有序收尾。

73
00:06:49,971 --> 00:06:59,057
第二种是已中断，循环从来不发，它只在崩溃恢复时由持久化后端合成，是唯一一个非循环出品的结局。

74
00:06:59,057 --> 00:07:03,012
看一眼原因字段，就知道这轮的结束方式。

75
00:07:03,012 --> 00:07:04,730
两个配套细节。

76
00:07:04,730 --> 00:07:09,755
第一个，日志只存粗粒度的已中止，不存是谁按的。

77
00:07:09,755 --> 00:07:14,851
用户还是父级属于运行时信息，回放不需要也不该知道。

78
00:07:14,851 --> 00:07:29,562
第二个，被清掉的排队消息没有任何轮次结束去描述它，要靠一个单遍折叠函数扫日志，从收件箱记录里的已取消结果算出丢弃未跑的数量，界面才能如实说有活被扔了、没跑。

79
00:07:29,562 --> 00:07:38,949
出处是 agent.ts 的第三百零二到三百零五行、第三百一十六到三百二十三行，以及消费工作模块的第八十七行。

80
00:07:38,949 --> 00:07:41,149
为什么这条纪律值钱。

81
00:07:41,149 --> 00:07:49,142
因为日志是权威状态，一旦某个退出路径不写终态，后续所有依赖日志的能力都会出现缺口。

82
00:07:49,142 --> 00:07:54,514
回放不知道边界在哪，分叉不知道从哪里切，审计分不清责任。

83
00:07:54,514 --> 00:08:00,163
而且这个缺口不会报错，它只是让某些轮次看起来永远没结束。

84
00:08:00,163 --> 00:08:05,872
所以判断标准可以这样记，异常路径和正常路径要出同样的账。

85
00:08:05,872 --> 00:08:11,461
这句话和数据库事务日志的提交与中止记录是同一个道理。

86
00:08:11,461 --> 00:08:15,584
模型换代、语言重写，这条纪律都不变。

87
00:08:15,584 --> 00:08:20,764
一句话记住，体面告别和意外身亡，日志里一眼可辨。

88
00:08:20,920 --> 00:08:25,485
取消好歹有收尾块善后，强杀连善后的机会都没有。

89
00:08:25,435 --> 00:08:29,029
进程死掉的瞬间，日志停在半截。

90
00:08:29,029 --> 00:08:34,497
轮次开始开着，某个工具调用记了调用，却永远等不到结果。

91
00:08:34,497 --> 00:08:42,214
重启后冷加载这份日志，摆在面前的是一道选择题，把没写完的轮次删掉，还是补齐。

92
00:08:42,214 --> 00:08:45,579
删掉看似干净，代价大得多。

93
00:08:45,579 --> 00:08:54,353
长周期任务的单个轮次可能非常庞大，几十个步骤、大量工具输出，这些在崩溃前都已经持久追加。

94
00:08:54,353 --> 00:08:57,466
截断等于把用户的劳动成果陪葬。

95
00:08:57,466 --> 00:08:59,377
这套系统选补齐。

96
00:08:59,377 --> 00:09:04,149
恢复逻辑生成几条确定性的合成事件把尾巴关上。

97
00:09:04,149 --> 00:09:13,704
先给每个悬空的工具调用补一条错误占位的工具结果，再关掉开着的步骤，最后合成轮次结束，结局标已中断。

98
00:09:13,704 --> 00:09:15,471
顺序有讲究。

99
00:09:15,471 --> 00:09:22,863
步骤还开着就写轮次结束违反日志不变式，所以先补步骤的边界，再补轮次的。

100
00:09:22,863 --> 00:09:28,812
合成事件的时间戳复用最后一条真实事件的，绝不发明未来时间。

101
00:09:28,812 --> 00:09:34,005
出处是 session 下 repair.ts 的第一百二十六到一百三十二行。

102
00:09:34,005 --> 00:09:41,060
给悬空工具调用补的那条占位结果，措辞分两种情况，这一处写得非常用心。

103
00:09:41,060 --> 00:09:55,591
工具记录了启动但结果没落盘的，占位文本告诉模型结果未知，只有只读或幂等操作才可以重试，有副作用的要先核实外部状态或问用户，原文强调不要盲目重试。

104
00:09:55,591 --> 00:09:59,377
工具压根没启动的，直接说需要就重试。

105
00:09:59,377 --> 00:10:03,440
出处是 repair.ts 的第九十一到一百二十四行。

106
00:10:03,440 --> 00:10:08,969
这两句措辞的区别看似很小，其实是在替模型做安全判断。

107
00:10:08,969 --> 00:10:15,339
分不清这两种情况，模型就会盲目重试一个可能已经执行了一半的写操作。

108
00:10:15,339 --> 00:10:27,045
把这一处单独拎出来讲，是因为它体现了一个很值得学的习惯，恢复逻辑不只是把数据结构补完整，还要把补出来的那条内容写成对下游安全的形式。

109
00:10:27,045 --> 00:10:33,175
合成事件是给模型看的，不是给状态机看的，所以要按读者的需要来写。

110
00:10:33,175 --> 00:10:35,783
再补一句官方原文的立场。

111
00:10:35,783 --> 00:10:56,352
持久化文档里写得很直白，它不会截断日志，在长周期任务中单个轮次可能非常庞大，而这些事件在崩溃前已被持久追加，后端改为用一个合成的轮次结束去关闭这个遗留轮次，在不改变其前后任何独立事件的情况下配平被中断的执行。

112
00:10:56,490 --> 00:11:01,500
还有一条容易忽略的边界，这套修复只对冷会话生效。

113
00:11:01,450 --> 00:11:14,274
会话还活着的时候，加载会等权威内存快照落盘、只在日志配平时返回，活跃轮次没闭合就直接拒绝，绝不给一个正在跑的轮次插入合成边界。

114
00:11:14,274 --> 00:11:17,207
出处是持久化文档第十七行。

115
00:11:17,207 --> 00:11:19,490
为什么这条边界重要。

116
00:11:19,490 --> 00:11:27,664
如果对热会话也做修复，就可能在运行中插入一条合成边界，而这个轮次其实还在正常工作。

117
00:11:27,664 --> 00:11:31,810
结果是一个活着的轮次被凭空宣布死亡。

118
00:11:31,810 --> 00:11:36,341
所以修复的前提是确认没有活着的权威状态。

119
00:11:36,341 --> 00:11:38,565
写盘那头也有讲究。

120
00:11:38,565 --> 00:11:47,387
持久化插件批量落盘，循环在领取下一轮之前用刷新做检查点，把顺序和写盘错误都看在眼里。

121
00:11:47,387 --> 00:11:49,923
为什么这个思路长期成立。

122
00:11:49,923 --> 00:12:00,332
追加式日志加读取时修复，就是数据库预写日志恢复的思路，崩溃后不改写历史，只补足让状态机能继续走的最小事件。

123
00:12:00,332 --> 00:12:06,197
只要权威状态放在事件日志里，恢复逻辑重写多少遍都长这样。

124
00:12:06,197 --> 00:12:12,567
反过来说，敢截断日志的系统，等于默认单轮工作便宜到可以随便扔。

125
00:12:12,567 --> 00:12:15,680
这个假设在长任务时代不成立。

126
00:12:15,830 --> 00:12:20,515
把两个原理合起来做一次推演，这是本集最实用的一段。

127
00:12:20,465 --> 00:12:23,987
同一个轮次，取消发生在两个不同时刻。

128
00:12:23,987 --> 00:12:26,967
第一种，模型流式输出到一半。

129
00:12:26,967 --> 00:12:30,153
第二种，命令行工具正在执行。

130
00:12:30,153 --> 00:12:39,395
第一种情况下，日志从步骤开始到轮次结束之间会有流式片段、步骤结束，最后是轮次结束，原因是已中止。

131
00:12:39,395 --> 00:12:43,374
排队消息被清掉，靠折叠算出丢弃数量。

132
00:12:43,374 --> 00:12:50,309
第二种情况下，多一条工具调用记录，取消信号顺着传到进程组，工具被杀。

133
00:12:50,309 --> 00:12:53,830
工具结果可能已经落盘，也可能没有。

134
00:12:53,830 --> 00:12:57,833
如果没落盘，就留下一条悬空的工具调用。

135
00:12:57,833 --> 00:13:04,443
现在把这两种情况的事故换成强杀，重启后修复分别要合成几条收尾事件。

136
00:13:04,443 --> 00:13:11,931
第一种，流式中断时没有悬空的工具调用，只需要关掉开着的步骤、再合成轮次结束。

137
00:13:11,931 --> 00:13:21,403
第二种，有一条记录了启动的工具调用，要先补一条结果未知的占位，再关步骤，最后补轮次结束，一共三条。

138
00:13:21,403 --> 00:13:31,342
这个推演说明一件事，崩溃恢复的合成顺序不是随意的，它严格对应日志不变式的要求，先补内层边界，再补外层边界。

139
00:13:31,342 --> 00:13:33,061
再顺着推一步。

140
00:13:33,061 --> 00:13:37,929
第一种情况的轮次结束原因是已中止，因为有人按了取消。

141
00:13:37,929 --> 00:13:42,629
换成强杀之后，同一个位置的结局变成已中断。

142
00:13:42,629 --> 00:13:49,215
内容完全一样，结局字段不同，而这个不同正是回放者唯一能依靠的信息。

143
00:13:49,215 --> 00:13:53,674
这就是为什么前面反复强调要区分两种死法。

144
00:13:53,674 --> 00:13:55,357
最后一句提醒。

145
00:13:55,357 --> 00:14:00,417
修复之后的重入，靠的是冷加载时读日志重建状态。

146
00:14:00,417 --> 00:14:11,619
所以修复只补齐最小事件、绝不改写已提交事件这一条，保证了重入之后看到的历史和崩溃前完全一致，加上合成的那几条收尾。

147
00:14:11,619 --> 00:14:15,838
用户重启之后能接着聊，靠的就是这个。

148
00:14:16,000 --> 00:14:17,632
最后收五条。

149
00:14:17,582 --> 00:14:22,029
第一，取消权要跟着作用域走，不要做全局开关。

150
00:14:22,029 --> 00:14:26,428
一轮一个取消控制器，生命周期和轮次一样长。

151
00:14:26,428 --> 00:14:32,342
全局标志位会留下僵尸工作，也会让上一轮的回调取消掉下一轮。

152
00:14:32,342 --> 00:14:36,032
第二，取消要显式传递，协作式收手。

153
00:14:36,032 --> 00:14:41,993
在每个等待边界前后自己检查，不要用竞速半路丢弃异步任务。

154
00:14:41,993 --> 00:14:45,551
信号是显式参数，不靠全局状态。

155
00:14:45,551 --> 00:14:49,565
第三，权力交接要在发布终态之前完成。

156
00:14:49,565 --> 00:14:56,681
完成态和取消态之间不能留窗口，否则迟到的取消会把完整内容标记成中断。

157
00:14:56,681 --> 00:15:01,416
第四，中断是正常业务，不记账的中断才是事故。

158
00:15:01,416 --> 00:15:08,616
所有退出路径都写终态，异常路径和正常路径出同样的账，重建现场的人才不用猜。

159
00:15:08,616 --> 00:15:12,378
日志只存粗粒度结局，不存是谁按的。

160
00:15:12,378 --> 00:15:15,671
第五，崩溃恢复只补齐，不截断。

161
00:15:15,671 --> 00:15:23,339
冷加载给悬空工具补错误占位，先补步骤再补轮次，时间戳复用最后一条真实事件。

162
00:15:23,339 --> 00:15:28,339
敢截断的系统，等于默认单轮工作便宜到可以随便扔。

163
00:15:28,339 --> 00:15:38,231
一句话收尾，判断一个系统能不能扛事故，不要看它正常时跑得多顺，要看它的日志在异常退出时还认不认账。

