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

2
00:00:06,382 --> 00:00:14,579
这一集讲两件事，它们都属于同一个主题，上下文装不下了怎么办，以及会话凭什么第二天还能接着改。

3
00:00:14,579 --> 00:00:17,824
素材还是来自 OpenAI 的 Codex 仓库。

4
00:00:17,824 --> 00:00:19,711
上半场是压缩。

5
00:00:19,711 --> 00:00:30,480
你在同一个线程里让 agent 改了三十轮，前二十轮它还记得工作区在哪、哪几个文件不能动，到第三十轮它突然问你当前目录是什么。

6
00:00:30,480 --> 00:00:35,012
再过几轮，已经批准过的测试命令又被问了一遍。

7
00:00:35,012 --> 00:00:39,134
用量顶到窗口边上，系统必须动手砍历史。

8
00:00:39,134 --> 00:00:45,036
问题在于砍哪一段、留哪一段，以及砍完之后剩下的东西怎么摆。

9
00:00:45,036 --> 00:00:47,055
下半场是存储。

10
00:00:47,055 --> 00:00:51,814
昨天关了终端，今天会话列表还在，对话也能接着改。

11
00:00:51,814 --> 00:00:57,271
这两件事看起来像同一份存储，落盘的时候却走两条轨。

12
00:00:57,271 --> 00:01:00,805
上轨按行追加，下轨只抄封面。

13
00:01:00,805 --> 00:01:05,697
理解了这两条轨，你就知道中途拔电到底会丢什么。

14
00:01:05,697 --> 00:01:12,728
两件事的共同点是一句很硬的话，原文和派生视图必须分开，而且顺序不能反。

15
00:01:12,728 --> 00:01:15,456
先保证原文，再修镜像。

16
00:01:15,456 --> 00:01:18,858
允许镜像落后，禁止镜像超前。

17
00:01:18,858 --> 00:01:21,093
顺序反了会怎么样。

18
00:01:21,093 --> 00:01:25,745
你会得到一个看起来存在、点进去却是空的对象。

19
00:01:25,745 --> 00:01:29,158
用户能看见标题，点进去没有内容。

20
00:01:29,158 --> 00:01:36,574
这种残废状态比干脆看不见要难查得多，因为所有的健康检查都会认为它是好的。

21
00:01:36,720 --> 00:01:38,665
先把场景说具体。

22
00:01:38,615 --> 00:01:42,629
你让 agent 重构一个模块，来回改了三十轮。

23
00:01:42,629 --> 00:01:49,853
前面那些轮次里，你说过工作区路径，说过哪几个文件不要动，批准过某条测试命令。

24
00:01:49,853 --> 00:01:53,843
这些信息都在历史里，但窗口是有上限的。

25
00:01:53,843 --> 00:01:57,629
用量顶到窗口边上，系统只有三条路。

26
00:01:57,629 --> 00:02:01,740
要么报错停止，要么压缩，要么换一扇新窗。

27
00:02:01,740 --> 00:02:06,235
报错体验最差，换窗丢得最多，压缩是默认答案。

28
00:02:06,235 --> 00:02:08,963
难点在压缩这件事的细节上。

29
00:02:08,963 --> 00:02:11,559
什么时候压，是时机问题。

30
00:02:11,559 --> 00:02:15,105
怎么压、压完剩下什么，是实现问题。

31
00:02:15,105 --> 00:02:26,043
这两件事如果纠缠在一起，代码会迅速膨胀，因为你每加一种提供方、每加一种触发方式，都要在原来的判断里再塞一个分支。

32
00:02:26,043 --> 00:02:29,252
三条路的取舍也值得先说清楚。

33
00:02:29,252 --> 00:02:32,509
报错体验最差，用户什么也做不了。

34
00:02:32,509 --> 00:02:36,223
换窗丢得最多，前面所有约定一起没了。

35
00:02:36,223 --> 00:02:43,314
压缩是默认答案，代价是要维护一份摘要，以及要回答摘要该摆在哪这个麻烦问题。

36
00:02:43,314 --> 00:02:47,954
还有一个容易漏掉的分支，用户可以手动触发压缩。

37
00:02:47,954 --> 00:02:53,987
手动这一档和自动最大的区别在于，它会打断当前正在跑的轮次。

38
00:02:53,987 --> 00:03:03,350
自动压缩是顺着流程走，手动压缩是把流程掐断重来，两者的入口不同，后面会讲到为什么这点很重要。

39
00:03:03,350 --> 00:03:11,379
Codex 的做法是把两者拆开，时机一个维度，实现另一个维度，两两组合，共用同一套入口。

40
00:03:11,379 --> 00:03:14,324
听起来抽象，接下来把它拆开讲。

41
00:03:14,324 --> 00:03:20,478
先记住一句结论，什么时候该换窗，和窗里装什么，是两件独立的事。

42
00:03:20,620 --> 00:03:23,118
时机这一维有三个取值。

43
00:03:23,068 --> 00:03:28,729
发消息之前，工具跑完还要续跑的中途，以及用户手动触发。

44
00:03:28,729 --> 00:03:31,349
实现这一维也有三个取值。

45
00:03:31,349 --> 00:03:37,539
本地再叫一次模型写摘要，旧的远端压缩接口，新的远端压缩接口。

46
00:03:37,539 --> 00:03:40,483
另外还有一种不叫模型的空窗。

47
00:03:40,483 --> 00:03:45,796
三个乘三个是九种组合，但源码里没有九套并列的函数。

48
00:03:45,796 --> 00:03:51,854
自动路径共用同一个入口，手动路径共用另一个入口，一共两套。

49
00:03:51,854 --> 00:03:55,075
九宫格是乘法表，不是九份实现。

50
00:03:55,075 --> 00:03:58,620
三个自动时机先问同一个判定函数。

51
00:03:58,620 --> 00:04:05,748
它在两种情况下为真，一是用量超过缓冲后的压缩限额，二是窗口真的满了。

52
00:04:05,748 --> 00:04:16,193
中途那一路还多一道闸，除了满，还要满足两个条件，后面还要继续采样，并且模型刚请求了新窗口或者用量已经到顶。

53
00:04:16,193 --> 00:04:18,007
只满不续怎么办。

54
00:04:18,007 --> 00:04:23,632
这一轮自然结束，等下一轮用户消息到来时走预采样那一档。

55
00:04:23,632 --> 00:04:28,945
这个分支很值得记住，它说明不是每一次满窗都要立刻压缩。

56
00:04:28,945 --> 00:04:30,628
然后是分发器。

57
00:04:30,628 --> 00:04:37,058
它的第一句看一个叫令牌预算的开关，开了就换空窗，远端和本地全部跳过。

58
00:04:37,058 --> 00:04:46,517
关了再按提供方能力选，认新接口且特性开走远端新版，认新接口但特性关走旧接口，不支持就走本地。

59
00:04:46,517 --> 00:04:53,116
默认情况下这个开关是关的，远端新版是开的，所以默认会话走远端新版。

60
00:04:53,116 --> 00:05:00,640
这里的设计价值在于，换提供方只需要改分发器表里的一行，改时机只需要动相位那一维。

61
00:05:00,640 --> 00:05:04,233
加法变乘法，是这个拆分最直接的收益。

62
00:05:04,233 --> 00:05:06,962
顺带提一个容易忽略的细节。

63
00:05:06,962 --> 00:05:15,003
除了这两个维度，分析用的事件还另有两套标签，一套记录触发来源，一套记录触发原因。

64
00:05:15,003 --> 00:05:22,130
也就是说，运行时做决策用的是相位加实现，事后做分析用的是另外两套标签。

65
00:05:22,130 --> 00:05:28,200
决策和分析分开打标签，是让埋点不污染控制流的一个简单办法。

66
00:05:28,350 --> 00:05:34,513
远端压缩有个本地没有的风险，服务端带回来的记录可能夹着过期的指令。

67
00:05:34,463 --> 00:05:35,798
举个例子。

68
00:05:35,798 --> 00:05:43,298
服务端返回的历史里还留着一份开发者指令，那是二十轮之前的系统设定，早就被改过了。

69
00:05:43,298 --> 00:05:52,072
如果你不滤，它就会和本地按当前环境重新渲染的上下文叠在一起，效果等价于有人偷偷改了旧的一行。

70
00:05:52,072 --> 00:06:00,593
所以远端结果进门先过一道滤网，而且这道滤网是完整的穷尽匹配，不是挑几个常见情况处理。

71
00:06:00,593 --> 00:06:08,346
丢掉的东西有四类，开发者指令、非用户内容伪装成的用户包装、工具调用、压缩触发项。

72
00:06:08,346 --> 00:06:14,980
留下的也有四类，真实的用户消息、持久化的钩子提示、助手消息、压缩项。

73
00:06:14,980 --> 00:06:18,490
这张清单的价值在于它是穷尽的。

74
00:06:18,490 --> 00:06:24,860
任何一类没写进去的东西，要么必须丢，要么必须留，不存在默认放行。

75
00:06:24,860 --> 00:06:35,341
处理外部数据时，穷尽匹配比白名单加兜底可靠得多，因为外部数据的形状会变，而穷尽匹配会在编译期逼着你重新表态。

76
00:06:35,341 --> 00:06:43,899
新版接口复用同一个过滤函数，说明这张滤网是被当成合同对待的，不是某一次实现的临时措施。

77
00:06:43,899 --> 00:06:49,884
再看留下的那四类里有一条值得单独说，持久化的钩子提示要留下。

78
00:06:49,884 --> 00:06:51,026
为什么。

79
00:06:51,026 --> 00:06:57,288
因为它是用户或组织明确要求长期生效的指令，不是某一轮的临时上下文。

80
00:06:57,288 --> 00:07:01,278
压缩可以砍掉对话，但不能砍掉这类约束。

81
00:07:01,278 --> 00:07:08,742
这一条划出了压缩的权力边界，它能动的是发生过的事，不能动的是必须一直有效的事。

82
00:07:08,890 --> 00:07:13,623
过滤完之后还有一个容易被忽略的问题，摘要该摆在哪。

83
00:07:13,573 --> 00:07:15,388
答案取决于时机。

84
00:07:15,388 --> 00:07:28,393
发消息前和手动这两档，用的是不注入策略，替换后的历史里不含初始上下文，并且会把引用上下文项清掉，下一轮普通轮次会走完整的重新注入。

85
00:07:28,393 --> 00:07:37,071
中途这一档用的是插在最后一条真实用户消息之前，当前环境和权限插到那儿，摘要仍然留在队尾。

86
00:07:37,071 --> 00:07:39,270
为什么中途这么特殊。

87
00:07:39,270 --> 00:07:42,984
因为模型被训练成摘要是历史上最后一项。

88
00:07:42,984 --> 00:07:53,309
中途压完还要在同一轮继续采样，如果上下文插到了摘要后面，这条训练约束就破了，模型会表现得像没看见刚才的压缩。

89
00:07:53,309 --> 00:07:56,109
插入函数还留了两层兜底。

90
00:07:56,109 --> 00:07:59,919
找不到真实用户消息，就插在摘要之前。

91
00:07:59,919 --> 00:08:04,006
连摘要都没有，就插在最后一条压缩项之前。

92
00:08:04,006 --> 00:08:12,203
这种情况几乎不会发生，但兜底写在代码里，说明作者承认自己对历史的形状没有绝对把握。

93
00:08:12,203 --> 00:08:17,119
三种实现最后都汇到同一个替换函数，整表替换。

94
00:08:17,119 --> 00:08:21,145
版本号只在这一刻加一，普通的追加不碰它。

95
00:08:21,145 --> 00:08:22,287
为什么。

96
00:08:22,287 --> 00:08:27,419
因为追加每轮都在发生，只有重写才意味着历史被换掉了。

97
00:08:27,419 --> 00:08:34,655
安全审查复用记录的时候会核对父版本号，版本一变，旧的审查前缀就不能再复用。

98
00:08:34,655 --> 00:08:39,751
这就是版本号存在的唯一理由，标记替换，不标记增长。

99
00:08:39,751 --> 00:08:43,645
再解释一下发消息前那一档为什么不注入。

100
00:08:43,645 --> 00:08:56,422
它把引用上下文项也一并清掉，目的很直接，下一轮普通轮次会走一次完整的重新注入，环境、权限、工作区状态全部按当前事实重新生成。

101
00:08:56,422 --> 00:09:05,568
也就是说，这一档选择了重建而不是继承，代价是多花一点 token，收益是模型看到的环境永远是最新的。

102
00:09:05,700 --> 00:09:10,193
还有一种情况，用户只想换一扇干净的窗，不要摘要。

103
00:09:10,143 --> 00:09:16,946
空窗跳过模型，也跳过服务端，安装一扇新窗口，摘要字段是空字符串。

104
00:09:16,946 --> 00:09:22,390
模型在新窗口里看不到旧对话，只看到此刻的环境和权限。

105
00:09:22,390 --> 00:09:27,426
这跟另外一个换窗工具的合同是一致的，换窗不摘要。

106
00:09:27,426 --> 00:09:31,405
关键设计在于，它仍然走压缩的生命周期。

107
00:09:31,405 --> 00:09:33,148
为什么要这么绕。

108
00:09:33,148 --> 00:09:41,501
因为如果空窗走另一套独立流程，钩子和压缩条目就看不见这件事，监控和审计会出现盲区。

109
00:09:41,501 --> 00:09:47,715
复用同一条生命周期，代价只是摘要字段为空，收益是事件流完整。

110
00:09:47,715 --> 00:09:54,470
这个开关默认是关的，理由很实在，避免用户在没意识到的时候丢掉整段对话。

111
00:09:54,470 --> 00:09:57,210
再补两个失败处理的细节。

112
00:09:57,210 --> 00:10:07,763
本地路径自己碰上满窗，不会递归调用自动压缩，它删掉最老的一条再打一次，只剩一条还超就标记满窗并返回错误。

113
00:10:07,763 --> 00:10:19,205
远端失败也不会改走本地，普通满窗触发的预压缩和中途压缩都不带兜底参数，第一次远端失败就直接返回，超时还不在可重试的名单里。

114
00:10:19,205 --> 00:10:26,068
这些取舍的共同点是，宁可失败，也不要在没人注意的地方悄悄改变产物形态。

115
00:10:26,068 --> 00:10:28,640
再补一条关于递归的对比。

116
00:10:28,640 --> 00:10:44,594
另一个项目在判定函数开头就把两种来源直接挡掉，注释写明它们是从主会话分叉出来的子代理，再触发压缩会死锁，并且连续失败三次之后熔断，注释里记录过单会话连续失败上千次的事故。

117
00:10:44,594 --> 00:10:50,543
Codex 的手动压缩任务根本不进轮次循环，所以这种标签可以不存在。

118
00:10:50,543 --> 00:11:00,988
换来的是中途路径没有对等的连续失败计数器，作者把这个赌注写在了注释里，只要压缩把用量压到限额以下，就不必担心死循环。

119
00:11:00,988 --> 00:11:06,529
两种做法各有道理，前者更保守，后者更相信不变量。

120
00:11:06,680 --> 00:11:09,502
下半场换话题，讲存储。

121
00:11:09,452 --> 00:11:11,796
列表要快，恢复要对。

122
00:11:11,796 --> 00:11:15,245
一份文件很难同时满足这两件事。

123
00:11:15,245 --> 00:11:24,164
按行追加的日志文件写起来便宜，用命令行工具就能读，但按工作区、置顶、归档去筛，它就不合适。

124
00:11:24,164 --> 00:11:30,570
关系型数据库擅长这些过滤，却不该成为恢复时拼模型输入的地方。

125
00:11:30,570 --> 00:11:35,065
有两件看起来相反的事故，其实指向同一条规则。

126
00:11:35,065 --> 00:11:40,041
数据库文件被删掉，列表空了一阵又长回来，对话还在。

127
00:11:40,041 --> 00:11:46,219
你手动改库里的标题和工作目录，刷新之后有时跟着变，有时又变回去。

128
00:11:46,219 --> 00:11:50,979
前者说明镜像可以重建，后者说明镜像可以被原文覆盖。

129
00:11:50,979 --> 00:11:54,993
但反过来不成立，原文丢了，镜像救不回来。

130
00:11:54,993 --> 00:11:57,337
所以 Codex 拆成两轨。

131
00:11:57,337 --> 00:12:02,890
会话层不直接碰文件，它把构造好的条目交给当前线程对象。

132
00:12:02,890 --> 00:12:08,647
没有句柄或者追加失败，轮次本身不因此中断，错误只进日志。

133
00:12:08,647 --> 00:12:16,279
这一句也很重要，落盘失败不该让对话停下来，否则存储问题会直接变成可用性问题。

134
00:12:16,279 --> 00:12:25,330
然后线程对象先按策略过滤一份观察用的副本，交给存储的仍然是原始切片，存储自己再跑一遍白名单。

135
00:12:25,330 --> 00:12:30,654
流式增量、审批、警告这些瞬时事件进不了日志文件。

136
00:12:30,654 --> 00:12:36,724
而压缩项、轮次上下文、世界状态、会话元数据一律留下。

137
00:12:36,870 --> 00:12:39,416
两轨的写入顺序是固定的。

138
00:12:39,366 --> 00:12:43,272
先让日志文件落盘，再投影到数据库。

139
00:12:43,272 --> 00:12:48,524
投影失败可以下次重做，日志失败数据库不能顶上去。

140
00:12:48,524 --> 00:12:50,495
为什么顺序不能反。

141
00:12:50,495 --> 00:12:57,202
如果先写数据库再补日志，进程死在两步中间，列表里会出现点不开的会话。

142
00:12:57,202 --> 00:13:00,544
用户看见标题，点进去没有对应行。

143
00:13:00,544 --> 00:13:07,407
这种不一致比列表暂时为空难查得多，因为你看到的是一个存在但残废的对象。

144
00:13:07,407 --> 00:13:14,197
源码注释把数据库写成了可重建视图，并且明确写着刷新屏障必须先赢。

145
00:13:14,197 --> 00:13:20,219
落地的那一步也很朴素，一行数据加一个换行，写完再刷新缓冲。

146
00:13:20,219 --> 00:13:24,870
注意这里刷新的是文件缓冲，没有再调用强制同步。

147
00:13:24,870 --> 00:13:30,880
所以进程被立刻杀掉的时候，最后几行可能还停在内核页缓存里。

148
00:13:30,880 --> 00:13:35,760
下次打开会补上换行，坏掉的半行计进解析错误。

149
00:13:35,760 --> 00:13:38,032
读路径也是分开的。

150
00:13:38,032 --> 00:13:44,931
恢复、分叉、压缩回放只读日志文件，逐行解码，不从数据库表拼历史。

151
00:13:44,931 --> 00:13:53,488
列表优先走数据库，库不存在、打开失败、回填没完成，一律退回扫目录，并且打一个稳定的指标。

152
00:13:53,488 --> 00:14:00,243
这个退回逻辑很关键，它保证了镜像撒谎的时候，列表还能降级而不是报错。

153
00:14:00,243 --> 00:14:06,193
还有一件事必须落进原文，那就是压缩本身产生的三条记录。

154
00:14:06,193 --> 00:14:12,575
它们按固定顺序写入，先是压缩项，再是世界状态，最后是轮次上下文。

155
00:14:12,575 --> 00:14:14,498
顺序为什么重要。

156
00:14:14,498 --> 00:14:21,157
因为世界状态是这份新历史的基线，它必须跟在被替换的历史后面才有意义。

157
00:14:21,157 --> 00:14:23,560
恢复的时候反过来读。

158
00:14:23,560 --> 00:14:33,007
从后往前扫，碰到带替换历史的压缩项就切断更早的后缀，同时清掉更早的基线，然后再正序重放世界状态。

159
00:14:33,007 --> 00:14:36,914
完整快照重置基线，增量补丁往上合并。

160
00:14:36,914 --> 00:14:41,878
这三样东西对列表几乎没用，投影的时候直接跳过。

161
00:14:41,878 --> 00:14:51,036
镜像不是全文索引，它只存列表和筛选要用到的字段，连标题都是取自用户消息，不去猜模型回复里的内容。

162
00:14:51,036 --> 00:14:56,782
这条边界很清楚，镜像服务于人找会话，原文服务于模型读历史。

163
00:14:56,782 --> 00:14:58,945
一句话总结这几层。

164
00:14:58,945 --> 00:15:06,914
日志挡住历史丢失，数据库挡住列表太慢，回填挡住镜像变空，退回挡住镜像撒谎。

165
00:15:06,914 --> 00:15:12,455
任何一层都可以失败，默认让列表降级，不要让恢复降级。

166
00:15:12,610 --> 00:15:15,312
第五条先说，因为它最好用。

167
00:15:15,262 --> 00:15:22,089
任何系统里同时存在原文和派生视图时，允许派生落后，禁止派生超前。

168
00:15:22,089 --> 00:15:26,103
写的时候先落原文，读的时候允许降级。

169
00:15:26,103 --> 00:15:29,409
这条不需要任何框架，今天就改。

170
00:15:29,409 --> 00:15:36,536
第四条，处理外部返回的结构化数据，用穷尽匹配做过滤，不要用白名单加兜底。

171
00:15:36,536 --> 00:15:41,885
外部数据的形状会变，穷尽匹配会在编译期逼你重新表态。

172
00:15:41,885 --> 00:15:45,743
第三条，把时机和实现拆成两个维度。

173
00:15:45,743 --> 00:15:51,560
加法变乘法，新加一种提供方只改分发器的一行，不改判定入口。

174
00:15:51,560 --> 00:15:56,127
第二条，摘要的位置不是审美问题，是训练约束。

175
00:15:56,127 --> 00:16:02,305
中途压缩必须把摘要留在队尾，插到它后面，等于模型没看见这次压缩。

176
00:16:02,305 --> 00:16:05,695
第一条，版本号只在重写时前进。

177
00:16:05,695 --> 00:16:09,240
追加每轮都发生，它不值得一个版本号。

178
00:16:09,240 --> 00:16:14,529
版本号的唯一职责是标记替换，让下游知道旧的东西作废了。

179
00:16:14,529 --> 00:16:19,637
五条串起来还是那句话，原文和视图分开，顺序不能反。

