夜雨聆风学习资料网

ARTICLE · 1034327

DeepSeek Harness 插件开发入门:读懂插件结构,手写第一个自定义

DeepSeek Harness 插件开发入门:读懂插件结构,手写第一个自定义

读完这篇,你能从零写出一个真正加载进 dsh 的插件,并且知道它为什么能加载、为什么加载不了。

前置条件:Node.js ≥ 20,已跑通 dsh 并能启动 Web UI。有 TypeScript 基础更好,但没有也能跟。


一、先别写代码:搞懂 dsh 的插件机制

DeepSeek Harness(命令行叫 dsh)是 DeepSeek 开源的 Agent 运行框架,MIT 协议,目前是 developer preview。它的核心口号是一句话:

Everything is a plugin.

这句话不是营销。模型适配器、工具注册表、会话日志、沙箱、存储、调度循环、甚至 Web 界面本身——全是插件。框架里没有一个"特权内核",你在用的所有能力,都是启动时由配置拼装出来的一棵插件树。

底层驱动它的是一个叫 Cordis 的插件框架。你不需要读完 Cordis 的论文,只需要记住三件事。

1. 一个插件 = 一个导出 apply(ctx) 的模块

import type { Context } from '@deepseek-ai/cordis'export const name = 'my-plugin'export function apply(ctx: Context) {  // 在这里注册你的一切能力}

name 是身份证,apply 是入口,ctx 是你和框架交互的唯一通道。想加工具?ctx.tools.register()。想监听事件?ctx.on()。想打日志?ctx.logger

2. 依赖决定加载顺序,而不是文件顺序

插件之间通过 ctx.<key> 互相找服务,彼此不 import 具体实现。你用哪些服务,就在 inject 里声明:

export const inject = ['tools', 'systemPrompt']export function apply(ctx: Context) {  ctx.tools.register(/* ... */)   // 框架保证 tools 已就绪}

框架会等你声明的服务全部就绪之后才调用你的 apply。这就消灭了"插件 A 用了插件 B 的服务,但 B 还没加载完"这个经典问题。这叫空间可组合性——你不用手写启动顺序。

3. 卸载是可逆的:所有注册自动回滚

通过 ctx 注册的一切,框架都记着。插件卸载时:

  • ctx.on(...)
     注册的监听 → 自动 off
  • ctx.tools.register(...)
     注册的工具 → 自动注销
  • ctx.systemPrompt.register(...)
     注入的提示词 → 自动移除

你不需要写任何清理代码。 但框架不知道的资源(定时器、WebSocket、数据库连接、占用端口的 server)要你自己交代后事:

ctx.effect(() => {  const timer = setInterval(() => ctx.logger.info('心跳'), 5000)  return () => clearInterval(timer)   // 卸载时框架调用它})

和 React 的 useEffect 一个思路。忘了写 disposer 的典型症状是:第二次启动报端口被占用


二、一个正式插件的目录结构

dsh 插件有"两半",分别跑在两个环境里:

半区
文件
运行环境
能干什么
宿主端 Host
index.mjs
Node.js 进程
读写文件、执行命令、注册工具/命令/提示词/HTTP 接口
客户端 Client
client/index.mjs
浏览器页面
画 UI、响应点击、调宿主端的接口

两半可以只写一半。纯宿主插件的 client/index.mjs 留空就行。

最小可用结构:

dsh-tool-log/├── package.json          # 包描述 + DSH 装载声明(最关键)├── cordis.patch.yml      # 配置层:声明插件以什么 id 装进配置树├── index.mjs             # 宿主端入口├── client/│   └── index.mjs         # 客户端入口(可选)└── README.md

如果只是想快速验证一个想法,连目录都不用建——一个 .ts 文件 + 一个 cordis.yml 就够了,后面第三节会讲。


三、配置文件:每个字段都是坑

3.1 package.json

这是最讲究的一个文件,逐字段解释:

{  "name": "dsh-tool-log",  "version": "0.1.0",  "description": "记录 Agent 每一次工具调用",  "type": "module",  "main": "index.mjs",  "license": "MIT",  "keywords": ["dsh", "dsh-plugin", "log"],  "peerDependencies": {    "@deepseek-ai/cordis": "^4.0.1"  },  "dsh": {    "bundle": {      "patch": "./cordis.patch.yml"    },    "client": {      "inject": ["@deepseek-ai/dsh-client-locale"],      "platform": "web"    }  },  "exports": {    ".": "./index.mjs",    "./client": "./client/index.mjs",    "./cordis.patch.yml": "./cordis.patch.yml",    "./package.json": "./package.json"  },  "files": [    "index.mjs",    "client",    "cordis.patch.yml",    "README.md"  ]}

关键点:

  • "dsh".bundle.patch
    :告诉 dsh"装载我这个包时,把这份 patch 叠进配置树"。少了这个字段,包会当成普通依赖装进来,配置层永远不激活——这是"明明装上了却什么都没发生"的头号原因。
  • files 必须包含 cordis.patch.yml
    。漏了的话,发布出去的 tarball 里 dsh.bundle.patch 指向一个根本没打包进去的文件,装完照样什么都不做。
  • peerDependencies
     声明"宿主环境会提供这些依赖",插件自己不打包它们。
  • type: "module"
     + main: "index.mjs":ESM 包。

3.2 cordis.patch.yml

- insert:    - id: tool-log      name: dsh-tool-log

就三行:往配置树里插入一行插件。

  • id
    :它在配置树里的名字,自取,建议全小写、且一旦定下就不要改——后续配置层靠 id 定位来覆盖你。
  • name
    必须等于 npm 包名。这两个字符串对不上,模块解析直接失败。

3.3 配置层是怎么叠加的

理解这个,能帮你省掉一半的调试时间。启动时按固定顺序叠:

空配置  → 各 bundle 的 patch(按 profile 里 bundles 的顺序)  → profile 自己的 cordis.patch.yml  → $DSH_HOME/cordis.patch.yml(用户全局)  → 启动参数 --patch 指定的覆盖层

后叠的覆盖先叠的,而且是整行替换,不是深合并——所以你想改一个配置块里的某个键,得把整个块的键都重述一遍。


四、实战:手写一个"工具调用日志"插件

目标:把 Agent 每一次工具调用(名称、参数、成功与否、耗时)写进本地 JSONL 文件,方便审计和回溯。

4.1 宿主端 index.mjs

// index.mjs — dsh-tool-log 宿主端入口import { appendFileSync, mkdirSync } from 'node:fs'import { join } from 'node:path'export const name = 'tool-log'export const inject = ['tools']export async function apply(ctx) {  const dir = join(process.cwd(), 'logs')  mkdirSync(dir, { recursive: true })  const file = join(dir, `tool-calls-${new Date().toISOString().slice(0, 10)}.jsonl`)  const write = (entry) => {    appendFileSync(file, JSON.stringify({      ts: new Date().toISOString(),      ...entry,    }) + '\n')  }  // 记录调用开始  ctx.on('tool/call', (e) => {    write({ phase: 'call', tool: e.name, args: e.arguments })    ctx.logger.info('[tool-log] 调用 %s', e.name)  })  // 记录调用结果  ctx.on('tool/result', (e) => {    write({      phase: 'result',      tool: e.name,      ok: !e.error,      error: e.error?.message,    })  })  ctx.logger.info('[tool-log] 已启动,日志写入 %s', file)}

三个固定约定:

  1. 导出 name(小写);
  2. 导出 apply(ctx)(装载函数,启动时调用一次);
  3. 用了哪些服务就在 inject 里声明。

⚠️ 事件名(tool/call / tool/result)在 developer preview 阶段可能随版本变动。写完如果发现没触发,先去官方文档的 event 列表核对你当前版本的事件名,别急着怀疑逻辑。

4.2 升级:加一条斜杠命令和 HTTP 接口

让插件不只是"默默记录",还能被主动查询:

export const inject = ['tools', 'commands', 'webServer']export async function apply(ctx) {  // ... 上面的日志逻辑 ...  // 斜杠命令:输入框里出现 /logs  ctx.inject(['commands'], (c) => {    c.commands.register({      name: 'logs',      description: '查看今天的工具调用统计',      async handler() {        const stat = summarize(file)        return { kind: 'success', text: stat }      },    })  })  // HTTP 接口:GET /api/tool-log  ctx.inject(['webServer'], (c) => {    c.webServer.register({      kind: 'exact',      path: '/api/tool-log',      handler: async (req, res) => {        res.writeHead(200, { 'content-type': 'application/json' })        res.end(JSON.stringify({ ok: true, file }))      },    })  })}

4.3 带配置的版本(可选但推荐)

把路径之类的写死在代码里是坏习惯。导出一个 Config,让部署方能改:

export const Config = Schema.object({  dir: Schema.string().default('logs'),  maxBytes: Schema.number().default(1024 * 1024),})export function apply(ctx, config) {  const dir = join(process.cwd(), config.dir)  // ...}

有 Config 导出时,apply 的签名是 (ctx, config);没有时是 (ctx)。框架会自动校验配置再传进来。


五、本地加载与调试:三条路

路线 A:临时 overlay(最快,适合验证想法)

不用建完整包,一个文件 + 一个 yml 就能跑。

mkdir -p scratch-plugin/src

scratch-plugin/src/hello.ts

import type { Context } from '@deepseek-ai/cordis'export const name = 'hello'export function apply(ctx: Context) {  ctx.logger.info('[hello] 插件加载成功!')}

scratch-plugin/cordis.yml

- insert:    - id: hello      name: /你的仓库绝对路径/scratch-plugin/src/hello.ts

启动:

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

终端打印 [hello] 插件加载成功! 就成了。

注意:npx 方式启动的 dsh 加载不了本地插件,要源码安装(git clone + pnpm install + pnpm run build)才能走这条路。

路线 B:装进 profile(正式)

# 在插件项目目录下执行cd ~/projects/dsh-tool-logdsh plugin --profile web add ./# 验证配置树(不启动服务)dsh --profile web --dump-config# 启动dsh --profile web

改完代码的完整刷新动作:

Ctrl+C                                  # 1. 停掉 serverdsh plugin --profile web add ./         # 2. 重装(见"副本坑")dsh --profile web                       # 3. 重启Ctrl+Shift+R                            # 4. 浏览器硬刷新

路线 C:调试的正确姿势

先把问题一分为二,能省掉大量瞎猜:

dsh --profile web --dump-config
  • 输出里没有你的 id
     → 是装配问题:包名没解析到、patch 没打包、缺 dsh.bundle 字段。先修配置,别碰代码。
  • 有 id 但没效果
     → 是代码问题:去看终端堆栈。

再记住一条:

  • apply
     里抛异常是 loud 的:进程退出 + 完整堆栈,直接指到你那一行。
  • 模块解析失败是 silent
     的:Cordis 只通过 logger 报一句,而且这些日志可能在 console exporter 挂上之前就输出了,看起来像"插件加载了但什么都没做"。

所以开发期请在 apply 的第一行留一句 console.log('[my-plugin] apply 进来了')它没打印,你就知道是哪一半的问题。


六、踩坑清单(按被坑概率排序)

1. 改了代码页面没反应 —— 副本坑 + 没有热加载

dsh plugin add ./ 走的是 pnpm 的 file: 协议,会把包复制进 profile 的 node_modules,不是软链。你改的是源文件,跑的是副本。而且 dsh 目前没有宿主端热加载——Web app bundle 的 HMR 行是关着的。

解:改完 → dsh plugin --profile web add ./ → 重启 → 硬刷新 → 开一个新会话(旧会话不会加载新插件)。

2. 忘了声明 inject

// ❌ 用了 tools 却没声明export function apply(ctx) {  ctx.tools.register(/* ... */)   // ctx.tools is undefined}// ✅export const inject = ['tools']export function apply(ctx) {  ctx.tools.register(/* ... */)}

ctx.tools 是别的插件提供的服务,那个插件没加载完它就是 undefined

3. 用了 export default

具名导出和默认导出混用,会让 Loader 丢掉 inject 等元数据。函数插件一律用具名导出export const name / export const inject / export function apply

4. 路径写成相对路径

patch overlay 只贡献配置,不改变模块解析。本地文件插入必须用绝对路径,相对路径一律解析失败,而且失败是静默的。

5. name 和包名不一致

cordis.patch.yml 里的 name 必须等于 npm 包名;手动放包时,目录名、package.json 里的 name、配置里引用的 name 三者必须完全一致

6. 插件装进了 A profile,却在跑 B profile

插件只在装进去的那个 profile 生效。dsh web 用的是 web profile,dsh --profile headless 用的是另一个。看起来"装了却没生效",多半是这个。

7. 自己申请的资源没写 disposer

定时器、端口、文件句柄,包进 ctx.effect() 并返回清理函数。典型症状是第二次启动报 EADDRINUSE

8. API 还在变

官方明说了 developer preview 阶段会有破坏性变更。所以:别依赖内部实现,只挂在官方扩展点(event / service)上;插件代码写得松一点,别和某个具体版本绑死。


七、接下来

  • 想让别人也能装:发布到 npm,仓库打上官方的 dsh-plugin topic,按插件市场的流程提交。
  • 想找参考实现:GitHub 搜 dsh-plugin 标签,或去社区维护的 Awesome 列表,按分类看别人的宿主端/客户端是怎么写的。
  • 想深入理解:docs/cordis-primer.md 和 docs/architecture.md(源码仓库里的文档比官网详细得多)。

一句话总结:dsh 插件的本质就是一个导出 apply(ctx) 的模块,难点从来不在代码,而在"它到底有没有被装配进那棵插件树"。记住 dsh --profile web --dump-config 这一条命令,你就已经赢过一半的人了。

相关学习资料