10. Hook

Hook 是真正的「钩子」:注入、兜底与留痕

上一篇讲权限时,我在「六步评估链」里,专门跳过了排在最前面的一关,Hooks,还欠了「路径白名单怎么做」的账。这一篇就来还债。不过 hook 的能力远不止「权限第一关」,越往下拆你会发现,它是往 Agent 运转里塞自己逻辑的万能钩子

👆你在这里
👆你在这里

🪧 上一篇未展开的第一道闸,其实是个万能钩子

如果把 hook 简单定义为「权限链最前面那道闸」,那就把它看小了。具体实践过后,我的体会是:hook 是整个 Harness 里,能亲手介入 Agent 运转的最灵活的一个入口。 它替我干了三件性质完全不同的事:注入、兜底、留痕,这一篇就围着这三件事,展开来讲

🧭 概念:Hook 是 Agent 生命周期上的一排「插槽」

一个 Agent 跑起来会经过若干关键时刻:它「要调一个工具了」「工具刚调完」「想结束这一轮了」「上下文要压缩了」「一个会话刚启动」

hook,就是这些关键时刻预留好的一排插槽。 把自己的一段代码插进某个槽里,到那个时刻它就被自动触发、跑一遍,还能就地干预接下来的走向。Claude Code Harness Book 把这类机制归到「Interrupts(打断)」。作一个形象的比喻,hook 就是你在 Agent 自动运转的流水线上,几个关键工位上装的传感器加机械臂,物件一到位,自动伸手做点什么

这套「插槽」具体能干什么呢?官方文档把 hook 的用途归成五类:拦截危险操作、审计工具调用、转换输入输出、要求人工审批、追踪会话生命周期。这五个分类很有启发性,后面你会看到,SmartWriter 真正用上的三件事(注入、兜底、留痕),其实是从这五种里挑出来、对写作场景有帮助的实用子集:「转换输入输出」落地成了注入,「追踪生命周期」落地成了兜底,「审计」落地成了留痕

🔩 Claude Code:hook 是写在配置里的一组「到点就触发」的规则

在 Claude Code 里,hook 并没有那么高深,它就是写在配置文件(.claude/settings.json)里的一组规则。每条规则说清三件事:在哪个生命周期事件上触发匹配哪个工具或场景、以及触发时干什么。Learn Claude Code 的源码解读系列第四篇专门拆了 Claude Code 的 hook 机制,感兴趣的可以去深入看下

其中,「匹配哪个工具或场景」这条,是精细化控制的关键:完全可以让一个 hook 只盯着 BashWrite 这一两个工具生效,其余工具照常放行,不用为了防一个工具,把所有工具的执行都搭上一道审查

那些常见的事件槽,名字大多顾名思义。我按一次会话从头到尾的时间顺序把它们捋一遍,扫一眼就有整体印象了:SessionStart(会话刚启动)、UserPromptSubmit(用户刚提交一句话)、PreToolUse(某个工具执行前)、PostToolUse(工具执行后)、Notification(需要通知用户时,比如正等着你给权限)、Stop(模型想结束这一轮)、SubagentStop(一个子 agent 干完了)、PreCompact(上下文压缩前)、SessionEnd(会话结束)

这一排槽,几乎把 Agent 一生里所有「值得插一手」的时刻都覆盖到了。(不过有个坑要特别说下:上面这些是 Claude Code 完整支持的事件,但 Python SDK 目前只覆盖了其中一部分,比如 SessionStartSessionEndPostCompact 这几个,TypeScript SDK 支持,但 Python SDK 的 hook 回调清单里没有它们,只能退而求其次,靠配置文件里的 shell 命令 hook 顶替。这算是两个 SDK 之间一个挺大的功能差距,后面会专门讲到这引起的麻烦)

还有个区别值得一提,因为它关系到后面怎么写 hook:在 Claude Code 的命令行里,hook 触发时通常是去跑一段 shell 命令;而到了 SDK 里,它更自然地变成一个回调函数。事件一到,SDK 就把当时的现场信息(是什么工具、参数是什么)打包成一份 JSON,递给你的函数;你的函数看一眼、处理完,再返回一个决定(放行、拦下、或者改写)回去。所以在 SDK 语境里说「写一个 hook」,其实就是写一个「到某个时刻会被自动叫醒、拿到现场、还能当场拍板」的函数

Claude Code 原生这一层,讲的是「怎么配」;接下来 SDK 这一层,要讲的是「怎么写」。挑几个对写作最有用的槽,把我的逻辑插进去

🛠 SDK:我最常用的几个插槽

从上面那一整排槽里,我在项目里用得最多、也最能说明问题的,是这么三个:

  1. PreToolUse(工具执行前):它在工具真正跑起来之前触发,让我能改写工具的入参,或者干脆直接拦下这次调用
  2. Stop(模型想结束时):模型觉得这一轮干完了、想停,这个槽会触发,我甚至能在这儿阻止它结束,逼它再补做点什么
  3. PreCompact(压缩之前):这是 Python SDK 里唯一跟压缩相关的槽。压缩发生前先触发,让我有机会确保磁盘上的 CLAUDE.md 是完整的。等压缩一结束,CLI 会从磁盘重新读取 CLAUDE.md,被摘掉的内容就回来了。(你可能会问:那压缩之后触发的 PostCompact 呢?TypeScript SDK 有这个槽,但 Python SDK 目前没有,所以只能在压缩做预防)

具体到 Python SDK 里,注册一个 hook 靠的是 HookMatcher:告诉 SDK 这个 hook 挂在哪个事件上(比如 PreToolUse)、只匹配哪个工具(呼应前面 Claude Code 里说的「匹配哪个工具或场景」,比如只对 Bash 生效),以及真正干活的回调函数。回调函数得写成 async def,接收工具名、参数这些现场信息,处理完返回一个 dict;这个 dict 里真正拍板「放行还是拦下」的字段叫 permissionDecision,取值是 allowdenyask 这几种。不想干预就返回空 dict,SDK 默认当作没意见、照常放行

上下文压缩之前,PreCompact hook 会先触发;压缩之后,CLI 会从磁盘重新读取 CLAUDE.md,让被摘掉的常驻内容回来。而 custom tool 的返回值(tool_result)不会自动重跑,压缩摘了就没了。 换句话说,想让某样东西「压缩后还能自动回来」,得把它的注入托付给 PreCompact hook + CLAUDE.md 的磁盘重读机制,而不是托付给一个普通工具的返回值。这条机制,正是后面画像兜底方案能立住的基础

🧰 SmartWriter:hook 承包了三件事

回到概念部分那五种官方用途:SmartWriter 从里面挑了三种,反复在用。「转换输入输出」变成了注入,「追踪生命周期」变成了兜底,「审计」变成了留痕。三件事挨个说下

注入:在工具执行前,动点手脚

第一件事:路径白名单

我写了个挂在 PreToolUse 上的守卫(就叫它 path_guard)。每次模型要读、写、改一个文件,在它真正动手之前,这个守卫先被触发,检查一遍目标路径:

# PreToolUse hook:工具真正执行前被调用
def path_guard(tool_name, tool_input):
    path = tool_input.get("file_path", "")
    if is_sensitive(path):        # 命中 .env / .ssh / 私钥等
        return deny("这个文件不能碰")
    if out_of_workspace(path):    # 越出了工作目录
        return deny("超出工作区范围")
    return allow(tool_input)      # 放行(也可在此改写入参再放行)

路径校验这种「在执行前拦一道」的活,天生就很适合挂 PreToolUse。还有另一个同类的守卫,负责给某个工具的调用自动补一个参数(让它在后台运行)。这类逻辑的共同点是:它们都在工具放行之前,动一动入参、或者拦一拦。 可以用「注入型逻辑 = 在放行前动手脚」这个特性来记住

兜底:把「必须发生」的事,焊死

第二件事:专盯着那些「一步都不能少」的关键环节

最典型的就是画像。第 4 篇讲过,用户的写作画像是靠 profile_reader 组装好、写进 CLAUDE.md 的一个区块注入的。可万一一个长写作任务写到中途触发了压缩,把这个画像区块摘掉了呢?那模型后面就「忘了」你的风格

这时候前面那条关键事实就派上用场了:压缩前 PreCompact hook 会先触发,压缩后 CLI 会从磁盘重读 CLAUDE.md。 所以我在 PreCompact 这个槽上插了道保险:每次压缩之前,先检查一遍磁盘上那个画像区块还在不在,万一丢失了就立刻补写回去。这样压缩一结束,CLI 从磁盘重新读取 CLAUDE.md 时,画像区块就是完整的,画像免费续命。这正是第 6 篇那条「关键环节别赌模型自觉、用确定性机制焊死」的又一次落地,只不过这次焊死的手段是 hook。(你可能会想:要是能在会话启动时也检查一遍岂不更稳?可惜 Python SDK 没有 SessionStart 槽,所以我只能在压缩前这一个窗口做防御。好在 CLAUDE.md 在会话启动时本来就会从磁盘读一次,画像区块只要在磁盘上完整就行)

留痕:Stop hook 做快照,但克制地「只做快照」

第三件事:藏在 Stop 这个槽里

每当模型停下一轮,我用 Stop hook 把这一版成稿悄悄快照一份,存进任务的元数据目录。这份快照有什么用?它是第 8 篇那个 compute_diff 的上游,有了「AI 成稿的这一版」,等你之后手动改了稿,我才能算出「AI 版 vs 你改后版」的差异,进而采集那个衡量提效的指标,喂给画像闭环

但这里我做得比较克制,Stop hook 只做快照,不在这个环节顺手触发终检。 一开始我确实是这样想的:反正模型每次停下都会触发 Stop,顺手跑个全文终检多好。但很快就否了:Stop 分不清模型这次停,是「整篇写完了」还是「刚干完一小步、喘口气」。 要是每次停都跑一遍重量级的终检,用户会被打扰到崩溃。所以终检这件事,还是要交给用户:你觉得写得差不多了,主动敲一下 /final_check 再跑。hook 负责那些「无感、必做」的杂活(快照),把「重量级、该由人决定时机」的事留给用户

🧨 踩过的坑:注入型逻辑挂 canUseTool,是死代码

🔧 避坑 · 注入逻辑挂上去了,然而它一次都没被执行

最早要给某个工具的调用注入一个参数时,顺手挂在了第 9 篇讲的那个 canUseTool 审批回调里,心想「反正都是在工具执行前介入」。结果本地怎么测都不生效,翻日志一看,发现那个回调压根没被调用过

挖到根上,答案就在第 9 篇那张六步图的顺序里:Hooks → Deny → Ask → Mode → Allow → canUseTool。我要注入的那个工具,本身是在 Allow 清单里的(读写这类工具,本来就该自动放行)。而一个工具只要命中了 Allow,就在第 5 关被放行了,根本走不到第 6 关那个 canUseTool。所以我挂在 canUseTool 上的注入逻辑,是一段永远不会被执行的死代码,静静躺在那儿,不报错,也不生效,最坑

正解是:注入型逻辑必须挂到评估链最前面PreToolUse hook 上。因为它要在「放行」之前动手脚,就得站在所有放行判断的最前头。在一条有顺序的处理链上挂逻辑,先想清楚你的逻辑该在哪个位置生效,挂错了位置,它可能连门都进不去

这不是我自己观察出的经验规律,官方文档也有明确说明:PreToolUse hook 在所有其他权限检查之前运行,哪怕开着最激进的 bypassPermissions 模式,hook 的拒绝依然生效。先踩坑、后翻文档验证,这些绕不开的弯路啊,到 Vibe Coding 时代了我还在走 😳

它和第 9 篇那个「强制审批靠不进 Allow 清单」的机制,其实是同一条顺序的一体两面,把它俩画在一起看,会更具体明了:

工具调用权限链
工具调用权限链

一句话总结就是:想在放行前「动手脚」的(注入),挂最前面的 hook;想在放行前「拦住问用户」的(审批),靠故意不进 Allow、让它落到最后的 canUseTool。 Allow 这一关就是那道分水岭,跨过去就自动放行,你所有的介入意图,都得想清楚该站在分水岭的哪一侧

⚖️ 垂直落点:关键环节,用 hook 焊死,不赌模型自觉

回到贯穿全专栏的对照:

⚖️ 取舍现场 · 关键环节怎么保证,通用和垂直在想什么

通用 Agent 的常见做法 SmartWriter 为什么
必做环节 写进提示词,靠模型自觉执行 用 hook 在生命周期点上焊死 模型会漏、会跳,关键步漏不得
越权访问 靠模型「别碰敏感文件」的自觉 PreToolUse 路径白名单,执行前硬拦 自觉不是防线,代码才是
长任务掉状态 听天由命 压缩后 hook 自动重注入画像 只有 hook 会被自动重跑

hook 这一篇的内核,跟前面几篇其实是一脉相承的:凡是「必须发生」「绝不能发生」的确定性要求,我都不放心交给模型的自觉,而是找一个生命周期上的确定时刻,用代码把它焊死。 第 6 篇焊死的是题材方法论的注入,第 9 篇焊死的是危险操作的禁止,这一篇焊死的是路径、画像和快照。hook,就是那把最趁手的「焊枪」

写到这里,「与世界交互」这个板块就整个收官了。回头看这三篇:第 8 篇给 Agent 配了工具(让它有手),第 9 篇用权限框住工具(让它的手不乱伸),第 10 篇用 hook 在关键时刻介入(让必做的事一定发生、该拦的一定拦住)。到这儿,我们的写作副驾已经能安全地、可控地跟真实世界打交道了

不过我们目前聊的,一直都还是「单个 Agent 怎么把一件件事做好」。要是碰上一篇结构复杂的长文,一个 Agent 从头写到尾,很容易写着写着就乱了方向,或者把该查证的、该终检的都在一个上下文里搅成一锅粥

这时候就需要更高一层的组织了:先谋后动地做规划,以及把一些独立的活外包给「分身」去干。从下一篇起,我们进入「编排与分工」,先聊 Plan Mode,以及为什么「有现成的 plan 模式却不用」。咱们继续聊