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

2
00:00:06,382 --> 00:00:09,038
这是 Codex 系列的第十七集。

3
00:00:09,038 --> 00:00:18,209
这一集要解决的工程问题很具体，你手里有一份挂钩配置，里面写了几条处理器，可真正跑起来的只有一部分。

4
00:00:18,209 --> 00:00:22,512
为什么名字里有、配置也认、就是执行不了。

5
00:00:22,512 --> 00:00:24,639
先说为什么值得关心。

6
00:00:24,639 --> 00:00:34,471
挂钩点是智能体框架对外开放的生命周期插口，几乎所有团队都会在这里接安全审计、接合规检查、接日志上报。

7
00:00:34,471 --> 00:00:39,182
一旦你以为接上了、其实没接上，那条防线就是假的。

8
00:00:39,182 --> 00:00:45,745
更麻烦的是它不会报错拦你，它只在日志里留一行字，会话照常继续。

9
00:00:45,745 --> 00:00:53,882
所以这一集的主线是一句话，挂钩点能改什么，由事件合同决定，不是由配置文件里那个名字决定。

10
00:00:53,882 --> 00:01:03,641
这里说的事件合同，包含两层意思，一层是这个事件在生命周期的哪个位置，另一层是这个处理器以什么方式返回。

11
00:01:03,641 --> 00:01:09,495
位置决定了它还有没有机会改下一步，返回方式决定了它说的话算不算数。

12
00:01:09,495 --> 00:01:12,728
两层都对上了，挂钩才真的生效。

13
00:01:12,728 --> 00:01:14,867
再补一句适用范围。

14
00:01:14,867 --> 00:01:23,641
这一集讲的全部是服务端可配置的挂钩，不涉及插件声明的另一条路，也不讨论图形界面里的审批弹窗怎么画。

15
00:01:23,641 --> 00:01:30,625
我们只关心一件事，从配置写下去，到引擎真正改了流程，中间到底隔了几道门。

16
00:01:30,625 --> 00:01:32,199
内容分三块。

17
00:01:32,199 --> 00:01:39,290
第一块讲内核对外其实有三张宽度不同的表，认得出、解析得过、跑得了是三件事。

18
00:01:39,290 --> 00:01:45,937
第二块讲失败默认放行，超时和崩溃都不等于拦截，要拦必须给明确理由。

19
00:01:45,937 --> 00:01:53,954
第三块讲位置决定合同，挂钩点在生命周期的哪个位置，决定了它还有没有机会改已经发生的事。

20
00:01:53,954 --> 00:01:57,524
末尾收五条可以带走的设计原则。

21
00:01:57,680 --> 00:01:59,817
设想一个真实场景。

22
00:01:59,767 --> 00:02:03,373
你刚把一份挂钩配置从别的框架搬过来。

23
00:02:03,373 --> 00:02:13,757
文件里有三条处理器，一条命令拦危险 shell，一条让小模型审用户提交，一条在会话结束前再起一个子会话跑检查工具。

24
00:02:13,757 --> 00:02:16,822
在原来的框架里三条都能跑。

25
00:02:16,822 --> 00:02:19,214
贴进 Codex，启动会话。

26
00:02:19,214 --> 00:02:20,897
命令那条亮了。

27
00:02:20,897 --> 00:02:24,334
后两条日志各写一句 not supported yet。

28
00:02:24,334 --> 00:02:30,176
协议枚举里明明列着这两种类型，配置解析也认这两个标签。

29
00:02:30,176 --> 00:02:33,842
可装进运行表的只有命令和 MCP 工具。

30
00:02:33,842 --> 00:02:37,736
内核对外其实是三张表，宽度不一样。

31
00:02:37,736 --> 00:02:39,515
第一张是协议面。

32
00:02:39,515 --> 00:02:43,805
它列出十一个事件名，走 snake_case 序列化。

33
00:02:43,805 --> 00:02:49,923
旁边四个处理器类型也在，命令、MCP 工具、提示词、子代理。

34
00:02:49,923 --> 00:02:54,022
出处是 protocol.rs 的第一千五百一十行附近。

35
00:02:54,022 --> 00:02:56,017
第二张是配置面。

36
00:02:56,017 --> 00:03:00,897
它用类型标签把这四个名字都接住，后两个是空结构体。

37
00:03:00,897 --> 00:03:04,322
解析能过，字段里没有可执行内容。

38
00:03:04,322 --> 00:03:09,779
出处是 hook_config.rs 的第一百八十三到一百八十七行。

39
00:03:09,779 --> 00:03:11,690
第三张是运行表。

40
00:03:11,690 --> 00:03:16,678
发现阶段看见后两个就跳过，文案是那句 not supported yet。

41
00:03:16,678 --> 00:03:21,041
引擎里真正可执行的种类只有命令和 MCP 工具。

42
00:03:21,041 --> 00:03:29,262
普通用户配置里，这两条只进警告，会话继续；托管必选挂钩配了它们，启动会直接失败。

43
00:03:29,262 --> 00:03:34,130
出处是 discovery.rs 的第六百二十六到六百四十五行。

44
00:03:34,130 --> 00:03:36,017
为什么要这么设计。

45
00:03:36,017 --> 00:03:38,517
配置面可以比执行器宽。

46
00:03:38,517 --> 00:03:44,238
先把生态里已有的四个类型接住，未知标签才不会把整份配置打爆。

47
00:03:44,238 --> 00:03:59,663
这是很实际的取舍，如果解析阶段直接拒绝不认识的类型，用户搬一份配置过来就要先删掉一半才能启动，体验极差；反过来只要解析时宽容，运行前再挑，用户至少能把会话开起来。

48
00:03:59,663 --> 00:04:09,723
代价是搬家的人会按枚举名理解能力，所以发现阶段必须留下稳定的文案，必选策略碰到未实现的类型必须拒绝启动。

49
00:04:09,723 --> 00:04:12,103
这里有个区分值得记住。

50
00:04:12,103 --> 00:04:26,526
普通用户配置里这两条只进警告，会话继续，因为那是个人选择，错了不影响别人；托管必选挂钩配了它们，启动会直接失败，因为那是组织级的安全承诺，不能悄悄降级。

51
00:04:26,526 --> 00:04:33,220
同一个未实现类型，两种处理，依据是这条配置的来源是个人还是组织。

52
00:04:33,220 --> 00:04:38,617
换个语言重写，最小形态仍然是两张表加一个跳过。

53
00:04:38,740 --> 00:04:44,315
再看一个细节，能看出这件事在产品上被当真到什么程度。

54
00:04:44,265 --> 00:04:49,132
JSON 挂钩的运行时类型名直接写成了 ClaudeHooksEngine。

55
00:04:49,132 --> 00:04:52,414
兼容不是注释里的愿望，是类型名。

56
00:04:52,414 --> 00:04:56,644
标准输入喂 JSON，标准输出按模式解析。

57
00:04:56,644 --> 00:05:02,558
旁边还留着一条旧的通知路，只在会话收工时发一条命令就不管了。

58
00:05:02,558 --> 00:05:04,216
两条路不要混。

59
00:05:04,216 --> 00:05:09,313
出处是 engine 目录下 mod.rs 的第一百零七到一百一十九行。

60
00:05:09,313 --> 00:05:12,930
横向看一眼别的框架，差别更清楚。

61
00:05:12,930 --> 00:05:22,005
还原源码里事件枚举有二十七项，Codex 的十一点都在，另外还有失败后事件、通知、工作树和文件变更。

62
00:05:22,005 --> 00:05:33,471
那边的小模型钩子真的会跑，构造用户消息时会绕开常规输入处理，避免再次触发用户提交事件；子代理钩子会起一轮完整查询。

63
00:05:33,471 --> 00:05:43,952
结论是，两边的事件名高度重合，这是对齐动作；但一边的运行面比配置面宽，另一边反过来，先把四个类型接住，执行器后补。

64
00:05:43,952 --> 00:05:46,031
还有两条对比值得记。

65
00:05:46,031 --> 00:06:02,021
第一套方案的工具管道在前后执行上各开一道插件瀑布，原生插件能做兼容桥能做的一切，文件映射只留五点，没有改写、没有审批前钩子，类型不是命令的、异步的，解析后直接跳过。

66
00:06:02,021 --> 00:06:09,773
一个是先有插件再补文件兼容，一个是先有文件合同再让插件往同一引擎里塞声明。

67
00:06:09,773 --> 00:06:17,670
第三套则用十五个事件名补观察面，但拦截判断只对工具前返回真，没有审批前挂钩。

68
00:06:17,670 --> 00:06:26,576
三种接法摆在一起，能看出一个共同的分歧点，各家都在补自己的短板，补的方向由它原本的形态决定。

69
00:06:26,576 --> 00:06:33,463
所以评估一个框架的挂点能力时，不要数事件名，要去数运行表收了几种。

70
00:06:33,600 --> 00:06:37,516
现在讲第二个原理，也是最反直觉的一条。

71
00:06:37,466 --> 00:06:42,838
有人写了一条工具前挂钩，超时设成一秒，脚本里睡五秒。

72
00:06:42,838 --> 00:06:45,843
他们以为挂钩崩溃等于拦截。

73
00:06:45,843 --> 00:06:52,814
结果是工具照样执行，日志里这条挂钩的状态是失败，文案带 timed out after 一秒。

74
00:06:52,814 --> 00:06:55,362
原因在超时处理的写法上。

75
00:06:55,362 --> 00:06:59,293
超时把错误写成一句话，退出码是空的。

76
00:06:59,293 --> 00:07:04,293
解析看见错误只标失败，拦截控制位保持默认的假。

77
00:07:04,293 --> 00:07:07,165
工具注册表于是继续往下走。

78
00:07:07,165 --> 00:07:12,742
出处是 command_runner.rs 的第三百一十七到三百二十六行。

79
00:07:12,742 --> 00:07:14,798
会拦的路只有两条。

80
00:07:14,798 --> 00:07:18,776
一条是在 JSON 里明确给出拒绝或拦截。

81
00:07:18,776 --> 00:07:22,334
另一条是退出码二并且标准错误非空。

82
00:07:22,334 --> 00:07:25,903
退出码二却没有理由，算失败，不拦。

83
00:07:25,903 --> 00:07:30,314
异步挂钩即使返回拒绝，也加不上控制效果。

84
00:07:30,314 --> 00:07:34,750
只有同步、可信、未超时的处理器能改下一步。

85
00:07:34,750 --> 00:07:40,723
出处是 pre_tool_use.rs 的第二百六十一到二百七十七行。

86
00:07:40,723 --> 00:07:42,237
为什么这么定。

87
00:07:42,237 --> 00:07:48,944
一条挂掉的检查挂钩不该让所有工具停摆，可用性被放在拦截可靠性前面。

88
00:07:48,944 --> 00:08:02,045
这个选择可以推演一下反面，如果失败按拦截处理，那么任何一次网络抖动、任何一次脚本依赖缺失，都会让整个会话卡死，用户唯一的出路是删掉挂钩重开。

89
00:08:02,045 --> 00:08:08,055
相比之下，漏拦一次可以靠日志事后追，卡死一次是当场不可用。

90
00:08:08,055 --> 00:08:15,531
想改流程，就必须同步、必须有明确决策；想发通知，可以异步、最多八个并行。

91
00:08:15,531 --> 00:08:20,182
这句要记住，要拦就给理由，沉默和超时都放行。

92
00:08:20,182 --> 00:08:31,889
对应到写法上，就是不要把安全逻辑藏在脚本的异常退出里，要明确写一句拒绝，并且把理由写进标准错误，让模型和用户都能看见。

93
00:08:32,040 --> 00:08:36,112
审批路径上的规则是反的，这里最容易出事。

94
00:08:36,062 --> 00:08:40,641
审批请求跑在安全审查和用户审批界面之前。

95
00:08:40,641 --> 00:08:42,480
它不改工具输入。

96
00:08:42,480 --> 00:08:47,673
折叠规则是，任一拒绝立刻赢，否则保留最后一次允许。

97
00:08:47,673 --> 00:08:54,476
允许被映射成一次性的已批准，不进会话缓存，下次同样的命令还要再问一遍。

98
00:08:54,476 --> 00:08:59,187
出处是 approvals.rs 的第四百五十四到四百七十四行。

99
00:08:59,187 --> 00:09:01,903
再看跨框架搬家的一个坑。

100
00:09:01,903 --> 00:09:09,920
别家框架的输出里有一个更新输入的字段，Codex 把它标成保留字段，看见就按失败关闭处理。

101
00:09:09,920 --> 00:09:15,677
从那边原样搬一条带改写的审批挂钩，在这里会失败，不会改写。

102
00:09:15,677 --> 00:09:17,961
为什么这一层要反过来。

103
00:09:17,961 --> 00:09:21,976
因为审批层不能把看不懂的字段当成允许。

104
00:09:21,976 --> 00:09:28,394
拦截路径上默认放行是为了可用性，审批路径上默认拒绝是为了安全。

105
00:09:28,394 --> 00:09:34,584
同一个系统里两种默认值并存，不是不一致，是两层的风险方向不同。

106
00:09:34,584 --> 00:09:36,615
还有一个实操含义。

107
00:09:36,615 --> 00:09:40,942
允许被映射成一次性的已批准，不进会话缓存。

108
00:09:40,942 --> 00:09:48,310
这意味着你没法靠审批挂钩做一次长期授权，同样的命令第二次执行还要再问一遍。

109
00:09:48,310 --> 00:09:58,983
这看起来麻烦，但它保证了每一次敏感操作都有一次明确的记录，审计的时候能追到单次行为，而不是追到一条早就过期的策略。

110
00:09:58,983 --> 00:10:04,512
想做长期授权，应该去改策略配置，不是靠挂钩缓存。

111
00:10:04,660 --> 00:10:09,910
第三个原理，挂钩点能改什么，跟它在生命周期的位置绑定。

112
00:10:09,860 --> 00:10:12,889
十一个挂钩点按生命周期排开。

113
00:10:12,889 --> 00:10:22,997
主轴依次是会话开始、用户提交、工具前、审批请求、工具本体、工具后、压缩前、压缩后、轮次结束、会话结束。

114
00:10:22,997 --> 00:10:27,348
子代理另走一条细轴，只在线程派生时响。

115
00:10:27,348 --> 00:10:29,692
每个点的合同都不一样。

116
00:10:29,692 --> 00:10:34,247
工具前能拦工具、能改输入，失败默认放行。

117
00:10:34,247 --> 00:10:40,677
工具后只在工具成功之后跑，这时候拦截拒绝的是结果，副作用已经发生。

118
00:10:40,677 --> 00:10:49,980
轮次结束的拦截必须带着继续的提示词，才会在轮次层继续，不重发会话开始事件；没有提示词的拦截被忽略。

119
00:10:49,980 --> 00:10:55,894
真正把控制权交回任务壳的是停止标记，而且停止优先于拦截。

120
00:10:55,894 --> 00:11:00,581
出处是 turn.rs 的第五百零九到五百三十八行。

121
00:11:00,581 --> 00:11:11,182
用户提交那一步的拦截被写成停止，停的是当前这条用户消息，不会把标准错误当成下一轮的提示词，匹配器在这里被忽略。

122
00:11:11,182 --> 00:11:14,235
会话开始那一步尊重继续为假。

123
00:11:14,235 --> 00:11:19,620
子代理开始那一步只做上下文注入，同样的字段会被丢掉。

124
00:11:19,620 --> 00:11:22,492
会话结束是拆卸期通知。

125
00:11:22,492 --> 00:11:27,961
超时默认一秒，上限三秒，给上层服务的五秒关机留余量。

126
00:11:27,961 --> 00:11:33,742
退出码零就是完成，标准输出整段丢掉，MCP 形态直接跳过。

127
00:11:33,742 --> 00:11:38,310
出处是 session_end.rs 的第二十到二十四行。

128
00:11:38,310 --> 00:11:40,810
还有一条很多人忽略的。

129
00:11:40,810 --> 00:11:48,129
挂钩文本进模型之前会先变成开发者角色的片段，默认预算两千五百个近似 token。

130
00:11:48,129 --> 00:11:56,134
超限时全文写到临时目录，模型只看见头尾预览加一行路径；写盘失败就直接截断。

131
00:11:56,134 --> 00:12:04,560
这条规则的实际影响是，你让挂钩吐一大段检查报告，模型大概率只看见开头和结尾，中间全丢。

132
00:12:04,560 --> 00:12:10,365
所以挂钩输出应该写成结论先行的一句话，而不是完整日志。

133
00:12:10,365 --> 00:12:15,353
出处是 output_spill.rs 的第五十三到九十一行。

134
00:12:15,353 --> 00:12:21,350
共同点很清楚，挂钩点一旦走过它能改的那一段，就改不了已经发生的事。

135
00:12:21,350 --> 00:12:37,220
工具还没跑，你才能改输入或者跳过；工具跑完，你只能改模型看见的那一截；到了拆卸期只剩几秒，读 JSON、回灌上下文、再等外部调用，都会把关机拖过上限，于是它退化成纯通知。

136
00:12:37,220 --> 00:12:43,313
换一套运行时，该问的仍然是这一句，这个点还来不来得及改已经发生的事。

137
00:12:43,440 --> 00:12:48,257
把两条原理合起来做一次推演，这是本集最实用的一段。

138
00:12:48,207 --> 00:12:52,787
项目里有一条工具前命令挂钩，匹配器对着无害的 echo。

139
00:12:52,787 --> 00:12:56,681
第一趟把超时设成一秒，脚本里睡五秒。

140
00:12:56,681 --> 00:13:02,270
结局是工具照常执行，模型什么都不知道，挂钩状态是失败。

141
00:13:02,270 --> 00:13:08,015
第二趟把脚本改成退出码二，并向标准错误写一句被测试拦截。

142
00:13:08,015 --> 00:13:13,712
结局是工具被拦下，挂钩状态是拦截，模型看见那句理由。

143
00:13:13,712 --> 00:13:18,303
同一个挂钩点，两种返回值把工具带去了两个方向。

144
00:13:18,303 --> 00:13:23,977
差别不在脚本有没有报错，而在返回值有没有把控制位明确置起来。

145
00:13:23,977 --> 00:13:38,111
第一趟脚本确实出问题了，睡了五秒被超时打断，但引擎把它记成失败，控制位没动；第二趟脚本也退出了非零码，可它同时把理由写进了标准错误，于是被判成拦截。

146
00:13:38,111 --> 00:13:41,188
顺着这个推演再往前走一步。

147
00:13:41,188 --> 00:13:48,700
假如第二趟脚本退出码是二，标准错误却是空的，结局会退回第一趟，工具照常执行。

148
00:13:48,700 --> 00:13:55,731
很多人排查到这里会以为自己的脚本没跑，其实脚本跑了，只是说的话引擎听不懂。

149
00:13:55,731 --> 00:14:04,866
所以排查线上问题时，顺序应该是先看挂钩状态是失败还是拦截，再看有没有给理由，最后才看脚本本身。

150
00:14:04,866 --> 00:14:09,409
绝大多数所谓挂钩没生效，其实都停在前两步。

151
00:14:09,550 --> 00:14:11,182
最后收五条。

152
00:14:11,132 --> 00:14:15,315
第一，认得出、解析得过、跑得了要分开看。

153
00:14:15,315 --> 00:14:21,613
评估一个挂点能力，去数运行表收了几种，不要数协议枚举里有几个名字。

154
00:14:21,613 --> 00:14:26,469
配置面比执行器宽是有意的，别把宽容当成承诺。

155
00:14:26,469 --> 00:14:30,399
第二，默认放行是可用性选择，不是缺陷。

156
00:14:30,399 --> 00:14:36,974
想改变流程，就必须同步、可信、未超时，并且给出明确的拒绝理由。

157
00:14:36,974 --> 00:14:42,947
退出码二加空的标准错误不算拦截，这是特别容易被忽略的一条规则。

158
00:14:42,947 --> 00:14:45,447
第三，位置决定合同。

159
00:14:45,447 --> 00:14:52,863
要改输入，只能挂在工具执行之前；工具跑完再拦，拦的是结果，副作用已经落盘。

160
00:14:52,863 --> 00:14:58,909
要在轮次层继续，轮次结束的拦截必须带提示词，否则被静默忽略。

161
00:14:58,909 --> 00:15:02,058
第四，审批层的默认值是反的。

162
00:15:02,058 --> 00:15:09,089
保留字段、看不懂的输出一律按拒绝处理，因为那一层不能把歧义当成允许。

163
00:15:09,089 --> 00:15:14,678
跨框架搬家时，先检查对方输出里有没有你这边标了保留的字段。

164
00:15:14,678 --> 00:15:17,562
第五，拆卸期只做通知。

165
00:15:17,562 --> 00:15:28,212
会话结束那一步只有几秒预算，读 JSON、回灌上下文、再等 MCP 都会把关机拖过上限，所以标准输出整段丢掉。

166
00:15:28,212 --> 00:15:33,656
想在收尾时影响模型，应该挂在轮次结束而不是会话结束。

167
00:15:33,656 --> 00:15:38,512
一句话收尾，挂钩点的名字不给你能力，事件合同才给。

168
00:15:38,512 --> 00:15:43,524
写配置之前，先问一句，这个点还来不来得及改下一步。

