第 四 章 Agent 架构实操 深入学习 · 交互式 含 QA 测试

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

💡 四个结构化挂载点:UserPromptSubmit / PreToolUse / PostToolUse / Stop。
在现有 Agent Loop 中加入四个 Hook 挂载点,同时不破坏消息配对、工具校验和权限边界。Hook 是 Agent 内部生命周期的拦截点:日志、补上下文、改写工具输入输出、建议权限或控制是否续跑。Hook 的 allow 只是建议,不能绕过系统 deny。
本章进度
0%
1 本章导读与学习目标
  • 说出四个 Hook 事件各自触发在流水线的哪一步,以及为什么是这四个位置。
  • 解释为什么 Hook 的返回值必须是结构化对象,而不是 "stop" 这类字符串。
  • 说清「Hook 说 allow」和「权限系统说 allow」的区别——前者只是建议。
  • 讲出一个 Post Hook 要求停止时,同一轮里其他工具调用会发生什么,以及为什么不能直接 return。
  • 解释 Stop Hook 为什么最多只能强制续跑一次。
  • 判断一个 Hook 抛异常时,会得到哪个错误码、handler 有没有执行过。
你需要先具备说明
读完第 1—3 章本章不新增任何工具或权限规则,只在已有流水线上开孔
记得 prepare → permission → invoke四个挂载点全部围绕这条顺序摆放
记得 deny > ask > allow > passthrough本章的 Hook 建议要挂进这个合并逻辑
记得 validateToolPairing()本章一半的设计约束都来自「每个调用必须配对」
本章导读(你在这) ↓ ① 先看一眼真实的执行顺序 ← 一次运行的完整日志,四个事件各打一行 ↓ ② 验收结果 + 为什么需要扩展点 ← 循环为什么不该继续长大 ↓ ③ 四个事件 + HookContext ← 位置固定、数据按事件裁剪 ↓ ④ HookResult ← 本章设计的核心:为什么不用字符串 ↓ ⑤ HookRegistry ← 顺序、合并规则、短路 ↓ ⑥ 四个事件逐个看 ← Submit / Pre / Post / Stop 各自能做什么 ↓ ⑦ 两个硬约束 ← Hook allow 只是建议;停止也要保持配对 ↓ ⑧ 故障映射 + P03/P04 差异 ↓ ⑨ 运行、验证与小结 ← 动手 + 排错 + 自测
想先看画面?跳到第 3 节「先看一眼真实的执行顺序」,一条命令看四行 [Hook] 日志。
2 术语速查(本章第一次出现的词)
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回调违反契约(返回值类型不对、用了非法字段、改了工具身份)。
3 先看一眼真实的执行顺序
一次只读任务npm run ch04 -- --prompt '读取 README.md 并概括运行方式',stderr 会打出四行([Permission] 是第 3 章审计,[Hook] 是本章新增):
[Hook] UserPromptSubmit                ← ① prompt 已提交,模型还没收到请求
[Hook] PreToolUse: read_file           ← ② 工具已查到、参数已过 Zod,权限还没判
[Permission] read_file: allow (default) - No permission rule blocked the request
[Hook] PostToolUse: read_file -> ok    ← ③ handler 跑完了,结果还没写进历史
[Hook] Stop                            ← ④ 模型给出了无工具调用的回答,准备结束
用户提交 prompt
  ├─ ① UserPromptSubmit ─ 能加 system 上下文,不能改/删 prompt
模型返回 tool_calls → ToolRegistry.prepare
  ├─ ② PreToolUse ────── 能改参数、提权限建议、直接阻断
PermissionPolicy / approval → handler(真正的副作用)
  ├─ ③ PostToolUse ───── 能改结果、能要求本轮结束
写入配对的 tool result
模型返回无工具调用的回答
  └─ ④ Stop ──────────── 能强制再问一次模型(最多一次)
事件为什么在这里
UserPromptSubmit模型还没看到任何东西,是唯一能影响本轮输入的时机
PreToolUse参数已经可信(过了 Zod),但副作用还没发生——唯一能无损阻断的时机
PostToolUse结果已经产生,但还没进历史——唯一能改结果的时机
Stop循环准备结束,是唯一能判断「任务真的做完了吗」的时机
注意每一行的「唯一」挂载点不是随便挑的位置,是那个信息刚好齐备、动作又还来得及的位置。早一步信息不全,晚一步无法挽回。
一个更有用的例子:三个真实需求
需求不用 Hook 会怎样挂在哪
每次运行都提醒模型核对验收标准改 system prompt,但那会破坏静态前缀 / KV CacheUserPromptSubmit 返回 additionalContext
禁止写某个构建脚本生成的文件在 write_file handler 里加 if,工具实现被业务规则污染PreToolUse 返回 blockingError
工具输出太长就截断在每个工具的 handler 里各写一遍截断逻辑PostToolUse 返回 updatedOutput
这就是本章要换来的东西三个需求,三个不同位置,Agent Loop 一行不改,工具实现一行不改
4 核心知识点
为什么不把扩展逻辑塞进循环

同一个循环膨胀到两百多行:日志、写文件确认、结果通知、自动备份全塞进来,变成谁也不敢改的 if-else 堆。问题不在需求太多,而在扩展行为和核心编排没有分开。Hook 就是在确定的位置发布结构化上下文,让已注册回调按顺序响应——循环只负责确定性编排。

四个事件:HookContext 只给需要的数据

HOOK_EVENTS 冻结元组同时定义运行时集合和 TS 类型。每个事件的 HookContext 只有自己需要的字段:Submit 只拿 user message;Pre 只拿已校验的 prepared;Post 拿 prepared + result;Stop 拿 history + stopHookActive。给 Pre 塞一条 message 会立即得到 HookContractError,而不是让回调猜。

export const HOOK_EVENTS = Object.freeze([
  "UserPromptSubmit", "PreToolUse", "PostToolUse", "Stop",
] as const);
export type HookEvent = (typeof HOOK_EVENTS)[number];
HookResult:为什么不用字符串

一个 "stop" 至少有四种可能的意思(拒绝本次调用 / 执行但别再问 / 整个 run 失败 / 别让 Stop 续跑),副作用完全不同。"stpo" 拼错了运行时只会当成「未知返回值」忽略——正在阻断危险写入的 Hook 静默失效。结构化 HookResult 每个字段只有一种语义,且只在特定事件合法,用 validateFor() 检查非法字段。

interface HookResultOptions {
  readonly permissionBehavior?: PermissionBehavior; // Pre 建议
  readonly updatedInput?: PreparedToolCall;          // Pre 改写参数
  readonly updatedOutput?: ToolResult;               // Post 改写结果
  readonly additionalContext?: readonly ChatMessage[];// 四类,只能 system
  readonly blockingError?: ToolResult;                // Pre 阻断
  readonly preventContinuation?: boolean;             // Post 本轮结束
  readonly forceContinue?: ChatMessage;               // Stop 续跑一次
}
只允许 system 是三种风险的最小交集若允许注入 tool 消息 → validateToolPairing 抛 orphan tool result;允许带 toolCalls 的 assistant → 模型以为请求过这些工具但等不到结果;允许 user → 指令来源被伪造。system 消息不参与工具配对,也不会被误认为用户意图。
为什么「延后写入」工具阶段产生的 additionalContext 不会立刻 push,而是攒到 deferredContext,等所有 tool result 都写完才追加。否则 system 会插在两个 tool result 之间,破坏配对。
HookRegistry:注册顺序 = 执行顺序

每个事件独立回调列表;run() 对每个回调用 await,同步/异步都保持注册顺序。返回普通 object 不算合法 HookResult(运行时 instanceof 检查)。合并规则按字段分:

updatedInput / updatedOutput  后覆盖前   ← 流水线加工,后者看到前者产出
additionalContext            顺序拼接
preventContinuation          布尔 OR     ← 只要有一个要求停就停(更保守的赢)
permissionBehavior           deny>ask>allow>passthrough (更保守的赢)
Pre 遇 blockingError 立即短路后续 Pre 回调
Stop 第一次遇 forceContinue 后停止后续 Stop 回调

三个 Post Hook 依次注册,Hook 2 看到的是 Hook 1 的产出——想做「先脱敏、再截断」还是反过来,靠注册顺序。

UserPromptSubmit:只能补充,不能改写

User Prompt 刚提交、模型还没收到请求时触发。可以记录输入或增加 system context(如「本轮需要特别核对验收结果」)。run() 先运行 Hook,再按固定顺序写入历史。P04 不允许替换、删除或阻断原始 user message。

const submitted = userMessage(prompt);
const promptHook = await hooks.runUserPrompt(submitted);
history.push(submitted, ...promptHook.additionalContext);
PreToolUse:副作用之前的扩展点

工具已存在、参数已过 Zod,但权限尚未判断、handler 尚未运行。可补上下文、改写合法输入、提权限建议或返回结构化阻断错误。命中 blockingError 后权限和 handler 都不跑,Error [hook_blocked] 仍回填给原调用。

// 阻断写入受管理文件
if (argumentsValue.path === "generated.txt") {
  return new HookResult({ blockingError: toolError(
    "hook_blocked", "generated.txt is managed elsewhere") });
}
updatedInput 的硬约束只能改参数,不能换工具身份:tool_call_id 不变、工具名不变、StoredToolDefinition 必须是同一个对象(引用相等)、新 arguments 仍过原 Zod schema。否则恶意 Hook 可以把 write 工具伪装成 read 绕过权限——名字可以伪造,对象引用不行。改写后的参数会重解析 + structuredClone + 深度冻结,审批看到和执行用的是同一个不可变副本,杜绝「批准 A 执行 B」的竞态。
Hook allow 只是建议,不是授权

Pre Hook 返回 permissionBehavior: "allow",Loop 不会直接执行,而是转成结构化建议(source 是 pre-tool-hook)传给 PermissionPolicy,第 3 章的硬边界、Shell 审批、固定规则仍一起合并。只要任一系统参与方 deny,Hook 的 allow 就不会触发审批或 handler。

Pre Hook → hard permission policy → approval → handler
// Hook 是扩展机制,不是第二套权限系统
passthrough 的语义Hook 不产生任何建议,工具是否执行完全由 PermissionPolicy 决定。Claude Code 官方同样强调:Hook 可以拒绝调用,但保持沉默并不会自动批准调用。
PostToolUse:串联观察和结果改写

在 handler 完成后、tool message 写入历史前运行。`context.result` 反映前一个 Post Hook 的产出;返回 updatedOutput 覆盖结果。handler 成功或返回结构化错误时运行 Post;Pre 阻断、permission deny、Pre Hook 异常时不运行 Post。

// 长输出截断
if (result === undefined || result.isError || result.content.length <= 100_000) {
  return new HookResult();
}
return new HookResult({ updatedOutput: toolSuccess(
  `\${result.content.slice(0, 100_000)}\n[truncated]`) });
preventContinuation 也必须保持配对

Post Hook 要求停止时,Loop 不能直接 return——同一 assistant 消息可能还有其它 tool call,直接 return 会让它们成为孤儿,OpenAI 会拒绝、validateToolPairing 抛 missing tool results。

call-1 → 正常执行并触发停止
call-2 → Error [hook_stopped_continuation]: Skipped after PostToolUse ...
call-3 → Error [hook_stopped_continuation]: ...
先写入全部三个 tool result,再以 call-1 的结果正常结束
一句话记住「提前结束」是业务需求,「消息配对」是协议约束。业务需求要在协议约束内实现,不能反过来。call-2/call-3 不是被拒绝执行,是根本没被执行。
Stop:最多强制续跑一次

模型返回没有工具调用的回答时运行 Stop Hook。第一次 forceContinue 追加明确的 user 消息并再次请求模型,stopHookActive=true;第二次模型停止时回调仍执行并看到 stopHookActive,即使再返回 forceContinue 也会被移除。一次 run() 最多增加一个模型 turn,不会死循环。

turn N    模型停止 → Stop Hook(active:false) → forceContinue → 续跑
turn N+1  模型停止 → Stop Hook(active:true)  → forceContinue 被移除 → 结束
限制的是控制权,不是执行权第二次仍执行回调是刻意的:日志、指标、清理这些副作用不该因为「不能续跑了」就被跳过。
Hook 故障如何返回
故障工具结果handler 已执行
prepare() 自身抛异常tool_preparation_error
Pre 返回非法输入更新hook_contract_error
Pre 抛出其他异常hook_execution_error
权限评估或审计异常permission_evaluation_error
Post 抛出异常hook_execution_error
Post 那行「是」是这张表的重点Post 发生在 handler 之后,失败时不能声称副作用没发生。Pre 异常 → 磁盘没变化,可安全重试;Post 异常 → 文件可能已写,不该盲目重试。两者错误码相同(教学简化,第 11 章处理幂等)。工具阶段故障转成配对错误继续循环;Submit/Stop 在工具配对之外,非法结果让 run() 显式失败。
5 机制流程
1
UserPromptSubmit

prompt 已提交,模型还没收到请求;可加 system 上下文,不能改写 prompt。

2
PreToolUse

参数已过 Zod,权限未判;可改参数、提建议、阻断。

3
PermissionPolicy

Hook 建议并入 deny>ask>allow>passthrough。

4
handler

真正副作用发生。

5
PostToolUse

可改结果、可要求本轮结束;preventContinuation 保持配对。

6
写入配对结果

additionalContext 延后追加。

7
Stop

最多强制续跑一次;第二次停止后结束。

6 运行第 4 章
0
准备环境

Set-Location code + npm ci

1
先跑离线测试

npm run test:ch04 预期 12 个文件 94 个测试,不需要 Key。

2
看四个 Hook 各打一行日志

跑只读任务,stderr 出现四行 [Hook]。

3
让 Hook 和审批同时出现

写文件任务里 [Permission] 与 [Hook] 交替。

4
统一入口

agent-tutorial -- run --chapter 4

5
(可选)完整离线门禁

typecheck / test / lint / format / build。

第 1 步 · 离线测试输出
npm run test:ch04
 Test Files  12 passed (12)
      Tests  94 passed (94)
# 从第 3 章的 61 涨到 94,多出的 33 个几乎全在 hooks.test.ts / ch04-hooks.test.ts
第 2—3 步 · 四行日志与 Hook+审批
npm run ch04 -- --prompt '读取 README.md 并概括运行方式'
# stderr → [Hook] UserPromptSubmit
#          [Hook] PreToolUse: read_file
#          [Permission] read_file: allow (default) - ...
#          [Hook] PostToolUse: read_file -> ok
#          [Hook] Stop

npm run ch04 -- --prompt '把一句话写入 hook-demo.txt'
# 写文件时仍是第 3 章的逐次审批,只是多了四行 [Hook] 生命周期日志
常见报错排查
现象原因 / 处理
看不到任何 [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() 最多续跑一次,正常
7 验证与实验

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
8 本章小结
三句话版本
  • Hook 是在四个确定位置发布结构化上下文,让扩展逻辑从核心编排里搬出去。
  • 返回值用结构化 HookResult 而不是字符串,因为控制流语义必须能被校验。
  • 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不做。用户输入不该被扩展代码改写
检查你是否真的读懂了(不看文章回答)
  • [Hook] PreToolUse 和 [Permission] 哪个先出现?为什么不能反过来?
  • 为什么 additionalContext 只允许 system 消息?允许 tool 消息会怎样?
  • 一个恶意 Pre Hook 想把 write_file 伪装成 read_file,哪一条检查拦住了它?
  • 三个 Post Hook 依次改写结果,第三个看到的是哪个版本?
  • Post Hook 要求停止时,同一轮里其它调用为什么变成 hook_stopped_continuation?
  • Stop Hook 第二次还能 forceContinue 吗?回调还会执行吗?
  • Post Hook 抛异常,handler 执行过没有?为什么这个信息很重要?
9 QA 测试环节(自测题)
已完成 0 / 8 · 答对 0
Q1. 四个 Hook 事件中,唯一能「在副作用发生前无损阻断」的是?
Q2. 为什么 HookResult 不用 "stop" 这种字符串?
Q3. Pre Hook 返回 permissionBehavior "allow",会发生什么?
Q4. 防止恶意 Pre Hook 把 write_file 伪装成 read_file,靠哪条检查?
Q5. 一个 assistant 消息带 call-1/call-2/call-3,call-1 的 Post Hook 要求停止,会怎样?
Q6. Stop Hook 的 forceContinue 第二次为什么不生效?
Q7. Post Hook 抛异常时,handler 执行过没有?
Q8. additionalContext 为什么只允许 system 消息?