Skip to content

CH 21 · 钩子插件与拦截:在工具执行前动手脚

全文字数2560 字预估耗时约 22 分钟前置CH 20(defineTool)难度可照做

本章目标

CH 20 我们让模型能调用自己的工具。这一章更进一步:在工具执行之前、之后,挂上你自己的逻辑——记录谁调了什么、拦截不该调的工具、决定放行还是拒绝。

这就是"钩子插件"。它是 dsh 权限体系、沙箱、审计这些能力的地基,也是"一切皆插件"最典型的体现。

工具调用不是一条直线

CH 11 我们提过一个概念:模型说要调工具,不是直接执行,而是走一条可扩展的流水线。官方把它做成了"受守卫的流水线"——每个环节都能被插件拦截、增强。

一条工具调用要经过这些环节:

text
模型要调用工具

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 步:建目录、装依赖

在一个你想放插件的工作区里:

powershell
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/schemastery

schemastery 是定义配置 schema 的库(插件要可配置,靠它声明配置的形状和默认值)。package.json 里记得加 "type": "module"

第 2 步:写钩子插件

新建 hook-demo\src\audit.js

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):

yaml
- insert:
    - id: audit
      name: 'file:///E:/你的工作区/hook-demo/src/audit.js'
      config:
        denyTools: ['pwsh']

这里 configpwsh(PowerShell)加进拒绝名单。注意:插件代码一个字没改,行为就变了——这正是配置的意义,也是官方设计原则说的"无硬编码可调参数":能在 cordis.yml 里改的值,就不要写死在代码里。

第 4 步:跑起来看效果

用 headless 跑一次,让模型去调 pwsh:

powershell
cd 你的工作区目录
dsh --profile headless --patch "./hook-demo/cordis.yml" "用 pwsh 运行 Get-ChildItem 列出当前目录"

我实际跑出来的终端输出:

两条黄色的 [audit] 日志,是我们的钩子在记录:模型先调了 skill,又调了 pwsh——每次工具调用都经过了我们挂的钩子。而 pwsh 在拒绝名单里,所以它被 deny 拦下来了,模型感知到被拒,主动报告"当前会话禁止调用 pwsh",还给出了替代方案。

一个插件,同时做到了审计(看得见)和拦截(管得住)。这就是钩子的力量。

让 dsh 自己搭:一条提示词搞定

这个插件让 dsh 来写更快。直接在 Web UI 的输入框发:

text
在我当前的工作区帮我写一个钩子插件:工具被调用前打印一行日志,并能通过配置拒绝指定的工具。按官方规范实现,跑一次 headless 验证它能记录和拦截,最后告诉我结果。

它会自己去读官方文档、写插件、验证记录和拦截都生效。你只负责验收。

这是我实际跑这条提示词的结果:它先自己梳理了一版实现要点——插件只能命名导出 name / inject / applyinject: ['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 让插件可配置(拒绝名单)
  • [ ] 能说出"无硬编码可调参数"这条设计原则

Open Source · MIT · Community Driven