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

2
00:00:06,382 --> 00:00:14,062
上一集我们把 Grok Build 的骨架拆开了，入口在哪，界面和 Agent 宿主为什么分开，会话怎么隔离。

3
00:00:14,062 --> 00:00:19,831
这一集往下钻一层，看四个很具体、也很容易被写错的实现细节。

4
00:00:19,831 --> 00:00:22,764
第一个，上下文快满了怎么办。

5
00:00:22,764 --> 00:00:27,475
自动压缩什么时候触发，阈值是多少，有没有第二遍处理。

6
00:00:27,475 --> 00:00:33,629
第二个，提示词是怎么渲染出来的，渲染之前的那些输入数据长什么样。

7
00:00:33,629 --> 00:00:38,185
第三个，工具集的配置在哪注册，注册晚了会怎样。

8
00:00:38,185 --> 00:00:42,956
第四个，一个工具到底算不算只读，这个判断由谁给。

9
00:00:42,956 --> 00:00:49,567
这四个问题有个共同点，它们都是那种你以为自己知道、但一写就错的细节。

10
00:00:49,567 --> 00:00:55,276
比如自动压缩阈值，很多人凭直觉说百分之八十，实际是八十五。

11
00:00:55,276 --> 00:01:01,298
比如工具只读，很多人以为只读就等于自动执行，其实完全不是一回事。

12
00:01:01,298 --> 00:01:07,367
所以这一集的口号是，别猜默认值，去看 Default 实现和枚举定义。

13
00:01:07,367 --> 00:01:08,978
我们开始。

14
00:01:09,124 --> 00:01:11,165
先看上下文预算。

15
00:01:11,115 --> 00:01:19,084
策略结构体叫 CompactionPolicy，它的 Default 实现给了五个字段，五个全是硬事实，我们一个一个念。

16
00:01:19,084 --> 00:01:26,403
第一个，auto_compact_threshold_percent，类型 u32，默认值八十五。

17
00:01:26,403 --> 00:01:28,951
这是自动压缩阈值百分比。

18
00:01:28,951 --> 00:01:35,310
第二个，compact_model，类型是一个 Option 包着的 String，默认 None。

19
00:01:35,310 --> 00:01:38,951
没指定的时候，用的是当前 Session 的模型。

20
00:01:38,951 --> 00:01:46,596
注意这里的设计，压缩可以用一个更便宜的小模型来做，但默认不指定，也就是沿用当前模型。

21
00:01:46,596 --> 00:01:49,925
这是个成本开关，默认取保守值。

22
00:01:49,925 --> 00:01:55,646
第三个，memory_flush_enabled，类型 bool，默认 false。

23
00:01:55,646 --> 00:01:59,781
启用之后，压缩之前才会跑一次 memory flush turn。

24
00:01:59,781 --> 00:02:01,235
默认不跑。

25
00:02:01,235 --> 00:02:08,230
第四个，wall_clock_budget_secs，类型 u64，默认三百。

26
00:02:08,230 --> 00:02:11,860
这是单次压缩的墙钟预算，单位秒。

27
00:02:11,860 --> 00:02:18,567
三百秒就是五分钟，压缩这件事有时间上限，超了就得按超时路径处理。

28
00:02:18,567 --> 00:02:24,168
第五个，two_pass_enabled，类型 bool，默认 false。

29
00:02:24,168 --> 00:02:28,122
它由配置解析后写入，默认走 single-pass 路径。

30
00:02:28,122 --> 00:02:35,778
记住这五个默认值，你就不会在复述里说出「默认开启 memory flush」或者「默认两遍压缩」这种话。

31
00:02:35,778 --> 00:02:37,629
两个都是 false。

32
00:02:37,780 --> 00:02:39,280
阈值怎么判。

33
00:02:39,230 --> 00:02:44,771
判断函数叫 exceeds_threshold，在 xai-token-estimation 这个 crate 里。

34
00:02:44,771 --> 00:02:53,930
它接收三个参数，used 是已用 token，context_window 是上下文窗口，threshold_percent 是阈值百分比。

35
00:02:53,930 --> 00:03:00,805
函数第一行就处理了一个边界，如果 context_window 等于零，直接返回 false。

36
00:03:00,805 --> 00:03:08,184
这个防御很关键，因为窗口为零在真实系统里是会出现的，尤其是配置还没加载完的时候。

37
00:03:08,184 --> 00:03:11,069
真正的比较用的是饱和乘法。

38
00:03:11,069 --> 00:03:16,514
used 乘一百，context_window 乘阈值百分比，然后比大小。

39
00:03:16,514 --> 00:03:20,372
注意这里没有做浮点除法，是整数乘法。

40
00:03:20,372 --> 00:03:26,766
为什么用乘法不用除法，因为整数乘法没有精度损失，也没有除零风险。

41
00:03:26,766 --> 00:03:29,494
饱和两个字还会防止溢出。

42
00:03:29,494 --> 00:03:41,790
调用位置在 Agent 的 should_auto_compact，它接收 total_tokens 和一个 NonZeroU64 的 context_window，再转调 exceeds_threshold。

43
00:03:41,790 --> 00:03:44,783
这里有个事实校正值得记一下。

44
00:03:44,783 --> 00:03:52,595
策略结构体本身没有 should_compact 这个方法，网上有些复述会编一个出来，源码里不存在。

45
00:03:52,595 --> 00:03:55,252
我们手算一遍边界加深印象。

46
00:03:55,252 --> 00:04:00,901
假设上下文窗口是十万，阈值八十五，触发点就是八万五千。

47
00:04:00,901 --> 00:04:04,134
用八万四千九百九十九，不触发。

48
00:04:04,134 --> 00:04:07,752
用八万五千，触发，因为是大于等于。

49
00:04:07,752 --> 00:04:09,699
用九万，触发。

50
00:04:09,699 --> 00:04:12,463
再说说两遍压缩什么时候成立。

51
00:04:12,463 --> 00:04:22,139
当 two_pass_enabled 为 true 的时候，接近阈值可以投机地在后台先总结历史前缀，得到一份中间摘要，这是第一遍。

52
00:04:22,139 --> 00:04:29,182
正式压缩的时候，把这份中间摘要和近期尾部组合起来再总结一次，这是第二遍。

53
00:04:29,182 --> 00:04:33,401
配置为 false 的时候，就走原来的 single-pass 路径。

54
00:04:33,401 --> 00:04:41,321
重点一句，开启 two_pass_enabled 会改变压缩路径，但不会把默认值改成 true。

55
00:04:41,321 --> 00:04:43,317
默认还是 false。

56
00:04:43,468 --> 00:04:47,624
第二个话题，PromptContext，可检查的渲染输入。

57
00:04:47,574 --> 00:04:51,168
它保存的是 Agent 专属的模板输入。

58
00:04:51,168 --> 00:04:58,704
结构上通过 Serde 的 derive 获得序列化能力，然后交给 ToolBridge 的 render_prompt 完成渲染。

59
00:04:58,704 --> 00:05:00,976
先做一处事实校正。

60
00:05:00,976 --> 00:05:06,769
有些复述会说这个类里有专门的 JSON 转换方法，源码里没有。

61
00:05:06,769 --> 00:05:13,740
可序列化能力全部来自 Serialize 和 Deserialize 这两个 derive，没有额外定义专用方法。

62
00:05:13,740 --> 00:05:19,822
工具描述也不是 PromptContext 自己拼的，是渲染路径结合 ToolBridge 处理的。

63
00:05:19,822 --> 00:05:22,851
字段分三组看，好记很多。

64
00:05:22,851 --> 00:05:25,122
第一组是版本与模板。

65
00:05:25,122 --> 00:05:36,829
version、prompt_mode、audience、prompt_body、system_prompt、build_timestamp_utc、system_prompt_label。

66
00:05:36,829 --> 00:05:38,968
第二组是配置与身份。

67
00:05:38,968 --> 00:05:53,404
agents_md_files、persona_summaries、role_instructions、persona_instructions、memory_enabled、memory_global_path、memory_workspace_path。

68
00:05:53,404 --> 00:05:55,952
第三组是用户运行环境。

69
00:05:55,952 --> 00:06:04,654
os_name、shell_path、working_directory、current_date、is_non_interactive。

70
00:06:04,654 --> 00:06:14,173
看到第三组你应该会心一笑，这就是那些「提示词里告诉你现在什么系统、什么 shell、在哪个目录、今天几号」的信息来源。

71
00:06:14,173 --> 00:06:20,411
它们不是魔法，是 PromptContext 里的普通字段，序列化之后渲染进提示词。

72
00:06:20,411 --> 00:06:22,442
还有一个工程细节。

73
00:06:22,442 --> 00:06:31,264
部分字段用了 skip_serializing_if，作用是空值不写进序列化结果，持久化输出因此变小。

74
00:06:31,264 --> 00:06:39,906
你做对账或者做快照测试时要注意这件事，空字段在输出里不出现，这不是丢数据，是刻意省略。

75
00:06:40,060 --> 00:06:45,322
决定用哪个基础模板的是 TemplateOverride，一个枚举，三个变体。

76
00:06:45,272 --> 00:06:47,736
第一个是 None，标准模板。

77
00:06:47,736 --> 00:06:53,517
它带默认标注，Primary 用标准 base template，Subagent 用对应的紧凑模板。

78
00:06:53,517 --> 00:06:58,697
注意同一个变体在不同角色下效果不同，这点容易被忽略。

79
00:06:58,697 --> 00:07:05,272
第二个是 Codex，源码注释把它定义为 apply-patch profile 的提示词模板，按需解密。

80
00:07:05,272 --> 00:07:11,414
第三个是 Custom，带一个 String，由调用方提供完整自定义模板字符串。

81
00:07:11,414 --> 00:07:18,710
三个变体覆盖三种需求，走标准、走某个预设 profile、或者调用方完全自己给。

82
00:07:18,710 --> 00:07:21,642
设计上很干净，没有第四种。

83
00:07:21,642 --> 00:07:24,395
为什么值得单独给一个枚举。

84
00:07:24,395 --> 00:07:33,193
因为模板选择是提示词体系里最容易产生分歧的地方，不同角色、不同 profile、不同调用方都想改它。

85
00:07:33,193 --> 00:07:41,438
用一个枚举把三条路显式列出来，比散落的字符串开关好维护得多，也更容易在代码审查里被拦住。

86
00:07:41,438 --> 00:07:44,383
再补一句渲染职责的边界。

87
00:07:44,383 --> 00:07:55,164
PromptContext 只负责存输入，TemplateOverride 只负责定基础模板，真正把两者拼成最终字符串的是 TemplateRenderer 加 ToolBridge 这条渲染路径。

88
00:07:55,164 --> 00:07:58,649
三个角色各管一段，谁都不越界。

89
00:07:58,804 --> 00:08:03,934
第三个话题，工具集预设注册表，这块有点绕，我慢慢讲。

90
00:08:03,884 --> 00:08:11,528
config.rs 允许 crate 之外的扩展代码，在当前进程内注册按名称解析的工具集构建函数。

91
00:08:11,528 --> 00:08:13,619
三个事实必须分清。

92
00:08:13,619 --> 00:08:18,006
第一，注册表里存的是函数指针，不是配置对象。

93
00:08:18,006 --> 00:08:24,629
类型叫 ToolsetPresetBuilder，等于一个接收空参数、返回 ToolServerConfig 的函数。

94
00:08:24,629 --> 00:08:29,593
注册表保存的是这个函数，查询的时候才调用它生成配置。

95
00:08:29,593 --> 00:08:35,711
这意味着每次解析都会重新构建一份配置，而不是复用同一个对象。

96
00:08:35,711 --> 00:08:38,595
第二，可见性只管公开枚举。

97
00:08:38,595 --> 00:08:42,273
可见性枚举有两个值，Public 和 Internal。

98
00:08:42,273 --> 00:08:47,069
Public 会进入 preset_names 和公开 preset 集合。

99
00:08:47,069 --> 00:08:53,812
Internal 不进入公开枚举，但仍然能被 toolset_for_preset 按名称解析。

100
00:08:53,812 --> 00:09:01,083
这是最容易搞混的一点，Internal 不是不可见，是不公开列举，你按名字找还是能找到。

101
00:09:01,083 --> 00:09:03,679
第三，注册表属于进程。

102
00:09:03,679 --> 00:09:10,530
全局 HashMap 被 OnceLock 和 Mutex 包住，生命周期覆盖当前进程，读写都受锁保护。

103
00:09:10,530 --> 00:09:14,232
然后是注册时序，这是本段的重点。

104
00:09:14,232 --> 00:09:19,797
如果配置 A 已经解析完成，之后注册的新 preset 不会回写到配置 A。

105
00:09:19,797 --> 00:09:23,836
现有的 ToolServerConfig 保持原样，不会自动变。

106
00:09:23,836 --> 00:09:31,228
如果配置 B 在注册之后才解析，那它会重新查询全局注册表，能看到晚注册的 preset。

107
00:09:31,228 --> 00:09:37,574
所以同一个注册动作，对已解析的配置和未来的解析，效果完全不同。

108
00:09:37,574 --> 00:09:44,160
源码注释也明确要求，尽量在第一次解析之前完成注册，以保证启动行为一致。

109
00:09:44,160 --> 00:09:46,600
顺带回答一个实用问题。

110
00:09:46,600 --> 00:09:51,853
如果你要设计一个仅供测试用的 preset，应该调哪个注册函数。

111
00:09:51,853 --> 00:10:02,706
答案是 register_internal_toolset_preset，builder 类型是那个函数指针，而且它不会出现在 preset_names 里。

112
00:10:02,706 --> 00:10:07,586
注册完之后，已经解析的会话配置不会自动变化。

113
00:10:07,732 --> 00:10:11,359
第四个话题，ToolKind 的默认只读语义。

114
00:10:11,309 --> 00:10:16,201
is_read_only 是工具种类这一层的默认分类。

115
00:10:16,201 --> 00:10:18,281
具体工具可以覆盖它。

116
00:10:18,281 --> 00:10:25,480
而且，最终到底执不执行，还要经过规则、沙箱、Hook 和交互批准这些控制。

117
00:10:25,480 --> 00:10:27,704
先看只读为 true 的分支。

118
00:10:27,704 --> 00:10:37,535
Read、Search、Lsp、ListDir、List、MemorySearch、MemoryGet、WebSearch、WebFetch、EnterPlan、ExitPlan、AskUser。

119
00:10:37,535 --> 00:10:40,372
这些种类的默认值是只读。

120
00:10:40,372 --> 00:10:42,716
再看只读为 false 的分支。

121
00:10:42,716 --> 00:10:55,083
Edit、Delete、Write、Move、Execute、Plan、Task、Skill、SearchTool、UseTool、Monitor、GoalUpdate，另外还包括后台任务、媒体生成、部署等种类。

122
00:10:55,083 --> 00:10:57,860
这里有两个事实要特别记住。

123
00:10:57,860 --> 00:11:03,942
第一，Task 明确位于 false 分支，也就是说 Task 这个种类默认不是只读。

124
00:11:03,942 --> 00:11:07,007
第二，源码里没有 TaskOutput 这个 kind。

125
00:11:07,007 --> 00:11:10,877
很多复述会写 TaskOutput，源码里不存在。

126
00:11:10,877 --> 00:11:17,716
还有一个显示层的细节，Execute 这个种类在界面上的标签是 Run Command，不是 Execute。

127
00:11:17,716 --> 00:11:21,357
这是 presentation_name 函数里写死的。

128
00:11:21,357 --> 00:11:25,865
最后是边界提醒，也是我认为本集最重要的一句。

129
00:11:25,865 --> 00:11:35,083
is_read_only 描述的是默认副作用语义，你不能从它单独推出「自动执行」，也不能推出「必须弹窗」。

130
00:11:35,083 --> 00:11:42,427
命令规则、工作区权限、沙箱、Hook、用户批准，这五类信号都会影响最终结果。

131
00:11:42,427 --> 00:11:47,872
只读分类只是一个默认值，一个起点，不是权限判决。

132
00:11:48,028 --> 00:11:51,042
收尾，把这集能带走的列一下。

133
00:11:50,992 --> 00:11:54,610
第一，别猜默认值，去看 Default 实现。

134
00:11:54,610 --> 00:12:00,692
压缩阈值八十五、墙钟三百秒、memory flush 默认关、两遍压缩默认关。

135
00:12:00,692 --> 00:12:03,336
四个数字，全在 Default 里。

136
00:12:03,336 --> 00:12:11,293
第二，阈值判断用整数饱和乘法，不用浮点除法，而且要处理 context_window 为零的边界。

137
00:12:11,293 --> 00:12:14,887
这是很实在的防御式写法，可以直接抄。

138
00:12:14,887 --> 00:12:20,980
第三，压缩可以用独立的小模型，但默认不指定，沿用当前 Session 模型。

139
00:12:20,980 --> 00:12:25,187
成本开关默认保守，这符合生产系统的习惯。

140
00:12:25,187 --> 00:12:30,055
第四，PromptContext 是可序列化的渲染输入，不是渲染器。

141
00:12:30,055 --> 00:12:31,966
渲染交给 ToolBridge。

142
00:12:31,966 --> 00:12:35,992
序列化能力来自 derive，没有专用转换方法。

143
00:12:35,992 --> 00:12:38,204
别在复述里发明方法。

144
00:12:38,204 --> 00:12:45,464
第五，工具集注册表存的是函数指针，可见性只管公开枚举，注册表属于进程。

145
00:12:45,464 --> 00:12:51,653
晚注册不影响已解析配置，只影响后续解析，所以注册要尽量早。

146
00:12:51,653 --> 00:12:57,339
第六，ToolKind 的默认只读只是分类默认值，不是权限判决。

147
00:12:57,339 --> 00:13:02,483
Task 是 false，没有 TaskOutput 这个 kind，Execute 显示成 Run Command。

148
00:13:02,483 --> 00:13:04,610
第七，也是总纲。

149
00:13:04,610 --> 00:13:09,262
这套源码有大量「看起来应该有、实际上没有」的东西。

150
00:13:09,262 --> 00:13:15,860
should_compact 方法没有，TaskOutput kind 没有，专用 JSON 转换方法没有。

151
00:13:15,860 --> 00:13:20,716
写复述的时候，没在源码里亲眼看到的，就不要写。

152
00:13:20,716 --> 00:13:24,706
最后抛一个问题给你，回去看看你自己的项目。

153
00:13:24,706 --> 00:13:31,365
你的配置默认值是写在 Default 实现里，还是散落在构造函数和环境变量里。

154
00:13:31,365 --> 00:13:37,399
如果答案是后者，你迟早会遇到「线上默认值和文档不一致」这种事故。

155
00:13:37,399 --> 00:13:41,942
把默认值收敛到一个地方，是这集最便宜的一条改进。

156
00:13:41,942 --> 00:13:45,331
本集到此，我们下一集继续往下拆。

