生命不息,折腾不止。这篇带你真正动手给 DeepSeek Harness 写第一个插件——从空文件一路写到「模型能点你写的工具」,看完你会明白「一切皆插件」到底是怎么一回事。

前面几篇我们把 DeepSeek Harness(简称 dsh)装起来、接上了中转站,模型能跑、工具能用。但那都是别人的插件。今天兑现一句早就埋下的伏笔:自己写一个插件

dsh 这套框架最有意思的地方,就是那句「Everything is a Plugin」——工具是插件、模型适配器是插件、连 agent 循环本身都是插件。理解了「插件」怎么写,你就拿到了整个框架的万能钥匙。下面跟着我一步步来,全程不用 API Key,五分钟就能跑通。

一、先把概念捋顺:插件到底是个啥

dsh 底层的插件框架叫 Cordis。在 Cordis 眼里,一个插件最核心的就是一个导出函数 apply,它接收一个上下文对象(ctx)。插件通过这个 ctx 把自己贡献的所有东西注册进去。

三种插件形态,从简到繁:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
import { Service, type Context } from '@deepseek-ai/cordis'

// 1. 函数插件:最常用,写个 apply 就行
export function apply(ctx: Context) {}

// 2. 对象插件:带 apply 方法的对象
export const objectPlugin = {
name: 'object-plugin',
apply(ctx: Context) {},
}

// 3. 类插件:Service 子类,需要对外暴露「服务」时才用
export class MyService extends Service {
constructor(ctx: Context) {
super(ctx, 'myTutorialService')
}
}

日常 90% 的活儿,用第 1 种函数插件就够了。只有要对外提供「服务」(一个别的插件能通过 ctx.xxx 调用的具名能力)时,才升级到第 3 种类插件。后面到了「注册工具」那一步,你会看到 ctx.toolsctx.llm 这些其实都是服务。

另外还有个可选的 name 导出,纯粹是给诊断信息用的标签,写上没坏处。

二、准备环境(真·三分钟)

官方文档把教程例子放在仓库内的 tmp/ 目录(已经 git 忽略,随便折腾)。前置是 Node.js + pnpm,跟着跑:

1
2
3
4
5
6
7
git clone https://github.com/deepseek-ai/deepseek-harness.git
cd deepseek-harness
pnpm install

# 教程专用目录,各章节的例子都写这里
mkdir -p tmp/cordis-tutorial
cd tmp/cordis-tutorial

每一章都用同一条命令启动:

1
node --import tsx ../../vendor/cordis/bin.js

这个 bin.js 是仓库自带的单文件启动器:它会创建一个根 Context、挂载 Loader 插件,然后读取当前目录下的 cordis.yml 来组合整个应用。--import tsx 让 Node 直接跑 TypeScript,不用先编译。

关键认知:你的插件文件里没有任何「启动代码」,它只描述自己贡献了什么;真正把一堆插件拼成应用的是 cordis.yml。这跟传统「写 main 函数」的思路完全不一样。

三、第一个插件:让控制台喊一嗓子

tmp/cordis-tutorial 下新建 hello.ts

1
2
3
4
5
6
7
import type { Context } from '@deepseek-ai/cordis'

export const name = 'hello'

export function apply(ctx: Context) {
console.log('hello from my first plugin')
}

再建一个 cordis.yml,告诉 Loader 挂这个插件:

1
- name: './hello.ts'

运行:

1
node --import tsx ../../vendor/cordis/bin.js

预期输出:

1
hello from my first plugin

没别的东西需要跑,进程自己就退出了。整个过程是:启动器建根 Context → 挂 Loader → Loader 读 cordis.yml 找到 ./hello.ts → Cordis 调用你的 apply(ctx)。就这么简单。

顺手做个反向实验:把 apply 里改成抛异常:

1
2
3
export function apply(ctx: Context) {
throw new Error('apply exploded')
}

再跑一次,进程会直接报错退出。插件加载失败会明确报错,不会悄悄跳过——这是好事,出事你能第一时间知道。

⚠️ 但有个例外要记住:如果 cordis.yml 里的模块路径或包名拼错了(没法解析),Cordis 只会通过 logger 报告,不会让进程崩溃,而且这条报告在启动早期很可能还没被 console 输出器看到就丢了。所以**「新增的插件好像没生效」,第一件事先检查拼写**。

四、让插件活起来:生命周期 + 配置

光会喊一嗓子不算本事。真实的插件会开定时器、建连接、占资源,那这些东西怎么保证「不用了能干净地关掉」?答案是一个词:effect

Cordis 自己管理的注册(比如后面要讲的 ctx.on、服务注册)会自动在插件卸载时撤销。但它管不到的资源(定时器、网络连接、文件 watcher),你得手动包进 ctx.effect(),返回值就是释放函数:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
import type { Context } from '@deepseek-ai/cordis'

export const name = 'lifecycle-demo'

function heartbeat(ctx: Context) {
ctx.effect(() => {
const timer = setInterval(() => console.log('tick'), 200)
return () => {
clearInterval(timer)
console.log('heartbeat cleaned up')
}
})
}

export function apply(ctx: Context) {
const fiber = ctx.plugin(heartbeat)
ctx.effect(() => {
const timer = setTimeout(async () => {
await fiber.dispose()
process.exit(0)
}, 700)
return () => clearTimeout(timer)
})
}

几点值得记下来:

  • ctx.plugin(heartbeat) 能把一个来自代码的函数直接挂成子插件,跟 YAML 里写配置项效果一样,返回的 fiber 就是这个已加载实例的运行时句柄。
  • effect 主体在加载时执行,返回的 disposer 在卸载时执行。生命周期和插件一致的资源,你基本用不着手动调释放。
  • 每个插件实例背后都有一个 fiber 状态机,在 PENDING → LOADING → ACTIVE → UNLOADING → DISPOSED 之间流转,中途可能掉进 FAILED

再说配置。插件可以在 cordis.yml 里带一块 config,配一个 schema 让 Cordis 在跑 apply 之前先校验。dsh 用 Schemastery 定义 schema:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
import type { Context } from '@deepseek-ai/cordis'
import Schema from '@deepseek-ai/schemastery'

export const name = 'config-demo'

export interface Config {
greeting: string
targets: string[]
}

export const Config: Schema<Config> = Schema.object({
greeting: Schema.string().default('Hello'),
targets: Schema.array(Schema.string()),
})

export function apply(ctx: Context, config: Config) {
for (const t of config.targets) {
console.log(`${config.greeting}, ${t}!`)
}
}

对应的 cordis.yml

1
2
3
- name: './config-demo.ts'
config:
targets: ['alpha', 'beta']

运行输出:

1
2
Hello, alpha!
Hello, beta!

greeting 没填,schema 默认值自动补上——apply 拿到的永远是一份完整、通过校验的配置。如果你塞个错误类型(比如把 targets 写成字符串),会得到一条精准的 ValidationError,插件进 FAILED 状态,绝不带病上岗。还可以用 !!js 标签写运行时才计算的配置值,比如 greeting: !!js process.env.DEMO_GREETING ?? 'Hello'

五、真家伙:注册一个模型能调用的工具

前面都是铺垫,这才是今天的重头戏——写一个模型真能点名的工具。运行时靠 ctx.tools.register() 把一个工具注册进去,工具本身用官方 @deepseek-ai/dsh-toolsdefineTool 来定义:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
import type { Context } from '@deepseek-ai/cordis'
import { defineTool } from '@deepseek-ai/dsh-tools'

export const name = 'get-time'
export const inject = ['tools']

export function apply(ctx: Context) {
ctx.tools.register(defineTool({
name: 'get_time',
description: '获取当前北京(东八区)时间,回答"现在几点"这类问题。',
parameters: {},
output: {
schema: { type: 'string' },
render: (_args, value) => [{ type: 'text', text: value }],
},
async execute() {
return new Intl.DateTimeFormat('zh-CN', {
timeZone: 'Asia/Shanghai',
dateStyle: 'full',
timeStyle: 'long',
}).format(new Date())
},
}))
}

逐行拆一下:

  • inject: ['tools']:告诉 Cordis「我这个插件依赖 tools 服务」,工具注册表就绪之前我会一直停在 PENDING,apply 里就能放心用 ctx.tools。这也解释了为什么 cordis.yml 里的顺序不重要——决定启动顺序的是依赖关系,不是文件顺序。
  • ctx.tools.register(...):注册的 disposer 会自动挂到插件上,插件卸载时工具跟着注销,不用手动管。
  • defineToolparameters 规约转成展示给模型的 JSON Schema,还会在 execute 运行前校验模型塞进来的参数;output.render 是把结果渲染成模型能读的文本块。

想让工具收参数,把 parameters 写出来就行(拿到参数的例子参考下面的 greet 工具)。配套再写一个「观察者」,监听每次工具调用结果:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
import type { Context } from '@deepseek-ai/cordis'
import type {} from '@deepseek-ai/dsh-tools'

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

export function apply(ctx: Context) {
ctx.on('tools/result', (exec, result) => {
const text = result.content
.map(block => (block.type === 'text' ? block.text : ''))
.join('')
console.log(`[tool-logger] ${exec.name} -> ${text}`)
})
}

组合并运行,cordis.yml 长这样:

1
2
3
4
- name: '@deepseek-ai/dsh-system-prompt'
- name: '@deepseek-ai/dsh-tools'
- name: './tool-logger.ts'
- name: './get-time.ts'
1
node --import tsx ../../vendor/cordis/bin.js

输出:

1
[tool-logger] get_time -> 2026年9月5日星期六 20时00分00秒 GMT+8

注意 @deepseek-ai/dsh-system-prompt 必须列上——工具要把 schema 贡献进系统提示词,得先把 systemPrompt 服务的提供方挂上,否则工具插件会一直 PENDING(「为什么我的插件没输出」最常见的答案就是这个)。

到这里你其实已经看到完整链路了:模型发起一次工具调用 → execute 干活 → tools/result 事件广播 → 结果喂回模型。一个真实的 agent,就是这套组合再叠上 LLM 适配器、agent loop、持久化和 UI 入口。顺着这个思路,你甚至能在 executefetch 任意 HTTP 接口——想接另一个模型走 OpenAI 兼容协议,跟上一篇接中转站的套路是一模一样的,只是把「接模型」这一层包成了插件里的一个工具。

六、写插件最常踩的三个坑

  1. 插件静默没输出:八成是 PENDING 状态。要么 inject 声明的服务没人提供(比如漏了 @deepseek-ai/dsh-tools@deepseek-ai/dsh-system-prompt),要么 cordis.yml 里路径、包名拼错。先查拼写,再查依赖。

  2. 服务命名撞车:所有服务名共享一个扁平命名空间,toolsllm 这种通用名已经被 harness 占了。自己定义服务记得加前缀或命名空间,别裸用一个 timeuser 之类的词。

  3. disposer 顺序想当然:disposer 按注册的逆序触发,但多个异步 disposer 是并发跑的。如果拆除步骤必须严格按顺序,就把它们塞进同一个 disposer 里依次 await,别指望注册顺序能保证先后。

官方仓库里这套教程一共七章(第一个插件 → 生命周期 → 服务 → 事件 → 配置 → 组合与热重载 → 接入 harness),今天这篇把最核心的第 1、2、3、5、7 章串成了一条能跑通的实战链路。剩下的「事件」「组合与 HMR」值得你去原仓库 docs/cordis-tutorial 补完,尤其热重载——改完插件浏览器里刷一下就生效,开发体验很爽。

生命不息,折腾不止。下一篇我们玩点大的:DeepSeek Harness 多 Agent 协作实战——一个任务拆给多个 Agent 并行干活,主 Agent 派生子代理、跨代理通信,看它怎么用 dsh-plugin-subagent 把「一个人干不完的活」拆成流水线。