写在前面:系列是为了帮助大家更好的去理解Agent Harness基础设施,并不是想重复造轮子,真实开发建议选择一个成熟的SDK或Harness框架,才是最合适的选择~
1. 引言:skill 和 rule 都不是强制层,底线靠谁
上一篇《自己动手实现一个 agent:skills 与 rules 机制》把话说到一半:skill 管「能干什么」,rule 管「不能碰什么」,但它俩都是触发时才加载的建议层——模型可以不听。那不能妥协的底线靠谁?靠这篇拆的 hooks。钩子把「纪律」从「让 AI 自觉」变成「由工具强制执行」。 skill 和 rule 是建议,模型「记得的时候」遵守;hook 是脚本,挂上就一定执行,不由模型自觉。一句话点题:AI 不记得你说过什么,但 hook 记得。
举个具体的。系列先导篇第 3 篇《动手开发你的第一个 agent 03:给它划安全边界》里,我把审批写死进流水线——Approver 按档位 decide,danger 档一律 deny。当时这么干没问题,但我越用越别扭:拦什么、放什么,本质是「策略」;策略要能随时改、随时换,不该固化在 harness 主体里,更不该靠模型当次「想起来」。这篇就把写死的审批拆出来,改造成可编程的钩子。
2. 先看两个成熟 harness:Claude Code 的钩子、dsh 的流水线
动手前先看看两个成熟 harness 把「纪律」放在哪。
Claude Code 把纪律做成一堆生命周期钩子。 官方文档(写作日 WebSearch 核实,code.claude.com/docs/en/hooks)列出的钩子事件有二十多个,不同版本数目会浮动。按触发频率分三组:每会话一次的 SessionStart / SessionEnd、每回合一次的 UserPromptSubmit / Stop、每次工具调用一次的 PreToolUse / PostToolUse。钩子本身是脚本(也可以是一个 HTTP 端点、一个 MCP 工具、一段 LLM 提示词),挂上就由 Claude Code 在对应节点自动执行。
最常用的两个,正好一前一后:
- PreToolUse:工具执行前跑。能拦(返回 deny 就阻止调用)、能改(
updatedInput改写工具参数)。它管的是「出去的调用」——模型要调 Bash 之前,钩子可以先看一眼命令是不是rm -rf。 - PostToolUse:工具成功后跑。能记(记录结果)、能改(
updatedToolOutput替换模型看到的输出)、能 block。它管的是「回来的结果」——工具跑完了,钩子校验一下输出对不对、记一笔账。
matcher 按工具名过滤(比如只对 Bash 拦),还能用 if 条件按参数过滤(比如只对 rm * 拦)。
dsh 把纪律放在六阶段工具流水线里。 我在《Agent Harness 架构到底需要些什么》拆过 dsh:工具调用要过一条显式的六阶段流水线——prepareExecution(pre-execute waterfall + 审批询问 + 单调守卫)→ tools/execute(timeout/retry)→ 工具 body → createSuccessResult → postExecute(post-execute waterfall)→ finalizeContent + tools/result。权限门禁挂 pre-execute,结果转换挂 post-execute——两道口,一前一后。
两家一个靠钩子、一个靠流水线,做法不同,落点一样:工具调用前有一道闸、调用后有记录。 我把它压成一句:最少要有 pre 和 post 两道口,流水线可以短,不能没有。
3. Demo 先行:事件发布 + 钩子注册表
理论收住,动手。给配套工程加一层钩子,代码在 examples/first-agent/step7-hooks/index.js,零依赖,node 直接跑。
先贴核心:钩子注册表。它干两件事——hook(eventName, fn) 注册处理函数,emit(eventName, context) 在流水线关键节点广播。外部往注册表里「插」策略,harness 主体不认识它们。
// ============ 钩子注册表(HookRegistry)============
// 事件发布 + 注册表:外部通过 hook(eventName, fn) 注入拦截 / 记录函数。
// emit(eventName, context) 在流水线关键节点广播,把控制权交给注册的钩子。
// 这就是「把纪律写进工具」的落点:策略是一组外部函数,harness 主体不认识它们。
export class HookRegistry {
constructor() {
this.handlers = new Map() // 事件名 -> Set<fn>
}
// hook(eventName, fn):注册钩子,返回「卸载函数」。
// 卸载函数是这次设计的关键:钩子不是永久生效的,策略可以装上也可以拆下。
hook(eventName, fn) {
if (!this.handlers.has(eventName)) this.handlers.set(eventName, new Set())
this.handlers.get(eventName).add(fn)
return () => this.handlers.get(eventName)?.delete(fn)
}
// emit(eventName, context):广播事件。
// 返回 { allow: true },或 { allow: false, reason }(有人拦截)。
// 对可拦截事件,任何一个钩子返回 { allow: false, reason } 就短路返回
// (fail-fast),后续钩子不再执行;若钩子返回 { allow: true, args },
// 则把 args 透传给流水线,实现「改写参数」。
// 真实工程:emit 不应在遇 deny 时立即短路整个广播——真实 harness 会让钩子链按注册顺序
// await 逐个执行,每个钩子 try/catch 隔离:一个钩子抛错不影响后续钩子与流水线主体;
// 还会给钩子加超时,防止某个钩子卡死整个 loop。本 demo 简化为「遇 deny 短路返回」。
async emit(eventName, context) {
const fns = this.handlers.get(eventName)
if (!fns || fns.size === 0) return { allow: true }
let rewrittenArgs
for (const fn of fns) {
const res = (await fn(context)) ?? { allow: true }
if (res.allow === false) {
return { allow: false, reason: res.reason ?? `blocked by ${eventName}` }
}
if (res.args !== undefined) rewrittenArgs = res.args // 最后一个改写参数的钩子生效
}
return { allow: true, ...(rewrittenArgs !== undefined ? { args: rewrittenArgs } : {}) }
}
}
注册表有了,把广播点埋进执行流水线。对比系列先导篇第 2 篇《动手开发你的第一个 agent 02:给它装手和眼睛》那条 pre/execute/post 流水线,改动就一处:pre 和 post 不再写死逻辑,改成 emit 两个事件,把控制权交给钩子。新增一个 ToolBlocked 事件,专门给「被拦」记账。
export class ToolPipeline {
constructor(registry, hooks) {
this.registry = registry
this.hooks = hooks // 事件发布依赖的钩子注册表
}
async run(toolCall) {
const tool = this.registry.get(toolCall.name)
if (!tool) return { ok: false, error: `unknown tool: ${toolCall.name}` }
// 1) PreToolUse:工具执行前广播。钩子可以拦,也可以改写参数。
const pre = await this.hooks.emit('PreToolUse', { toolCall, tool })
if (!pre.allow) {
// 被拦:工具 execute 根本没被调用(对照 step3 的 deny,但策略在外面)。
const blocked = {
ok: false,
blocked: true,
reason: pre.reason,
error: `pre-hook blocked: ${pre.reason}`,
}
// ToolBlocked:专门给「记账」的时机——PostToolUse 语义是「用过了」,
// 这次没用到,所以单独广播,让拦截这件事也有现场。
await this.hooks.emit('ToolBlocked', { toolCall, reason: pre.reason, result: blocked })
return blocked
}
// 真实工程:allow/deny/args 的返回形状是本 demo 自定义的约定——真实 harness 会定义标准
// 事件协议(如 PreToolUse 事件对象含 input 可改、决定 allow/deny),让第三方钩子按协议接入。
// pre 钩子还能改写参数:返回 { allow: true, args }(如归一化路径、补默认值)。
const args = pre.args ?? toolCall.arguments
// 2) execute:真做。失败不崩,转成结果对象。
let value
try {
value = await tool.execute(args)
} catch (err) {
const failed = { ok: false, error: `${tool.name} execute failed: ${err.message}` }
await this.hooks.emit('PostToolUse', { toolCall: { ...toolCall, arguments: args }, result: failed })
return failed
}
const result = { ok: true, content: typeof value === 'string' ? value : JSON.stringify(value, null, 2) }
// 3) PostToolUse:工具执行后自动广播——记账 / 校验都挂这儿。
await this.hooks.emit('PostToolUse', { toolCall: { ...toolCall, arguments: args }, result })
return result
}
}
这段代码值得读三遍。第一遍看结构:run() 只在正确的位置广播事件,拦不拦、记不记,全由外部钩子决定。第二遍看那个 ToolBlocked:PostToolUse 的语义是「用过了」,被拦的调用根本没执行,不该硬塞给 PostToolUse,所以单独广播,让「拦截」这件事也有现场。第三遍看 loop 的透明性——ReactLoop 完全不感知钩子,被拦的工具对 loop 来说就是一次失败的工具调用,照常回写。
然后挂上演示钩子。pre 这边两个:安全策略拦危险命令、参数改写做路径归一化。
// ============ 演示钩子 1:安全策略(PreToolUse)============
// 这就是「把纪律写进工具」:模型提议什么,钩子审一遍再放行。
// 危险模式清单:这类命令大多不可逆(rm -rf 全家桶),模型一旦提议就拦。
const DANGEROUS_PATTERNS = ['rm -rf', 'rm -r', 'mkfs', 'dd if=', 'shutdown', 'reboot', 'format', 'rd /s', 'del /s']
function safetyPreHook({ toolCall }) {
if (toolCall.name !== 'run_command') return { allow: true }
const cmd = String(toolCall.arguments?.command ?? '')
// 第一层:命中危险模式,直接拦
const matched = DANGEROUS_PATTERNS.find((p) => cmd.includes(p))
if (matched) {
return { allow: false, reason: `危险命令模式「${matched}」被策略拦截` }
}
// 第二层:即便命令看起来无害(如 whoami),命令执行类工具也默认禁止——
// 命令该由人工审批,而不是让模型直接执行。这就是「拦截危险命令类」。
return { allow: false, reason: '命令执行类工具默认禁止:命令应由人工审批,不交给模型直接执行' }
}
// 真实工程:审批与钩子并存、职责分开——审批走审批服务(一次 fail-closed,定「该不该做」),
// 钩子走扩展点(定「接进来干什么」);step3 的三档审批不会被钩子取代,二者叠加使用。
// ============ 演示钩子 2:参数改写(PreToolUse)============
// pre 钩子不只拦截,还能改写参数。这里演示归一化:把 './x' 写成 'x'。
// 真实系统里常拿它做:路径归一化、密钥脱敏、补默认值、注入租户 ID。
function pathNormalizePreHook({ toolCall }) {
const path = toolCall.arguments?.path
if (toolCall.name === 'read_file' && typeof path === 'string' && path.startsWith('./')) {
const fixed = path.slice(2)
console.log(` [pre-hook] 参数改写:${path} → ${fixed}`)
return { allow: true, args: { ...toolCall.arguments, path: fixed } }
}
return { allow: true }
}
post 这边一个记账,外加一个回合观测。两个账本:audit 记「真的执行了的」,blockedLog 记「被拦下来的」。
// ============ 演示钩子 3:审计记录(PostToolUse + ToolBlocked)============
// 两个账本:audit 记「真的执行了的」,blockedLog 记「被拦下来的」。
// 有 pre 拦、有 post 记,出了事才有现场可以追溯——这就是两道口缺一不可的体现。
const audit = []
const blockedLog = []
function auditPostHook({ toolCall, result }) {
audit.push({
name: toolCall.name,
args: toolCall.arguments,
ok: result.ok,
brief: result.ok ? String(result.content).slice(0, 30) : result.error,
})
console.log(` [post-hook] 记录:${toolCall.name} → ${result.ok ? 'ok' : 'ERR'}(累计 ${audit.length} 次)`)
}
function blockedHook({ toolCall, reason }) {
blockedLog.push({ name: toolCall.name, args: toolCall.arguments, reason })
console.log(` [blocked-hook] 拦截:${toolCall.name} ${JSON.stringify(toolCall.arguments)} —— ${reason}`)
}
// ============ 演示钩子 4:回合观测(TurnEnd)============
// 回合级钩子不拦任何东西,只做「可观测」——追踪、计数、打点。
// 这就是广播点不只工具前后、还要有回合前后的原因:挂了钩子,
// 整个生命周期都看得见,而 loop 一行不用改。
function turnEndHook({ userText, turns }) {
console.log(` [turn-hook] 回合结束:${userText} → 用了 ${turns} 个 step`)
}
在 main() 里组装——钩子是「插」进来的,拦什么、记什么全在外部策略里,流水线一行没改:
// 真实工程:钩子注册表是内存态,这里 hook() 一注册、重启即失——真实 harness 用配置驱动,
// 从配置文件(如 .claude/settings.json 的 hooks 段)加载钩子定义,运行时无需改代码。
const hooks = new HookRegistry()
hooks.hook('PreToolUse', safetyPreHook) // 安全策略:拦命令执行类 + 危险模式
hooks.hook('PreToolUse', pathNormalizePreHook) // 参数改写:归一化路径
hooks.hook('ToolBlocked', blockedHook) // 记账:被拦下来的调用
hooks.hook('PostToolUse', auditPostHook) // 记账:每次真的执行了的调用
hooks.hook('TurnEnd', turnEndHook) // 可观测:回合生命周期
跑起来。本机真实输出(逐字取自 step7-hooks/PRACTICE.md,Windows 11 / Node v22.12.0 / mock 模型):
$ node step7-hooks/index.js
=== 场景 1:放行 —— read_file 正常执行,post 钩子每次记账 ===
[user] 读文件 package.json
[post-hook] 记录:read_file → ok(累计 1 次)
[tool:read_file] -> ok
[turn-hook] 回合结束:读文件 package.json → 用了 2 个 step
[assistant] 工具 read_file 返回了:{
"name": "first-agent",
"version": "0.1.0",
"private"…
=== 场景 2:被拦 —— 命令执行类工具,pre 钩子拦截(不进执行)===
[user] 执行命令 whoami
[blocked-hook] 拦截:run_command {"command":"whoami"} —— 命令执行类工具默认禁止:命令应由人工审批,不交给模型直接执行
[tool:run_command] -> BLOCKED(命令执行类工具默认禁止:命令应由人工审批,不交给模型直接执行)
[turn-hook] 回合结束:执行命令 whoami → 用了 2 个 step
[assistant] 工具 run_command 返回了:pre-hook blocked: 命令执行类工具默认禁止:命令应由人工审批,不交给模型直接执行
=== 场景 3:被拦 —— 直接调用 pipeline,危险模式匹配 ===
[blocked-hook] 拦截:run_command {"command":"rm -rf /"} —— 危险命令模式「rm -rf」被策略拦截
直接 run_command(rm -rf /) → 被拦:危险命令模式「rm -rf」被策略拦截
=== 场景 4:改写 —— pre 钩子归一化路径后再执行 ===
[pre-hook] 参数改写:./package.json → package.json
[post-hook] 记录:read_file → ok(累计 2 次)
直接 read_file(./package.json) → ok(路径已被改写为 package.json)
=== 纪律的证据:两个账本 ===
· audit —— 真的执行了的工具调用(post 钩子记账):
- read_file path=package.json → ok
- read_file path=package.json → ok
· blocked —— 被拦下来的调用(ToolBlocked 钩子记账):
- run_command command=whoami → 命令执行类工具默认禁止:命令应由人工审批,不交给模型直接执行
- run_command command=rm -rf / → 危险命令模式「rm -rf」被策略拦截
四个场景,逐行拆。场景 1 是放行:read_file 正常执行,post 钩子每次记一笔。场景 2 最关键:mock 提议 run_command whoami,pre 钩子拦下(BLOCKED),工具 execute 没跑,拦截被 ToolBlocked 记账,loop 照常继续、模型读到「被拒」后总结收尾——这行就是「纪律由工具强制执行」的现场,不是模型自觉。场景 3 是直接调 pipeline 测危险模式。场景 4 证明 pre 不只拦还能改:./package.json 被改写成 package.json,改写后的参数进了 audit 账本。
自测也跑一遍:
$ node step7-hooks/test.js
✅ ① pre 钩子拦截时:工具不执行,且调用被记录为拦截
✅ ② post 钩子在每次工具执行后触发
✅ ③ 钩子可卸载;未匹配的工具调用不受影响
✅ ④ 被拦调用以 toolResult 回写 loop 历史(模型能读到「被拒」)
全部通过:pre 拦得住、post 记得到、钩子可装卸——纪律由工具强制执行。
test.js 里的哨兵手法值得抄:fragileExecuted 是外部状态哨兵,fragile 一旦 execute 就置 true。断言它还是 false,证明「被拦截时工具 execute 绝不能被调用」——不是返回失败,是根本没进执行。
想换真实模型?照工程 README 设 OPENAI_BASE_URL / OPENAI_API_KEY / OPENAI_MODEL 三个环境变量就自动切到 llm/real.js,mock/real 同一个 complete(messages, tools) 签名,换 provider 一行改。真实 API 我本机没跑(无 key),标「待核实」。
4. 两个核心设计:事件发布与钩子注册表
拆开看,这套东西就两个设计,各管一摊。
设计一:事件发布——loop 在关键节点广播。 广播点分两级:回合级(TurnStart / TurnEnd)和工具级(PreToolUse / PostToolUse / ToolBlocked)。先给一张定位图,把广播点标出来。
图里一个细节值得单独说:为什么广播点放在流水线和 turn 的边界,而不是 execute 里?因为 execute 是工具 body,是「干活」的地方;纪律要加在「干活之前」和「干活之后」。loop 完全不感知钩子,是这套设计最顺的地方——纪律加在流水线上,loop 一行不用改。
设计二:钩子注册表——策略是外挂的。 hook(eventName, fn) 注册、返回卸载函数;emit 遍历处理函数,任何一个返回 { allow: false } 就短路。两个要点:
- 拦截是短路,改写是透传。 拦截是 allow/deny 二元,任何一个钩子返回 deny 就 fail-fast;改写不一样,钩子返回
{ allow: true, args },emit 要把它透传出去。这个点我踩过坑:一开始 emit 只返回 allow/deny,pre 钩子想改参数时 args 被丢掉,流水线读到的永远是原始参数。修正:emit 收集最后一个 args,折进返回值,流水线再pre.args ?? toolCall.arguments。这是个很容易漏的设计——「拦截」是二元,「改写」需要额外通道。 - 卸载是必需。
hook()返回卸载函数,策略可以装上也可以拆下。test ③ 固化了这个行为。
最后说「复用审批三档」。safetyPreHook 里有两层判断,都是系列先导篇第 3 篇审批三档的翻版:危险模式清单是 denylist 一票否决;「命令执行类工具默认禁止」是把 run_command 挂到 danger 档——命令该由人工审批,不交给模型直接执行。区别只在:第 3 篇的 approve 是硬编码进流水线,这里策略是外挂的,要改拦什么,改函数就行,流水线一行没动。
还有一条设计纪律必须讲:被拦的结果必须回写 loop 历史。 若被拦只返回、不回写 toolResult,mock 这种确定性模型下一步会再提同一个 run_command,一路撞满 maxTurns=10 停机。把被拦作为 { role:'toolResult', content: error } 回写(与第 3 篇的 deny 回写同源),模型读到「被拒」就改口。test ④ 专门固化了这个行为——我猜你踩坑时最可能漏掉的就是这条。
demo 是教学最小版,有几处简化,真实工程得补,我逐条说。第一,emit 是同步短路:一个钩子 deny 就停,异常也不隔离;真实 harness 让钩子链按注册顺序逐个 await,每个钩子 try/catch 包住,一个抛错不影响后续钩子与流水线主体,还带超时防某个钩子卡死 loop。第二,我只挂了 5 个事件点,真实工程全生命周期都可挂——会话开始/结束、用户输入提交、每次模型回复、工具调用前后、配置加载后,Claude Code 就有 SessionStart/SessionEnd/UserPromptSubmit/Stop 等 20+ 个。第三,注册方式:我在代码里 hook() 注册、内存态重启即失,真实 harness 用配置驱动,从 .claude/settings.json 的 hooks 段加载钩子定义,运行时不用改代码。第四,allow/deny/args 返回形状是我自定义的约定,真实工程会定标准事件协议,像 PreToolUse 事件对象含 input 可改、决定 allow/deny,第三方钩子按协议接入。第五,我用钩子模拟了先导篇第 3 篇的审批语义,真实工程把两者拆开——审批走审批服务(一次 fail-closed,管「该不该做」),钩子走扩展点(管「接进来干什么」),叠加使用、互不取代。
5. 对照 dsh:六阶段流水线的 pre/post 最小投影
第四节那套东西,对照 dsh 的六阶段流水线,一眼看穿你写的是什么。
紫色两道口,就是你 emit('PreToolUse') 和 emit('PostToolUse') 的位置。dsh 六阶段里,你的 pre 口对应 prepareExecution 的 pre-execute waterfall(拦截、审批、守卫挂这),你的 post 口对应 postExecute 的 post-execute waterfall(接受、阻断、替换、附加上下文),你的 execute 对应中间那两段。
我压成一句:你的钩子注册表,是 dsh 六阶段流水线 pre/post 两道口的最小投影。 你只实现了「广播 + 注册」,dsh 在这两道口之间还塞了审批询问、单调守卫、三个瀑布、finalizeContent——但位置和意图一模一样。这就是「白给的架构能力」:你没抄 dsh 的实现,抄的是它的站位。
这也回应了我在《拆三家》下的落点:工具流水线最少要有 pre 和 post 两道口。只有 pre 没有 post:拦得住坏事,但「到底发生了什么」没人记账,出了事没有现场。只有 post 没有 pre:只能事后发现事故,拦不住。两道口都有:事前能拦、事后能记——纪律闭环。Claude Code 的 PreToolUse/PostToolUse、dsh 的 pre/post 两个 waterfall,都至少保留这两道口,就是这个原因。
一个小差异,诚实说。Claude Code 把「工具失败」单独拆成 PostToolUseFailure 事件;我的最小版偷懒了——execute 抛错时也走 PostToolUse,只是 result.ok=false。想学 Claude Code 拆开,加一个 PostToolUseFailure 事件就行,广播点的位置我已经留了。
呼应一句旧文。在《复杂软件系统的 Vibe Coding 实践三:工具配置与迭代收尾》里,我把 hook 用在 TDD 上:PreToolUse 拦「没先写失败测试就写生产代码」,PostToolUse 写完代码自动跑测试。那是在 Claude Code 的配置里用的现成机制——当时我是用户,现在你自己造出来了同一个机制。Claude Code 的钩子是产品功能,你的钩子是你 harness 里的接缝:前者给你配,后者你来定。
6. 结论:钩子=把纪律写进工具,AI 不记得你记得
回顾这一篇:给 harness 加了事件发布 + 钩子注册表,把写死的审批改造成可编程的钩子,跑通了四条真实行为——pre 拦得住、post 记得到、钩子可装卸、被拦能回写。
钩子把「纪律」从「让 AI 自觉」变成「由工具强制执行」。 skill 和 rule 是建议,模型可以不听;hook 是脚本,挂上就一定执行。策略写在 hook 里,只要 hook 在,它就永远生效——不依赖模型当次有没有「想起来」。
流水线可以短,不能没有。 最少要有 pre 和 post 两道口:pre 管拦截、审批、改写输入;post 管记录、校验、改写结果。你的 harness 已经长到第七格,子代理、技能与规则、钩子都装上了。

303

被折叠的 条评论
为什么被折叠?



