老周的工具笔记

老周的工具笔记

已发布 24 篇文章

钩子(Hooks):在任务的每个节点插进自己的逻辑

钩子(Hooks):在任务的每个节点插进自己的逻辑

技能是「agent 主动调起来的一段能力」,钩子(Hooks)不一样——它是挂在任务生命周期节点上的自动触发器,你不用在对话里提它,它到点自己跑。简单说:技能是「你叫它干活」,钩子是「到这个节点就必须干」。这个区别决定了它们的用法:技能负责「主流程里的某一步」,钩子负责「跨流程、无差别地发生在每个节点上的横切逻辑」。

把钩子用好了,很多脏活(格式化、记日志、门控拦截、通知)能 silently 完成;用不好,它会在你最不想被打断的时候,把整个任务掐断。下面把钩子讲透。

一、钩子有哪些触发点

钩子按「任务进行到哪一步」挂在不同的事件上。常见节点包括(具体事件名与可用性以官方文档当前版本为准):

  • 任务开始前 / 用户输入提交时:用户刚发一句话、还没处理,就能先拦截。适合做输入审计、敏感词门控、自动补充上下文。
  • 工具调用前(PreToolUse):某个工具即将执行前。适合做二次确认、参数校验、限流、危险操作拦截。
  • 工具调用后(PostToolUse):工具跑完、结果回来。适合自动格式化输出、落盘、发通知、脱敏。
  • 任务结束 / 停止时(Stop):一轮回答要收尾。适合做总结、清理临时文件、写复盘日志。
  • 子代理收尾时:子代理干完活。适合回收产物、合并结果。

一句话记忆:凡是「在某个动作发生的前/后」你想插一脚的地方,基本都能挂钩子。钩子的威力恰恰来自它的「无差别」——不管主流程怎么变,这个节点上的逻辑都会执行。

二、钩子能干什么(实操清单)

举几个我常用的硬核场景,按「顺手类」和「门控类」分开:

2.1 自动格式化产物(顺手类)

技能生成了代码或报告,不想每次手动整理。在 PostToolUse 挂个钩子,跑 prettier / gofmt / markdownlint,把产物就地收拾干净。这类钩子永远返回 0,失败了也不该阻断主任务。

#!/usr/bin/env bash
TARGET="$1"
[ -f "$TARGET" ] && npx prettier --write "$TARGET" 2>/dev/null
exit 0   # 关键:永远返回 0,别让格式化失败阻断主任务

2.2 全量操作留痕(顺手类)

每次工具调用都把入参、结果摘要写进本地日志。排错时这就是第一手证据(呼应第 01 篇的「日志给你眼睛」)。尤其当你怀疑「某个工具被调用了异常多次」时,翻 trace 日志一目了然。

echo "$(date -Iseconds) $TOOL_NAME $ARGS" >> ~/.openclaw/hooks/trace.log

2.3 危险操作门控(拦截类)

PreToolUse 里检查参数,发现要删库、要强推、要覆盖重要文件,直接拦下并提示。这是把「人容易手滑」用机器挡一层。拦截类钩子才在非预期时返回非零。

2.4 敏感信息脱敏(拦截类)

PostToolUse 里扫描工具返回,发现疑似密钥、token、身份证号,自动打码再回传给模型上下文。这能大幅降低隐私泄漏风险,又不影响主流程拿到的可用信息。

2.5 成本与调用统计(顺手类)

每次工具调用记一笔 token 消耗或费用,定期汇总。你不用盯盘就知道哪个技能最费钱,该优化的优化。

2.6 收尾通知(顺手类)

Stop 时如果本轮产出了关键文件,自动推一条消息到你的渠道(IM/邮件),你不用一直盯着。

三、写钩子的注意事项

钩子跑在 agent 的执行链路里,位置和权重都很高,写错代价大。几条铁律:

3.1 别阻塞太久

钩子是同步卡在节点上的。里面跑个 sleep 30 或者去等一个慢接口,整个任务就被你拖死。耗时操作要么异步甩出去(nohup ... &),要么干脆别放钩子里,改放技能脚本里。

3.2 退出码决定命运

钩子返回非零,很多实现会把它当成「拦截/失败」,直接阻断该节点后续。所以:顺手类钩子始终 exit 0;门控类才在非预期时返回非零。这是新手最容易踩的坑——一个只想记个日志的钩子,因为脚本里某行出错返回了 1,结果整个任务被掐。

# 门控钩子:只有明确危险才拦
if echo "$ARGS" | grep -q "rm -rf /"; then
  echo "BLOCKED: 危险操作被钩子拦截" >&2
  exit 2   # 非零 = 拦截
fi
exit 0

3.3 输入输出走约定通道

钩子通常通过标准输入拿到上下文(比如触发的事件体、工具入参),通过标准输出回传结果或反馈。不要在钩子里随便读全局状态、改共享文件——多任务并发时你会踩到竞态。所有需要的数据,都从 stdin 拿,结果都从 stdout 回。

3.4 别在钩子里改「不该改的东西」

钩子的设计意图是「在节点插逻辑」,不是「重写主任务」。在 PreToolUse 里偷偷改用户的工具入参、在 Stop 里静默删文件,都属于埋雷行为,后期极难排查。钩子只做可逆的、低副作用的事:记一笔、格式化一下、拦一下明显的危险。凡是「改了就回不去」的动作,三思。

3.5 路径要用变量,别写死

和第 01 篇说的一样,钩子脚本里优先用 {baseDir} 这类变量或基于脚本自身位置算路径,写死绝对路径换个机器就废。钩子往往和技能一起迁移,写死路径是你给自己埋的跨环境地雷。

3.6 注意运行环境差异

钩子执行环境的 PATH、工作目录可能和你的交互式 shell 不同。脚本里用到的命令,最好写绝对路径或在脚本开头 export PATH,否则「我手动跑好好的,钩子里就找不到命令」会反复出现。

四、钩子配置长什么样(思路,非官方字段)

为避免编造具体字段名,这里只给结构思路:通常是一份配置(JSON 或 YAML),描述「在哪个事件上、跑哪个脚本、传什么参数」。大致逻辑是:

事件节点: PreToolUse
执行脚本: hooks/guard.sh
作用:     拦截危险命令

具体键名、是否支持多个钩子叠加、失败时是否可配置为「仅警告不阻断」,都以官方文档当前版本为准。但无论字段怎么变,「事件 + 脚本 + 退出码语义」这三件套是不变的。

五、钩子失效排查

钩子「看起来没生效」是最常见的抱怨。按顺序查:

  1. 钩子到底注册上没有:确认钩子配置挂在了正确的事件节点上,事件名拼错是最常犯的错。
  2. 钩子脚本本身能不能跑:先单独执行这个脚本,传一份样例输入进去,看它自己报不报错。脚本本身炸了,agent 那端只会看到「钩子没反应」。
  3. 退出码是不是误杀:如果你本意是「顺手做事」却返回了非零,节点会被拦截,表现为「任务卡住/中断」。临时改成 exit 0 验证。
  4. 环境变量对不对:钩子运行环境的 PATH、工作目录可能和你的交互式 shell 不同。
  5. 是否被沙箱限制:某些环境下钩子执行受沙箱约束,禁止联网或禁执行。具体行为以官方文档当前版本为准。
现象优先怀疑验证动作
钩子从不触发事件名/注册错单独跑脚本 + 查配置
触发但任务被卡退出码非零误杀临时 exit 0 对比
触发但啥也没干脚本内部静默失败看 stderr / 加日志
有时生效有时不环境/竞态固定输入复现

六、一个钩子实战案例

场景:团队里有人多次误执行 git push --force,搞丢过代码。我挂一个 PreToolUse 钩子专门拦强推:

#!/usr/bin/env bash
read -r EVENT   # 从 stdin 读事件体(结构以官方为准)
if echo "$EVENT" | grep -q "push --force|push -f"; then
  echo "已拦截 git push --force,如确需强推请走评审流程" >&2
  exit 1        # 拦截
fi
exit 0

上线后第一周就拦下三次手滑。关键点:它是「拦截类」,所以该返回非零就返回非零;它只拦这一个明确危险动作,不碰其他,所以不会误伤正常流程。

七、钩子与技能的本质区别(再强调一次)

很多人分不清「这段逻辑该写进技能还是写成钩子」。一句判断标准:它跟某一步主流程强相关、只在那一步有用 → 写进技能;它要横切多个流程、在每个节点无差别发生 → 写成钩子。比如「生成报告后顺手格式化」是横切(任何产出都可能要格式化),适合钩子;「调用搜索工具前先构造查询语句」是主流程的一环,适合写在技能指令里。

如果把本该在技能里的逻辑塞进钩子,钩子会越变越胖,且和主流程耦合得说不清;反过来,把横切逻辑写进每个技能,你会重复写五遍同样的格式化代码。分清楚边界,系统才清爽。

实际项目里,我习惯把钩子脚本统一放在技能目录的 hooks/ 子目录,并在 SKILL.md 里用一句话说明每个钩子挂在哪个事件、干什么。这样半年后回来,不用翻配置就能回忆起全貌。文档即记忆,写钩子的人最该明白这一点——你今天省下的那行注释,会在三个月后的某次故障里变成半小时的迷路。

八、多钩子叠加与执行顺序

同一个事件上往往可以挂多个钩子(具体是否支持叠加、按顺序还是并行,以官方文档当前版本为准)。这里有个隐性坑:钩子之间可能互相影响。比如钩子 A 改了文件,钩子 B 基于「文件未被改」做判断,结果 B 误判。所以多个钩子挂在同一节点时,脑子里要有一张「执行先后图」,确认前一个钩子的副作用不会让后一个钩子前提失效。不确定的时候,把有依赖的两个逻辑合并进同一个钩子脚本,反而更可控。

九、钩子的性能与开销

钩子挂在热路径上(比如每次工具调用都触发),它的耗时会被放大成「调用次数 × 钩子耗时」。一个看起来只要 200ms 的钩子,碰到一轮几十次工具调用,就吃掉好几秒,还顺带增加 token 与上下文负担。对策:热路径上的钩子能轻则轻,能异步就异步;真正重的逻辑挪到 Stop 或定时任务里做,别卡在每次调用上。

还有一个容易忽略的点:钩子产生的日志本身也会累积。如果每个工具调用都写一行 trace,一天下来可能是几十万行,既不方便查,也占磁盘。建议给钩子日志加轮转(按大小或时间切分),并只记录「值得记」的信息——比如失败的工具调用、被拦截的危险操作,而不是每一次平凡的 success。日志是排错用的,不是用来刷存在感的,写得越多越没人看。

钩子是「隐形的同事」:你雇它在每个节点默默干活,但它没有判断力,严格按你写的脚本行事。把脚本写短、写纯、写稳,它就是你最省心的帮手;把逻辑塞复杂、依赖外部状态,它就变成最隐蔽的故障源。先想清楚「这是顺手还是门控」,再下笔。