SSD Nodes Learn 🎉 VPS $5.50/月起
指南 Matt Connor作者: Matt Connor · 更新于 2026-08-21

如何编写 DeepSeek Harness 的 dsh 插件

从空文件夹构建 dsh 插件:配置关键 package.json 字段、用补丁挂载插件、注册实际工具,并实现所需的两个钩子。本文基于 dsh 0.1.0-rc.7,覆盖主机端结构与加载验证。

dsh 插件实际是什么

dsh 插件是一个 npm 包。它导出一个 apply 函数,并附带一个小型 YAML 文件,用于告知 DeepSeek Harness 加载该插件。无需先学习单独的插件 SDK。dsh 是一个 Cordis 应用,“一切都是插件”是字面意义:工具注册表、agent 循环、会话存储和 Web 服务器,都是同一插件树中的节点,你的包会加入这棵树。

Cordis 是一个通用的组合框架,独立开发,长期以来一直作为 Koishi 聊天机器人框架的基础。它负责加载和卸载插件,并解析插件之间的依赖关系。它不了解 agent。所有与 agent 相关的功能,都来自构建在其上的 harness 包。因此,下面的插件结构很小。大部分功能都是继承而来。

插件包含两个部分。主机部分运行在 Node 中,用于注册工具和事件监听器,也可以提供自己的服务。浏览器部分运行在 Web UI 中,用于注册界面插槽。第一个插件几乎总是仅包含主机部分,因此在需要之前可以将浏览器部分视为可选。

本指南基于 @deepseek-ai/dsh 版本 0.1.0-rc.7 编写,该版本于 19 August 2026 使用 npm latest 标签发布。dsh 仍处于开发者预览阶段,其 README 明确说明后续会有不兼容变更。以下每个键名都是根据截至该日期的上游文档和代码仓库确定的。在依赖这些键名之前,请重新检查相关内容,因为预览版 API 可能会在不同的候选发布版本之间重命名字段。如果 harness 尚未运行,请先按照 在 VPS 上部署 DeepSeek Harness配置 dsh API 密钥和模型 完成设置,然后再返回本节。

打包任何内容前,先加载一个临时文件

先打包再排查问题,是低效的做法。先加载一个文件,确认运行时会调用您的代码,然后再进行打包。

在 harness checkout 目录之外创建一个文件夹,并在其中放入一个文件。

import type { Context } from '@deepseek-ai/cordis'

export const name = 'hello-plugin'

export function apply(ctx: Context) {
  console.log('[hello-plugin] plugin loaded')
}

export const name 是用于在诊断信息中标识插件的元数据。apply 是完整的契约:Cordis 会调用它一次,并传入一个作用域限定到您插件的上下文。在该上下文上注册的任何内容,插件释放时都会由 Cordis 自动撤销。

在旁边写入 cordis.yml

- insert:
    - id: hello
      name: '/absolute/path/to/scratch-plugin/hello.ts'

现在启动一个叠加了该文件的 profile。

dsh web --patch ./scratch-plugin/cordis.yml

如果 dsh 不在您的 PATH 中,npx @deepseek-ai/dsh web --patch ./scratch-plugin/cordis.yml 可以执行相同的操作。该 npx 路径可能提供缓存中的旧版候选版本,而不是本指南所述的版本。因此,如果 harness 直接拒绝某个文档中记录的 flag,请先按照dsh 安装和版本错误的修复方法排查,再怀疑您自己的文件。启动 dsh 的终端中应显示 [hello-plugin] plugin loaded。如果没有任何输出,说明该行未解析成功。

name 字段接受 npm 包名称或文件系统路径,上游文档说明该路径必须是绝对路径。当临时插件没有输出时,首先检查相对路径的 ./hello.ts。其次检查文件扩展名。文档中的流程是在 harness 仓库的克隆目录中以 pnpm dsh web --patch ... 运行,其中 TypeScript 条目通过 tsx 加载。如果您的 dsh 来自 npm,请将该行指向纯 JavaScript 文件,或先构建该文件。

--patch 是启动器 flag,其叠加层最后应用,在所有 bundle 和您自己的 profile patch 之后。因此,临时叠加层始终具有最高优先级,这正适合迭代修改。

编写能完成实用任务的最小工具

日志行只能证明插件已加载。工具才能证明插件已成为代理的一部分。

import type { Context } from '@deepseek-ai/cordis'
import { defineTool } from '@deepseek-ai/dsh-tools'

export const name = 'greet-tool'
export const inject = ['tools']

export function apply(ctx: Context) {
  ctx.tools.register(defineTool({
    name: 'greet',
    description: 'Greet someone by name.',
    parameters: {
      name: { type: 'string', required: true, description: 'The name to greet' },
    },
    output: {
      schema: { type: 'string' },
      render: (_args, value) => [{ type: 'text', text: value }],
    },
    async execute(args) {
      return `Hello, ${args.name}!`
    },
  }))
}

export const inject = ['tools'] 是人们容易遗漏的一行。Cordis 配置中的条目会并发启动,因此条目在文件中的位置不能保证加载顺序。加载顺序由声明的依赖关系决定。inject 会告知 Cordis:在调用您的 apply 之前,先等待 ctx.tools 存在。如果没有这一行,您的代码可能在注册表尚未准备好时运行,因而无法完成注册。

对象的其余部分定义了模型可见的契约。parameters 是参数架构,execute 接收已根据该架构解析的参数。output.schema 描述 execute 返回的值,render 则将该值转换为模型读取的内容块。将这两部分分开,接口就可以显示一种内容,而模型读取另一种内容。

启动配置文件,然后要求助手通过姓名向某人问候。回复会通过您的 execute 返回。通过 ctx 注册是可逆的,因此释放插件时,工具也会自动注销。对于 Cordis 无法管理的资源,例如套接字或文件句柄,请调用 ctx.effect() 并向其传入一个释放器。

真正会接触到的两个插件扩展点

完整的扩展点列表很长。前两个插件几乎都会用到其中的两个。

会话事件是持久化并记录日志的事件流。事件名称为 session/eventturn/startturn/endstep/startstep/enduser/messageassistant/messageassistant/chunktool/calltool/result。为其附加普通监听器即可。

ctx.on('tool/call', (payload) => {
  console.log('[my-plugin] tool/call', JSON.stringify(payload))
})

先打印一次载荷并读取它。不要从任何指南(包括本文)中复制载荷字段名,因为载荷结构是预览 API 中变化最大的部分。

第二个扩展点是 waterfall。agent/pre-stepagent/requestagent/request-errorllm/streamtools/* 事件属于 waterfall,waterfall 监听器的签名不同。它接收一个 next 回调,只有调用该回调后,链才会继续。

ctx.on('agent/request', async (payload, next) => {
  const startedAt = Date.now()
  const downstream = await next()
  console.log('[my-plugin] model request took', Date.now() - startedAt, 'ms')
  return downstream
})

如果忘记调用 await next(),就没有添加钩子。你实际上将模型调用替换为空操作,代理会在此停止,因为对于有意拒绝请求的网关插件而言,短路是设计行为。这一差异是初次编写插件时产生大多数困惑的原因。先写好 next() 调用,再编写其周围的逻辑。

agent/request 封装模型调用本身。其载荷包含发起调用的代理、当前打开的轮次编号、请求所属的步骤,以及该轮次的中止信号。因此,它适合用于请求日志记录器或速率限制器。tools/* waterfall 在下一层具有相同的结构。tools/pre-execute 在分派前允许、拒绝或请求批准。tools/execute 封装分派过程。tools/post-execute 可以替换或阻止规范化结果。tools/result 仅观察已冻结的结果。

将其打包为其他人可以安装的包

包是一个 npm 包,其 package.json 声明包含指向补丁文件的 dsh.bundle 字段。这个声明是临时文件与可安装包之间的全部区别。

{
  "name": "dsh-plugin-hello",
  "version": "0.1.0",
  "type": "module",
  "main": "lib/index.js",
  "files": ["lib", "cordis.patch.yml", "README.md", "LICENSE"],
  "engines": { "node": "^22.19 || >=24", "dsh": ">=0.1.0-rc.6" },
  "dsh": { "bundle": { "patch": "./cordis.patch.yml" } },
  "keywords": ["dsh-plugin", "deepseek-harness"],
  "scripts": { "build": "tsdown", "prepare": "pnpm run build" },
  "exports": {
    ".": { "types": "./lib/index.d.ts", "default": "./lib/index.js" },
    "./cordis.patch.yml": "./cordis.patch.yml",
    "./package.json": "./package.json"
  }
}

旁边的 cordis.patch.yml 很简短。

- insert:
    - id: dsh-plugin-hello
      name: dsh-plugin-hello

name 行是包名称,因此这两个字符串必须匹配。id 行是用户覆盖您的配置时,后续层所定位的内容,因此应选择稳定的值,且不要将其用于其他插件。

files 必须列出 cordis.patch.yml。如果省略它,发布的 tarball 会包含一个指向未被打包文件的 dsh.bundle.patch,因此包虽然可以安装,却不会向树中贡献任何内容。

请从包含插件目录的目录中,将其安装到配置文件中。

dsh plugin --profile demo add ./dsh-plugin-hello
dsh --profile demo --dump-config
dsh --profile demo

dsh plugin --profile <name> 会将其余参数转发给该配置文件目录中的 pnpm,因此 addremove 的行为与 pnpm 一致。使用 dsh plugin --profile demo remove dsh-plugin-hello 卸载。webheadless 配置文件会在首次使用时根据随包提供的模板自动创建,其他配置文件名称必须通过 dsh plugin 创建。

为何组合树中缺少您的行

组合过程从空条目列表开始,并按固定顺序叠加各层。首先处理 profile 的 dsh.profile.bundles 中列出的每个 bundle,顺序与列表一致。然后处理 profile 自身的 cordis.patch.yml。接着处理 $DSH_HOME/cordis.patch.yml。最后处理命令行中的 --patch overlay。后加载的层会按 id 替换先前的行。

Profile 位于 $DSH_HOME/profiles/<name> 下。每个 profile 目录包含一个 package.json,其中带有 dsh.profile manifest 及其有序的 bundles 列表,此外还包含用户自己的补丁文件。Bundle 名称首先从 dsh 安装目录解析,其次从 profile 的 node_modules 解析;pnpm 会将不在安装目录中的插件放在这里。

dsh --profile demo --dump-config 会输出完整的组合树,但不会启动任何内容。该输出是调试时的判断依据。如果缺少您的行 id,问题出在组合过程:名称无法解析,或补丁文件从未被打包。如果行存在但没有任何效果,问题就在您的代码中。先回答这个问题,就能避免大部分猜测。

实际在哪里显示加载错误

apply 内抛出的错误很明显。进程会因该异常退出,堆栈跟踪会指向您自己的代码行。

解析失败通常不会直接显示。加载器会通过 Cordis logger 报告无法解析的模块,而不是使进程崩溃。上游教程还提醒,这些消息可能会在启动时看似丢失,因为它们是在附加控制台导出器之前输出的。因此,路径拼写错误看起来与插件已加载但未执行任何操作完全相同。这也是为什么在阅读代码前运行上面的 --dump-config 检查很有价值。

开发期间,请将 console.log 作为 apply 中的第一条语句。缺少它可以帮助您判断问题属于哪一部分,之后删除它也不会产生额外成本。在服务器上进行迭代时,请在前台运行测试程序,而不要通过服务管理器运行。这样,加载器输出会直接显示在终端中,而不必稍后再去查看日志。

无需重启整个系统即可迭代

对于今天的主机端,实际做法是重启。Web 应用包中共享的热模块重新加载功能已禁用,文件中还注明,完成重新加载生命周期测试后才会重新启用。客户端的重新加载链始终处于挂载状态,但只有在构建监视器重写客户端包后才会运行。因此,它对 Node 端同样不起作用。

与其追逐尚未实现的重新加载功能,不如降低重启成本。将插件保存在一个文件中。通过 --patch 加载插件,而不是将其安装到配置文件中。这样,编辑和运行之间不需要经过构建步骤或 pnpm 步骤。通过 ctx 注册所有内容,避免重启后遗留重复工具或过期的监听器。使用 ctx.effect() 分配的资源必须配备真正的释放函数,因为缺少释放函数通常会导致第二次运行失败:第一次运行仍占用着端口。

如果您针对的是运行在服务器上的测试框架,而不是笔记本电脑上的测试框架,以上内容都不变,但 Web UI 的绑定地址确实很重要。3080 端口上的回环绑定说明了页面为何不会自动打开,以及应如何处理。

浏览器端部分,以及应当在多大程度上信任它

仅当插件需要自己的界面时才添加此项。它与 bundle 在同一个 dsh 字段中声明。

{
  "dsh": {
    "client": {
      "platform": "web",
      "inject": [],
      "external": [],
      "immediately": false
    }
  },
  "exports": {
    ".": "./src/index.ts",
    "./client": "./src/client/apply.ts",
    "./package.json": "./package.json"
  }
}

"platform": "web" 是必需的。如果软件包没有 ./client 导出,扫描器就会报错。因此,导出映射是 manifest 的组成部分,而不是可选的便利配置。客户端入口接收扩展了客户端运行时类型的 Cordis Context,所有注册都必须通过 applyctx.slots.register 完成。此处不允许存在模块级副作用。

import type { Context } from 'cordis'
import type { DshClientContext } from '@deepseek-ai/dsh-client-runtime'

export async function apply(ctx: Context & DshClientContext) {
  ctx.slots.register({ name: 'domain.entry.slot' }, MyComponent)
}

开始之前,需要了解以下两点。客户端 manifest 中的 inject 只是文档信息,不负责调度:它记录软件包级依赖关系,但不控制激活顺序。external 用于声明基线之外的模块请求,以便在插件请求这些模块之前将其实例化。这是预览版中变化最快的部分,因此应在编写代码当天阅读 harness repository 中的 packages/client/AGENTS.md,而不是在阅读相关指南时参考它。

发布,并说明插件会访问哪些内容

dsh-plugin 主题添加到 GitHub 仓库后,其他人在查找插件时就会在浏览列表中看到它。这意味着你获得了陌生用户的信任,也承担相应责任。这些责任正好对应我们在安装 dsh 插件前的审核指南中要求读者检查的内容,因此,按照该检查清单编写插件,是最容易通过审核的方式。

  • 固定依赖版本。传递依赖使用 caret 范围时,上周安全的软件包本周可能运行不同的代码。这正是npm 供应链攻击服务器的具体机制。
  • 在清单中说明插件会访问哪些内容。你的 inject 列表应诚实、机器可读地概括插件会使用哪些 harness 服务。审核者几秒钟就能读完,并据此形成判断。
  • 不要静默发起网络请求。如果工具会调用 API,请在 README 中写明主机名,并使端点可配置。插件若连接到从未声明的服务器,负责审核此类内容的人员会将其从列表中移除。
  • 严格控制 files。发布整个工作目录,可能导致误放的凭据文件进入注册表。
  • 为 git 安装程序提供 prepare 脚本,确保其无需仅开发环境中的依赖即可完成构建,并在 README 中说明用户必须在其配置文件的 pnpm-workspace.yaml 中允许该构建。
  • 根据你构建并测试所用的候选版本,为 README 添加日期标记。使用预览 API 的读者需要知道你使用的是哪个版本。

要从外部了解一个完整插件的形态,请阅读值得安装的 dsh 插件,并注意每个 README 在安装前说明了哪些内容。如果你曾为其他代理编写扩展,Claude Code 插件的组成方式可以作为有用的对比。harness 会提供一个实时对象图和可逆注册机制。这比文件清单拥有更大的权限,也带来更大的责任。

FAQ

编写 dsh 插件是否必须发布到 npm?

不需要。在 cordis.yml overlay 中使用绝对文件系统路径,并通过 dsh web --patch ./scratch-plugin/cordis.yml 加载,即可在 harness 中运行自己的代码。路径必须是绝对路径。只有在其他人需要安装插件时,打包才有意义。即使在这种情况下,也可以使用 dsh plugin --profile demo add ./my-plugin 安装本地目录,在不接触 registry 的情况下测试打包后的形式。

为什么插件已加载,但工具始终没有出现?

先运行 dsh --profile demo --dump-config。如果输出中没有您的 row id,说明插件根本没有挂载,原因在于组合配置,而不是代码。如果存在该 row,请检查 export const inject = ['tools']。Cordis 配置中的条目会并发启动,因此文件顺序不会决定加载顺序。如果没有该声明,Cordis 不会等待工具注册表,您的 apply 可能在 ctx.tools 尚不可用时运行,因而无法完成注册。

cordis.yml 与 cordis.patch.yml 有什么区别?

cordis.yml 是完整的条目列表。cordis.patch.yml 是叠加在其上的一层,按 id 定位 row,用于插入新 row 或替换现有配置。bundle 通过 package.json 中的 dsh.bundle.patch 指向自己的 patch 文件。各层按固定顺序应用:先按 profile 中列出的顺序处理每个 bundle,然后处理 profile 的 patch 文件,接着处理 $DSH_HOME/cordis.patch.yml,最后处理任何 --patch overlay。后应用的层优先。

agent 运行时可以热重载 dsh 插件吗?

截至 0.1.0-rc.7,web profile 中的 host 部分不支持。该 bundle 发布时会禁用共享的热模块重载 row,并在文件中注明:完成重载生命周期测试后才会恢复。请改为设计快速重启方案:使用单个文件,通过 --patch 加载且无需构建步骤;所有注册都通过 ctx 完成,避免资源从一次运行泄漏到下一次运行。对于 Cordis 无法自行清理的资源,请使用带 disposer 的 ctx.effect()