hooks 拦截机制:工具调用前后的权限注入、参数改写与审计脱敏
function calling 工具系统 解决了「模型怎么发出一次工具调用、注册表怎么把结果回流」的问题,但它默认每一次调用都被信任:参数通过 JSON Schema 校验后直接进 handler,能写文件就真的写、能跑命令就真的跑。真实 harness 不敢这么天真——测试文件不该被 agent 改写,超出预算的参数要被收敛,带密钥的回流不能原样进上下文,每一次调用还要留下可追溯的审计记录。这些治理动作都发生在同一条路径上:工具调用的前后拦截点,也就是本篇要讲的 hooks 拦截机制。
本篇是原理系列的第五篇,在纯标准库工程 mini-agent 上完成 M5:新增 HookedToolRegistry 与四个内置钩子,在不改动主循环一行代码的前提下,给工具调用装上 pre 裁决链与 post 接力链。所有实验不依赖外部 API,拒绝、改写、脱敏、审计四类证据都来自同一条真实驱动脚本的输出。
这张流程卡对应全篇的主干判断:钩子不是工具本身的一部分,而是注册表在调度路径上插入的治理层。模型感知不到钩子的存在,它只看到自己的调用有时成功、有时被结构化错误拒绝;工具 handler 也感知不到钩子,它收到的参数已经是裁决后的版本。治理逻辑与业务能力因此可以各自演化。
本文要回答的问题
- 没有钩子的裸注册表在权限、参数、审计三个面上分别漏了什么?
- pre 钩子的裁决有几种形态,「拒绝」如何做到零副作用?
- post 钩子能改写结果到什么程度,为什么被拒绝的调用也要过 post 链?
- 多个钩子串成链时按什么顺序执行,短路语义和接力语义差在哪?
- 怎样在不改主循环、不破坏只读批次并发的前提下把钩子挂上去?
一、裸注册表的三个治理缺口
M2 的 ToolRegistry.dispatch 路径很短:查工具声明、做参数 schema 校验、反射取 handler 执行、把异常兜成结构化错误。这条路径对「调用本身是否应该发生」没有任何判断,工程上立刻露出三个缺口。
第一个缺口是权限无差别。edit_file 对工作区里任何路径一视同仁,模型若决定重命名测试函数,handler 会忠实地把 test_calculator.py 改掉——而测试是验证 agent 自身工作的基准,放任它改测试等于让考生自己改考卷。schema 校验拦不住这类请求,因为参数格式完全合法,问题在语义层面:这个工具对这个对象有没有资格被调用。
第二个缺口是参数不受预算约束。list_files 的 max_entries 允许模型一次要 100 个条目,每条回流都占用上下文窗口。模型并没有恶意,它只是倾向于「多拿一点总没错」,但 harness 有自己的窗口预算,需要一个不问模型、强制执行的收敛点。
第三个缺口是结果与痕迹都不受治理。读文件可能读回带密钥的配置行,原样进消息历史后,它会随后续每一次请求离开本机;同时整个调用过程没有独立于模型对话之外的审计流,事后无法回答「这个 agent 到底碰过哪些文件、哪次被拒」。
| 治理面 | 裸注册表的行为 | 钩子介入后的行为 |
|---|---|---|
| 调用资格 | 合法参数一律执行 | pre 策略按工具名与路径谓词裁决,拒绝零副作用 |
| 参数预算 | 模型给多少用多少 | pre 改写钩子把参数收敛到 harness 上限内 |
| 结果内容 | handler 输出原样回流 | post 脱敏钩子替换敏感行,磁盘文件不动 |
| 调用痕迹 | 只散落在对话消息里 | post 审计钩子每次调用落一行 JSON,拒绝也记 |
四个诉求恰好分布在调用的两侧:资格与参数发生在 handler 之前,结果与痕迹发生在 handler 之后。这就是 pre/post 两类钩子划分的来源,也是 hooks 拦截机制的基本骨架。
二、拦截点全景:pre 链、dispatch 与 post 链
M5 在注册表的调度路径上开了三个拦截点:参数校验之后、handler 之前是 pre 区;handler 返回之后、结果写入消息历史之前是 post 区;拒绝路径单独从 pre 区短路,但短路产生的错误结果仍要绕行经过 post 区。
读这张图抓住颜色分工:蓝色是入口与 pre 裁决面,红色是唯一的拒绝短路,灰色是不加治理时本来就存在的父类调度,紫色是 post 治理面,绿色是回到对话的回流面。最关键的拓扑细节有两条:其一,红色节点没有任何边连向灰色 handler,这是「拒绝零副作用」的图示表达;其二,红色节点有一条边汇入紫色 post 链,被拒绝的调用不执行,但照样被审计——否则权限事件恰恰成了审计流里最容易消失的一类记录。
拦截点之所以放在参数校验之后而不是模型输出之后,是因为钩子收到的 arguments 至少是结构合法的字典,策略代码不必再处理 JSON 解析失败这类低层问题;放在结果回流之前,则保证模型永远看不到未经 post 治理的内容,脱敏不可能被「模型换个说法再问一次」绕过。
三、pre 钩子:两种裁决与三个内置实现
pre 钩子的契约极简:输入工具名与当前参数字典,输出一个 PreVerdict。裁决只有两种形态——放行(可以附带改写后的新参数)或拒绝(必须附原因),没有第三种暧昧状态。
@dataclass
class PreVerdict:
allowed: bool
arguments: dict = None
reason: str = ""
@classmethod
def pass_(cls, arguments=None):
return cls(True, arguments=arguments)
@classmethod
def deny(cls, reason):
return cls(False, reason=reason)PermissionPolicy 是权限注入的样板实现,它把策略拆成两层:modes 字典按工具名映射 allow、deny、ask 三种模式,path_rules 再给写操作(edit_file 与 run_test)加一层路径谓词。谓词是普通函数,返回非空字符串就是拒绝原因,返回空串放行——教学工程用它保护测试文件,真实系统里换成路径前缀白名单、目录分级是同一套接口。ask 模式值得单独说一句:无人值守下没有「人」可问,钩子按拒绝处理并在原因里写明本应询问,而不是挂起整个会话;交互式 harness 在这里替换成确认弹窗即可,契约不变。
ArgumentRewrite 演示参数改写这一形态。它只盯 list_files,当请求的 max_entries 超过构造时给定的上限,就复制一份参数字典、把超限值夹到上限,再用 PreVerdict.pass_(rewritten) 放行:
def before(self, name, arguments):
if name == "list_files":
rewritten = dict(arguments)
value = rewritten.get("max_entries", 50)
if value > self.max_entries_cap:
rewritten["max_entries"] = self.max_entries_cap
return PreVerdict.pass_(rewritten)
return PreVerdict.pass_()注意两处细节:改写基于字典拷贝而不是原地修改,原始调用在审计记录里保持原样;不超限的调用与不相关的工具都返回不带参数的放行裁决,注册表看到 verdict.arguments is None 就沿用当前参数,区分「没改写」与「显式改写成空」。
链式语义在 HookedToolRegistry.dispatch 里只有十行:pre 钩子按注册顺序串行调用,每个钩子拿到的是上一个钩子裁决后的参数;任一钩子拒绝,立刻构造 hook_denied 结构化错误、记入 self.denied 并短路,后面的 pre 钩子与真实 handler 都不会执行。测试用三个记录型钩子固定了这个顺序——第二个钩子拒绝时,第三个钩子的调用计数保持为零。
四、post 钩子:接力脱敏、强制审计与拒绝路径
post 钩子的契约是「输入工具名、最终参数、当前结果,输出一个结果」,因此它天然是接力形态:前一个钩子的输出就是后一个钩子的输入,顺序敏感。M5 给了两个内置实现,分别对应结果治理与痕迹治理。
RedactSecrets 做结果脱敏:只在成功结果且内容含标记行时动作,把带 SECRET 标记的整行替换成掩码,其余行原样保留,并构造新的 ToolResult 传给下一棒。它治理的是回流给模型的视图,不是磁盘——测试里专门断言读回的消息中找不到明文 token,而工作区文件里的原行分毫未动。这种「视图与存储分离」是脱敏钩子能安全默认开启的前提。
AuditLog 做强制审计:每次调用落一行 JSON,字段固定为工具名、成功与否、结构化错误与参数,sink 既可以是文件路径也可以是带 append 方法的列表,后者让 headless 测试无需碰文件系统。它不修改结果,原样传递,因此放在 post 链的末端也不影响脱敏效果。
最反直觉但最重要的语义是:被 pre 拒绝的调用仍然完整走过 post 链。dispatch 在拒绝分支里先构造错误结果,再逐个调用 post 钩子才返回。这样设计的理由在审计面上一目了然——拒绝事件是权限体系最该留下的信号,如果拒绝就绕过审计,日志里将只剩成功调用,所有越权尝试全部无痕。拒绝与放行在 post 侧的差别只剩 result.ok 一个字段,审计钩子用同一套代码覆盖两条路径。
| 钩子 | 类型 | 动作对象 | 链式语义 | 副作用边界 |
|---|---|---|---|---|
| PermissionPolicy | pre | 调用资格 | 可短路,后续钩子不执行 | 拒绝时 handler 不运行 |
| ArgumentRewrite | pre | 参数字典副本 | 输出喂给下一棒 | 不改原始调用记录 |
| RedactSecrets | post | 回流结果内容 | 输出喂给下一棒 | 只改视图,磁盘不动 |
| AuditLog | post | 外部审计 sink | 末端记录,原样透传 | 只追加,不改结果 |
五、一次被拒绝调用的完整时序
把 M5 驱动脚本的第一个观察点展开成时序:模型试图把 test_calculator.py 里的 test_add 重命名,权限钩子的路径谓词命中保护规则,整个调用在 handler 之前结束,模型在下一轮读到拒绝原因后自行改道。

时序图里有三个值得停下来看的点。第一,handler 生命线在整段交互中没有被激活,这不是省略画法,而是短路的真实语义——测试文件零改动由后续断言兜底。第二,post 链在拒绝分支同样被激活,审计行的 ok 字段为 False,与成功调用共用同一个 JSON 形状。第三,主循环拿到的错误结构与工具执行错误同构,都是 {code, message, tool} 三元组,run_loop 不需要为「被钩子拒绝」学习任何新语法,模型也把它当作一次普通的失败工具调用来改道。
驱动脚本里模型的后续行为正好演示了这种自我修正:被拒后它没有重试同一调用,而是转为读源码、列目录,最后一次写操作落在受允许的业务文件 calculator.py 上并成功。权限策略改变的是行为边界,不是对话流程。
六、零侵入挂接:只包装 dispatch,并发自动过钩子
M5 对主循环的侵入仍然是零,手法与 M4 的注册表包装一脉相承:HookedToolRegistry 继承 ToolRegistry,工具声明、schema 校验、handler 全部复用父类,只覆写 dispatch 一个方法。run_loop 拿到的仍是同一个注册表接口,构造时换成带钩子的子类即可。
这个覆写点的选择还免费解决了并发问题。M2 为只读批次实现了 dispatch_many:整批只读调用提交线程池并发执行,而线程池里提交的正是 self.dispatch:
def dispatch_many(self, calls):
"""整批只读调用并发执行;结果始终按请求顺序返回。"""
if len(calls) > 1 and all(self.is_readonly(call["name"]) for call in calls):
with ThreadPoolExecutor(max_workers=len(calls)) as pool:
futures = [pool.submit(self.dispatch, call["name"], call["arguments"])
for call in calls]
return [future.result() for future in futures]
return [self.dispatch(call["name"], call["arguments"]) for call in calls]因为多态分发落到子类覆写后的版本,并发批次里的每个调用自动穿过完整钩子链,不需要为并发单独写一套治理代码。测试里的只读批次用例固定了这条保证:一个 list_files 加一个 read_file 并发执行,参数改写在结果里生效(条目数被夹到 10),审计流收到两行记录;并发只影响审计行的落盘先后,因此用例只校验工具名集合而不校验顺序。
线程安全的代价由钩子自身约束:无状态的策略与改写钩子天然安全;有状态的 AuditLog 只做追加写,文件 sink 每次开关句柄写一行,列表 sink 的 append 在 GIL 下原子。教学工程没有引入锁,真实系统的高吞吐审计通常走队列加后台刷盘,接口形状不变。
七、实测对账:四个观察点与七个用例
驱动脚本 scripts/drive_m5.py 跑一条四调用的脚本化会话,四个观察点——拒绝零副作用、参数收敛、密钥脱敏、全量审计——在同一次运行里全部出现,下面是完整终端记录。
对照记录逐行对账:第一次 edit_file 返回 ok=False,错误信息是路径谓词给出的「测试文件受保护」;第三次 list_files 请求 100 个条目,回流首行明确写着「10 个条目」,参数改写在模型毫无察觉的情况下生效;会话末尾的汇总行给出三条硬证据——被拒绝调用数为 1 且原因在案、测试文件未被改动的检查为 True、审计记录 4 条且工具序列覆盖全部四次调用(含被拒的那次)、脱敏生效的回流消息 1 条。
审计与脱敏的汇总结论再摘一段原始输出:
被拒绝调用数: 1 [{'tool': 'edit_file', 'reason': '测试文件受保护:禁止 agent 直接修改测试'}]
测试文件未被改动: True
审计记录 4 条,工具序列: edit_file -> read_file -> list_files -> edit_file
脱敏生效的回流消息数: 1这四行各自封死一个反驳角度:拒绝数与原因证明策略真的在裁决而不是建议;文件检查证明拒绝路径没有任何写副作用穿透;审计序列长度等于调用总数,证明拒绝没有打断记录;脱敏计数证明 read_file 回流里的密钥行在进消息历史前已被替换。演示工作区的 calculator.py 里确实写着带 SECRET 标记的 token 行,而模型在对话中只能看到掩码。
headless 测试把这些口头结论固化成 7 个回归用例,工程当前 M1 至 M6 合计 43 个用例在标准库下全部通过。
| 用例 | 固定的契约 |
|---|---|
| deny_short_circuits_without_side_effect | 拒绝返回 hook_denied,文件原样,拒绝事件进审计 |
| allow_path_goes_through | 允许路径正常执行且改动落盘 |
| ask_mode_denies_headless | ask 在无人值守下按拒绝处理,原因注明人工确认 |
| argument_rewrite_clamps_listing | 100 条请求被夹到上限,超限条目不出现 |
| hook_chain_order_and_short_circuit | 多 pre 按注册顺序执行,拒绝后第三个钩子零调用 |
| redact_secret_line_in_result | 回流无明文 token,磁盘明文保留 |
| readonly_batch_also_passes_hooks | 并发批次同样过改写与审计,结果按请求序回流 |
实验环境
- 机器/系统:MacBook Pro(6 核 Intel Core i7-9750H,16 GB 内存)/ macOS 26.5.2(Build 25F84)
- 工具版本:Python 3.15.2(仅标准库,无第三方依赖)
- 日期:2026-11-14
- 完整命令:
cd demos/mini-agent
python3 scripts/drive_m5.py # M5 钩子链完整演示
python3 -m unittest discover -s tests # M1-M6 共 43 个 headless 用例演示工作区由 drive_m5.py 每次从 fixtures 重置:写入带 SECRET 标记的 calculator.py 与 15 个笔记文件;钩子装配为 pre 链 PermissionPolicy(保护测试文件)加 ArgumentRewrite(上限 10),post 链 RedactSecrets 加 AuditLog;消息 trace 落 traces/m5-trace.jsonl,审计流落 traces/m5-audit.jsonl。
代码获取
mini-agent M5 的钩子模块、驱动脚本与全部测试已同步到公开仓库:
- 仓库地址:https://github.com/derekzhuo/geek-doc.cn
- 目录:mini-agent/
- 复现方式:进入
mini-agent/,执行make run-m5查看本篇完整演示;make test跑全部回归用例。想试自己的策略,只需实现before或after方法并在构造HookedToolRegistry时传入,无需改动主循环与任何工具 handler。
这一篇在知识体系里的位置
hooks 拦截机制挂在 Agent Loop 解剖 的工具调度环节上,拦截产生的结构化错误沿用 function calling 工具系统 的错误回流形状,模型把拒绝当作普通失败处理。它与 context 压缩机制 从两侧保护上下文:压缩治理窗口里已经存在的内容,参数改写与脱敏则在内容进入窗口之前设卡。注册表包装、主循环零改动的挂接手法,与 子代理编排 的 ForkingToolRegistry 同构,二者可以叠加——子代理内部的注册表同样可以挂钩子。本系列的总入口与里程碑地图见 agent-internals 索引。
总结
hooks 拦截机制的本质是在工具调度路径上插入一层与业务能力正交的治理面:pre 钩子链在 handler 之前串行裁决,能放行、拒绝或改写参数,任一拒绝即短路并保证零副作用;post 钩子链在结果回流之前接力处理,脱敏只改模型可见的视图而不动磁盘,审计对成功与拒绝一视同仁地留下结构化记录。mini-agent M5 用一个只覆写 dispatch 的注册表子类挂接全部钩子,主循环一行未改,只读批次的并发路径因为多态分发自动获得同等治理;真实驱动演示出一次拒绝、一次参数收敛、一次脱敏与四条审计记录,7 个 headless 用例把短路顺序、拒绝留痕、视图存储分离与并发过钩子逐条固定成可回归的契约。