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

2
00:00:06,382 --> 00:00:11,310
这一集讲一个非常具体但几乎每个 Agent 都会踩的工程问题。

3
00:00:11,310 --> 00:00:17,836
运行时往模型上下文里塞的那段说明文字，进历史之后，你还认得出来吗。

4
00:00:17,836 --> 00:00:20,012
先说三个真实现象。

5
00:00:20,012 --> 00:00:28,497
用户刚批准了某个包管理器的命令，下一轮模型还在问要不要跑，因为批准记录进历史之后没人认得出它。

6
00:00:28,497 --> 00:00:34,447
压缩刚结束，模型突然忘了工作区在哪、沙箱是只读还是可写。

7
00:00:34,447 --> 00:00:44,014
还有，分叉一条会话，旧的仓库提示还在，新目录的提示又叠上来，模型同时看见两套互相打架的规则。

8
00:00:44,014 --> 00:00:50,997
这三件事的共同来源是同一类操作，运行时往模型上下文里塞了一段文字。

9
00:00:50,997 --> 00:00:59,867
批准前缀、工作区、权限档案、被中断的轮次、当前时间，全是告诉模型一件它自己看不到的事实。

10
00:00:59,867 --> 00:01:05,384
写代码的时候，一段格式化字符串就能拼出来，五分钟上线。

11
00:01:05,384 --> 00:01:07,079
问题出在事后。

12
00:01:07,079 --> 00:01:22,515
这段文字进了历史之后，压缩要不要留它、会话恢复要不要认它、界面要不要把它藏起来、世界状态要不要拿它当模型已经知道的证据，全都依赖同一件事，你还能不能从纯文本里把它认回来。

13
00:01:22,515 --> 00:01:26,878
各处再写一遍前缀判断，过几周四处就漂了。

14
00:01:26,878 --> 00:01:34,354
更麻烦的是未知标签会漏进用户消息的解析里，把运行时的说明当成用户说的原话。

15
00:01:34,354 --> 00:01:40,268
所以真正的问题不是怎么塞进去，而是塞进去之后还能不能认回来。

16
00:01:40,268 --> 00:01:46,674
塞进去是一次性的动作，认回来是长期的约束，每加一种注入都要付一次。

17
00:01:46,674 --> 00:01:55,244
换句话说，这类功能的成本不在第一天那五分钟，而在之后每一次压缩、每一次恢复、每一次分叉。

18
00:01:55,244 --> 00:02:01,410
如果第一天省下了那点结构化工作，后面每一次都要用人工排查补回来。

19
00:02:01,410 --> 00:02:04,150
Codex 的答案是先造类型。

20
00:02:04,150 --> 00:02:11,878
这一集就拆这三条思路，注入先变成类型，有标和无标要写清楚，组装按类型字段分拣。

21
00:02:11,878 --> 00:02:17,984
最后横向对比另一套 harness 的答法，看同一道题的另一种解法代价付在哪。

22
00:02:17,984 --> 00:02:19,294
开始。

23
00:02:19,420 --> 00:02:23,204
第一个思路，把每一种注入收成一个类型。

24
00:02:23,154 --> 00:02:28,610
这个 trait 不在主 crate 里，而在独立的 codex-context-fragments 这个包里。

25
00:02:28,610 --> 00:02:31,615
每个实现必须同时回答四件事。

26
00:02:31,615 --> 00:02:40,786
这段以 user 还是 developer 身份进 Responses API，头尾 marker 分别是什么，中间的 body 怎么写，要不要单独占一条消息。

27
00:02:40,786 --> 00:02:42,925
渲染只有一条规则。

28
00:02:42,925 --> 00:02:47,949
把头尾 marker 和 body 直接首尾相接，中间不加任何分隔符。

29
00:02:47,949 --> 00:02:50,798
空白和换行算 body 自己的。

30
00:02:50,798 --> 00:02:53,827
两个 marker 都为空，就只输出 body。

31
00:02:53,827 --> 00:02:59,560
渲染结果再收成一个协议层的消息对象，内容只有一个输入文本项。

32
00:02:59,560 --> 00:03:04,440
所以注入的终点是协议对象，不是一段随手拼的字符串。

33
00:03:04,440 --> 00:03:07,036
这里有个细节值得停一下。

34
00:03:07,036 --> 00:03:10,017
问 marker 的方法带 self，走实例。

35
00:03:10,017 --> 00:03:13,827
问类型 marker 的方法不带 self，走类型。

36
00:03:13,827 --> 00:03:15,666
为什么要分两套。

37
00:03:15,666 --> 00:03:18,106
因为你手里不一定有原对象。

38
00:03:18,106 --> 00:03:26,387
压缩之后、恢复之后，你只有历史里的纯文本，这时候仍然能问一句，这段文本像不像某某 fragment。

39
00:03:26,387 --> 00:03:29,740
反向识别是从文本回推类型的。

40
00:03:29,740 --> 00:03:36,952
但要注意，以 dyn 形式持有的 fragment 调不了匹配方法，反向识别必须点到具体类型。

41
00:03:36,952 --> 00:03:43,250
这不是设计缺陷，而是刻意的约束，它逼着你明确说出要认的是哪一种。

42
00:03:43,250 --> 00:03:48,875
加一种新的注入，要新建文件、实现 trait、挂进 core 的 context 模块。

43
00:03:48,875 --> 00:03:51,182
目录里三十九个模块。

44
00:03:51,182 --> 00:03:53,827
这个摩擦力本身就是治理。

45
00:03:53,827 --> 00:04:00,257
组装函数收的是已实现 trait 的 fragment，业务侧就失去了随手拼标签的调用点。

46
00:04:00,257 --> 00:04:03,298
最后说为什么这样长期成立。

47
00:04:03,298 --> 00:04:06,098
渲染和识别共用一份定义。

48
00:04:06,098 --> 00:04:13,274
换个语言重写这个项目，最小形态仍然是一份接口、一份注册表、同一对 marker。

49
00:04:13,274 --> 00:04:18,598
这个形状的通用性在于，它不依赖任何具体的序列化格式。

50
00:04:18,750 --> 00:04:22,966
第二个思路，有标和无标要分开，并且写清楚。

51
00:04:22,916 --> 00:04:26,162
先看如果所有注入都带标会怎样。

52
00:04:26,162 --> 00:04:31,186
一次性通知也会在压缩之后被重新认出来，再喂一遍。

53
00:04:31,186 --> 00:04:42,291
反过来如果都不带标，压缩和分叉就分不清用户的原话和运行时塞进去的说明，分叉会把过期的环境上下文当成用户输入重新提交。

54
00:04:42,291 --> 00:04:44,395
所以规则是这样的。

55
00:04:44,395 --> 00:04:56,378
窗口身份、环境、中断、图片缩放、管理侧开发者指令，这些事后要认、要比对、要在压缩之后决定是否重新注入，所以带 begin 和 end 两个 marker。

56
00:04:56,378 --> 00:05:05,981
一次性通知，比如批准前缀、网络规则入册、剩余 token 的一句提醒，类型 marker 返回两个空串，默认匹配恒为假。

57
00:05:05,981 --> 00:05:09,238
匹配规则只看整段文本的首尾。

58
00:05:09,238 --> 00:05:17,027
先去掉开头空白比前缀，再去掉结尾空白比后缀，ASCII 大小写不敏感，两个都中才算命中。

59
00:05:17,027 --> 00:05:19,491
中间夹了什么完全不管。

60
00:05:19,491 --> 00:05:25,741
注意是两个都中才算命中，只匹配开头不算，这个细节能挡掉一大批误判。

61
00:05:25,741 --> 00:05:30,116
换句话说，无标的 fragment 是主动放弃了可逆性。

62
00:05:30,116 --> 00:05:36,883
它们的存在意义只在这一轮，一轮之后就该消失，被重新喂一遍反而是噪声。

63
00:05:36,883 --> 00:05:38,541
还有一条补救。

64
00:05:38,541 --> 00:05:45,681
developer 侧另有一张前缀表，用来补一部分无标识别，覆盖面比 user 侧的 matcher 列表窄。

65
00:05:45,681 --> 00:05:56,174
这张表的存在本身就说明了一个事实，无标不是免费的，失去可逆性之后总有一天会发现有几处还是想认回来，于是被迫再补一张表。

66
00:05:56,174 --> 00:06:01,330
补表能覆盖的永远是过去那些已知标签，覆盖不了将来。

67
00:06:01,330 --> 00:06:07,868
这张表里至今留着一个旧标签，注释写明是为了认旧版本持久化下来的包装。

68
00:06:07,868 --> 00:06:09,803
这句话分量很重。

69
00:06:09,803 --> 00:06:14,154
marker 一旦写进 rollout，就变成恢复合同的一部分。

70
00:06:14,154 --> 00:06:16,618
改标签等于改协议。

71
00:06:16,760 --> 00:06:20,556
这里有一处特别容易看走眼，值得单独讲。

72
00:06:20,506 --> 00:06:30,181
UserInstructions 这个类型，它的 begin marker 是 Markdown 一级标题形式的 AGENTS.md instructions，end marker 是 INSTRUCTIONS 的闭合标签。

73
00:06:30,181 --> 00:06:35,650
但协议里另有一对 user_instructions 标签，这个 struct 并没有用。

74
00:06:35,650 --> 00:06:37,272
后果是什么。

75
00:06:37,272 --> 00:06:43,571
如果有人按协议常量去写匹配逻辑，就会认漏仓库里真实渲染出来的文本。

76
00:06:43,571 --> 00:06:51,443
也就是说，你以为你在认这一类注入，实际上一个都认不到，而且不会报错，只会静默地少做事。

77
00:06:51,443 --> 00:06:59,568
这类 bug 的隐蔽性在于，代码读起来完全正确，常量名字也对得上，只是和真实渲染产物对不上。

78
00:06:59,568 --> 00:07:04,556
唯一的防线是拿真实输出去验，而不是拿常量名去推。

79
00:07:04,556 --> 00:07:06,335
再往前推一步。

80
00:07:06,335 --> 00:07:12,044
把批准前缀这个开关打开，压缩之后默认的匹配还能不能把它认回来。

81
00:07:12,044 --> 00:07:14,604
答案是不能，因为它无标。

82
00:07:14,604 --> 00:07:23,559
developer 侧要靠那张前缀表补这一刀，而改错常量的时候编译器不会响，因为两边都是合法的字符串。

83
00:07:23,720 --> 00:07:29,319
在往下走之前，先把这套机制的边界划清楚，否则容易高估它。

84
00:07:29,269 --> 00:07:32,201
挡得住的，是没登记的注入。

85
00:07:32,201 --> 00:07:41,396
你要往上下文里塞东西，就必须有一个实现 trait 的类型，随手格式化出来的标签进不去组装函数，也进不了 matcher 列表。

86
00:07:41,396 --> 00:07:43,752
这是把门关在入口。

87
00:07:43,752 --> 00:07:45,543
挡不住的有两类。

88
00:07:45,543 --> 00:07:49,653
第一类，实现体里面格式化出来的动态字符串。

89
00:07:49,653 --> 00:07:54,389
类型只约束形状和标记，不约束 body 里到底写了什么。

90
00:07:54,389 --> 00:07:57,622
匹配认的是文本形状，不是语义。

91
00:07:57,622 --> 00:08:04,954
所以一个类型可以合法地每轮往 body 里塞完全不同的内容，而类型层不会有任何反应。

92
00:08:04,954 --> 00:08:08,884
第二类更现实，登记了但每轮塞四十 KB。

93
00:08:08,884 --> 00:08:14,966
类型系统完全不会响，因为它只回答 role、marker 和 body，不回答大小。

94
00:08:14,966 --> 00:08:19,172
这一层是评审规则的事，不是类型能解决的。

95
00:08:19,172 --> 00:08:23,884
所以准确的说法是，类型负责可追溯性，不负责节制。

96
00:08:23,884 --> 00:08:28,800
可追溯性让每一段都能被认回来、被审计、被单独处理。

97
00:08:28,800 --> 00:08:30,879
节制是另一套机制。

98
00:08:30,879 --> 00:08:36,432
把两件事混在一起期待一个类型全解决，是常见的误判。

99
00:08:36,580 --> 00:08:42,142
第三个思路，组装不是随手 push 字符串，而是按类型字段分拣。

100
00:08:42,092 --> 00:08:44,400
先看不这么做会怎样。

101
00:08:44,400 --> 00:08:49,172
各处随手追加，顺序靠约定，单独成条靠注释。

102
00:08:49,172 --> 00:08:58,871
结果就是 token 预算说明和权限说明挤在同一条 developer 消息里，压缩滤网没法按条处理，混装之后要回滚也拆不开。

103
00:08:58,871 --> 00:09:06,876
源码里的注释自己都承认，初始上下文构建可能把 contextual fragment 和持久的 developer 文本捆在一起。

104
00:09:06,876 --> 00:09:13,198
Codex 的做法是，第一次组装发生在会话构建初始上下文的那个函数里。

105
00:09:13,198 --> 00:09:20,157
它按三个字段把 fragment 分进几组，role、marker 的开头、以及是否要求单独成条。

106
00:09:20,157 --> 00:09:28,114
分成可合并的 developer 段、必须单独成条的 developer 段、user 段，以及要置顶或置底的特殊段。

107
00:09:28,114 --> 00:09:30,686
窗口身份这一条比较特别。

108
00:09:30,686 --> 00:09:38,595
在对应的 feature 打开并且模型有上下文窗口时，它在世界状态循环之前就被推进单独组。

109
00:09:38,595 --> 00:09:41,107
最终输出顺序是固定的。

110
00:09:41,107 --> 00:09:48,511
先一条合并好的 developer 消息，再一条条单独的 developer 消息，最后一条 contextual user 消息。

111
00:09:48,511 --> 00:09:53,679
注意分拣的依据是类型字段，不是字符串里有没有某个词。

112
00:09:53,679 --> 00:09:59,196
字符串里有没有某个词帮不上忙，因为那不是结构，只是巧合。

113
00:09:59,196 --> 00:10:02,261
这套分拣还带来一个直接的好处。

114
00:10:02,261 --> 00:10:07,934
点到模型输入里的任意一段文字，都能回到某个具体的 fragment 类型。

115
00:10:07,934 --> 00:10:15,855
这条性质让上下文审计成为可能，你知道每一段来自哪里、由谁负责、该不该在压缩里留下来。

116
00:10:15,855 --> 00:10:21,588
还有一个字段把能不能和别人挤在同一条 developer 消息里也收进了类型。

117
00:10:21,588 --> 00:10:28,655
token 预算上下文、图片缩放通知、管理侧开发者指令，这三个选择单独成条。

118
00:10:28,655 --> 00:10:38,379
代价是多占一条消息、多一次 role 切换，好处是压缩滤网可以按条处理，要丢掉哪一类内容时不用先拆字符串。

119
00:10:38,379 --> 00:10:42,309
这个取舍值得在自己的项目里照着做一遍。

120
00:10:42,309 --> 00:10:49,220
判断标准很简单，这类内容会不会被单独处理、单独丢弃、单独展示。

121
00:10:49,220 --> 00:10:51,528
会，就单独成条。

122
00:10:51,528 --> 00:10:53,246
不会，就合并。

123
00:10:53,246 --> 00:11:02,153
别因为省一条消息的开销把它们捆在一起，捆起来的代价在压缩和回滚时才付，而且比一条消息贵得多。

124
00:11:02,280 --> 00:11:06,280
再看同一道题的另一种答法，横向对比一下。

125
00:11:06,230 --> 00:11:10,809
另一个 harness 把原则收成一句话，模型可见即已记录。

126
00:11:10,809 --> 00:11:16,182
意思是抵达模型请求的一切，都必须能从会话日志重建。

127
00:11:16,182 --> 00:11:21,002
新增一项模型可见输入，就需要新增一个会话事件。

128
00:11:21,002 --> 00:11:24,030
它的执行面是一个不变量模块。

129
00:11:24,030 --> 00:11:33,922
在流式调用上挂监听，从会话日志推导出期望值，再和即将发出的消息数组做序列化比较，对不上就直接失败。

130
00:11:33,922 --> 00:11:37,780
这道闸开在崩溃点，也就是发请求那一刻。

131
00:11:37,780 --> 00:11:43,958
它的好处是能抓住组装之后又改了消息这类只有运行时才出现的漂移。

132
00:11:43,958 --> 00:11:52,432
代价是关掉不变量服务，这道闸就没了，所以它是一个可开关的运行时保障，不是编译期的形状约束。

133
00:11:52,432 --> 00:11:54,175
两边真源不同。

134
00:11:54,175 --> 00:12:00,317
那边的真源是事件日志，消息只是投影，所以它不需要 fragment trait 这一层。

135
00:12:00,317 --> 00:12:05,533
Codex 的真源是封闭的类型集合，再用 marker 做事后识别。

136
00:12:05,533 --> 00:12:07,095
代价也不同。

137
00:12:07,095 --> 00:12:16,855
Codex 的类型挡得住没实现 trait 就塞进渲染，但挡不住实现体里面格式化出来的动态字符串，因为匹配认的是文本形状。

138
00:12:16,855 --> 00:12:22,059
而且它在编译期就付完了成本，运行时刻额外开销很小。

139
00:12:22,059 --> 00:12:24,824
这两种付法各有适用场景。

140
00:12:24,824 --> 00:12:34,607
如果你的上下文来源本来就高度动态、一天新增好几种，那么每次加一个类型的摩擦会变成负担，运行时对账更合适。

141
00:12:34,607 --> 00:12:47,192
反过来，如果你的上下文来源相对封闭、一年加不了几十种，但每一条都要能被压缩、被恢复、被 UI 单独处理，那么把每件东西先收成类型是更划算的。

142
00:12:47,192 --> 00:12:52,095
两边都为可追溯付钱，区别只在于钱付在哪一次。

143
00:12:52,250 --> 00:12:56,623
最后收一下，六条能直接用在自己项目里的东西。

144
00:12:56,573 --> 00:13:02,017
第一，往模型上下文里塞的每一段文字，先收成一个类型。

145
00:13:02,017 --> 00:13:06,344
类型必须自己回答 role、头尾标记和 body 三个问题。

146
00:13:06,344 --> 00:13:09,806
回答不上来的，说明它还不该被塞进去。

147
00:13:09,806 --> 00:13:13,135
第二，渲染和识别共用一份定义。

148
00:13:13,135 --> 00:13:16,128
渲染走实例，识别走类型。

149
00:13:16,128 --> 00:13:19,926
只有一份定义，压缩和恢复才不会漂。

150
00:13:19,926 --> 00:13:23,544
第三，可逆性要在类型上显式写清楚。

151
00:13:23,544 --> 00:13:31,561
压缩后要重认的强制头尾标记，一次性通知可以无标，但必须在注释里写明主动放弃可逆。

152
00:13:31,561 --> 00:13:36,597
第四，组装按类型字段分拣，不按字符串内容分拣。

153
00:13:36,597 --> 00:13:39,866
顺序写在字段上，不能靠约定。

154
00:13:39,866 --> 00:13:46,260
第五，识别函数只从注册表做匹配，不许再写第二套文本前缀判断。

155
00:13:46,260 --> 00:13:49,806
写了第二套，就等于宣布类型层失效。

156
00:13:49,806 --> 00:13:55,527
第六，拿真实渲染产物去验匹配逻辑，不要拿协议常量名去推。

157
00:13:55,527 --> 00:14:04,145
这一集里那个认漏的例子，代码读起来全对，只是和真实输出对不上，而且改错常量时编译器不会响。

158
00:14:04,145 --> 00:14:05,960
再补一句边界。

159
00:14:05,960 --> 00:14:15,635
前面已经说过，类型挡得住没登记的注入，挡不住登记了但每轮塞进去四十 KB，也挡不住实现体里格式化出来的动态内容。

160
00:14:15,635 --> 00:14:20,250
类型负责让每一段都能被认回来，节制要靠另一层规则。

161
00:14:20,250 --> 00:14:22,474
把这六条收成一句话。

162
00:14:22,474 --> 00:14:33,195
往模型上下文里塞东西这件事，难点从来不在塞进去那一刻，而在于事后的每一次压缩、恢复、分叉、审计，都要还能认回来。

163
00:14:33,195 --> 00:14:38,387
先给它造一个类型，就是在为每一次事后场景提前付款。

164
00:14:38,387 --> 00:14:40,491
这一集就到这里。

