ARTICLE DETAIL

资讯详情

深耕网站视觉设计与运营推广的一线实战洞察。

自己动手写Agent Harness【hooks】:生命周期钩子实现

自己动手写Agent Harness【hooks】:生命周期钩子实现 写在前面系列是为了帮助大家更好的去理解Agent Harness基础设施并不是想重复造轮子真实开发建议选择一个成熟的SDK或Harness框架才是最合适的选择~1. 引言skill 和 rule 都不是强制层底线靠谁上一篇《自己动手实现一个 agentskills 与 rules 机制》把话说到一半skill 管「能干什么」rule 管「不能碰什么」但它俩都是触发时才加载的建议层——模型可以不听。那不能妥协的底线靠谁靠这篇拆的 hooks。钩子把「纪律」从「让 AI 自觉」变成「由工具强制执行」。skill 和 rule 是建议模型「记得的时候」遵守hook 是脚本挂上就一定执行不由模型自觉。一句话点题AI 不记得你说过什么但 hook 记得。举个具体的。系列先导篇第 3 篇《动手开发你的第一个 agent 03给它划安全边界》里我把审批写死进流水线——Approver 按档位 decidedanger 档一律 deny。当时这么干没问题但我越用越别扭拦什么、放什么本质是「策略」策略要能随时改、随时换不该固化在 harness 主体里更不该靠模型当次「想起来」。这篇就把写死的审批拆出来改造成可编程的钩子。2. 先看两个成熟 harnessClaude 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工具调用要过一条显式的六阶段流水线——prepareExecutionpre-execute waterfall 审批询问 单调守卫→ tools/executetimeout/retry→ 工具 body → createSuccessResult → postExecutepost-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 主体不认识它们。exportclassHookRegistry{constructor(){this.handlersnewMap()// 事件名 - Setfn}// hook(eventName, fn)注册钩子返回「卸载函数」。// 卸载函数是这次设计的关键钩子不是永久生效的策略可以装上也可以拆下。hook(eventName,fn){if(!this.handlers.has(eventName))this.handlers.set(eventName,newSet())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 短路返回」。asyncemit(eventName,context){constfnsthis.handlers.get(eventName)if(!fns||fns.size0)return{allow:true}letrewrittenArgsfor(constfnoffns){constres(awaitfn(context))??{allow:true}if(res.allowfalse){return{allow:false,reason:res.reason??blocked by${eventName}}}if(res.args!undefined)rewrittenArgsres.args// 最后一个改写参数的钩子生效}return{allow:true,...(rewrittenArgs!undefined?{args:rewrittenArgs}:{})}}}注册表有了把广播点埋进执行流水线。对比系列先导篇第 2 篇《动手开发你的第一个 agent 02给它装手和眼睛》那条 pre/execute/post 流水线改动就一处pre 和 post 不再写死逻辑改成 emit 两个事件把控制权交给钩子。新增一个 ToolBlocked 事件专门给「被拦」记账。exportclassToolPipeline{constructor(registry,hooks){this.registryregistrythis.hookshooks// 事件发布依赖的钩子注册表}asyncrun(toolCall){consttoolthis.registry.get(toolCall.name)if(!tool)return{ok:false,error:unknown tool:${toolCall.name}}// 1) PreToolUse工具执行前广播。钩子可以拦也可以改写参数。constpreawaitthis.hooks.emit(PreToolUse,{toolCall,tool})if(!pre.allow){// 被拦工具 execute 根本没被调用对照 step3 的 deny但策略在外面。constblocked{ok:false,blocked:true,reason:pre.reason,error:pre-hook blocked:${pre.reason},}// ToolBlocked专门给「记账」的时机——PostToolUse 语义是「用过了」// 这次没用到所以单独广播让拦截这件事也有现场。awaitthis.hooks.emit(ToolBlocked,{toolCall,reason:pre.reason,result:blocked})returnblocked}// 真实工程allow/deny/args 的返回形状是本 demo 自定义的约定——真实 harness 会定义标准// 事件协议如 PreToolUse 事件对象含 input 可改、决定 allow/deny让第三方钩子按协议接入。// pre 钩子还能改写参数返回 { allow: true, args }如归一化路径、补默认值。constargspre.args??toolCall.arguments// 2) execute真做。失败不崩转成结果对象。letvaluetry{valueawaittool.execute(args)}catch(err){constfailed{ok:false,error:${tool.name}execute failed:${err.message}}awaitthis.hooks.emit(PostToolUse,{toolCall:{...toolCall,arguments:args},result:failed})returnfailed}constresult{ok:true,content:typeofvaluestring?value:JSON.stringify(value,null,2)}// 3) PostToolUse工具执行后自动广播——记账 / 校验都挂这儿。awaitthis.hooks.emit(PostToolUse,{toolCall:{...toolCall,arguments:args},result})returnresult}}这段代码值得读三遍。第一遍看结构run()只在正确的位置广播事件拦不拦、记不记全由外部钩子决定。第二遍看那个 ToolBlockedPostToolUse 的语义是「用过了」被拦的调用根本没执行不该硬塞给 PostToolUse所以单独广播让「拦截」这件事也有现场。第三遍看 loop 的透明性——ReactLoop 完全不感知钩子被拦的工具对 loop 来说就是一次失败的工具调用照常回写。然后挂上演示钩子。pre 这边两个安全策略拦危险命令、参数改写做路径归一化。// 演示钩子 1安全策略PreToolUse// 这就是「把纪律写进工具」模型提议什么钩子审一遍再放行。// 危险模式清单这类命令大多不可逆rm -rf 全家桶模型一旦提议就拦。constDANGEROUS_PATTERNS[rm -rf,rm -r,mkfs,dd if,shutdown,reboot,format,rd /s,del /s]functionsafetyPreHook({toolCall}){if(toolCall.name!run_command)return{allow:true}constcmdString(toolCall.arguments?.command??)// 第一层命中危险模式直接拦constmatchedDANGEROUS_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。functionpathNormalizePreHook({toolCall}){constpathtoolCall.arguments?.pathif(toolCall.nameread_filetypeofpathstringpath.startsWith(./)){constfixedpath.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 记出了事才有现场可以追溯——这就是两道口缺一不可的体现。constaudit[]constblockedLog[]functionauditPostHook({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}次)}functionblockedHook({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 一行不用改。functionturnEndHook({userText,turns}){console.log([turn-hook] 回合结束${userText}→ 用了${turns}个 step)}在main()里组装——钩子是「插」进来的拦什么、记什么全在外部策略里流水线一行没改// 真实工程钩子注册表是内存态这里 hook() 一注册、重启即失——真实 harness 用配置驱动// 从配置文件如 .claude/settings.json 的 hooks 段加载钩子定义运行时无需改代码。consthooksnewHookRegistry()hooks.hook(PreToolUse,safetyPreHook)// 安全策略拦命令执行类 危险模式hooks.hook(PreToolUse,pathNormalizePreHook)// 参数改写归一化路径hooks.hook(ToolBlocked,blockedHook)// 记账被拦下来的调用hooks.hook(PostToolUse,auditPostHook)// 记账每次真的执行了的调用hooks.hook(TurnEnd,turnEndHook)// 可观测回合生命周期跑起来。本机真实输出逐字取自step7-hooks/PRACTICE.mdWindows 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 pathpackage.json → ok - read_file pathpackage.json → ok · blocked —— 被拦下来的调用ToolBlocked 钩子记账 - run_command commandwhoami → 命令执行类工具默认禁止命令应由人工审批不交给模型直接执行 - run_command commandrm -rf / → 危险命令模式「rm -rf」被策略拦截四个场景逐行拆。场景 1 是放行read_file 正常执行post 钩子每次记一笔。场景 2 最关键mock 提议run_command whoamipre 钩子拦下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.jsmock/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/denypre 钩子想改参数时 args 被丢掉流水线读到的永远是原始参数。修正emit 收集最后一个 args折进返回值流水线再pre.args ?? toolCall.arguments。这是个很容易漏的设计——「拦截」是二元「改写」需要额外通道。卸载是必需。hook()返回卸载函数策略可以装上也可以拆下。test ③ 固化了这个行为。最后说「复用审批三档」。safetyPreHook里有两层判断都是系列先导篇第 3 篇审批三档的翻版危险模式清单是 denylist 一票否决「命令执行类工具默认禁止」是把 run_command 挂到 danger 档——命令该由人工审批不交给模型直接执行。区别只在第 3 篇的 approve 是硬编码进流水线这里策略是外挂的要改拦什么改函数就行流水线一行没动。还有一条设计纪律必须讲被拦的结果必须回写 loop 历史。若被拦只返回、不回写 toolResultmock 这种确定性模型下一步会再提同一个run_command一路撞满 maxTurns10 停机。把被拦作为{ 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.okfalse。想学 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 已经长到第七格子代理、技能与规则、钩子都装上了。
返回列表