生命不息,折腾不止。上次说好的「写插件」来了——今天手把手给 dsh 的 Agent 造一个 IP 查询工具,让它多一门手艺。

从基础安装到接中转站,dsh 已经跑起来了。但光会用别人的东西,那只是「用户」;能往里加自己的东西,才叫「玩家」。dsh 最迷人的地方就在这:一切皆插件,工具、模型、会话、UI 全都可以自己换。今天就兑现承诺,写第一个插件。

一、先搞懂:dsh 的「一切皆插件」到底是啥

DeepSeek Harness(简称 dsh)的核心设计就一句话:一切皆插件。模型适配器、工具、会话存储、Agent 循环、甚至 Web UI 的面板,全都是插件。底层由 Cordis 这个元框架负责加载、卸载和依赖解析,插件之间通过「服务」和「事件」协作。

所以插件不是外挂,它可以替换或扩展 Agent 的任何部分。理解了这个,你就摸到了 dsh 的命门。

那一个插件到底长啥样?官方文档(仓库里的 docs/user/develop/basic/index.zh.md)说得很直白:

插件是一个导出 apply 函数的 TypeScript 模块。框架在加载时调用 apply,传入一个 ctx(上下文对象),你通过 ctx 注册能力。

最简单的插件,就这么几行:

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

export const name = 'my-plugin'

export function apply(ctx: Context) {
// 在这里注册你的能力
}

几个要点记一下:

  • name:插件名,要唯一
  • apply(ctx):入口函数,ctx 是「上下文对象」,注册工具、监听事件、开定时器都通过它
  • inject:声明依赖,比如 export const inject = ['tools'],框架会等工具注册表就绪后才调用你的 apply

还有两个很贴心的设计:

  • 自动清理:通过 ctx 注册的事件监听、工具、定时器,插件卸载时全部自动清理,你永远不用手写 removeListener
  • ctx.effect():如果你有网络连接这种要手动关闭的资源,用 ctx.effect(() => { ...; return () => 清理 }) 告诉框架怎么收尾

插件有三种写法:函数(日常够用)、对象(带生命周期钩子)、类(Service 子类,给别人提供服务)。写工具插件,函数形式基本就够了。

二、最小闭环:先让 dsh 认你这个插件

环境要求:Node ^22.19 或 >=24,低于这个版本 dsh 起不来。还没装 dsh 的先把 Web 版跑起来:

1
2
npx @deepseek-ai/dsh web
# Web UI 地址:http://127.0.0.1:3080

然后建个本地开发目录,写一个最朴素的插件:

1
mkdir -p scratch-plugin/src
1
2
3
4
5
6
7
8
// scratch-plugin/src/hello-plugin.ts
import type { Context } from '@deepseek-ai/cordis'

export const name = 'hello-plugin'

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

再建一个覆盖层文件 cordis.yml。注意:本地开发阶段,插件路径要写绝对路径,相对路径会解析失败:

1
2
3
4
# scratch-plugin/cordis.yml
- insert:
- id: hello
name: '/绝对/路径/到/scratch-plugin/src/hello-plugin.ts'

用覆盖层启动:

1
npx @deepseek-ai/dsh web --patch ./scratch-plugin/cordis.yml

启动日志里看到 [hello-plugin] plugin loaded!,说明 dsh 已经认你了。最小闭环达成:加载插件 → 注册能力 → 生效

三、实战:写一个 IP 归属地查询工具

插件本身没意思,给 Agent 加个真能干的工具才有意思。今天的目标:让 dsh 里的 Agent 学会查 IP 归属地——你说「帮我看看 8.8.8.8 是哪的」,它调工具给你返回「美国 弗吉尼亚州 Ashburn,运营商 Google LLC」。

我用纯 ESM 写(免构建,Node 直接跑),接口用 ip-api.com 的免费 JSON 接口,不用注册不用 key,自用完全够(免费版每分钟约 45 次限额,商用要付费,以官网为准)。

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
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
// index.js
import { defineTool } from '@deepseek-ai/dsh-tools'

export const name = 'ip-lookup'
export const inject = ['tools']

export function apply(ctx) {
ctx.tools.register(defineTool({
name: 'ip_lookup',
description: '查询 IP 地址的归属地信息(国家、城市、运营商)',
parameters: {
ip: { type: 'string', required: true, description: '要查询的 IP 地址,例如 8.8.8.8' },
},
async execute(args) {
const res = await fetch(`http://ip-api.com/json/${args.ip}?lang=zh-CN`)
const data = await res.json()
if (data.status !== 'success') {
throw new Error(`查询失败:${data.message || '未知错误'}`)
}
return {
ip: data.query,
country: data.country,
regionName: data.regionName,
city: data.city,
isp: data.isp,
}
},
output: {
schema: {
type: 'object',
additionalProperties: true, // 这个必须写 true,否则注册直接失败
properties: {
ip: { type: 'string' },
country: { type: 'string' },
regionName: { type: 'string' },
city: { type: 'string' },
isp: { type: 'string' },
},
},
render(output) {
return `IP ${output.ip} 归属地:${output.country} ${output.regionName} ${output.city},运营商:${output.isp}`
},
},
}))
}

代码不长,拆开讲:

  • defineTool@deepseek-ai/dsh-tools 导入,帮你做参数校验和输出校验
  • parameters:声明参数 schema,模型会照着这个自动生成调用参数
  • execute(args):真正干活的地方,这里就是调 ip-api.com 的 HTTP 接口
  • output.schema:返回值校验;output.render:把结构化结果渲染成模型能直接读的话
  • :object 类型的输出 schema 必须写 additionalProperties: true,不然注册直接失败——我在这上面栽过

装进 profile 跑起来(两种方式二选一):

1
2
3
4
5
# 方式一:本地路径直接装(需要 pnpm,没有就先 npm install -g pnpm)
dsh plugin --profile web add .

# 方式二:开发时用覆盖层(不需要 pnpm)
npx @deepseek-ai/dsh web --patch ./cordis.patch.yml

然后在 Web UI 里开个新会话,直接说:

帮我查一下 8.8.8.8 的归属地

你会看到 Agent 展开工具调用:IN 传参、OUT 返回结果,一气呵成。至此「加载插件 → 注册工具 → 模型调用 → 返回结果」的完整闭环就通了。

顺带说一句:不想手敲这些代码的话,可以把插件开发文档扔给 DeepSeek 让它自己写——社区里已经有人这么干出了 arXiv 搜索插件。模型写插件、你负责验收,这就是 Agent 时代的生产方式。dsh 默认接 DeepSeek 官方,习惯走中转的朋友也可以在 provider 里配 ai.aklibk.com,基础接入方式前面那篇讲过了,不重复。

四、从本地到发布:三件套打包

本地能跑只是第一步,想让插件真正可安装、可分享,需要三件套:

1. package.json(声明这是个 dsh 插件)

1
2
3
4
5
6
7
8
9
{
"name": "dsh-ip-lookup",
"version": "0.1.0",
"type": "module",
"main": "index.js",
"files": ["index.js", "cordis.patch.yml"],
"keywords": ["dsh-plugin", "deepseek-harness"],
"dsh": { "bundle": { "patch": "./cordis.patch.yml" } }
}

2. index.js——就是上面那个插件本体

3. cordis.patch.yml(告诉 dsh 怎么挂载它)

1
2
3
- insert:
- id: ip-lookup
name: dsh-ip-lookup

⚠️ files 数组里必须包含 cordis.patch.yml!漏了它,包能装上但插件层根本不生效——这是发布目录里最常见的翻车点,装完发现啥都没有,先查这个。

发布三步走:

  1. 推到 GitHub 公开仓库
  2. 仓库打上 dsh-plugin topic——这是官方约定的发现机制,社区目录就靠它索引
  3. README 里写清安装方式、插件能访问什么、测试过的 dsh 版本

之后别人一条命令就能装:

1
dsh plugin --profile web add github:你的账号/dsh-ip-lookup

装完重启 profile 就生效。如果插件装了但界面/工具没出现,别急着猜,先看配置树:

1
dsh --profile web --dump-config

它会打印当前真正组合出的插件树,你的插件在不在里面一目了然——不在就是安装环节的问题,不是代码问题。

五、踩坑清单 & 安全提醒(必看)

把这一路的坑汇总一下:

  1. object 输出 schema 要写 additionalProperties: true,否则 defineTool 注册直接失败
  2. files 漏掉 cordis.patch.yml → 装上了但没挂载层,等于白装
  3. patch 是整行替换,不是深合并:改配置要重述整行 config,只写想改的那个字段会丢掉其它配置
  4. 插件是可信代码:dsh 插件跑在宿主进程里,能碰你的文件、网络、浏览器、终端。装社区插件前先看 README 和安装脚本,最好固定 tag/commit,再单独开个 profile 试装,别拿主力工作区当试验场
  5. 本地开发挂本地路径时用绝对路径,相对路径会解析失败

写完第一个插件,dsh 在你手里就不再是「开箱即用」的工具,而是「随你捏」的工作台了。

生命不息,折腾不止。下一篇咱们玩个大的:把一个任务拆给多个 Agent 并行干——多 Agent 协作实战,看看 dsh 怎么让几个「分身」同时开跑、最后汇总结论。