09 · Hooks:给 Agent 开一组可编程的插槽
用户想在工具执行前后、回合结束时插入自己的逻辑——自动格式化、提交前检查、强制“测试不过不许停”。讲清生命周期钩子的设计:PreToolUse 拦截/改写、PostToolUse 反应、以及最有意思的 Stop 钩子如何“拽住”想收工的 Agent。
09 · Hooks:给 Agent 开一组可编程的插槽
权限是内置的安全策略,但用户总有自己的、五花八门的诉求:“每次改完文件自动跑一遍格式化”“提交前必须过 lint”“测试没绿之前不许它说完成”。这些没法全塞进 Agent 内核。这一篇,我们给它开一组可编程的插槽。
一、背景:内核装不下所有人的规矩
不同团队、不同项目,对 Agent 有完全不同的额外要求:
- 前端项目:每次
edit_file后,自动prettier --write。 - 后端项目:任何
bash命令,先记一条审计日志。 - 严格团队:Agent 说“我做完了”之前,必须确认测试通过,否则打回去继续干。
这些逻辑因人而异、因项目而异,不可能硬编码进 Agent。你需要的是一种机制:让用户在 Agent 生命周期的固定节点,挂上自己的代码。 这就是 Hooks。
二、解决方案:生命周期钩子
思路是在 Agent 循环的关键时刻,埋下若干命名的挂载点,每个点允许用户注册处理器。常见的挂载点:
| 钩子 | 触发时机 | 能干什么 |
|---|---|---|
| PreToolUse | 某个工具执行前 | 拦截、改写入参、或放行 |
| PostToolUse | 某个工具执行后 | 对结果做反应(格式化、通知、追加检查) |
| Stop | Agent 准备收工时 | 拽住它,强制它继续干 |
| UserPromptSubmit | 用户提交输入时 | 预处理、注入额外上下文 |
Hooks 是“用户态”对 Agent 的扩展,而且是确定性的。 注意这个关键区别:模型的行为是概率性的(你没法保证它每次都记得跑格式化);而钩子是确定性的代码——挂上了,就每次必然执行。所以 Hooks 的价值,恰恰在于把那些“必须每次都做、不容模型忘记”的事,从“祈祷模型配合”变成“机制保证”。凡是你不放心交给模型自觉的横切逻辑,都该用钩子钉死。
三、实现细节:钩子的契约与三种挂载点
钩子的契约:输入结构化事件,输出决定
一个钩子接收结构化的事件信息,返回一个决定。以 PreToolUse 为例:
type PreToolUseDecision =
| { decision: 'continue' } // 放行
| { decision: 'block'; message: string } // 拦截,理由喂回模型
| { decision: 'modify'; input: unknown } // 改写入参后放行
type PreToolUseHook = (tool: Tool, input: unknown) => Promise<PreToolUseDecision>它和权限系统的 canUseTool 长得很像——没错,PreToolUse 本质就是一个“用户可编程的权限闸门”。区别在于:权限是内置策略,钩子是用户挂的任意逻辑。两者叠加(通常钩子先跑,再走内置权限),构成一道可深度定制的关卡。
真实系统里,钩子常被实现为外部命令而非进程内函数:Agent 把事件序列化成 JSON 从 stdin 喂给一个用户指定的脚本,脚本处理后把决定以 JSON 从 stdout 返回。这样做的好处是语言无关——用户拿任何语言(bash、Python、Node)写钩子都行,Agent 完全不关心。代价是每次要起一个子进程。进程内函数快,但把用户锁死在 Agent 的语言里。工程上二者常并存。
PreToolUse:拦截与改写
接进08 篇的工具执行入口,排在权限之前:
for (const hook of hooks.preToolUse ?? []) {
const d = await hook(tool, input)
if (d.decision === 'block') return denied(d.message) // 钩子拦截,理由喂回模型
if (d.decision === 'modify') input = d.input // 钩子改写入参
}
// ...再走内置权限、再执行...Stop:拽住想收工的 Agent(最有意思的一个)
Stop 钩子挂在循环的出口——当模型不再要求调用工具、Agent 准备结束时。它有权说:"不行,你还没完,接着干。"
// 循环里,原本"没有工具调用就退出"的地方:
if (toolUses.length === 0) {
for (const hook of hooks.stop ?? []) {
const d = await hook(messages)
if (d.decision === 'block') {
// 钩子不让停!把它的要求作为一条新消息注入,继续循环
messages.push({ role: 'user', content: d.message, isMeta: true })
continue // ← 回到循环开头,Agent 被"拽"了回来
}
}
return { reason: 'completed' } // 所有 Stop 钩子都放行,才真正结束
}这个机制威力很大。比如一个“测试守门”钩子:
const testGate: StopHook = async () => {
const passed = await runTests()
return passed
? { decision: 'continue' }
: { decision: 'block', message: '测试仍未通过,请继续修复,不要停下。' }
}挂上它,Agent 就没法在测试还红着的时候溜号——它每次想说“搞定了”,都会被这个钩子打回去继续修,直到测试真的绿了。
Stop 钩子是把双刃剑,小心死循环。 如果钩子的条件永远满足不了(比如要求“零 lint 警告”,但有个警告 Agent 根本改不动),它会把 Agent 永远拽在循环里,反复“想停→被打回→再试→又想停”,烧光你的预算。所以 Stop 钩子的注入消息里,通常要给模型留一条退路(比如“若确实无法解决,请说明原因后停止”),并配一个最大重试次数做硬闸——这和03 篇讲的失控保护是同一个道理:任何能让循环延续的机制,都必须自带刹车。
四、Hooks 与权限、Agent Loop 的关系
把这三者摆在一起看,一个清晰的分层浮现出来:
它们共享同一批“关键时刻”(工具执行前后、回合结束),只是各自负责不同层面的关切。一个成熟 Agent 的可扩展性,很大程度上就体现在这套生命周期钩子的丰富度上——它决定了用户能把 Agent 定制到多深。
五、第四阶段收尾
第四阶段“安全与控制”到此完整:
我们的 Agent 现在既自主、又快、又可控、还可扩展。看起来很完整了?但有一头我们一直在回避的猛兽,始终没正面处理——上下文窗口。随着对话越滚越长、工具结果越堆越多,那个有限的窗口迟早会被撑爆。而这,是整个 Agent 工程里最难、最见功力的部分。
下一篇进入第五阶段:上下文工程。系好安全带。
本篇对应代码:配套仓库加入 src/hooks.ts(PreToolUse / Stop 钩子),接入工具执行前与循环出口。git checkout article-09。