很多程序员第一次学编程,都是从一句 Hello, World! 开始。屏幕上打印出的那行字看似简单,却意味着编辑、编译和运行这条最小链路终于真正走通了。

看过不少 DeepSeek Harness 的分析文章和架构介绍,但只要没有亲手把插件跑起来,对它的理解就总像隔着一层皮。所以这次我不再停在“看懂了”,而是沿着官方文档亲手做了一个 Hello Plugin:从最小插件、工具注册和配置校验,到组合包安装、profile 加载、发布与卸载,把插件开发的完整链路实操了一遍。

为了让这次实践不只停留在文章里,我把相关代码、案例和学习记录整理到了自己的开源项目 FlyAIBox/dsh101[1]。你可以一边阅读本文,一边对照仓库中的实际文件动手验证。

DeepSeek Harness 仍处于 Developer Preview(开发者预览)阶段,接口、包名和配置方式未来可能发生破坏性变化。实操时请优先以你当前 checkout 中的文档和 TypeScript 类型为准。

这篇文章的目标不是再讲一遍概念,而是让你能够跟着步骤把东西做出来。它覆盖本地插件、工具注册、配置校验、组合包安装、生命周期、服务和事件系统,并说明以下信息:

  • 每一段代码在框架中扮演什么角色;
  • 为什么官方要求这样配置;
  • 每一步做完后如何确认成功;
  • 容易混淆或直接导致失败的地方;
  • 哪些是官方文档明确说明的机制,哪些只是本文给出的实操建议。

一、完成这篇教程后,你会得到什么

你将依次完成四个可验证结果:

  1. 一个能够被 DeepSeek Harness Web UI 加载的最小插件;
  2. 一个可以被模型调用的 greet 工具;
  3. 一个接受 cordis.yml 配置并在加载时完成校验的插件;
  4. 一个能够安装进独立 profile 的本地组合包。

在完成代码练习后,你还会理解三组底层机制:

  • 插件为什么会等待依赖、自动卸载和自动清理;
  • 插件如何通过 Service 提供和消费能力;
  • 插件如何通过事件广播、短路和包装系统行为。

如果不想一开始就陷入实现细节,可以先用下面这张图浏览全文脉络:上层是最终做出的四个成果,中间是从源码启动到 npm 发布的八步动手路径,下层是每个关键阶段可以直接观察到的验收信号。

可以先用下面这张关系图建立整体印象:

profile
  └── 按顺序加载多个 bundle 的配置层
        └── cordis.patch.yml 插入或覆盖插件行
              └── loader 加载插件模块
                    └── Cordis 创建 Fiber
                          ├── 等待 inject 声明的服务
                          ├── 调用 apply(ctx, config)
                          ├── 跟踪工具、事件与 effect
                          └── 卸载时统一 dispose

二、开始之前:准备官方源码运行环境

官方“第一个插件”教程不是从空目录开始,而是假设你已经完成“从源码运行”。如果还没有准备,请先执行:

git clone https://github.com/deepseek-ai/deepseek-harness.git
cd deepseek-harness
pnpm install
pnpm run build
pnpm dsh web

官方明确说明:

  • pnpm run build 用于准备仓库产物;
  • pnpm dsh web 会直接使用已经构建好的产物,不会在每次启动时自动重新构建;
  • Web UI 默认运行在 http://127.0.0.1:3080。

因此,如果你刚拉取新提交、切换了分支,或者修改了需要构建的前端和包产物,应该主动重新运行 pnpm run build。不要把“启动命令能执行”误认为“当前浏览器里一定是最新代码”。

2.1 建议先做三个检查

在 DeepSeek Harness 仓库根目录运行:

node --version
pnpm --version
pwd

请记下 pwd 打印的绝对路径。后面注册本地插件时会用到它。例如:

/Users/your-name/code/deepseek-harness

2.2 关于模型凭据

“插件被加载”只需要 Web 服务正常启动;但要在聊天中让模型实际调用 greet,还需要 Web UI 中存在可用的模型提供方和凭据。你可以在设置页面配置,也可以按当前官方提供方文档配置环境或凭据文件。

这是两个不同的验收点:

  • 终端出现 [hello-plugin] plugin loaded!,证明插件加载成功;
  • 模型调用 greet 并收到 Hello, Ada!,证明工具注册、模型选择和凭据链路都正常。

如果第一个成功而第二个失败,不应先怀疑插件加载器,应先检查模型与凭据设置。

三、第一阶段:创建最小插件

3.1 创建练习目录

在 DeepSeek Harness 仓库根目录执行:

mkdir -p scratch-plugin/src

此时目录结构应该是:

deepseek-harness/
└── scratch-plugin/
    └── src/

这里的 scratch-plugin 是临时练习项目,不是最终发布包。官方用它演示如何通过一个 patch overlay,把仓库外的自定义能力插入现有 Web profile。

3.2 插件到底是什么

官方给出的最小定义是:在 Harness 中,插件是一个导出 `apply` 函数的 TypeScript 模块。

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

export const name = 'my-plugin'

export function apply(ctx: Context) {
  // Register capabilities here.
}

逐项解释:

  • Context 是 Cordis 提供的上下文类型;
  • name 是插件名称,主要用于识别与诊断;
  • apply 是插件入口;
  • ctx 是插件访问服务、注册工具、监听事件、创建子插件和登记清理逻辑的统一入口。

“插件加载”不是简单执行一个文件。更准确地说,Cordis 会为插件建立一个有生命周期的作用域,等待依赖满足,然后执行 apply,并记录插件通过 ctx 注册的资源,以便将来统一卸载。

3.3 创建插件文件

创建 scratch-plugin/src/my-plugin.ts:

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

export const name = 'hello-plugin'

export function apply(ctx: Context) {
  // Required dependencies are ready before apply runs.
  console.log('[hello-plugin] plugin loaded!')
}

这段代码虽然没有使用 ctx,但仍然保留参数,是为了展示标准插件入口。加载成功后,apply 会执行一次,并向启动终端打印日志。

3.4 用 cordis.yml 插入本地插件

先在仓库根目录运行:

pwd

然后创建 scratch-plugin/cordis.yml,把下面的示例根路径换成 pwd 的真实输出:

- insert:
    - id: hello
      name: '/absolute/path/to/deepseek-harness/scratch-plugin/src/my-plugin.ts'

假设你的仓库位于 /Users/your-name/code/deepseek-harness,实际内容应类似:

- insert:
    - id: hello
      name: '/Users/fly/code/deepseek-harness/scratch-plugin/src/my-plugin.ts'

这里有三个需要分开的概念:

  • id: hello 是这条配置行的稳定标识,后续 patch 可以按 id 覆盖它;
  • name 是 loader 要加载的模块位置;
  • insert 表示把这条插件配置插入现有配置树。

官方特别强调,本地插件路径必须使用绝对路径。

原因是 --patch 文件只贡献配置层,它不会把 loader 解析模块时的 profile 目录改成 patch 文件所在目录。换句话说,cordis.yml 放在 scratch-plugin 目录中,不代表 ./src/my-plugin.ts 一定相对于 scratch-plugin 解析。

实操提示:后续官方“插件配置”页面的 YAML 示例写了 name: './src/my-plugin.ts'。这与“必须使用绝对路径”的说明并不一致。为了让整条源码练习路径稳定可复现,本文后续继续使用绝对路径,只修改 config 部分。

3.5 启动 Web UI

在仓库根目录执行:

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

然后打开:

http://127.0.0.1:3080

成功时,启动终端会出现:

[hello-plugin] plugin loaded!

![图片展示的是在终端执行命令启动Web UI时的输出结果。命令为“pnpm dsh web --patch ./scratch-plugin/cordis.yml”,成功后终端显示“\[hello-plugin\] plugin loaded!”,并提示“asn web: http://127.0.0.1:3080”。该图片与上文“3.5启动Web UI”内容相关,用于说明在仓库根目录执行相关命令后,成功启动Web UI时终端的输出情况。](https://media.fange-kb.cn/assets/f5/f534c4ded0c4b225a044b37c541a40ac8114c1150aeb5b22cf9384c9905dffee/original.png)

3.6 本阶段验收清单

  • scratch-plugin/src/my-plugin.ts 已创建;
  • scratch-plugin/cordis.yml 中使用真实绝对路径;
  • Web UI 可以打开;
  • 启动终端打印 [hello-plugin] plugin loaded!;
  • 终端没有模块解析或 TypeScript 加载错误。

如果提示找不到模块,优先检查:

  1. name 是否仍是示例路径;
  2. 路径是否指向 my-plugin.ts 文件本身;
  3. 启动命令是否在 DeepSeek Harness 仓库根目录执行;
  4. 仓库是否已完成 pnpm install 和 pnpm run build。

四、插件为什么可以自动清理

官方文档说明,通过 ctx 注册的事件监听、工具等资源,会在插件卸载时自动清理。对于框架不知道如何关闭的外部资源,则使用 ctx.effect() 显式提供清理函数。

官方示例:

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

export function apply(ctx: Context) {
  ctx.effect(() => {
    const timer = setInterval(() => {
      console.log('heartbeat')
    }, 5000)

    // The returned function runs when the plugin unloads.
    return () => clearInterval(timer)
  })
}

这段代码的关键不在定时器,而在资源所有权:

  1. 插件加载时执行 effect,创建定时器;
  2. effect 返回 disposer,也就是资源释放函数;
  3. 插件卸载或被热替换时,Cordis 调用 disposer;
  4. 旧插件留下的定时器被清除,不会和新实例一起重复运行。

数据库连接、WebSocket、消息订阅和临时文件句柄也可以采用同样模式。判断是否要使用 `ctx.effect()` 的简单标准是:这个资源是否需要在插件失效时主动关闭,并且 Cordis 是否无法通过已有注册 API 自动知道该怎么关闭。

官方生命周期文档进一步明确:处置器会按注册顺序的逆序开始调用,但多个异步处置器可能并发执行,不保证一个完成后才执行下一个。如果两个清理步骤存在严格顺序依赖,应把它们放进同一个 disposer,并在内部串行 await。

五、声明插件依赖:inject

当插件需要 tools、llm 等服务时,必须声明依赖:

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

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

export function apply(ctx: Context) {
  // ctx.tools is ready here.
  ctx.tools.register(/* ... */)
}

inject 不是一份供人阅读的依赖说明,而是加载时真正参与调度的契约。它告诉 Cordis:只有在 tools 服务可用时,这个插件才能进入 apply。

它带来两项官方保证:

  • apply 执行时,声明的必需服务已经就绪;
  • 运行中如果必需服务消失,依赖插件会自动卸载;服务恢复后,插件会重新加载。

因此不要用“插件先加载,再在内部轮询等待服务”的方式绕过依赖系统。依赖关系应该被框架看见,Cordis 才能正确处理启动顺序、热替换和级联卸载。

六、插件支持的三种形态

6.1 函数形式

export const name = 'my-plugin'

export function apply(ctx: Context) {
  // ...
}

这是大多数业务扩展的首选,入口直接、状态少,也最接近本教程的代码。

6.2 对象形式

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

export default {
  name: 'my-plugin',
  inject: ['tools'],
  apply(ctx: Context) {
    // ...
  },
}

对象形式把 name、inject 和 apply 聚合在一个默认导出中,适合希望将插件元数据放在一起的情况。

6.3 类形式

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

export default class MyService extends Service {
  static inject = ['tools']

  constructor(ctx: Context) {
    super(ctx, 'myService')
    // Perform synchronous initialization in the constructor.
  }
}

官方建议:大多数情况下函数形式已经足够;当插件需要向其他插件提供服务时,可以使用继承 `Service` 的类形式。这里的 `super(ctx, 'myService')` 会把服务以 `myService` 这个名字挂载到上下文能力体系中。

七、第二阶段:把插件升级为模型可调用的工具

现在将最小插件替换为一个 greet 工具。

7.1 替换插件代码

将 scratch-plugin/src/my-plugin.ts 完整替换为:

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}!`
    },
  }))
}

7.2 逐项解释工具定义

export const inject = ['tools']

表示插件依赖工具注册表。只有 tools 服务准备好后,apply 才执行。

ctx.tools.register(...)

把一个工具定义注册到当前运行时。该注册属于插件作用域,插件卸载时会自动撤销。

defineTool({...})

创建规范工具定义。官方说明它会根据 parameters 推导并校验 args,因此 execute 里可以按声明后的结构访问 args.name。

name 与 description

这是提供给工具系统和模型的基本识别信息。工具名是调用标识,描述帮助模型判断什么时候应该选择它。

parameters

声明工具输入。这里要求一个必填字符串 name。模型如果发出不符合结构的参数,工具层可以在执行前发现问题。

execute

是真正的业务逻辑。它返回规范值 Hello, ${args.name}!。

output.schema

声明 execute 的规范返回值是字符串。规范值适合程序继续处理,不等同于最终直接展示给模型的内容块。

output.render

把规范值转换成面向模型的内容。这里生成一个文本内容块:

[{ type: 'text', text: value }]

因此完整链路是:

模型生成工具调用
  → parameters 校验参数
  → execute 执行业务逻辑
  → output.schema 约束规范结果
  → output.render 转换为模型可见内容
  → 模型收到 Hello, Ada!

7.3 启动并调用工具

如果服务没有运行,重新执行:

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

打开 http://127.0.0.1:3080,在对话中输入官方示例:

Use the greet tool to greet Ada.

预期结果是:模型选择 greet,传入类似 {"name":"Ada"} 的参数,并收到:

Hello, Ada!

7.4 本阶段验收清单

  • 插件声明了 inject = ['tools'];
  • Web 启动没有提示 tools 服务或工具 schema 错误;
  • 当前 Agent 能看到 greet 工具;
  • 模型发起了工具调用,而不是只用自然语言假装执行;
  • 工具结果中出现 Hello, Ada!。

如果模型只回复了一句问候、没有真正调用工具,可以先用更明确的提示词,确认当前 Agent preset 是否暴露该工具,并检查 Web UI 中的工具调用记录。插件代码正确不代表模型必然在每次对话中选择调用工具。

八、第三阶段:为插件增加可校验配置

配置的目的不是简单把常量搬到 YAML,而是让不同部署可以改变行为,同时让无效配置在插件启动阶段立即失败。

8.1 官方基础 Config 示例

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

export const name = 'my-plugin'

export interface Config {
  greeting: string
  maxRetries: number
  verbose?: boolean
}

export const Config: Schema = Schema.object({
  greeting: Schema.string().default('Hello'),
  maxRetries: Schema.number().default(3),
  verbose: Schema.boolean().default(false),
})

export function apply(ctx: Context, config: Config) {
  console.log(config.greeting)  // User value or schema default.
}

这里有两个同名导出:

  • interface Config 服务于 TypeScript,在开发阶段提供类型检查;
  • const Config 是运行时 schema,服务于 Cordis,在插件加载时校验真实配置并填充默认值。

只有 TypeScript 接口不够,因为接口在编译后不会作为运行时校验器存在;只有运行时 schema 也不理想,因为插件代码会失去明确的静态类型。两者同名,是官方采用的类型与运行时约束配对方式。

不要导出一个普通对象冒充 Config。官方明确指出,普通对象不满足 Cordis 要求的 Standard Schema 接口。

8.2 在 cordis.yml 中传入配置

官方配置页给出的结构是:

- insert:
    - id: hello
      name: './src/my-plugin.ts'
      config:
        greeting: 'Hi there'
        maxRetries: 5

但在当前“仓库根目录 + --patch ./scratch-plugin/cordis.yml”练习中,请保留第一阶段使用的绝对路径:

- insert:
    - id: hello
      name: '/absolute/path/to/deepseek-harness/scratch-plugin/src/my-plugin.ts'
      config:
        greeting: 'Hi there'
        maxRetries: 5

verbose 没有在 YAML 中提供,因此 schema 会填入默认值 false。greeting 和 maxRetries 则使用用户传入值。

8.3 严格校验示例

官方进一步给出:

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

export const name = 'validated-plugin'

export interface Config {
  apiKey: string
  timeout: number
  mode: 'fast' | 'accurate'
}

export const Config = Schema.object({
  apiKey: Schema.string().required(),
  timeout: Schema.number().default(30000),
  mode: Schema.union(['fast', 'accurate']).default('fast'),
})

export function apply(ctx: Context, config: Config) {
  // config is validated and type-safe.
}

此时:

  • 缺少 apiKey 会导致加载失败;
  • timeout 未提供时会得到 30000;
  • mode 只能是 fast 或 accurate,其他值会在加载时失败;
  • apply 接收到的是已经完成校验和默认值填充的配置。

这种“尽早失败”非常重要。配置错误如果没有在加载阶段暴露,就可能在真正处理任务、调用外部服务甚至写入业务数据时才出现。

8.4 官方配置设计原则

第一条:不同部署可能采用不同值的参数,应该定义为配置字段。

// Wrong: hardcoded timeout.
const TIMEOUT = 30000

// Correct: configurable.
export interface Config {
  timeoutMs: number  // Defaults to 30000.
}

检验方式是:能否只修改 cordis.yml 就改变这个值,而不需要修改插件源码。

第二条:配置错误应该“响亮”。

应尽量在 schema 中完整表达必填、类型、枚举和默认值约束,让无效部署在插件加载时失败,而不是进入运行状态后静默使用错误值。

第三条:服务或已经注册资源的引用不应伪装成普通配置。它们应通过依赖注入获得,这样框架才能管理可用性和生命周期。

8.5 配置与 HMR

HMR 是 Hot Module Replacement(热模块替换)。它指应用运行期间,在不重启整个 dsh web 进程的情况下,重新加载发生变化的插件代码或配置。可以把标题改得更直白:

官方说明,修改 cordis.yml 中某个插件的 config 会触发热替换:

  1. 卸载旧插件实例;
  2. 清理旧实例注册的工具、事件与 effect;
  3. 使用新配置加载新实例;
  4. 再次执行 apply(ctx, config)。

这解释了为什么自动清理不是附属功能,而是插件开发的基础。如果旧注册无法可靠撤销,修改一次配置就可能得到两个工具、两个监听器或两条后台连接。

九、第四阶段:打包成可安装组合包

前面的 --patch 适合本地开发。要把插件交付给其他 profile 使用,需要理解 bundle 和 profile。

9.1 两个概念、两种 manifest

官方定义如下:

  • bundle(组合包):附带一个配置层的 npm 包;在 package.json 中声明 dsh.bundle,回答“这个包贡献什么”;
  • profile:位于 $DSH_HOME/profiles/ 的可启动组合目录;在 package.json 中声明 dsh.profile,回答“这套配置包含哪些 bundle,以及按什么顺序组合”。

一句话区分:

插件作者交付 bundle;用户启动 profile。

官方还明确说明,没有一个东西同时既是 bundle 又是 profile。

9.2 创建组合包目录

在适当的工作目录执行:

mkdir -p hello-plugin

最终结构如下:

hello-plugin/
├── package.json
├── cordis.patch.yml
└── index.js

三个文件的职责分别是:

  • package.json:声明 npm 包和 dsh.bundle manifest;
  • cordis.patch.yml:声明这个 bundle 加入 profile 时贡献的配置层;
  • index.js:真正的插件模块入口。

9.3 创建 package.json

{
  "name": "dsh-hello-plugin",
  "version": "0.1.0",
  "type": "module",
  "main": "index.js",
  "files": ["index.js", "cordis.patch.yml"],
  "dsh": { "bundle": { "patch": "./cordis.patch.yml" } }
}

重点解释:

  • type: module 表示该包使用 ESM(ECMAScript Modules)模块系统,代码通过 import 和 export 引入、导出功能;
  • main 指向插件入口;
  • files 决定打包发布时包含哪些文件;
  • dsh.bundle.patch 告诉 dsh:启用这个包时,应把哪个 patch 加入配置层栈。

9.4 创建插件入口

创建 hello-plugin/index.js:

export const name = 'hello-plugin'

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

这里使用 JavaScript,是为了得到一个不需要额外构建步骤的最小可安装包。后面从 GitHub 安装 TypeScript 源码时,会看到为什么构建环节需要额外处理。

9.5 创建 bundle patch

创建 hello-plugin/cordis.patch.yml:

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

与本地 --patch 不同,这里不再写源码绝对路径,而是写 npm 包名。安装进 profile 后,Node 模块解析会从 profile 的依赖中找到 dsh-hello-plugin。

如果一个 npm 包没有声明 dsh.bundle,它仍然可以被安装,但只会成为普通依赖;dsh plugin 会给出警告,也不会自动把它作为配置层激活。这种形式适合“只供其他插件 import 的库”,不适合“用户安装后就应启用的插件组合包”。

9.6 profile 中有什么

profile 目录主要包含:

  • package.json:记录 profile 的外部依赖、dsh.profile manifest 和有序 bundles 列表;
  • cordis.patch.yml:用户自己的 profile patch,应用在所有 bundle 层之后。

profile manifest 不需要用户手写,官方 dsh plugin 命令负责创建和维护。

9.7 安装进 demo profile

在包含 hello-plugin 目录的位置执行:

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

如果你正在 DeepSeek Harness 源码 checkout 中、没有使用全局安装的 CLI,则按官方提示改为:

pnpm dsh plugin --profile demo add ./hello-plugin

首次执行该命令pnpm dsh plugin --profile demo add ./hello-plugin时,如果 $DSH_HOME/profiles/demo 尚不存在,dsh 会创建这个 profile,并生成 package.json、cordis.patch.yml 和 pnpm-workspace.yaml。由于 demo 不是内置 profile,其 dsh.profile.bundles 初始只包含 @deepseek-ai/dsh-base。随后,pnpm 会把本地 hello-plugin checkout 链接到 profile 的 node_modules,并写入 dependencies 和 pnpm-lock.yaml。安装成功后,dsh 读取插件的 package.json;如果插件声明了 dsh.bundle.patch,就把插件的真实包名追加到 dsh.profile.bundles。下次运行 dsh --profile demo 时,dsh 会依次应用基础 bundle、插件 bundle 和 profile 自己的配置层。

生成结果类似:

{
  "name": "dsh-profile-demo",
  "private": true,
  "dependencies": {
    "dsh-hello-plugin": "link:/path/to/hello-plugin"
  },
  "dsh": {
    "profile": {
      "bundles": [
        "@deepseek-ai/dsh-base",
        "dsh-hello-plugin"
      ]
    }
  }
}

9.8 先检查配置,再启动

dsh --profile demo --dump-config
dsh --profile demo

在源码 checkout 中对应为:

pnpm dsh --profile demo --dump-config
pnpm dsh --profile demo

--dump-config 不启动应用,而是打印最终组合配置。输出中应看到类似 # == dsh-hello-plugin 的层标记。这是非常实用的排错方法:先证明 bundle 已进入配置层栈,再排查模块加载或插件运行。

移除命令是:

dsh plugin --profile demo remove dsh-hello-plugin

官方说明,该命令会同时移除依赖和对应配置层。添加、移除或更新 bundle 后,需要重启正在运行的 profile,当前进程不会在运行中更换整套 bundle 集合。

广告媒流(小程序)-AI生图

十、配置层的加载顺序

上一节已经把第三方 bundle 安装进 demo profile,并通过 --dump-config 确认它进入了配置层栈。但一个 profile 的最终配置并不只来自刚安装的 bundle:基础 bundle、其他已安装 bundle、用户保存的配置覆盖,以及本次启动临时传入的覆盖都可能修改同一条插件配置。

要判断某个配置值最终从哪里来,首先需要明确这些配置层的应用顺序。

dsh 从空配置开始,依次应用以下配置层:

  1. dsh.profile.bundles 中每个 bundle 的 patch,按列表顺序应用;
  2. profile 自己的 cordis.patch.yml;
  3. home 级 $DSH_HOME/cordis.patch.yml;
  4. 命令行中每个 --patch  overlay,按参数顺序应用。

可以把它理解为:

空配置
  → base bundle
  → 后续已安装 bundle
  → 当前 profile 的用户覆盖
  → 本机所有 profile 共享的覆盖
  → 本次启动命令临时传入的覆盖

越晚应用的层优先级越高。

10.1 不是深度合并,而是整段替换

这是最容易踩坑的官方规则之一:patch 命中目标行时,会替换这一行的整个 config 值,不是只深度合并你写出的几个键。

假设前一层为:

- id: example
  config:
    host: 127.0.0.1
    port: 3080
    verbose: false

后一层如果只写:

- id: example
  config:
    port: 8080

不能想当然地认为最终 host 和 verbose 一定保留。组合包作者覆盖已有行时,应重述该行需要的全部配置键;其余默认值尽量交给插件导出的 schema 处理。

这项设计也给用户保留了控制权:用户可以在 profile 的 cordis.patch.yml 中覆盖第三方 bundle 的行,而不需要修改第三方包源码。

10.2 内置包与外部包的解析

官方说明,内置 bundle 名称从 dsh 安装目录解析;pnpm 主要管理 profile 的树外依赖。因此第三方 bundle 可以依赖与当前 dsh 安装保持一致的 @deepseek-ai/dsh-base。

十一、让应用型 bundle 拥有自己的命令行参数

如果 bundle 不只是增加能力,还定义了一套可运行应用,可以挂载一个普通的启动参数提供方插件:

- id: hello-startup
  name: 'dsh-hello-plugin/startup'

官方约定是:

  1. 提供方插件声明 inject = ['cmdlineArgs'];
  2. 使用自己的 commander program 调用 @deepseek-ai/dsh-cmdline 的 parseCmdline;
  3. 在自己的 action 中把解析结果作为应用自有服务提供;
  4. 依赖这些参数的其他插件行注入该服务,并在 !!js 配置表达式中读取。

例如:

- id: my-app
  name: '@example/my-app'
  inject: [myAppStartup]
  config:
    port: !!js ctx.myAppStartup.port ?? 8080

这表示命令行提供的端口优先,否则回退到部署默认值 8080。

启动器会把自身 flag 之后的同一份不可变参数快照交给每个插件,所以应用可以添加自己的参数,而不必修改 dsh 启动器。多个插件也可以分别解析同一份快照。

遇到 --help 时,提供方不会发布服务,因此依赖它的应用行不会激活。这仍然是普通服务依赖行为,不需要为 loader 增加特殊插件类型。

十二、让自定义插件可以通过一条命令安装

第九节已经创建并本地安装了 hello-plugin/ 组合包。本节不再创建另一套示例,而是在同一个项目上补齐 npm 发布信息,使其他用户可以直接从 registry 安装它。本文使用的 npm 账户名是 flyaibox,因此个人 scope 是 @flyaibox,下面把第九节的 dsh-hello-plugin 改为 @flyaibox/dsh-hello-plugin。

可以先登录并确认当前 npm 账户:

npm login
npm whoami

npm whoami 应输出:

flyaibox

npm scope 来自 npm 用户名或组织名,不是自动读取的 GitHub 用户名。本节后面的 github:your-name/... 中,your-name 仍应替换为实际的 GitHub 仓库 owner;只有两个平台的账户名相同时,它才也写成 flyaibox。

发布后,用户可以执行:

dsh plugin --profile web add -w @flyaibox/dsh-hello-plugin

第九节使用 demo profile,是为了在只有基础 bundle 的环境中验证本地组合包;本节改用 web profile,是为了演示普通用户把已发布插件加入 Web 应用的实际命令。profile 虽然不同,安装的仍是同一个 hello-plugin 案例。

其中:

  • --profile web 指定插件安装到 Web profile,而不是安装到 dsh 程序本身;
  • add 让 pnpm 从 npm 下载包并写入 profile 的 dependencies;
  • -w 是 pnpm 的 --workspace-root 简写,明确允许把依赖写入 profile 的 workspace 根包;
  • 安装成功后,dsh 会检查包的 package.json;只有声明了 dsh.bundle 的包才会自动加入 dsh.profile.bundles,成为启动时应用的配置层。

下面沿用第九节的实操结果,完整走通“补充发布信息 → 本地打包验证 → 发布 npm → 用户安装 → 更新和卸载 → 提供 GitHub 源安装器”的交付流程。

12.1 准备一个可安装的组合包

继续使用第九节已经创建的目录,不需要重新创建 index.js 或另起一个插件项目:

hello-plugin/
├── package.json
├── index.js
└── cordis.patch.yml

这一步包含两处修改,没有新增运行时文件:

  1. 修改 `hello-plugin/package.json`:把包名改为本文使用的 npm scope @flyaibox,并补充公开发布所需的元数据和导出声明;
  2. 修改 `hello-plugin/cordis.patch.yml`:把配置行中的模块名同步改为新的 scoped package 名;
  3. 保持 `hello-plugin/index.js` 不变:插件入口、日志和运行行为继续沿用第九节的实现。

下面先用 JSONC 注释标出相对于第九节的修改。JSONC 只用于讲解,真实的 package.json 不允许保留这些注释:

{
  // 修改:第九节是 "dsh-hello-plugin";本文使用 npm 账户 flyaibox 的个人 scope。
  "name": "@flyaibox/dsh-hello-plugin",
  "version": "0.1.0",

  // 新增:供 npm 包详情页和许可证识别使用。
  "description": "A minimal installable DeepSeek Harness plugin.",
  "license": "MIT",

  "type": "module",

  // 修改:仍指向第九节的 index.js,只把路径写成明确的相对形式。
  "main": "./index.js",

  // 新增:明确允许用户通过包根路径加载插件入口。
  "exports": {
    ".": "./index.js",
    "./package.json": "./package.json"
  },

  // 保持:继续发布第九节已有的入口和 bundle patch。
  "files": [
    "index.js",
    "cordis.patch.yml"
  ],

  // 新增:scoped package 默认可能是私有包,这里明确按公开包发布。
  "publishConfig": {
    "access": "public"
  },

  // 新增:声明插件要求的最低 Node.js 版本。
  "engines": {
    "node": ">=22.19"
  },

  // 保持:这是第九节已经添加的组合包声明,不能删除。
  "dsh": {
    "bundle": {
      "patch": "./cordis.patch.yml"
    }
  }
}

实际写入 hello-plugin/package.json 的有效 JSON 如下:

{
  "name": "@flyaibox/dsh-hello-plugin",
  "version": "0.1.0",
  "description": "A minimal installable DeepSeek Harness plugin.",
  "license": "MIT",
  "type": "module",
  "main": "./index.js",
  "exports": {
    ".": "./index.js",
    "./package.json": "./package.json"
  },
  "files": [
    "index.js",
    "cordis.patch.yml"
  ],
  "publishConfig": {
    "access": "public"
  },
  "engines": {
    "node": ">=22.19"
  },
  "dsh": {
    "bundle": {
      "patch": "./cordis.patch.yml"
    }
  }
}

与第九节相比,最终结果保留了原来的 type、入口文件、发布文件列表和 dsh.bundle.patch,并增加了:

  • scoped npm 包名 @flyaibox/dsh-hello-plugin;
  • description 和 license;
  • 显式的 exports;
  • publishConfig.access: public 允许公开发布 scoped package;
  • engines.node 最低版本声明。

如果使用 TypeScript,应先把源码构建为 lib/index.js,再把 main、exports 和 files 改为对应的 lib/ 路径。不要发布一个只包含 src/*.ts、安装后却没有运行时入口的包。

hello-plugin/index.js 不需要修改,继续使用第九节的代码:

export const name = 'hello-plugin'

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

修改 hello-plugin/cordis.patch.yml。YAML 注释可以保留在真实文件中:

- insert:
    # 保持:沿用第九节的稳定配置行 id,后续覆盖仍然可以按 hello 定位。
    - id: hello
      # 修改:第九节是 dsh-hello-plugin;发布 scoped package 后必须同步包名。
      name: '@flyaibox/dsh-hello-plugin'

package.json.name 与 patch 中的 name 必须一致,否则 pnpm 虽然能安装组合包,loader 却会继续查找第九节的旧模块名 dsh-hello-plugin。id: hello 是配置行身份,不是 npm 包名,所以改包名时不需要修改它。如果 patch 还引用了其他第三方 npm 包,应把那些包列入当前组合包的 dependencies,确保用户只安装这个组合包就能得到完整运行依赖。

第九节的 demo profile 已经记录了旧包名。修改名称后如果还要继续使用该 profile,应先删除旧依赖,再从同一个 checkout 重新安装:

dsh plugin --profile demo remove dsh-hello-plugin
dsh plugin --profile demo add ./hello-plugin

重新安装后,$DSH_HOME/profiles/demo/package.json 中的 dependencies 和 dsh.profile.bundles 会改为 @flyaibox/dsh-hello-plugin;未设置 DSH_HOME 时,该文件位于 ~/.dsh/profiles/demo/package.json。

12.2 发布前先用 tarball 验证

先检查 npm 最终会收进哪些文件:

cd hello-plugin
npm pack --dry-run

输出中至少应该包含:

package.json
index.js
cordis.patch.yml

然后生成真实 tarball:

npm pack

命令会生成类似 flyaibox-dsh-hello-plugin-0.1.0.tgz 的文件。先把它安装进 Web profile:

dsh plugin --profile web add -w ./flyaibox-dsh-hello-plugin-0.1.0.tgz

dsh plugin 会把相对文件路径锚定到当前调用目录,因此不需要手工切换到 ~/.dsh/profiles/web。安装成功后检查最终组合配置:

dsh --profile web --dump-config

输出中应出现模块名 @flyaibox/dsh-hello-plugin 和配置行 id: hello。再启动或重启 Web profile:

dsh web

终端出现下面的日志,说明发布包中的入口和 patch 都能工作:

[hello-plugin] plugin loaded!

如果 dsh plugin 警告包没有声明 dsh.bundle,说明 package.json 中缺少该字段,或者发布包中的 package.json 与本地文件不一致。如果 --dump-config 中已有插件行,但启动时报模块不存在,应检查 files、main、exports 和构建产物。

12.3 发布到 npm

前面已经通过 npm whoami 确认当前登录账户是 flyaibox。发布前检查该 scope 下是否已经存在同名包,并查看已发布版本:

npm view @flyaibox/dsh-hello-plugin version

第一次发布时,npm view 返回 404 是正常现象。完成本地 tarball 验收后发布:

npm publish --access public

npm 的同一版本不能覆盖发布。每次重新发布前都要先更新 package.json 中的版本,例如:

npm version patch
npm publish --access public

发布完成后,可以从一个干净的 profile 再执行一次正式安装验证:

dsh plugin --profile web add -w @flyaibox/dsh-hello-plugin
dsh --profile web --dump-config

这时的完整数据流是:

npm registry
  → pnpm 下载 @flyaibox/dsh-hello-plugin
  → 写入 ~/.dsh/profiles/web/package.json 的 dependencies
  → dsh 发现包声明了 dsh.bundle.patch
  → 把 @flyaibox/dsh-hello-plugin 追加到 dsh.profile.bundles
  → dsh web 启动时应用 cordis.patch.yml
  → loader 从 profile 的 node_modules 加载插件入口

如果设置了 DSH_HOME,profile 会位于 $DSH_HOME/profiles/web,而不是 ~/.dsh/profiles/web。

12.4 用户如何安装和验收

插件 README 应至少给出下面的操作:

dsh plugin --profile web add -w @flyaibox/dsh-hello-plugin
dsh --profile web --dump-config
dsh web

如果 dsh web 已经在运行,应先停止旧进程,再重新启动。安装命令只修改下一次启动使用的 profile 依赖和组合包列表,不会把一个正在运行的进程替换为新配置。

还可以确认 pnpm 为什么安装了这个包:

dsh plugin --profile web why @flyaibox/dsh-hello-plugin

“设置 → 插件”中的页面不是每个插件自动获得的。只有同时发布浏览器端 client bundle,并注册对应设置页的插件,才会在 Web UI 中出现专属入口。对于上面的最小插件,应以 --dump-config 中出现插件行和终端打印加载日志作为验收结果。

12.5 更新和卸载

更新当前版本范围内的依赖:

dsh plugin --profile web update -w @flyaibox/dsh-hello-plugin

需要跨越当前版本范围并升级到 npm 的最新版本时,可以使用:

dsh plugin --profile web update -w --latest @flyaibox/dsh-hello-plugin

更新后重启 dsh web。卸载命令是:

dsh plugin --profile web remove -w @flyaibox/dsh-hello-plugin

pnpm 会从 profile 的 dependencies 删除该包,dsh 随后会把它从 dsh.profile.bundles 中移除。卸载后同样需要重启正在运行的 Web 进程。

12.6 直接安装 GitHub 源码

dsh 原生支持把 pnpm 能理解的 GitHub spec 转发给 profile:

dsh plugin --profile web add -w github:your-name/dsh-hello-plugin

用于可复现安装时,应锁定 commit:

dsh plugin --profile web add -w github:your-name/dsh-hello-plugin#

GitHub 仓库必须满足以下两种条件之一:

  1. 仓库提交了 main 或 exports 指向的构建产物,下载后可以直接运行;
  2. package.json 提供自包含的 prepare 脚本,安装时能够生成构建产物。

十三、写在最后:从 Hello World 到 Hello Plugin

跑通这个 Hello Plugin 之后,DeepSeek Harness 就不再只是一组架构名词。插件如何进入 profile、如何等待依赖、如何注册工具、如何清理副作用,以及最终如何被安装和启动,都变成了可以观察、修改和验证的真实链路。

如果你也觉得只看分析文章始终“隔着一层皮”,最有效的办法仍然是从一个足够小的插件开始:先让它被加载,再让模型调用一个工具,随后逐步加入配置、生命周期和发布流程。就像当年的 Hello, World! 一样,先把第一条链路跑通,后面的复杂机制才真正有了落点。

本文涉及的练习代码、配图案例和后续学习记录会继续整理在 FlyAIBox/dsh101[2]。如果你也在学习 DeepSeek Harness,可以直接从这个项目开始复现,并把自己的实验继续扩展下去。

备注:DeepSeek Harness 仍在快速演进,命令、接口和目录结构请以官方资料为准。DeepSeek Harness 官方基础教程[3]DeepSeek Harness GitHub 仓库[4]

引用链接

[1] FlyAIBox/dsh101: https://github.com/FlyAIBox/dsh101
[2] FlyAIBox/dsh101: https://github.com/FlyAIBox/dsh101
[3] DeepSeek Harness 官方基础教程: https://deepseek-harness.github.io/deepseek-harness/develop/basic/
[4] DeepSeek Harness GitHub 仓库: https://github.com/deepseek-ai/deepseek-harness