SSD Nodes Learn Hosting plans →
指南 Matt Connor作者: Matt Connor

如何编写 DeepSeek Harness 的 dsh 插件

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

dsh 插件实际是什么

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

Cordis 是一个通用组合框架,独立构建而成,多年来一直作为 Koishi 聊天机器人框架的基础。它负责加载和卸载插件,并解析插件之间的依赖关系。它不了解代理。所有与代理相关的功能都来自构建在其上的 harness 包,这也是下面的插件结构如此精简的原因。你获得的大部分能力都是继承而来的。

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

本指南基于 @deepseek-ai/dsh 版本 0.1.0-rc.7 编写,该版本于 19 August 2026 使用 npm latest 标签发布。dsh 目前是开发者预览版,其 README 明确说明后续会有不兼容变更。下面的每个键名都是在该日期从上游文档和代码仓库中读取的。本指南于 12 October 2026 重新检查时,npm latest 标签对应的版本为 0.2.0-rc.2,下面的章节会指出该版本行为不同的地方。在依赖某个键名之前,请再次确认,因为预览版 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() 并向其传入一个释放器。

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

完整的扩展点列表很长。其中两个扩展点几乎涵盖了第一个插件的所有需求。

会话事件是持久化并记录日志的事件流。在 0.1.0-rc.7 中,事件名称为 session/event、turn/start、turn/end、step/start、step/end、user/message、assistant/message、assistant/chunk、tool/call 和 tool/result。0.2.0-rc.2 版本没有 assistant/chunk。实时流式传输已移至 agent/assistant-stream,其帧只保留在进程中,永远不会写入日志;新增的持久化 assistant/attempt 事件会记录失败、重试和取消的尝试。你可以为其附加普通监听器。

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

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

第二个扩展点是瀑布流。agent/pre-step、agent/request、agent/request-error、llm/stream 和 tools/* 事件都属于瀑布流,瀑布流监听器的签名不同。它接收一个 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/* 瀑布流位于下一层,结构相同。tools/pre-execute 会在分发前允许、拒绝或请求批准。tools/execute 包装分发过程。tools/post-execute 可以替换或阻止规范化结果。tools/result 只观察已冻结的结果。

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

bundle 是一个 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,因此包虽然能够安装,却不会向树中贡献任何内容。

请从包含插件目录的目录将其安装到 profile 中。

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

dsh plugin --profile <name> 会将其余参数转发给该 profile 目录中的 pnpm,因此 add 和 remove 的行为与 pnpm 一致。使用 dsh plugin --profile demo remove dsh-plugin-hello 卸载。随附的 web、headless、sdk、sdk-minimal 和 acp profile 会在首次使用时根据模板自行创建。在 0.2.0-rc.2 中,其他名称有两种创建方式。dsh plugin 仅根据基础 bundle 启动它。dsh --profile <name> --from-default-profile <template> 会复制随附模板的 bundle 列表。启动尚不存在的自定义 profile 会失败,并提示运行 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 中的第一条语句。缺少它可以帮助您判断问题属于哪一部分,之后删除它也不会产生额外成本。在服务器上进行迭代时,请在前台运行测试程序,而不要通过服务管理器运行。这样,加载器输出会直接显示在终端中,而不必稍后再去查看日志。

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

在 0.1.0-rc.7 中,主机端的处理方式很简单:重启。该版本的 Web 应用包禁用了共享热模块重载行,并在文件中注明,完成重载生命周期测试后会重新启用。在 0.2.0-rc.2 中,该覆盖配置已移除。基础包会在每个配置文件中挂载 @deepseek-ai/dsh-hmr,并设置 root: [];注释为“配置文件默认重载;模块根目录需显式启用”。现在,配置文件和 home 补丁文件中的有效修改无需重启即可重新应用。除非将插件目录添加到该行的 root,否则系统仍不会监视插件自身的源文件。添加、删除或更新包仍需重启。客户端重载链始终会挂载,但只有构建监视器重写客户端包后才会运行,因此它对 Node 端也不起作用。

应让重启成本更低,而不是依赖必须显式启用的重载机制。将插件保存在单个文件中。使用 --patch 加载插件,而不是将其安装到配置文件中,这样编辑和运行之间无需构建步骤,也无需执行 pnpm 步骤。通过 ctx 注册所有内容,避免重启后残留重复工具或过期监听器。将自行分配的资源放在 ctx.effect() 中,并提供真正的释放函数,因为缺少释放函数的常见表现是第二次运行失败:第一次运行仍占用端口。

如果你是在服务器上运行的测试框架中开发,而不是在笔记本电脑上开发,以上内容都不变,但 Web UI 的绑定方式很重要。本地启动会在 http://127.0.0.1:3080 提供 Web UI,并在默认浏览器中打开;传入 --no-open 后则不会打开浏览器。通过 SSH 启动时只会输出主机 URL,因此不会在你的计算机上打开页面。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 导出,扫描器就会抛出错误。因此,导出映射是清单的一部分,而不是可选的便利配置。客户端入口接收经过 Cordis Context 扩展、加入客户端运行时类型的对象。所有注册都必须通过 apply 中的 ctx.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)
}

开始前需要了解两点。客户端清单中的 inject 用于记录文档信息,而不是调度:它记录包级依赖边,但不控制激活顺序。external 声明基线之外的模块请求,但当前客户端说明指出,它不是功能插件的依赖机制,而是为基础设施、传输层和生成的组装代码保留的。功能插件应将其留空。这是预览版中变化最快的部分。因此,编写代码当天应阅读 harness 仓库中的 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。如果输出中没有您的行 id,说明插件根本没有挂载,原因在组合配置,而不在代码。如果存在该行,请检查 export const inject = ['tools']。Cordis 配置中的条目会并发启动,因此文件顺序不会决定加载顺序。如果没有该声明,Cordis 不会等待工具注册表,您的 apply 可能在 ctx.tools 尚不可用时运行,因而无法完成注册。

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

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

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

在 0.1.0-rc.7 中不可以,因为该版本发布的 web bundle 禁用了共享热模块重载条目。在 0.2.0-rc.2 中,该条目会挂载到每个 profile,但默认只重载 profile 配置。模块根目录必须通过其 root 列表显式启用,因此除非添加插件代码所在目录,否则仍需重启。无论采用哪种版本,都应按快速重启进行设计:使用一个文件,通过 --patch 加载,不执行构建步骤,并通过 ctx 完成所有注册,避免一次运行中的内容泄漏到下一次运行。对于 Cordis 无法自行清理的资源,请使用带 disposer 的 ctx.effect()。