CH 21 · 钩子插件与拦截:在工具执行前动手脚
本章目标
CH 20 我们让模型能调用自己的工具。这一章更进一步:在工具执行之前、之后,挂上你自己的逻辑——记录谁调了什么、拦截不该调的工具、决定放行还是拒绝。
这就是"钩子插件"。它是 dsh 权限体系、沙箱、审计这些能力的地基,也是"一切皆插件"最典型的体现。
工具调用不是一条直线
CH 11 我们提过一个概念:模型说要调工具,不是直接执行,而是走一条可扩展的流水线。官方把它做成了"受守卫的流水线"——每个环节都能被插件拦截、增强。
一条工具调用要经过这些环节:
模型要调用工具
↓
pre-execute → 策略门:允许 / 拒绝 / 询问(权限、沙箱、拦截都在这一层)
↓
guard → 单调守卫:一旦拒绝,后续监听器无法撤销(最终防线)
↓
execute → 真正执行工具
↓
post-execute → 结果变换:改写返回值、附加内容
↓
result → 只读观测:看一眼结果,不能改
↓
结果回到模型"钩子"就是挂在某个环节上的插件:用 ctx.on('tools/xxx', ...) 订阅对应事件,在事件里做你想做的事。
各扩展点能做什么,官方文档有一张明确的表:
| 扩展点 | 作用 | 典型用法 |
|---|---|---|
tools/pre-execute | 工具执行前的决策层 | 允许 / 拒绝 / 询问,权限门禁 |
ctx.tools.guard() | 单调的最终拒绝 | 不可被后续监听器撤销的硬限制 |
tools/execute | 包裹整个分发周期 | 加超时、重试、指标收集 |
tools/post-execute | 显式变换结果 | 替换展示内容、追加模型可见上下文 |
tools/result | 只读观测不可变结果 | 审计日志、统计,不能改 |
pre-execute 是瀑布式事件:你的监听器可以返回 next()(放行)或 { kind: 'deny', reason: '...' }(拒绝)。
动手:写一个"审计 + 拦截"钩子插件
我们写一个既能记录每次工具调用、又能拒绝指定工具的钩子插件。拒绝名单做成可配置的——顺手把"插件可配置"这个能力一起练了。
第 1 步:建目录、装依赖
在一个你想放插件的工作区里:
New-Item -ItemType Directory -Path "hook-demo\src" -Force
cd "E:\software-workspace\DeepSeek harness demo\hook-demo" # 换成你自己的目录
npm init -y
npm install @deepseek-ai/schemasteryschemastery 是定义配置 schema 的库(插件要可配置,靠它声明配置的形状和默认值)。package.json 里记得加 "type": "module"。
第 2 步:写钩子插件
新建 hook-demo\src\audit.js:
import Schema from '@deepseek-ai/schemastery'
export const name = 'audit-hook'
export const Config = Schema.object({
denyTools: Schema.array(Schema.string()).default([]),
})
export function apply(ctx, config) {
ctx.on('tools/pre-execute', (exec, next) => {
console.log(`[audit] 工具将被调用: ${exec.name}`)
if (config.denyTools.includes(exec.name)) {
return { kind: 'deny', reason: `策略:本会话禁止调用 ${exec.name}` }
}
return next()
})
}逐行拆:
Config:声明插件可配置项。denyTools是一个字符串数组,默认空数组。apply(ctx, config)里config就是用户配置和默认值合并后的结果。ctx.on('tools/pre-execute', ...):订阅工具执行前事件。每个工具要被调用,都会先经过这里。console.log:审计——记下这个工具要被调用了。config.denyTools.includes(exec.name):如果这个工具在拒绝名单里,返回{ kind: 'deny', reason }拦截它。return next():否则放行,让流水线继续走。
第 3 步:在 cordis.yml 里传配置
新建 hook-demo\cordis.yml(路径换成你自己的,空格记得 %20):
- insert:
- id: audit
name: 'file:///E:/你的工作区/hook-demo/src/audit.js'
config:
denyTools: ['pwsh']这里 config 把 pwsh(PowerShell)加进拒绝名单。注意:插件代码一个字没改,行为就变了——这正是配置的意义,也是官方设计原则说的"无硬编码可调参数":能在 cordis.yml 里改的值,就不要写死在代码里。
第 4 步:跑起来看效果
用 headless 跑一次,让模型去调 pwsh:
cd 你的工作区目录
dsh --profile headless --patch "./hook-demo/cordis.yml" "用 pwsh 运行 Get-ChildItem 列出当前目录"我实际跑出来的终端输出:

两条黄色的 [audit] 日志,是我们的钩子在记录:模型先调了 skill,又调了 pwsh——每次工具调用都经过了我们挂的钩子。而 pwsh 在拒绝名单里,所以它被 deny 拦下来了,模型感知到被拒,主动报告"当前会话禁止调用 pwsh",还给出了替代方案。
一个插件,同时做到了审计(看得见)和拦截(管得住)。这就是钩子的力量。
让 dsh 自己搭:一条提示词搞定
这个插件让 dsh 来写更快。直接在 Web UI 的输入框发:
在我当前的工作区帮我写一个钩子插件:工具被调用前打印一行日志,并能通过配置拒绝指定的工具。按官方规范实现,跑一次 headless 验证它能记录和拦截,最后告诉我结果。它会自己去读官方文档、写插件、验证记录和拦截都生效。你只负责验收。

这是我实际跑这条提示词的结果:它先自己梳理了一版实现要点——插件只能命名导出 name / inject / apply、inject: ['tools'] 确保工具注册表就绪、用 ctx.on('tools/pre-execute', ...) 挂钩子(和官方 permission-gate 示例一致)、拒绝返回 { kind: 'deny', reason }、允许就 await next() 放行,连"拒绝名单走配置、不用改代码"都替你想好了。

这张轨迹图是它干活的完整过程:write 写插件文件、pwsh 跑自己的验证脚本(8 项检查全过)、todo_write 更新任务清单……每一条都是工具调用。
常见坑
| 问题 | 怎么回事 | 怎么处理 |
|---|---|---|
忘了 return next() | 瀑布式事件不 next,流水线会卡住 | pre-execute / post-execute 必须返回 next() 或决策 |
| deny 后模型还在重试 | 模型不知道这个工具永远不可用 | reason 写清楚,模型会看到并换方案 |
| 配置没生效 | cordis.yml 的 config 没写对 | 检查字段名和 schema 类型是否匹配 |
报 Cannot find package '@deepseek-ai/schemastery' | 插件目录没装依赖 | npm install @deepseek-ai/schemastery |
这一章你学到了什么
- [ ] 能说出工具调用流水线的主要环节(pre-execute / execute / post-execute / result)
- [ ] 会用
ctx.on('tools/pre-execute', ...)写一个钩子插件 - [ ] 会返回
{ kind: 'deny', reason }拦截工具,next()放行 - [ ] 会用
Config+ Schemastery 让插件可配置(拒绝名单) - [ ] 能说出"无硬编码可调参数"这条设计原则
