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

2
00:00:06,382 --> 00:00:10,697
这是 DeepSeek Harness 系列的第三集，也是一次两节合辑。

3
00:00:10,697 --> 00:00:13,713
素材八千多字，我会做取舍。

4
00:00:13,713 --> 00:00:15,612
两节分别讲什么。

5
00:00:15,612 --> 00:00:24,603
第一节讲一条不变量，模型能看到的一切都必须能从日志重建，发请求之前还要现场验一遍，验不过直接崩。

6
00:00:24,603 --> 00:00:32,199
第二节讲插话，智能体正在干活的时候你想说句话，这条消息该排哪条队、什么时候被处理。

7
00:00:32,199 --> 00:00:39,074
把它们放在一起的理由是，两节都在回答同一个问题，新进来的东西什么时候算数。

8
00:00:39,074 --> 00:00:44,519
第一节回答的是，一条内容进模型之前，必须先成为一条事件。

9
00:00:44,519 --> 00:00:50,468
第二节回答的是，一条消息进循环之前，必须先决定它属于哪一班车。

10
00:00:50,468 --> 00:00:52,752
开场先说第一节的动机。

11
00:00:52,752 --> 00:00:59,987
大多数聊天程序都有两份对话，内存里一份数组，磁盘上一份存档，各写各的。

12
00:00:59,987 --> 00:01:08,389
某天进程崩了，你从存档恢复会话，恢复出来的历史比模型当时实际看到的少了一条工具结果。

13
00:01:08,389 --> 00:01:15,240
模型接下来的回答全对不上号，你还查不出原因，因为两份状态谁也证明不了谁。

14
00:01:15,240 --> 00:01:18,798
只要真相有两份，它们迟早漂移。

15
00:01:18,940 --> 00:01:22,531
这套系统的做法是把真相压缩到一份。

16
00:01:22,481 --> 00:01:39,024
规矩写在仓库根部的规范文档第一百零七行，原话翻译成中文是，模型可见等价于已记录，任何进入模型请求的内容都必须能从会话日志重建，一条新的模型可见输入需要先成为一条会话事件。

17
00:01:39,024 --> 00:01:45,418
出处是仓库 AGENTS 文档第一百零七行，核对日期二零二六年八月十三日。

18
00:01:45,418 --> 00:01:46,620
拆开说。

19
00:01:46,620 --> 00:01:55,202
会话日志是一份只追加的事件流，任何想让模型看到的内容，必须先变成一条事件写进日志。

20
00:01:55,202 --> 00:02:00,682
消息历史从日志派生，官方文档的说法是从不单独存储。

21
00:02:00,682 --> 00:02:06,812
所以在这套系统里，日志就是对话本身，系统里没有第二份对话状态。

22
00:02:06,812 --> 00:02:10,190
出处是子系统文档 session 的第五行。

23
00:02:10,190 --> 00:02:12,894
然后是标题里那个双向箭头。

24
00:02:12,894 --> 00:02:15,466
它表示两个方向都要成立。

25
00:02:15,466 --> 00:02:18,795
日志推得出请求，这是顺着的方向。

26
00:02:18,795 --> 00:02:23,531
反过来，请求也必须能被日志解释，这是逆着的方向。

27
00:02:23,531 --> 00:02:30,190
光写日志做不到第二点，因为写日志是被动的，谁都可以绕过它直接改请求。

28
00:02:30,190 --> 00:02:33,519
所以还得有人在发请求的路口站岗。

29
00:02:33,519 --> 00:02:43,254
每次请求出站前，系统从日志现场重新派生一份应有的消息数组，和请求里实际带的那份做全量字符串比对。

30
00:02:43,254 --> 00:02:51,235
系统提示词、模型名、采样参数、工具清单，也要和日志里的请求头快照逐字段对上。

31
00:02:51,235 --> 00:02:52,942
全对上才放行。

32
00:02:52,942 --> 00:02:57,137
站岗的核心只有四行，短到可以当金句贴墙上。

33
00:02:57,137 --> 00:03:06,944
它证明一件事，每次派发前，系统真的会从日志重新派生一份消息，和实际请求做全量字符串比对，对不上就地失败。

34
00:03:06,944 --> 00:03:12,377
出处是核心包 agent-loop 下 invariant.ts 的第三十九到四十二行。

35
00:03:12,377 --> 00:03:14,961
这里有个细节特别值得学。

36
00:03:14,961 --> 00:03:20,754
检查用的派生函数，和恢复、回放用的是同一套公开函数。

37
00:03:20,754 --> 00:03:26,007
检查者和被检查者共享同一套重建规则，谁也没有私货。

38
00:03:26,007 --> 00:03:32,990
如果检查用一套私有实现，那么两套实现迟早也会漂移，等于给自己造了第二个真相。

39
00:03:32,990 --> 00:03:39,372
请求头的重建也只是一个七行的纯函数，扫一遍事件、取最后一个快照。

40
00:03:39,372 --> 00:03:46,740
出处是 invariant.ts 的第二十二到五十二行，以及 request-header.ts 的第六十五到七十一行。

41
00:03:46,880 --> 00:03:50,784
现在讲最硬的那个选择，比对不过时怎么办。

42
00:03:50,734 --> 00:03:54,905
设想站岗的发现对不上，只打一行告警会怎样。

43
00:03:54,905 --> 00:03:58,282
告警意味着违规请求已经发给了模型。

44
00:03:58,282 --> 00:04:05,590
某个插件绕过日志偷偷改了消息，模型看到的和日志记的，从这一刻起是两回事。

45
00:04:05,590 --> 00:04:08,739
日志接着记，记的全是错账。

46
00:04:08,739 --> 00:04:14,664
三天后有人拿这份日志回放排查，怎么都复现不出线上的怪行为。

47
00:04:14,664 --> 00:04:20,986
所以结论是，静默偏差比崩溃可怕，它把排查成本悄悄转嫁给了未来。

48
00:04:20,986 --> 00:04:28,222
系统选了最硬的那条路，比对不过就抛异常，这次请求当场作废，根本发不出去。

49
00:04:28,222 --> 00:04:38,498
设计笔记里明确否决过温和方案，对「比较连续请求、发散时告警」这条备选的判词是，因违规必须在接口层面不可表达而否决。

50
00:04:38,498 --> 00:04:42,296
出处是设计笔记里「曾考虑的替代方案」一节。

51
00:04:42,296 --> 00:04:44,376
配套还有两个小机关。

52
00:04:44,376 --> 00:04:52,272
第一个，检查器插在事件监听队列的队头，防止别的监听器提前短路、把检查静默跳过。

53
00:04:52,272 --> 00:04:55,025
站岗的人要第一个看到请求。

54
00:04:55,025 --> 00:05:01,707
第二个，请求对象和消息数组必须深度冻结，堵住先过检再改内容的后门。

55
00:05:01,707 --> 00:05:06,731
出处是 invariant.ts 的第二十到二十九行和第五十四行。

56
00:05:06,731 --> 00:05:08,630
为什么长期成立。

57
00:05:08,630 --> 00:05:10,626
这就是快速失败。

58
00:05:10,626 --> 00:05:16,623
崩溃把损失锁在零，日志里没有被污染的轮次，修好问题重跑就行。

59
00:05:16,623 --> 00:05:23,703
带病运行的成本是复利，跑得越久坏数据越多，最后连哪天开始坏的都查不出来。

60
00:05:23,703 --> 00:05:28,943
在边界上崩一次，比在三天后的诡异现象里考古便宜得多。

61
00:05:28,943 --> 00:05:34,364
一句话记住，请求发出去的那一刻，日志就再也解释不了模型。

62
00:05:34,364 --> 00:05:36,647
再补一句实操含义。

63
00:05:36,647 --> 00:05:43,090
这条策略对插件作者是硬约束，你不能图省事在出站钩子里改消息数组。

64
00:05:43,090 --> 00:05:50,193
想加内容，正确的做法是写一条事件进日志，让派生函数自己把它推导出来。

65
00:05:50,193 --> 00:05:57,477
绕过日志的任何写法，在这里都会被当场拦下，而不是在三天后的回放里才暴露。

66
00:05:57,477 --> 00:06:05,349
从生态角度看，这其实是在保护插件作者，把一类最难查的 bug 提前到了开发阶段。

67
00:06:05,500 --> 00:06:09,091
前两节的力气，回报在这里一起结算。

68
00:06:09,041 --> 00:06:13,344
日志既然是唯一真相，围绕它能白拿一整套能力。

69
00:06:13,344 --> 00:06:14,414
恢复。

70
00:06:14,414 --> 00:06:18,645
进程崩了，从日志重新派生一遍，接着聊。

71
00:06:18,645 --> 00:06:19,859
分叉。

72
00:06:19,859 --> 00:06:23,837
从任意一条事件岔出去，开一条平行会话。

73
00:06:23,837 --> 00:06:24,919
回放。

74
00:06:24,919 --> 00:06:30,279
把日志再派生一遍就是当年的请求，回放测试连密钥都不用。

75
00:06:30,279 --> 00:06:31,349
审计。

76
00:06:31,349 --> 00:06:37,238
界面上看到的轨迹就是模型看到的内容，背后有运行时断言背书。

77
00:06:37,238 --> 00:06:39,967
连压缩都被这条不变量罩着。

78
00:06:39,967 --> 00:06:50,448
压缩产生的摘要同样以事件形式写进日志，压缩之后的请求照样要过出站比对，压缩实现写出问题会当场崩，逃不掉。

79
00:06:50,448 --> 00:06:56,541
这套玩法有个学名，叫事件溯源，银行和会计系统用了很多年。

80
00:06:56,541 --> 00:07:01,145
不存余额，只存流水，余额永远从流水算出来。

81
00:07:01,145 --> 00:07:04,775
这里只是把交易流水换成了对话事件。

82
00:07:04,775 --> 00:07:09,787
只要事件流还是唯一真相，这些能力就一直是免费的副产品。

83
00:07:09,787 --> 00:07:12,130
横向对比一下方向差别。

84
00:07:12,130 --> 00:07:18,669
另一家的持久化是一条单行道，内存里的对话状态是主，落盘是从。

85
00:07:18,669 --> 00:07:30,375
每来一条消息，把内存条目拷一份丢进落盘通道，发送结果直接丢弃，落盘失败也不打断对话，压缩时还允许把整份落盘历史一次性替换掉。

86
00:07:30,375 --> 00:07:39,871
这是合理的产品取舍，只是持久层不承担正确性职责，没有任何路径把日志反向派生回来、和出站请求比对。

87
00:07:39,871 --> 00:07:48,777
第三家闭源，公开可见的是本地的 jsonl 会话日志和恢复功能，基于已公开证据属于事后记录型持久化。

88
00:07:48,777 --> 00:07:52,323
所以差别不在有没有日志，而在方向。

89
00:07:52,323 --> 00:07:59,330
前两家把持久化当恢复手段，这一家把日志升格为需要运行时证明的第一性原理。

90
00:07:59,480 --> 00:08:01,966
现在讲第二节，插话。

91
00:08:01,916 --> 00:08:08,117
只有一条消息队列的系统里，用户说话只有两种命运，打断或者排队。

92
00:08:08,117 --> 00:08:10,125
翻车场景很日常。

93
00:08:10,125 --> 00:08:15,449
智能体正在按计划改十个文件，改到第三个你发现方向偏了。

94
00:08:15,449 --> 00:08:22,505
打断，前面两个文件的活白干；排队，只能眼睁睁看它把十个文件全改错。

95
00:08:22,505 --> 00:08:28,093
你想做的只是补一句话，这两个选项都要你拿当前进度去换。

96
00:08:28,093 --> 00:08:30,413
先把循环拆成两层。

97
00:08:30,413 --> 00:08:38,887
轮次是一轮完整工作，步骤是一次模型请求外加它触发的工具执行，一个轮次里通常有好几个步骤。

98
00:08:38,887 --> 00:08:48,286
拆开的原因很实际，消息需要一个比整轮更细的投递点，模型下一次能看到新消息的机会，就是下一个步骤的开头。

99
00:08:48,286 --> 00:08:51,783
然后把说话的时机编码成两个参数。

100
00:08:51,783 --> 00:08:56,315
目标决定排哪条队，等下一班车还是插进当前这班。

101
00:08:56,315 --> 00:09:01,771
唤醒决定要不要叫醒司机，智能体空闲时是否立刻开工。

102
00:09:01,771 --> 00:09:07,228
三个接口全是同一个发送函数的参数预设，各自只有三行。

103
00:09:07,228 --> 00:09:10,197
第一种叫 followup，排队加叫醒。

104
00:09:10,197 --> 00:09:13,911
这句话和当前任务无关，是下一个任务。

105
00:09:13,911 --> 00:09:19,908
它进等下一班的队列，等当前轮次完整跑完才被接走，一班只接一条。

106
00:09:19,908 --> 00:09:25,029
唤醒的含义是，智能体睡着也会被叫起来马上开一班新车。

107
00:09:25,029 --> 00:09:28,935
例句是，这个改完之后，帮我再把测试补上。

108
00:09:28,935 --> 00:09:31,711
第二种叫 steer，插队加叫醒。

109
00:09:31,711 --> 00:09:35,678
想纠正正在进行的活，又不想牺牲已有进度。

110
00:09:35,678 --> 00:09:41,555
它进下一步的队列，当前这一步跑完，下一步开头就被模型看到。

111
00:09:41,555 --> 00:09:46,591
例句是，等等，配置文件用 YAML 写，别用 JSON。

112
00:09:46,591 --> 00:09:49,848
第三种叫 inject，插队但不吭声。

113
00:09:49,848 --> 00:09:55,449
插件想给模型塞一条环境提醒，又不想因此触发一轮空跑。

114
00:09:55,449 --> 00:10:02,769
它和 steer 同一条队，但智能体睡着就一直躺在队列里，等别的事把它叫醒时一起处理。

115
00:10:02,769 --> 00:10:07,553
例句是，顺便说一句，用户刚把分支切到主分支了。

116
00:10:07,553 --> 00:10:11,519
给人的补充是 steer，给环境的旁白是 inject。

117
00:10:11,519 --> 00:10:17,288
区分这两条的标准很简单，看这条内容是否值得触发一轮工作。

118
00:10:17,288 --> 00:10:20,569
值得就唤醒，不值得就静静等着。

119
00:10:20,569 --> 00:10:23,130
为什么值得做成三个接口。

120
00:10:23,130 --> 00:10:27,240
因为这是中断的分类学，和实现语言无关。

121
00:10:27,240 --> 00:10:38,214
任何智能体系统重写一遍，还是要回答同样两个问题，新消息等当前任务结束还是插进去，插进去时要不要立刻触发行动。

122
00:10:38,214 --> 00:10:46,002
把分类做成接口，用户补一句话就有了第三种命运，这是把中断粒度当产品能力来做。

123
00:10:46,002 --> 00:10:51,026
出处是 agent.ts 的第一百一十三到一百三十二行。

124
00:10:51,150 --> 00:10:55,162
队列有了，下一个问题是谁来取、怎么取。

125
00:10:55,112 --> 00:11:01,013
如果多处代码都能从同一条队列里读消息，两种事故迟早发生。

126
00:11:01,013 --> 00:11:11,350
循环取了一次、某个插件又取一次，同一条消息进两遍对话历史；或者读了还没处理完进程崩了，重启后这条消息不知去向。

127
00:11:11,350 --> 00:11:22,047
具体翻车是，崩溃恢复时重放日志，一条纠正消息被消费两遍，模型收到两条一模一样的指令，然后把同一个改动做了两次。

128
00:11:22,047 --> 00:11:23,550
答案叫领取。

129
00:11:23,550 --> 00:11:33,790
每个步骤开始前，循环调用一次领取，原子地把下一步队列的全部消息取走；碰上轮次边界，再多取一条等下一班的消息。

130
00:11:33,790 --> 00:11:38,850
注意是一条，连点三次 followup，会得到三个独立的轮次。

131
00:11:38,850 --> 00:11:42,564
取走这个动作落盘为一条纯删除事件。

132
00:11:42,564 --> 00:11:49,006
所以消息只有两种状态，还在队列里，或者归属某个轮次，没有中间态。

133
00:11:49,006 --> 00:11:54,078
崩溃后重放日志，照着删除事件走，不会重复消费。

134
00:11:54,078 --> 00:11:56,314
还有一个容易想错的点。

135
00:11:56,314 --> 00:11:59,751
被前置插件拒绝的批次不放回队列。

136
00:11:59,751 --> 00:12:05,605
领取先于裁决发生，拒绝时不开新步骤，轮次直接以被阻断收场。

137
00:12:05,605 --> 00:12:14,872
出处是 inbox.ts 的第七十一到七十八行，以及 agent.ts 的第二百二十九行、第二百六十六到二百六十九行。

138
00:12:14,872 --> 00:12:16,771
为什么长期成立。

139
00:12:16,771 --> 00:12:20,304
领取制是消息队列几十年的老共识。

140
00:12:20,304 --> 00:12:28,297
数据库里叫加锁读，队列服务里叫可见性超时，本质都是把读取和占有合成一个原子动作。

141
00:12:28,297 --> 00:12:36,074
只要系统同时满足两个条件，多个潜在消费者、崩溃后要能恢复，领取制就是标准答案。

142
00:12:36,220 --> 00:12:39,463
这一节最后一个机制，很精妙。

143
00:12:39,413 --> 00:12:44,557
你按了中止键停止当前活动，紧接着又发一条纠正消息。

144
00:12:44,557 --> 00:12:52,718
纠正的语义是插进当前轮次的下一个步骤，但这个轮次正在死掉，它的下一个步骤永远不会到来。

145
00:12:52,718 --> 00:12:56,144
如果照原样入队，只有两种坏结局。

146
00:12:56,144 --> 00:13:04,293
消息永远躺在队列里没人接，会话卡死；或者强行插进一个正在收尾的回合，行为没法预测。

147
00:13:04,293 --> 00:13:11,612
发送函数在入队之前先看一眼现场，这条消息要求唤醒，而当前活动已经被中止。

148
00:13:11,612 --> 00:13:23,583
那就把投递目标改写成等下一班，唤醒请求先上闩记着，等被中止的活动善后完毕、状态收敛到空闲，再重放唤醒，开一班全新的车。

149
00:13:23,583 --> 00:13:26,504
关键在判断发生在入队之前。

150
00:13:26,504 --> 00:13:31,480
所以队列里从头到尾不会出现一条注定没人接的消息。

151
00:13:31,480 --> 00:13:38,163
这一句很重要，它把问题的解决位置选对了，不是事后清理，是入口改写。

152
00:13:38,163 --> 00:13:46,204
inject 不要求唤醒，不受这个降级影响，照常排进下一步队列，等新一班车的第一站被顺路接走。

153
00:13:46,204 --> 00:13:48,103
为什么长期成立。

154
00:13:48,103 --> 00:13:53,535
这是并发系统的通用命题，事件到达时它的目标正在死亡。

155
00:13:53,535 --> 00:14:00,495
答案也是通用的，别追一个正在退出的执行体，把事件重新排到下一个稳定边界。

156
00:14:00,495 --> 00:14:07,802
操作系统给正在退出的进程递信号，Actor 系统给正在停机的 Actor 发消息，处理套路都一样。

157
00:14:07,802 --> 00:14:14,894
出处是 agent.ts 的第一百一十三到一百二十行、第一百六十四到一百九十三行。

158
00:14:15,050 --> 00:14:16,682
最后收五条。

159
00:14:16,632 --> 00:14:19,373
第一，真相只能有一份。

160
00:14:19,373 --> 00:14:22,882
日志就是对话本身，其余全是视图。

161
00:14:22,882 --> 00:14:28,014
只要真相有两份，它们迟早漂移，而且谁也证明不了谁。

162
00:14:28,014 --> 00:14:33,495
消息历史从日志派生，官方文档的说法是从不单独存储。

163
00:14:33,495 --> 00:14:38,195
第二，检查者和被检查者要共享同一套重建规则。

164
00:14:38,195 --> 00:14:42,906
如果出站比对用一套私有实现，等于给自己造了第二个真相。

165
00:14:42,906 --> 00:14:46,488
请求头的重建最好就是一个纯函数。

166
00:14:46,488 --> 00:14:50,070
第三，边界上崩一次，胜过事后告警。

167
00:14:50,070 --> 00:14:54,252
崩溃把损失锁在零，带病运行的成本是复利。

168
00:14:54,252 --> 00:14:58,495
请求发出去的那一刻，日志就再也解释不了模型。

169
00:14:58,495 --> 00:15:03,219
检查器要插在队头，对象要深度冻结，堵住后门。

170
00:15:03,219 --> 00:15:07,930
第四，把时机编码成数据，而不是散落在调用处。

171
00:15:07,930 --> 00:15:13,291
两条队列、两个参数，就把中断粒度变成了产品能力。

172
00:15:13,291 --> 00:15:20,611
取消息用领取制，读取和占有合成一个原子动作，崩溃恢复才不会重复消费。

173
00:15:20,611 --> 00:15:26,260
第五，事件到达时目标正在死亡，就改排到下一个稳定边界。

174
00:15:26,260 --> 00:15:31,957
降级判断要发生在入队之前，队列里不该出现注定没人接的消息。

175
00:15:31,957 --> 00:15:38,123
一句话收尾，先问新进来的东西什么时候算数，再决定它该落在哪里。

176
00:15:38,123 --> 00:15:43,976
这一句同时适用于进模型的每条内容，和进循环的每条消息。

