第 4 章 用 Hook 解耦 Agent 扩展逻辑 · Agent架构实操四 开始测验

顶级 AI Agent 是如何利用 Hook 解耦的?

循环从几十行膨胀到两百行,问题不在需求多,而在扩展行为和核心编排没分开。Hook = 在固定位置发布结构化上下文,让回调按顺序响应。
⏱ 约 14 分钟 🪝 4 类事件 🔁 注册顺序即执行顺序 +hooks capability

🎯 本章导读

读完这一章,你应该能用一句话回答下面每个问题。
读完能做到
  1. 说出四个 Hook 事件各自触发在流水线的哪一步,以及为什么是这四个位置。
  2. 解释为什么 Hook 的返回值必须是结构化对象,而不是 "stop" 这类字符串。
  3. 说清「Hook 说 allow」和「权限系统说 allow」的区别——前者只是建议。
  4. 讲出一个 Post Hook 要求停止时,同一轮里其他工具调用会发生什么,以及为什么不能直接 return。
  5. 解释 Stop Hook 为什么最多只能强制续跑一次。
  6. 判断一个 Hook 抛异常时,会得到哪个错误码、handler 有没有执行过。
你需要先具备什么
需要说明
读完第 1—3 章本章不新增任何工具或权限规则,只在已有流水线上开孔
记得 prepare → permission → invoke四个挂载点全部围绕这条顺序摆放
记得 deny > ask > allow > passthrough本章的 Hook 建议要挂进这个合并逻辑
记得 validateToolPairing()本章一半的设计约束都来自「每个调用必须配对」
建议的阅读路线
本章导读(你在这)
  ↓
① 先看一眼真实的执行顺序    ← 一次运行的完整日志,四个事件各打一行
  ↓
② 验收结果 + 为什么需要扩展点
  ↓
③ 四个事件 + HookContext    ← 位置固定、数据按事件裁剪
  ↓
④ HookResult                ← 为什么不用字符串
  ↓
⑤ HookRegistry              ← 顺序、合并规则、短路
  ↓
⑥ 四个事件逐个看            ← Submit / Pre / Post / Stop
  ↓
⑦ 两个硬约束                ← allow 只是建议;停止也要保持配对
  ↓
⑧ 故障映射 + P03/P04 差异
  ↓
⑨ 运行、验证与小结
提示 想先看画面?读下面的① 真实的执行顺序

📇 术语速查

本章第一次出现的词。先扫一眼,正文里遇到不认识的词回来查即可。不用背
术语一句话解释
Hook注册在固定位置的回调。由应用注册,不是插件沙箱
挂载点Loop 里发布结构化上下文的确定位置。本章共四个
HookEvent四个事件名之一:UserPromptSubmit / PreToolUse / PostToolUse / Stop
HookContext事件发布给回调的数据。不同事件的可用字段不同
HookResult回调的返回值。每个字段只有一种语义,且只在特定事件合法
HookRegistry按事件保存回调列表;注册顺序 = 执行顺序
permissionBehaviorPre Hook 提出的权限建议,不是决定
updatedInputPre Hook 改写后的参数。会被重新 Zod 校验并深度冻结
blockingErrorPre Hook 直接阻断,权限和 handler 都不跑
updatedOutputPost Hook 改写后的工具结果
additionalContext追加的 system 消息。只能是 system,且延后写入
preventContinuationPost Hook 表示本轮不再请求模型
forceContinueStop Hook 追加一条 user 消息并再问一次模型
stopHookActive标记「已经强制续跑过一次」,防死循环
短路出现 blockingError 或 forceContinue 时,后续同类回调不再执行
HookContractError回调违反契约(返回值类型不对、用了非法字段、改了工具身份)

📡 先看一眼真实的执行顺序

P04 的真实 CLI 注册了四个只打日志的 Hook。跑一个只读任务,stderr 上会看到这样一串([Permission] 是第 3 章审计,[Hook] 是本章新增):
[Hook] UserPromptSubmit                ← ① prompt 已提交,模型还没收到请求
[Hook] PreToolUse: read_file           ← ② 工具已查到、参数已过 Zod,权限还没判
[Permission] read_file: allow (default) - No permission rule blocked
[Hook] PostToolUse: read_file -> ok    ← ③ handler 跑完了,结果还没写进历史
[Hook] Stop                            ← ④ 模型给出无工具调用的回答,准备结束

四行的位置摆到第 3 章那条流水线上,每个事件的「为什么在这里」都有唯一性:

事件为什么在这里
UserPromptSubmit模型还没看到任何东西,是唯一能影响本轮输入的时机
PreToolUse参数已可信(过了 Zod),副作用还没发生——唯一能无损阻断的时机
PostToolUse结果已产生但还没进历史——唯一能改结果的时机
Stop循环准备结束,是唯一能判断「任务真的做完了吗」的时机
注意每行的「唯一」 挂载点不是随便挑的位置,是那个信息刚好齐备、动作又还来得及的位置。早一步信息不全,晚一步无法挽回。
一个更有用的例子:三个真实需求
需求不用 Hook 会怎样挂在哪
每次运行都提醒模型核对验收标准改 system prompt,但那会破坏静态前缀 / KV CacheUserPromptSubmit 返回 additionalContext
禁止写某个构建脚本生成的文件在 write_file handler 里加 if,工具实现被业务规则污染PreToolUse 返回 blockingError
工具输出太长就截断在每个工具的 handler 里各写一遍截断逻辑PostToolUse 返回 updatedOutput
回报 三个需求,三个不同位置,Agent Loop 一行不改,工具实现一行不改

🧠 核心概念

点击展开。
1 · 四个事件覆盖一次完整交互
用户提交 prompt
      ↓
UserPromptSubmit
      ↓
模型返回 tool_calls
      ↓
ToolRegistry.prepare
      ↓
PreToolUse
      ↓
PermissionPolicy / approval
      ↓
handler
      ↓
PostToolUse
      ↓
模型返回无工具回答
      ↓
Stop

它们不是随意命名的回调,每个事件都有自己的上下文和允许返回的字段。

2 · HookContext:不同事件只拿自己需要的数据
事件可用字段不能出现的字段
UserPromptSubmit一条 user messageprepared/result/history/stop
PreToolUse已校验的 preparedmessage/result/history/stop
PostToolUseprepared 和 resultmessage/history/stop
Stophistory、stopHookActivemessage/prepared/result

给 Pre Hook 上下文塞 user message 会立即得到 HookContractError。Pre/Post 只接收已通过查找+解析+Zod 校验的 PreparedToolCall,未知工具和非法参数不进入 Hook。

3 · HookResult:不用字符串暗号表达控制流

用裸字符串 "stop" 会失控:到底是拒绝工具、结束当前轮、还是要求模型再答一次?用明确字段:

字段含义允许事件
permissionBehavior向权限策略提交建议Pre
updatedInput替换当前调用的已验证输入Pre
blockingError权限和 handler 前阻断Pre
updatedOutput替换工具结果Post
preventContinuation正常结束当前工具轮Post
forceContinue追加 user 消息再请求模型Stop
additionalContext延后写入 system 上下文四类

additionalContext 只接受 system message——Hook 无法注入孤儿 tool result 或伪造带 toolCalls 的 assistant message 来破坏配对。

4 · Hook allow 只是建议,不是授权

Pre Hook 返回 permissionBehavior: "allow",Loop 不会直接执行,而是转成结构化建议传给 PermissionPolicy。第 3 章的工作区硬边界、Shell 默认审批、固定规则仍一起参与合并。

顺序不能交换 Pre Hook → 硬权限策略 → 审批 → handler。Hook 是扩展机制,不是第二套权限系统。任一系统参与方 deny,Hook 的 allow 就不触发审批或 handler。

Claude Code 官方同样:Hook 可拒绝调用,但保持沉默并不自动批准;没有返回决策时仍走正常权限流程(passthrough 语义)。

5 · Pre 的 updatedInput 不能换工具

updatedInput 只能改参数,不能换工具身份。注册表验证:tool_call_id 不变、工具名不变、StoredToolDefinition 必须是同一对象、新 arguments 仍过原 Zod schema。

为什么锁 definition 对象 同时锁定了 effect、handler 和 schema。否则有 bug 的 Hook 可把 write 工具伪装成 read 绕过权限。注册表还冻结参数副本,消除异步审批竞态——Hook 即使在审批期间改了原引用,权限和 handler 仍看到冻结的同一个值。
6 · preventContinuation 必须保持消息配对

一个 assistant message 可能含多个 tool call。第一个调用的 Post Hook 要求停止时,Loop 不能直接 return,否则其余调用成孤儿:

call-1 → 正常执行并触发停止
call-2 → Error [hook_stopped_continuation]
call-3 → Error [hook_stopped_continuation]
先写入全部三个 tool result,再以 call-1 结果正常结束

停止流程不能以破坏协议为代价。

7 · Stop 最多强制续跑一次

模型返回无工具调用的 assistant message 时,Loop 准备结束,运行 Stop Hook。第一次 forceContinue 追加 user 消息再请求模型。第二次模型停止时 Hook 仍执行,并看到 stopHookActive===true;即使再次返回 forceContinue,注册表也移除该请求。

为什么 一次 run() 最多增加一个模型 turn,不会形成 Stop Hook 死循环;生命周期日志仍能完整记录两次 Stop。
8 · Hook 故障如何返回
故障工具结果handler 是否已执行
Pre 返回非法输入更新hook_contract_error
Pre 抛其他异常hook_execution_error
权限评估/审计异常permission_evaluation_error
Post 抛异常hook_execution_error

Post 在 handler 之后,失败时不能声称副作用没发生。返回稳定错误并保留配对。UserPromptSubmit 和 Stop 在工具配对之外,非法结果让当前 run() 显式失败。

▶️ 交互演示:Hook 流水线

开关各种 Hook 行为,看流水线哪些节点被跳过/阻断/续跑。注意 Pre 阻断后 handler 和 Post 都不跑,但 tool 结果仍配对回填。
Pre 阻断(blockingError)
Pre 建议 allow
Post 改写输出
Post 停止本轮
Stop 强制续跑

🔑 一句话总结

UserPromptSubmit → model → prepare → PreToolUse → permission → handler → PostToolUse → 配对结果 → Stop

以后增加日志、通知或结果检查,只需注册新回调。权限优先级、工具身份和 OpenAI 消息配对仍由同一个 Agent Loop 维护。

🚀 运行第 4 章

前置条件和第 1—3 章一样。下面这 5 步,每步都给出预期结果。
第 0–1 步 · 环境与离线测试
Set-Location 'F:\笔记\Agent实操\code'
npm ci
npm run test:ch04
 Test Files  12 passed (12)
      Tests  94 passed (94)

从第 3 章的 61 个涨到 94 个,多出来的 33 个几乎全在 hooks.test.tsch04-hooks.test.ts 里。那行 配置错误: Missing required settings: ... 仍是被断言的预期输出。

第 2–3 步 · 看四行日志;让 Hook 和审批同时出现
npm run ch04 -- --prompt '读取 README.md 并概括运行方式'
# stderr: [Hook] UserPromptSubmit
#         [Hook] PreToolUse: read_file
#         [Permission] read_file: allow (default) - No ...
#         [Hook] PostToolUse: read_file -> ok
#         [Hook] Stop

npm run ch04 -- --prompt '把一句话写入 hook-demo.txt'
# 写文件时仍是第 3 章的逐次审批,只是多了四行 [Hook] 日志

对比着跑 ch03ch04 同一条 prompt:两次的 [Permission] 行一模一样,唯一差别是 P04 多出四行 [Hook]——这就是「解耦」的具体含义。

第 4–5 步 · 统一入口与完整离线门禁
npm run agent-tutorial -- run --chapter 4 --prompt '读取 README.md 并概括运行方式'

npm run typecheck
npm test
npm run lint
npm run format:check
npm run build

测试不读取真实 API Key,也不访问网络。真实 OpenAI 运行只是额外冒烟证据。

常见报错排查表
现象原因 / 处理
看不到任何 [Hook]Hook 日志写 stderr,你可能重定向掉了;别加 2>$null,或确认跑的是 ch04 而不是 ch03
[Hook] PostToolUse: xxx -> error工具本身返回错误结果。正常观察日志,Post 在工具失败时也会跑
只有 PreToolUse 没有 PostToolUse调用被 Pre 阻断或权限拒绝。正常行为
Error [hook_contract_error]Pre Hook 的 updatedInput 不合法:tool_call_id / 工具名 / definition 引用 / Zod 不过
Error [hook_execution_error]你写的 Hook 抛异常。Pre 和 Post 共用此码,自行判断在哪端
Error [hook_stopped_continuation]同轮前面的调用触发了 preventContinuation。这些调用没被执行,正常
启动报 hooks require chapter 4 or later给 P01–P03 传了 HookRegistry。换成 P04 或别传 hooks
Hook 返回普通对象报 must return HookResult必须 return new HookResult({...}),运行时 instanceof 检查
Stop 的 forceContinue 第二次没生效stopHookActive=true,一次 run() 最多续跑一次,正常

🧪 验证与实验

npm run test:ch04 执行 12 个测试文件、94 个测试。本章新增两个,其余是前 3 章累计测试。
测试文件验证内容
hooks.test.ts单元层:HookContext 字段裁剪、HookResult.validateFor()、注册顺序、合并规则、updatedInput 重校验与冻结、短路行为
ch04-hooks.test.ts端到端:Hook 与权限的优先级、阻断后 handler 未执行、preventContinuation 的配对、Stop 只续跑一次、P01–P03 行为未变
四个建议动手做的小实验
实验预期学到什么
一·注册两个 Post Hook 观察加工链第二个 Hook 的 context.result 是第一个的产出Post 是流水线,不是并列观察者
二·让 Pre Hook 阻断一个写入
npm run ch04 -- --prompt '把 hello 写入 blocked.txt'
Error [hook_blocked],handler 没跑blockingError 短路权限和 handler
三·让 Pre Hook 的 allow 挑战硬边界
npm run ch04 -- --prompt '把 hello 写入 ../outside.txt'
仍是 deny (workspace-boundary)Hook allow 只是建议;系统 deny 永远优先
四·在 Pre Hook 里就地修改参数抛 hook_contract_error参数不可变,必须显式返回新 updatedInput

📝 本章小结

三句话版本、一定要记住的七条、以及本章还没做什么
三句话版本
  1. Hook 是在四个确定位置发布结构化上下文,让扩展逻辑从核心编排里搬出去。
  2. 返回值用结构化 HookResult 而不是字符串,因为控制流语义必须能被校验。
  3. Hook 能建议、能改写、能阻断,但不能授权——系统 deny 永远优先。
一定要记住的七条
#结论出现在哪一节
1挂载点选在「信息刚好齐备、动作还来得及」的位置先看一眼真实的执行顺序
2additionalContext 只能是 system,且延后到配对完成后才写入HookResult
3updatedInput 必须重过 Zod 并深度冻结,否则会「批准 A 执行 B」PreToolUse
4definition 用引用相等锁定,防止 write 伪装成 readPreToolUse
5注册顺序 = 执行顺序 = 加工顺序;保守字段例外(取更严的)HookRegistry
6提前结束也要把同轮剩余调用补成配对错误preventContinuation
7Stop 最多续跑一次;第二次回调仍执行,只是控制权被收回Stop
本章代码边界(明确「还没做什么」)
已经有还没有说明
四个生命周期事件官方 14+ 类事件(SessionStart、PreCompact、SubagentStart 等)教学子集;按需在后续章节增加
顺序执行的进程内回调插件隔离、独立超时、并行执行不做。并行会让改写顺序和短路语义含混
blockingError / forceContinue 短路官方那种「跑完全部再合并」刻意简化;短路位置明确
结构化契约保护 Loop 状态操作系统级沙箱不做。Hook 是受信任的应用代码
统一的 hook_execution_error区分「副作用已发生 / 未发生」第 11 章讨论恢复与幂等时处理
UserPromptSubmit 追加上下文替换、删除或阻断原始 prompt不做。用户输入不该被扩展代码改写
检查你是否真的读懂了(不看文章回答)
  1. [Hook] PreToolUse 和 [Permission] 哪个先出现?为什么不能反过来?
  2. 为什么 additionalContext 只允许 system 消息?允许 tool 消息会怎样?
  3. 一个恶意 Pre Hook 想把 write_file 伪装成 read_file,哪一条检查拦住了它?
  4. 三个 Post Hook 依次改写结果,第三个看到的是哪个版本?
  5. Post Hook 要求停止时,同一轮里其它调用为什么变成 hook_stopped_continuation?
  6. Stop Hook 第二次还能 forceContinue 吗?回调还会执行吗?
  7. Post Hook 抛异常,handler 执行过没有?为什么这个信息很重要?
提示 答不上来的,回 真实执行顺序核心概念 再看一遍。

QA 测验