ARTICLE · 1034327
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(...)注册的监听 → 自动 offctx.tools.register(...)注册的工具 → 自动注销 ctx.systemPrompt.register(...)注入的提示词 → 自动移除
你不需要写任何清理代码。 但框架不知道的资源(定时器、WebSocket、数据库连接、占用端口的 server)要你自己交代后事:
ctx.effect(() => { const timer = setInterval(() => ctx.logger.info('心跳'), 5000) return () => clearInterval(timer) // 卸载时框架调用它})和 React 的 useEffect 一个思路。忘了写 disposer 的典型症状是:第二次启动报端口被占用。
二、一个正式插件的目录结构
dsh 插件有"两半",分别跑在两个环境里:
index.mjs | |||
client/index.mjs |
两半可以只写一半。纯宿主插件的 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)}三个固定约定:
导出 name(小写);导出 apply(ctx)(装载函数,启动时调用一次);用了哪些服务就在 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/srcscratch-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-plugintopic,按插件市场的流程提交。想找参考实现:GitHub 搜 dsh-plugin标签,或去社区维护的 Awesome 列表,按分类看别人的宿主端/客户端是怎么写的。想深入理解: docs/cordis-primer.md和docs/architecture.md(源码仓库里的文档比官网详细得多)。
一句话总结:dsh 插件的本质就是一个导出 apply(ctx) 的模块,难点从来不在代码,而在"它到底有没有被装配进那棵插件树"。记住 dsh --profile web --dump-config 这一条命令,你就已经赢过一半的人了。