Hook System - 主循环扩展机制
L08 要回答一个问题:如何在 L07 的基础上让 Agent 主循环可扩展,而不到处散落 if/else?
L07 给了我们骨架:权限管道 + 三种模式 + Bash特殊处理。但真实 Agent 需要在主循环各处插入额外行为——会话初始化、工具执行前后审计、日志记录。直接改主循环代码会导致散落 if/else,难以维护。怎么做?
本章讲三个机制:
| 机制 | 解决什么问题 | 核心设计 |
|---|---|---|
| Hook 管道 | 主循环固定时机对外发出调用 | 不改主循环代码,预留插口 |
| 三种事件 | 暴露关键时机 | SessionStart + PreToolUse + PostToolUse |
| 统一返回协议 | Hook 返回结果标准化 | exit_code: 0/1/2 三种状态 |
这三个机制叠加在 L07 的 Permission System 上,循环本身不变。理解 L08,就理解了 Agent 如何”主循环可扩展不散落”。
一、Hook 管道:预留插口机制
“Hook 不是主循环的替代品,是主循环在固定时机对外发出的调用。”
这句话概括了 Hook System 的本质。主循环不改,只是预留插口——在固定时机调用外部 hook。
Hook 与 Permission 的区别
很多人混淆 Hook 和 Permission:
| 机制 | 目的 | 返回语义 | 时机 |
|---|---|---|---|
| Permission | 允许/拒绝判断 | allow/deny/ask | 工具执行前 |
| Hook | 扩展/观察/拦截 | 0/1/2 | 工具执行前后 |
核心区别:
- Permission:判断”能不能执行”——安全决策。
- Hook:扩展”执行前后做什么”——行为插入。
执行顺序:Permission 先检查,Hook 后触发。
Permission 通过后才触发 Hook。Hook 无法绕过 Permission——安全高于扩展。
Hook 管道代码
def run_hooks(event_name: str, payload: dict) -> dict: for handler in HOOKS.get(event_name, []): result = handler(payload) if result["exit_code"] in (1, 2): # 阻止或补充 return result return {"exit_code": 0, "message": ""} # 正常继续关键点:HookRunner 统一调度——遍历 handlers,遇到阻止或补充就返回,否则继续下一个。
验证你的理解
Q1:Hook 可以绕过 Permission 直接执行工具吗?
:::details 答案 不可以。Permission 先检查,Hook 后触发。
执行顺序:tool_use → Permission 检查 → Hook PreToolUse → 执行工具 → Hook PostToolUse。
Permission 是安全层,Hook 是扩展层。安全高于扩展——Permission deny 后,Hook 不会触发。
这是设计决策:安全判断优先于行为插入。Hook 不能绕过 Permission,否则安全边界失效。 :::
二、三种事件:暴露关键时机
Hook 管道是机制,三种事件是时机暴露。
| 事件 | 时机 | 用途 |
|---|---|---|
| SessionStart | 会话开始时 | 初始化、加载配置、设置上下文 |
| PreToolUse | 工具执行前 | 拦截、补充、日志、审计 |
| PostToolUse | 工具执行后 | 结果处理、日志、审计、补充说明 |
SessionStart:会话初始化
event = { "name": "SessionStart", "payload": { "session_id": "abc123", "user_input": "帮我分析这个项目", },}用途:
- 加载用户配置
- 初始化 SkillLoader
- 设置会话上下文
特点:只在会话开始触发一次,后续不再触发。
PreToolUse:工具执行前
event = { "name": "PreToolUse", "payload": { "tool_name": "bash", "input": {"command": "pytest"}, },}用途:
- 拦截危险操作(exit_code=1)
- 补充上下文信息(exit_code=2)
- 记录审计日志
特点:每次工具执行前触发,可以阻止执行。
PostToolUse:工具执行后
event = { "name": "PostToolUse", "payload": { "tool_name": "bash", "input": {"command": "pytest"}, "output": "5 tests passed", },}用途:
- 处理执行结果
- 补充说明(exit_code=2)
- 记录审计日志
特点:每次工具执行后触发,不能阻止(已执行),只能补充。
验证你的理解
Q2:PostToolUse 的 exit_code=1(阻止)有意义吗?
:::details 答案 没有意义。工具已执行,无法阻止。
PostToolUse 在工具执行后触发——结果已产生,文件已修改,命令已运行。此时”阻止”没有意义。
PostToolUse 的返回协议:
- exit_code=0:正常结束
- exit_code=1:阻止(无意义,不使用)
- exit_code=2:补充说明
设计决策:PostToolUse 只用 exit_code=0 和 exit_code=2,不用 exit_code=1。 :::
三、统一返回协议:三种状态
Hook 返回结果必须标准化——统一返回协议。
result = { "exit_code": 0, # 0=继续, 1=阻止, 2=补充 "message": "", # 补充消息或阻止原因}三种状态:
| exit_code | 含义 | 适用事件 |
|---|---|---|
| 0 | 正常继续,主循环不变 | 所有事件 |
| 1 | 阻止动作,中断执行 | PreToolUse(PostToolUse 无意义) |
| 2 | 注入补充消息给模型,再继续 | PreToolUse、PostToolUse |
exit_code=0:正常继续
result = {"exit_code": 0, "message": ""}含义:Hook 不干预,主循环继续正常流程。
适用场景:观察、日志记录、审计。
exit_code=1:阻止动作
result = {"exit_code": 1, "message": "Dangerous command blocked"}含义:阻止工具执行,返回拒绝原因。
适用场景:PreToolUse 拦截危险操作。
执行流程:
PreToolUse → exit_code=1 → 阻止工具执行 → 返回拒绝原因给模型exit_code=2:注入补充
result = {"exit_code": 2, "message": "Remember to check test coverage"}含义:注入补充消息给模型,再继续执行。
适用场景:PreToolUse 补充上下文,PostToolUse 补充说明。
执行流程:
PreToolUse → exit_code=2 → 注入消息 → 继续执行工具PostToolUse → exit_code=2 → 追加说明 → 返回给模型验证你的理解
Q3:exit_code=2 和 exit_code=0 的区别是什么?
:::details 答案 exit_code=0 不注入消息,exit_code=2 注入补充消息。
exit_code=0:Hook 不干预,主循环继续正常流程。模型看不到 Hook 的存在。
exit_code=2:Hook 注入消息给模型,模型收到额外信息后再继续。消息可以是提示、警告、补充上下文。
场景差异:
- exit_code=0:观察、日志记录、审计(模型不需要知道)
- exit_code=2:补充上下文、提醒模型(模型需要知道)
例如:PreToolUse 检测到 pytest 命令,exit_code=2 注入 “记得检查覆盖率”,模型收到提示后再执行 pytest。 :::
四、回顾:L08 在循环上叠加了什么
回到开头的问题:如何在 L07 的基础上让 Agent 主循环可扩展,而不到处散落 if/else?
答案:
| 叠加机制 | 代码位置 | 循环的变化 |
|---|---|---|
| Hook 管道 | 主循环固定时机 | 不变——只是预留插口 |
| 三种事件 | SessionStart + PreToolUse + PostToolUse | 不变——只是时机暴露 |
| 统一返回协议 | HookResult 结构 | 不变——只是返回标准化 |
循环本身仍是 L01 的 while True + stop_reason + messages[]。L08 只是在”扩展层”叠加了 Hook 管道。
与后续课程的关系
理解 L08 后,你会发现后续课程都在 Hook System 上叠加——循环本身始终不变:
| 课程 | 关系 |
|---|---|
| L07 Permission | Permission 先检查,Hook 后触发。安全高于扩展 |
| L09 Memory System | SessionStart Hook 加载记忆,PostToolUse Hook 记录关键信息 |
| L10 Agent Teams | 多 Agent 协作时的 Hook 事件广播 |
| L11 Background Tasks | Hook 可以触发异步任务 |
核心洞察:Hook System 是”主循环可扩展”的机制。理解 L08,就理解了 Agent 如何”不改主循环也能插入行为”。后续课程都在扩展 Hook 机制——记忆加载、团队协作、异步任务。
五、心智模型升级:从 L0 到 L2
读完这篇文章,你的理解经历了怎样的升级?
L08 核心概念的理解升级路径
| 概念 | L0(表面) | L1(关联) | L2(深层) |
|---|---|---|---|
| Hook 管道 | ”到处插 if/else" | "预留插口,固定时机调用" | "Hook 不是主循环替代品,是主循环在固定时机对外发出的调用。HookRunner 统一调度——遍历 handlers,遇到阻止或补充就返回,否则继续。扩展机制:不改主循环代码,也能插入额外行为” |
| 三种事件 | ”会话开始/工具前后" | "暴露关键时机" | "最小事件集——SessionStart(初始化)+ PreToolUse(拦截/补充)+ PostToolUse(日志/审计)足够支撑核心扩展。先学3个事件 → 统一协议 → 扩事件面” |
| 统一返回协议 | ”返回结果" | "exit_code 三种状态" | "统一返回协议——先学统一语义,再学事件细化。0=继续(观察),1=阻止(PreToolUse),2=补充(注入消息)。Hook 至少需要三样:事件名 + payload + 返回结果” |
如何检验你达到了 L2?
用这个问题自测:
如果要实现”每次 bash 执行后记录日志”,应该用什么 Hook?返回什么?
:::details L2 层级回答 使用 PostToolUse Hook,返回 exit_code=0。
设计:
def log_bash_handler(payload):tool_name = payload.get("tool_name")if tool_name != "bash":return {"exit_code": 0, "message": ""}command = payload["input"]["command"]output = payload["output"]write_log(f"[bash] {command} → {output}")return {"exit_code": 0, "message": ""} # 正常继续理由:
- PostToolUse 在工具执行后触发,适合记录结果
- exit_code=0 表示正常继续,不注入消息给模型
- 日志记录是观察行为,模型不需要知道
如果需要提醒模型”记得检查覆盖率”,应该用 exit_code=2 注入消息。 :::
本章目标:让每个核心概念都从 L0 升级到 L2。理解 L08,不只是记住三种事件,而是掌握”如何在循环上预留插口扩展行为”的设计原则。
六、复习可视化
Hook 执行流程
Hook 与 Permission 关系
三种事件时机
统一返回协议
支持与分享
如果这篇文章对你有帮助,欢迎分享给更多人或赞助支持!