SDK 里的钩子和 CLI 里是同一套事件,只是处理器从「一个外部程序」换成了「你的函数」。 实测六个工具调用:拦住一个、改写一个、放行四个。 改写比拦住更好用——意图是对的,只是目标该换。
上一节讲自定义工具(往外扩),这一节讲钩子(往里收)。 第 5 章已经讲过事件和语义,这一节只讲「换成函数之后有什么不一样」。
一、两个位置
PreToolUse:工具执行前跑。能拦、能改参数、能放行。 PostToolUse:工具执行后跑。能看结果、能补记,但改不了已经发生的事。 「改不了已经发生的事」这一条很关键:想阻止就得用前一个,事后再拦已经晚了。
所以设计钩子的时候先问一句:这件事我要「阻止」还是「记录」?答案决定挂哪个。
二、真跑:六个调用逐个过前钩子
演示里六个调用:读文件、跑 rm -rf、改 .env、跑测试、写文件、改代码。 结果拦住一个(危险命令)、改写一个(.env 改成 .env.example)、放行四个。 被改写的那一个最有意思:它没被拦,而是把路径换掉了。
这比「拦住」更好用——模型的意图是对的(想改配置),只是目标该换一个。 而直接拦会让它反复重试同一个动作,你还得告诉它「换成哪个」。
三、后钩子拿到什么
工具名与参数:知道刚才干了什么,可以写审计日志。 执行结果:成功还是失败、返回了什么,可以统计和告警。 会话标识:归到哪一次会话,排查时能串起来。
三样都拿到之后,最有价值的用法是「统计」:哪类工具被调得最多、哪种失败最常见。
四、和命令行钩子的关系
事件名、字段、拦截语义都一样,差的只是「处理器写在哪儿」。 命令行钩子要序列化成 JSON 过管道,程序化钩子直接拿对象——后者写起来短,也少一类解析错误。 但命令行钩子有个优势:它跟语言无关,换团队、换栈都不用重写。
所以选哪个不是能力问题,是「这段逻辑归谁维护」的问题。
五、一个坑:钩子里别做重活
钩子跑在主循环上,它慢一次,每次工具调用都慢一次。 要发通知、要上报,就丢到队列里异步做——这和 5.9 节那条判据是一样的。 判断标准还是那句:这个钩子的失败会不会影响正确性?不会,就不该让主流程等它。
补充一条:钩子里的异常不要往外抛。抛了会把整个会话打断,而你的本意只是「记一笔」。
六、动手 Demo:两个钩子挂在工具调用上,六个调用被改了两次
SDK 里的钩子和 CLI 里的钩子是同一套事件,只是处理器换成了你的函数。 这里真跑六个工具调用,过一遍前钩子的三个分支:拦、改、放。
def _pre(call):
if call[u"name"] == u"Bash" and u"rm -rf" in call[u"args"][u"cmd"]:
return u"deny", u"危险命令"
if call[u"args"].get(u"path", u"").startswith(u".env"):
call[u"args"][u"path"] = u".env.example"
return u"rewrite", u"改指到示例文件"
return u"pass", u""
实跑输出:
--------------------------------------------------------------------
两个钩子挂在工具调用上,六个调用被改了两次
--------------------------------------------------------------------
SDK 里的钩子和 CLI 里的钩子是同一套事件,只是处理器换成了你的函数。
好处是不用起进程、不用传 JSON 字符串,直接读写对象。
① 两个位置
--------------------------------------------------------------------
| 钩子 | 什么时候跑 | 能做什么 |
|---|---|---|
| PreToolUse | 工具执行前 | 能拦、能改参数、能放行 |
| PostToolUse | 工具执行后 | 能看结果、能补记、但改不了已经发生的事 |
「改不了已经发生的事」这一条很关键:想阻止就得用前一个,事后再拦已经晚了。
② 真跑:六个调用逐个过前钩子
--------------------------------------------------------------------
| 调用 | 动作 | 原因 |
|---|---|:---:|
| Read src/app.py | pass | — |
| Bash rm -rf / | deny | 危险命令 |
| Edit .env → .env.example | rewrite | 改指到示例文件 |
| Bash pytest -q | pass | — |
| Write out.json | pass | — |
| Edit src/app.py | pass | — |
六个调用:拦住 1 个、改写 1 个、放行 4 个。
被改写的那一个最有意思:它没被拦,而是把路径从 .env 换成了 .env.example。
这比「拦住」更好用——模型的意图是对的(想改配置),只是目标该换一个。
③ 后钩子拿到什么
--------------------------------------------------------------------
| 拿到什么 | 能干什么 | 典型用法 |
|---|---|---|
| 工具名与参数 | 知道刚才干了什么 | 写审计日志 |
| 执行结果 | 成功还是失败、返回了什么 | 统计、告警 |
| 会话标识 | 归到哪一次会话 | 关联排查 |
后钩子跑在「事已至此」之后,所以它只适合记录和统计,不适合拦截。
④ 和命令行钩子的关系
--------------------------------------------------------------------
事件名、字段、拦截语义都一样,差的只是「处理器写在哪儿」。
命令行钩子要序列化成 JSON 过管道,程序化钩子直接拿对象——后者写起来短,也少一类解析错误。
所以选哪个不是能力问题,是「这段逻辑归谁维护」的问题。
⑤ 一个坑:钩子里别做重活
--------------------------------------------------------------------
钩子跑在主循环上,它慢一次,每次工具调用都慢一次。
要发通知、要上报,就丢到队列里异步做——这和 5.9 节那条判据是一样的:
只要失败不影响正确性,就不该让主流程等它。
! 钩子的注册方式以真实版本为准;这里演示的是「前能拦、后只能记」这件事。
①两个位置:PreToolUse 能拦能改能放,PostToolUse 只能看和记——它跑在「事已至此」之后。 ②六个调用:拦住一个、改写一个、放行四个。被改写那个是把 .env 换成 .env.example。 ③改写比拦住更好用:模型的意图是对的(想改配置),只是目标该换一个。 直接拦会让它反复重试同一个动作。 ④钩子跑在主循环上,别做重活。要发通知就丢队列异步做; 钩子里的异常也不要往外抛,抛了会把整个会话打断。
30 秒实验
- 把「改指」那条去掉,只看「拦」,重跑一遍。六个调用会变成什么样?模型接下来大概会做什么?
- 加一条前钩子:所有 Write 调用都额外记一行日志,重跑一遍。它属于「拦」还是「记」?该挂哪个位置?
- 给前钩子里加一句 time.sleep(1),重跑一遍。六个调用总共多花几秒?这说明什么?
常见误区
- 用 PostToolUse 做拦截。事已至此,拦不住。
- 能把参数改对的事直接拦掉。让意图保留、目标换掉,模型不容易反复重试。
- 在钩子里同步发网络请求。它跑在主循环上,每次工具调用都会被拖慢。
- 钩子里抛异常。会把整个会话打断,而你的本意只是记一笔。
- 为一次性需求写钩子。钩子是每次都要跑的,一次性的事该写进提示词。
自测三问(点开对答案)
1. 两个钩子位置分别能做什么? —— PreToolUse 在工具执行前,能拦、能改参数、能放行;PostToolUse 在执行后,只能看结果和记录。 2. 为什么「改写」有时比「拦住」更好? —— 因为模型的意图可能是对的,只是目标不对。拦住会让它反复重试,改写能保留意图换掉目标。 3. 钩子里为什么不能做重活? —— 它跑在主循环上,慢一次,每次工具调用都慢一次。要发通知就丢队列异步做。钩子是「你写的拦截」,下一节看它自带的那几道防线。
- ☐ (本篇完,下一篇 8.8 4 道安全防线)
相关笔记
- 事件生命周期:钩子挂在哪些事件上
- 4 道安全防线:四道安全防线
- Claude Code 橙皮书 · 索引
- 🐎 Claude Code 实战 · 索引