系列:ToyAgent 智能体知识库 24/26
Hooks 钩子:挂上去就生效的能力拦截点
一句话核心:工具执行前后不是裸奔的——宿主在 before / after 两个生命周期点暴露钩子位,审计、风控、脱敏这些横切逻辑从这里挂进去,挂钩即生效、摘钩即消失,工具代码一行不改。
为什么需要
审计、脱敏、风控三类需求有一个共同点:它们横切在所有工具上,而不是某一个工具的私有逻辑。反模式是把这些逻辑写进每个工具的实现里:
- 发短信工具里写一段"检查内容有没有敏感词",读数据库工具里再写一段"记录谁查了什么"——改一次风控规则要动 N 个工具类,漏一处就是事故;
- 工具作者的职责本来是"把这件事做好",安全合规却要每个作者都懂、都写对,职责彻底搅浑;
- 想临时关掉某个审计(比如压测时),只能改代码重新发布。
钩子把这横切逻辑抽出来,挂在宿主的生命周期点上,本质就是工具调用链上的 AOP:切面与目标完全解耦。配套三个组件各管一件事——HookService 管挂载与触发,HookMatcher 管"挂在哪类工具上",OutputMerger 管"多条 after 钩子按什么顺序合并输出"。
核心机制
两个挂载点,能力边界不同:
| 挂载点 | 时机 | 能做什么 | 典型钩子 |
|---|---|---|---|
| before | 工具执行前 | 拦截(DENY)/ 改写参数 / 只记录 | 审计(全工具记录)、风控(短信含"密码/验证码"即拒) |
| after | 工具返回后 | 改写输出 / 脱敏 / 追加告警 | 脱敏(手机号、邮箱打码) |
before 能改变"要不要执行、拿什么执行",after 能改变"把什么返回给模型"。两者的数据流向决定了它们天然分工:before 看到的是入参,after 看到的是出参。
钩子本体的最小结构是"名称 + 挂载点 + 匹配规则 + 处理器"四元组,处理器有一个约定好的返回协议:
// handler 返回协议:null=放行;"DENY:原因"=拦截;其他非 null 值=替换内容
record Hook(String name, String phase, List<String> match, Handler handler) {}
// before 风控:只挂在 send_sms 上,命中敏感词即拦截
hooks.add(new Hook("风控-敏感外发", "before", List.of("send_sms"), (tool, payload) ->
payload.contains("密码") ? "DENY:敏感信息禁止外发" : null));
// after 脱敏:挂在所有工具上,手机号打码
hooks.add(new Hook("脱敏", "after", List.of("*"), (tool, payload) ->
Pattern.compile("(1[3-9])\\d{9}").matcher(payload)
.replaceAll("$1*********")));
匹配规则(HookMatcher)是低耦合的关键:每条钩子自己声明挂在哪些工具上,"*" 表示全部;enabled=false 时该钩子整体短路,链路直通。管线的执行语义:
工具调用请求
→ before 链:按注册顺序执行,任何一条返回 DENY → 立即终止,工具不执行
→ 工具执行
→ after 链:按注册顺序依次加工,前一条的输出是后一条的输入(OutputMerger)
→ 最终结果返回给模型
before 链是"一票否决",after 链是"流水线加工"——语义不同,不可混用。
sequenceDiagram
participant MD as 模型
participant HS as HookService 管线
participant TL as 工具
MD->>HS: 工具调用请求
HS->>HS: before 链按注册顺序执行
Note over HS: 审计记录 / 风控判 DENY / 改写参数
alt 任一钩子返回 DENY
HS-->>MD: 返回拦截原因 工具未执行
else 全部放行
HS->>TL: 携带可能的改写参数执行
TL-->>HS: 原始输出
HS->>HS: after 链流水线加工
Note over HS: 脱敏打码 / 追加告警 / OutputMerger 合并
HS-->>MD: 返回加工后的最终结果
end
before 链 → 工具 → after 链 的完整时序:before 一票否决决定执不执行,after 流水线决定返回什么,工具本身对钩子零感知。
举例:对照实验轨迹
同一套工具,钩子开与关跑两遍,差异一目了然:
【钩子开启】
输入:查询:张三
before 链:审计钩子记录(tool=query_user)→ 放行
工具执行 → 返回 "张三,手机 13812345678,邮箱 [email protected]"
after 链:脱敏钩子匹配 "*" → 手机号/邮箱打码
模型收到:"张三,手机 138*********,邮箱 z***@x.com"
(模型只见到脱敏后的数据——敏感值从未进入模型上下文)
输入:发送:给 13812345678 发 密码是123456
before 链:审计记录 → 风控钩子命中 "密码" → "DENY:敏感信息禁止外发"
管线终止,send_sms 工具根本没执行
模型收到拦截原因,可以转向引导用户换安全方式
【钩子关闭(enabled=false)】
输入:查询:张三
链路直通 → 模型收到明文 "13812345678" —— 敏感数据裸奔进模型上下文
对比出钩子的价值:数据安全不是工具的自觉,是链路的制度
输入:审计日志
开启时:每次工具调用都有一条 before 记录(谁、何时、调了什么工具)
关闭时:无记录 —— 审计也是钩子,摘了就没了
这个实验揭示了一个生产级的认知:脱敏挂在 after 而不是让工具自己返回假数据,意味着脱敏策略可以集中管理、随时调整,且对"哪些数据算敏感"的判定升级时不用通知任何工具作者。同理,审计挂在 before 意味着连被拦截的调用也有记录——风控拦截本身就是一条安全事件。
从生产智能体开发视角举一反三,钩子位还能承载:限流熔断(before 统计调用频率)、成本核算(after 累计 token 消耗)、灰度实验(before 按租户改写参数)、PII 合规(after 按 GDPR 规则打码)。
教学版 vs 生产版
| 维度 | 教学版 | 生产版 |
|---|---|---|
| 钩子来源 | 代码里硬编码 add | SPI 插件化,热插拔(与插件机制篇打通) |
| 匹配 | 工具名列表 + "*" | 按工具/租户/参数特征多维匹配,规则中心化下发 |
| 审计 | 内存日志 | 落 SIEM,结构化事件 + 告警联动 |
| 脱敏 | 单条正则 | 正则规则库 + NER 模型识别 + 人工审核流程 |
| 链路语义 | 固定顺序、短路策略写死 | 短路策略、执行顺序可配置,钩子间可见性可控 |
面试考点
- "审计/脱敏这种横切需求在 Agent 里怎么落地?"——答生命周期钩子:before 管拦截与改写入参(风控/审计),after 管改写输出(脱敏/告警);挂钩即生效、摘钩即消失,工具零感知,本质是调用链上的 AOP。
- "before 和 after 钩子的能力边界?"——before 看到入参、可一票否决或改参,决定"执不执行";after 看到出参、可改写合并,决定"返回什么";脱敏放 after 的好处是敏感值根本不进模型上下文。
- "多条 after 钩子怎么协作?"——按注册顺序的流水线加工,前一条输出作为后一条输入,由 OutputMerger 负责合并语义;before 则是任一 DENY 即短路终止。
生产化路径
- 钩子声明为 SPI 插件,支持运行时装卸与灰度启用,与插件机制的隔离加载复用同一套基础设施;
- 审计事件接 SIEM:工具名、参数摘要、拦截原因、耗时全结构化,风控拦截自动触发告警;
- 脱敏升级为"正则规则库 + NER 识别 + 规则灰度 + 人工审核"的组合,误杀率作为核心指标;
- 钩子链治理:执行顺序、短路策略、超时兜底可配置;钩子自身故障不应阻断主链路(fail-open/fail-closed 按钩子级别区分);
- 与审批门禁联动:before 钩子命中的高危操作可升级为"发起人工审批"而非直接 DENY,兼顾安全与可用性。