生命不息,折腾不止。基础装好 dsh 只是开始,亲手写个插件,才算真正「拥有」了这个 Agent。

上篇《DeepSeek Harness 接入中转站教程》带你把 dsh 跑了起来,能对话、能干活了。但 dsh 和别的 Agent 框架最大的不同,是那句写在官网首页的口号:一切皆插件(Everything is a Plugin)。模型适配器是插件、工具是插件、会话存储是插件,连 agent loop 和 Web UI 本身都是插件——这意味着你对哪一块不满意,都可以自己换、自己写,不用 fork 整个仓库

今天这篇就手把手带你写两个插件:一个三分钟的 hello world,一个真正能被模型调用的工具,最后再把它升级成能查天气的实用工具。全程 TypeScript,但你没写过 TS 也照抄得动。

一、先搞懂:dsh 的插件到底是个啥

先看官方给的定义:插件是一个导出 apply 函数的 TypeScript 模块。框架加载插件时调用 apply,传进来一个 ctx(上下文对象),你通过 ctx 往系统里注册能力。

dsh 底层跑的是 Cordis 插件框架,它是个非常「小」的内核——只负责插件的挂载、卸载和依赖管理,其余所有业务逻辑都住在插件里。所以插件之间怎么协作?靠两个东西:

  • 服务(Service):每个能力占一个稳定的挂载点,比如 ctx.tools(工具注册表)、ctx.llm(模型适配)、ctx.sessions(会话存储)。别的插件要用某个能力,直接按 key 找,不用管实现是谁。
  • 依赖声明(inject):插件声明 inject: ['tools'],Cordis 会等 tools 服务就绪后才调用你的 apply。加载顺序由依赖决定,而不是文件顺序。

还有个很爽的设计:注册是可逆的副作用。你通过 ctx 注册的一切——事件监听、工具、定时器——插件卸载时自动清理,不用手动 removeListener 或 clearInterval。有需要手动释放的资源(比如网络连接),包进 ctx.effect() 返回一个清理函数就行。

二、第一个插件:hello-plugin,三分钟跑起来

要写 harness 插件,最省事的路子是从源码运行(官方推荐,因为插件要用 --patch 挂进 Web UI)。前置环境:Node 22.19+ / 24+、pnpm 11.7.0 左右,然后:

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

如果你只想跑现成插件不想写代码,一条 npx @deepseek-ai/dsh web 就启动了,但今天我们要动手,建议还是 clone 源码。

在仓库根目录建一个临时项目:

1
mkdir -p scratch-plugin/src

创建 scratch-plugin/src/my-plugin.ts,这就是一个完整的最小插件

1
2
3
4
5
6
7
8
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!')
}

然后创建 scratch-plugin/cordis.yml,把插件注册进去。注意两点:路径必须是绝对路径,patch 文件只贡献配置,不改变模块解析的目录:

1
2
3
- insert:
- id: hello
name: '/绝对路径/deepseek-harness/scratch-plugin/src/my-plugin.ts'

用覆盖层启动 Web UI:

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

打开 http://127.0.0.1:3080,同时看终端——启动过程中会打印 [hello-plugin] plugin loaded!。第一个插件,跑通了。

三、第一个真工具:greet,让模型学会「叫人」

光打日志不过瘾,插件真正的价值是给模型提供工具。工具在 dsh 里分两半:声明namedescriptionparameters)会被组装进系统提示词,模型据此决定什么时候调、用什么参数调;执行execute)在宿主进程里跑,结果再喂回模型。

官方工具定义 DSL 是 defineTool,五个要素:namedescriptionparametersoutputexecute。看一个完整例子——greet 工具,让模型学会按名字打招呼:

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

// 插件身份:包级别的唯一名字
export const name = 'greet-tool'

// 依赖声明:tools 注册表就绪后,apply 才会被调用
export const inject = ['tools']

export function apply(ctx: Context) {
ctx.tools.register(defineTool({
// 模型看到的名字:调用时使用 greet(name)
name: 'greet',
// 模型看到的描述:写清楚工具做什么、何时使用
description: 'Greet someone by name.',
// 参数 schema:required: true 的键才是必填,其余默认可选
parameters: {
name: { type: 'string', required: true, description: 'The name to greet' },
},
// 输出声明:规范 JSON 值 + 渲染成模型可见内容
output: {
schema: { type: 'string' },
render: (_args, value) => [{ type: 'text', text: value }],
},
// 工具主体:收到类型由 parameters 推导出的 args
async execute(args) {
return `Hello, ${args.name}!`
},
}))
}

保存为 scratch-plugin/src/greet-tool.ts,再写一个 patch 文件 scratch-plugin/greet.yml

1
2
3
- insert:
- id: greet
name: '/绝对路径/deepseek-harness/scratch-plugin/src/greet-tool.ts'

启动并测试:

1
pnpm dsh web --patch ./scratch-plugin/greet.yml

在 Web UI 里输入:Use the greet tool to greet Ada. 模型会调用 greet,然后收到 Hello, Ada! 这个工具结果。

模型眼里你的工具长这样——注册表把声明投影成 ToolSchema,自动流进系统提示词:

1
2
3
4
5
6
7
8
9
10
name: greet
description: Greet someone by name.
parameters:
type: object
properties:
name:
type: string
description: The name to greet
required:
- name

注意:outputexecute、UI 展示方法这些实现细节绝不会出现在模型请求里。模型永远只看到「这工具叫啥、能干啥、参数长啥样」。

四、升级实战:写一个能查天气的工具

greet 只是教学玩具,我们来写个真有用的——查天气工具,接免费的 wttr.in API,不需要注册、不需要 API key。

wttr.in 的用法很简洁:https://wttr.in/城市?format=j1 返回 JSON,current_condition[0] 里有当前温度、天气描述。插件代码:

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

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

export function apply(ctx: Context) {
ctx.tools.register(defineTool({
name: 'weather',
description: '查询指定城市的当前天气,传入城市名(中文或英文均可)',
parameters: {
city: { type: 'string', required: true, description: '城市名,如 北京 / Shanghai' },
},
output: {
schema: { type: 'string' },
render: (_args, value) => [{ type: 'text', text: value }],
},
async execute(args) {
// wttr.in 免费天气接口,无需 key,format=j1 返回 JSON
const url = `https://wttr.in/${encodeURIComponent(args.city)}?format=j1`
const res = await fetch(url)
if (!res.ok) throw new Error(`天气接口请求失败: ${res.status}`)
const data = await res.json()
const cur = data.current_condition[0]
return `${args.city} 当前 ${cur.temp_C}°C,天气:${cur.weatherDesc[0].value},湿度 ${cur.humidity}%`
},
}))
}

挂载、启动、测试:

1
2
# cordis.yml 里 insert 的 name 指向 weather-tool.ts 即可
pnpm dsh web --patch ./scratch-plugin/cordis.yml

然后在 Web UI 里输入:帮我看看北京今天天气怎么样? 模型就会调用 weather 工具,返回类似 北京 当前 28°C,天气:Sunny,湿度 60% 的结果。

这个例子展示了插件的核心玩法:dsh 的工具本质上就是「给模型接一个函数」,外面世界的任何 API——天气、汇率、快递、你公司的内部接口——都能变成模型随手可调的工具。写插件要反复测模型调用,想多模型轮着试的话,中转站一个 key 就能把 DeepSeek、Claude、GPT 全接上,多模型对接、价格还便宜,挺适合折腾阶段(详情见上篇教程)。

五、避坑指南:新手最容易踩的四个坑

  1. 路径必须是绝对路径cordis.yml 里插件 name 写相对路径,loader 会找不到模块。拿不准就 pwd 一下再拼。

  2. 插件没反应?先查 fiber 状态。每个已加载的插件实例都有个状态机:PENDING → LOADING → ACTIVE → UNLOADING → DISPOSED。PENDING 表示插件已声明但依赖的服务还没就绪——「为什么我的插件没有输出」最常见的答案。检查是不是 inject 漏了、或者依赖的插件没启用。

  3. 模型传的参数可能不合法,别慌defineToolexecute 之前会按 schema 做运行时校验,校验失败抛 ToolArgsError,注册表会把它作为模型可修正的错误返回——模型会看到哪里错了重新生成参数,而不是让插件崩溃。所以 execute 里的 args 和声明的类型是一致的。

  4. execute 只返回规范 JSON 值,别返回内容块。像 [{ type: 'text', text: '...' }] 这种是 render 的职责,你返回普通值(字符串、对象、数组都行),注册表校验冻结后交给 render 转成模型可见内容。基础设施故障才抛异常;「不理想但成功」的领域结果(比如进程非零退出)放进返回值,让 render 解释。

写完这五个小节,你的 dsh 已经从「开箱即用的 Agent」变成了「能自己造零件的 Agent」。下一步值得玩的,是把多个 Agent 组织起来干活。

生命不息,折腾不止。下一篇:《DeepSeek Harness 多 Agent 协作实战》——把一个大任务拆给多个 Agent 并行干,让 dsh 从「单兵」变「战队」,敬请期待。