AIGC标识 给 deepseek-harness 写一个工具插件:从开发到真实调用

给 deepseek-harness 写一个工具插件:从开发到真实调用

最近在接触 deepseek-harness(简称 dsh),顺手给它写了一个最小工具插件,把从开发、测试、发布到最终被模型真实调用的整个过程走了一遍。这里记录一下,包括过程中踩过的坑。

一、dsh 是什么

deepseek-harness 是 DeepSeek 的一个 agent 开发框架,设计上贯彻"一切皆插件":LLM 适配层是插件,工具是插件,执行策略也是插件。宿主是一个 Cordis 微内核,通过依赖注入和事件系统把各部分组装成完整的 agent 运行时。

对想扩展 dsh 的人来说,最小可交付的单元就是一个工具插件:通过 ctx.tools.register() 向模型暴露一个新工具,工具会自动进入系统提示词组装、参数校验和执行管线。也就是说,写一个工具,模型就能在会话里真正使用它。

这次的目标:从零写一个可运行、可发布、可被模型真实调用的最小工具插件。

二、最小工具插件的结构

一个 dsh 工具插件 = Cordis 插件四要素 + 一个工具定义。

四要素:

要素 说明
name 插件名,唯一标识
inject 依赖注入声明,['tools'] 表示需要工具注册服务
apply(ctx) 插件入口,在这里注册工具(注册是一个 effect,插件销毁时工具自动注销)
Config 可选,插件的可配置项

工具定义用 @deepseek-ai/dsh-tools 提供的 defineTool,核心三部分:

  1. parameters:模型可见的参数 schema,类型会自动推导进 executeargs
  2. output:结构化返回值声明 + 纯函数 render(模型看到结构化 value,界面看到 render 产物)
  3. execute:真正执行的地方,必须尊重 exec.signal 取消信号

有一个契约必须记住:工具返回 canonical JSON value,不是自然语言文本。不能返回"文件内容是 xxx"这种话让模型去解析,要直接返回结构化数据。

三、写第一个工具 read_file

项目结构保持最小:

my-dsh-tool/
├── package.json
├── tsconfig.json
├── src/
│   └── index.ts        # 插件入口
├── test/
│   └── index.test.ts   # vitest 单测
└── README.md

插件本体只有 31 行:

import { readFile } from 'node:fs/promises';
import type { Context } from '@deepseek-ai/cordis';
import { defineTool } from '@deepseek-ai/dsh-tools';

export const name = 'my-tool';
export const inject = ['tools'];

export function apply(ctx: Context) {
  ctx.tools.register(defineTool({
    name: 'read_file',
    description: 'Read a file from disk.',
    parameters: {
      path: { type: 'string', required: true, description: 'Absolute path' },
      limit: { type: 'number', description: 'Max chars to read' },
    },
    output: {
      schema: { type: 'string' },
      render: (_args, value) => [{ type: 'text', text: value }],
    },
    async execute(args, exec) {
      // args 已按 schema 校验:{ path: string; limit?: number }
      const text = await readFile(args.path, {
        encoding: 'utf8',
        signal: exec.signal,   // 尊重取消信号
      });
      return args.limit ? text.slice(0, args.limit) : text;
    },
  }));
}

几个值得注意的细节:

  • inject: ['tools'] 声明依赖注入,ctx.tools 才会可用
  • defineTool 的类型推导:parameters 写完,executeargs 类型就自动有了,不用手写
  • exec.signal:把取消信号透传给 readFile,模型中断时工具会快速失败而不是卡住
  • render 必须是纯函数:界面卡片展示逻辑,禁止任何 I/O 和随机性(流式和回放会反复执行它)

package.json 的关键字段(type: module 必配,scoped 包发布):

{
  "name": "@your-scope/my-dsh-tool",
  "type": "module",
  "main": "lib/index.js",
  "types": "lib/index.d.ts",
  "files": ["lib"],
  "publishConfig": { "access": "public" },
  "engines": { "node": "^22.19.0 || >=24.0.0" },
  "peerDependencies": {
    "@deepseek-ai/cordis": "^4.0.1",
    "@deepseek-ai/dsh-tools": "0.1.0-rc.6"
  }
}

四、单测:不启动宿主也能验证

工具可以脱离完整 dsh 宿主做测试:构造一个真实的 ToolRuntime 挂到 Cordis Context 上,再跑插件的 apply

function mount() {
  const ctx = new Context()
  ctx.provide('systemPrompt', { tools() {} })
  new ToolRuntime(ctx, {})
  apply(ctx)
  return ctx
}

然后直接对 ctx.tools 做断言。我写了 5 个用例:

  1. 模型可见 schema:read_file 出现在 ctx.tools.schemas() 里,参数结构正确
  2. 正常调用:返回结构化 canonical 值(不是 prose)
  3. 参数校验:缺必填参数 path 会被拒绝,isError: true
  4. 取消路径:已 abort 的 signal 让工具快速失败
  5. render 产物:返回的 content block 与 canonical value 一致

工具的核心契约(参数校验、取消、结构化返回)都被单测固定住,后续 dsh 版本升级时可以用它当兼容性检查。

五、发布到 npm

发布前自检配置时发现三个问题:

  1. files 白名单:不写的话会把 node_modulestest 等无关文件一起打进包
  2. 依赖位置不对:@deepseek-ai/cordis@deepseek-ai/dsh-tools 应该放 peerDependencies(宿主环境本来就有,避免双实例),而不是 dependencies
  3. 缺发布配置:scoped 包默认不公开,必须显式 publishConfig.access: "public"

修完这些,又补了 descriptionlicenserepositoryengineskeywordsREADME.md,才真正执行发布。

发布时遇到 403,需要 2FA。改用只授权这一个包的 granular access token 配置到 .npmrc 后成功。

发布不是一次性的——中间因为修 peer 依赖声明,版本从 0.1.0 升到 0.1.10.1.2,每次都执行 npm publish 并用 npm view 确认注册表里能看到。

六、挂载到 profile

dsh 的插件分发走 profile 机制:每个 profile(如 web、headless)是一个独立环境,通过 cordis.patch.yml 声明要加载的插件。

用官方命令挂载(会自动处理依赖安装与路径):

dsh plugin --profile headless add

手动改 patch 文件也可以。追加新 entry 的语法是 - insert:(无 id 追加;- id: 是定位已存在 entry 用的):

- insert:
    - id: my-tool
      name: '@your-scope/my-dsh-tool'

七、真实调用

挂载后做了两层验证。

第一层,headless 一次性会话:直接发指令要求调用工具,从日志确认工具被调度、被真实执行、结果被模型正确引用。

第二层,web 界面:启动 dsh web(默认本地端口 3080),向模型发送"用 read_file 读取 package.json 并告诉我 name 字段"。界面上能看到完整过程:

Think 推理 → Tool call · read_file · package.json(工具调用卡片)→ 最终回答
"name": "@your-scope/my-dsh-tool"

回答里的包名和磁盘上 package.json 的 name 字段完全一致(文中用 @your-scope 代指真实 scope),说明模型确实通过我们写的工具读了文件,而不是猜的。至此,从开发到真实调用这条路径就走通了。

八、遇到的问题和解决过程

按时间顺序记录,很多在官方文档里没有现成答案。

1. dsh-tools 依赖装不上

>=0.1.0 声明装不上。原因:dsh 家族包还在 preview 阶段,真实最新版本号是 0.0.1-rc.1(后来跟进到 0.1.0-rc.6),不是语义化版本能猜出来的。解决:npm view @deepseek-ai/dsh-tools versions 查真实版本号,锁定可用的 rc 版本。

2. TS2307:node:fs/promises 找不到

编译报找不到 Node 内置模块。解决:补装 @types/node,并在 tsconfig.json"types": ["node"]

3. npm publish 403:2FA 要求

发布被拒。解决:用 granular access token(授权范围只限这一个包)写入 .npmrc

4. 发布显示成功但注册表查不到

在某环境里 publish 显示成功,npm view 却查不到。原因是 registry 返回 404 后延迟同步出 200,属于环境的伪成功。解决:以真实终端 npm publish + npm view <pkg> 确认为准。

5. 最大的坑:会话崩溃 Cannot read properties of undefined (reading 'prepare')

插件挂载后启动会话直接报 UNKNOWN: Cannot read properties of undefined (reading 'prepare'),工具调度失效。

根因是 dsh-tools 双实例:手动 npm 安装时 profile 解析到 dsh-tools@0.0.1-rc.1,而宿主 dsh 内嵌的是 0.1.0-rc.6。两个实例导出的工具调度符号不同,插件注册的工具找不到正确的调度器。

解决分三步:

  1. 改用官方 dsh plugin --profile headless add(pnpm 路径)重建 profile,避免手动 npm 安装导致的双实例
  2. 插件 peerDependencies 从 0.0.1-rc.1 升级到 0.1.0-rc.6,与宿主内嵌版本对齐
  3. 测试代码同步更新:ToolRegistry 类更名为 ToolRuntime

教训:插件框架的依赖必须与宿主内嵌版本严格一致,手动装出双实例是这类"符号不匹配"崩溃的主要来源。

6. pnpm store 路径被沙箱拦截

pnpm 安装时写磁盘被沙箱拦截。解决:--store-dir 指向项目内路径,并重定向临时目录环境变量。

7. setx 设置环境变量后新终端读不到

setx 只写注册表,不广播到已运行的进程;explorer 不重启,新终端继承的还是旧环境。解决:重启 explorer 进程使环境广播刷新。

8. patch 语法错误:entry "my-tool" not found

- id: my-tool 报 entry not found。原因:- id: 是定位已存在 entry 的语法,新增 entry 必须用 - insert: 无 id 追加。

9. API key 明文落盘

.env 里存了 API key,明文在磁盘上。解决:迁移到用户级环境变量,从项目目录删除 .env 文件。

10. 发布配置三个 P0

见第五节:files 白名单、依赖放 peerDependencies、scoped 包必须 publishConfig.access: "public"

九、总结

从 31 行的 read_file 到模型在界面上真实读出文件内容,这条路径验证了 dsh 插件机制的最小闭环:

defineTool 注册 → schema 进入模型视野 → 模型决策调用 → execute 真实执行 → render 回传 → 模型组织回答

几点心得:

  1. 先写单测再谈发布:工具契约(参数校验、取消、结构化返回)是踩坑高发区,单测能提前发现
  2. 发布前过一遍配置检查:files 白名单、peerDependencies、publishConfig.access
  3. 挂载用官方命令:dsh plugin add 会自动处理依赖路径,手动 npm 安装容易搞出双实例
  4. 版本对齐是生命线:peer 版本必须与宿主内嵌版本一致,否则就是莫名的运行时崩溃
  5. 确认结果要复核:npm view 确认发布、界面确认调用,不要轻信中间层的"成功"提示

参考:官方仓库 deepseek-harness(GitHub 上的 deepseek-ai/deepseek-harness 项目,文档里有工具编写、插件形态、架构等说明)。

posted @ 2026-08-15 11:06  难当鸿鹄  阅读(33)  评论(0)    收藏  举报