给 DeepSeek Harness 写插件:从 hello-plugin 到一条能力缝 deepseek harness plugin tutorial DeepSeek Harness AgentEngineering AI工程
2439 字
12 分钟

给 DeepSeek Harness 写插件:从 hello-plugin 到一条能力缝

先说结论#

上一篇我把 Harness 关了,因为它的 Web UI 不是我的菜;但「一切皆插件」这个架构我认。这篇把官方插件教程(basic → framework → practice)从头跑了一遍,而且没停在 hello-world:我写了一个真正有用的 web_fetch 工具插件,并用官方 mock LLM 跑出了真实的调用轨迹,截图为证。

给 dsh 写插件的门槛比想象低:一个导出 apply(ctx) 的 TypeScript 函数就是完整的插件。 真正要掌握的其实不是 API,而是三个心智模型:

  1. 一切注册皆可逆——通过 ctx 注册的东西,卸载时自动回收
  2. 依赖驱动加载——inject 声明依赖,加载顺序由框架推导
  3. 能力缝 = 定义 + 提供者 + 消费者——可替换的能力边界

另外有个彩蛋放最后:dsh 的多模型适配层,底层就是 Pi(@earendil-works/pi-ai)。

文中所有命令和输出都是我 8 月 18 号在 Node v24.18 + pnpm 11.7 上实跑的,包括一个真实的报错和一次真实的踩坑。

准备:从源码跑#

npx @deepseek-ai/dsh web 跑起来容易,但挂本地插件需要 --patch 指向本地文件,从源码检出最顺:

Terminal window
git clone https://github.com/deepseek-ai/deepseek-harness
cd deepseek-harness
pnpm install
pnpm run build
pnpm dsh web # 开发模式,直接跑 TS 源码

要求 Node ^22.19 || >=24。比我预想的轻:shallow clone 86M,install 约 21s,build 约 3s。

第一个插件:hello-plugin#

插件就是一个导出 apply 函数的 TS 模块。官方教程的原话:「这就是完整配置」

scratch-plugin/src/my-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!')
}

然后写一个 patch 层,把它插进启动树。插件路径必须是绝对路径:

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

启动时用 --patch 挂上:

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

实测输出:

$ node --import tsx/esm apps/cli/src/bin.ts web --patch ./scratch-plugin/cordis.yml
[hello-plugin] plugin loaded!
dsh web: http://127.0.0.1:3080

一次通过。这里的关键概念是 patch 层叠:不 fork、不改源码,你的插件是「挂到旁边」的,卸掉它整个框架回到原样。

生命周期:为什么不用写清理代码#

Cordis 里每个插件是一个 Fiber,状态机长这样:

PENDING → LOADING → ACTIVE
↘ FAILED
ACTIVE → UNLOADING → DISPOSED

三个实际含义:

  • 依赖没就绪就不加载inject: ['tools'] 声明的服务消失时插件自动卸载,服务回来自动重载——热替换 provider 不需要手动重启任何东西
  • 注册自动回收ctx.on() 的事件监听、ctx.tools.register() 的工具,卸载时全部自动撤销,不需要 removeListener
  • 需要显式清理的资源用 ctx.effect,返回一个 disposer:
export function apply(ctx: Context) {
ctx.effect(() => {
const timer = setInterval(() => console.log('heartbeat'), 5000)
return () => clearInterval(timer) // 插件卸载时执行
})
}

三种插件形态:函数(多数场景够用)、对象(带 name/inject/apply 的字面量)、(继承 Service,当你需要给别的插件提供 service 时用)。新手从函数形式开始就行。

一个有实际意义的工具:web_fetch#

hello-plugin 只会打日志。教学意义够了,但我要一个模型真的能用的东西。dsh 基础包不带网页抓取工具,而「读一个 URL」是 agent 的高频需求——于是写了 web_fetch:抓页面、剥 HTML、抽正文,纯 Node 内置能力,零依赖、不要 API key:

scratch-plugin/src/web-fetch.ts
import { defineTool } from '@deepseek-ai/dsh-tools'
export const name = 'web-fetch-plugin'
export const inject = ['tools']
const MAX_CHARS = 6000
function stripHtml(html: string): { title: string; text: string } {
const titleMatch = html.match(/<title[^>]*>([\s\S]*?)<\/title>/i)
const title = titleMatch ? titleMatch[1].replace(/\s+/g, ' ').trim() : ''
const body = html
.replace(/<script[\s\S]*?<\/script>/gi, ' ')
.replace(/<style[\s\S]*?<\/style>/gi, ' ')
.replace(/<[^>]+>/g, ' ')
const text = body
.replace(/&nbsp;/g, ' ').replace(/&amp;/g, '&')
.replace(/&lt;/g, '<').replace(/&gt;/g, '>')
.replace(/&quot;/g, '"').replace(/&#39;/g, "'")
.replace(/\s+/g, ' ').trim()
return { title, text }
}
export function apply(ctx: any) {
ctx.tools.register(defineTool({
name: 'web_fetch',
description: 'Fetch a web page and return its title and readable text content (truncated to ' + MAX_CHARS + ' chars).',
parameters: {
url: { type: 'string', required: true },
},
output: {
schema: {
type: 'object',
properties: {
title: { type: 'string' },
url: { type: 'string' },
chars: { type: 'number' },
truncated: { type: 'boolean' },
text: { type: 'string' },
},
additionalProperties: false,
},
render: (_args, value) => [{ type: 'text',
text: `## ${value.title}\n${value.text}` }],
},
async execute(args) {
const resp = await fetch(args.url, {
headers: { 'user-agent': 'dsh-web-fetch-plugin/0.1 (+educational example)' },
signal: AbortSignal.timeout(15000),
})
if (!resp.ok) throw new Error(`HTTP ${resp.status} for ${args.url}`)
const html = await resp.text()
const { title, text } = stripHtml(html)
const truncated = text.length > MAX_CHARS
return {
title, url: args.url, chars: text.length, truncated,
text: truncated ? text.slice(0, MAX_CHARS) + '…' : text,
}
},
}))
}

挂进同一个 patch 层:

- insert:
- id: hello
name: '/绝对路径/scratch-plugin/src/my-plugin.ts'
- id: web-fetch
name: '/绝对路径/scratch-plugin/src/web-fetch.ts'

这里我踩了个真坑。 第一版我把 output.schema 写成了 parameters 那种属性 map({ total: { type: 'number' }, ... }),启动直接报错:

Error: dsh: plugin tree failed to load: ... unsupported JSON schema:
schema.type must be string/number/integer/boolean/null/array/object/json, or use oneOf

原因:output.schema 是单个 ValueSchemaSpec,必须把 type: 'object'properties 包进去,而不是直接写属性。这个报错的好处是精准定位到 loader 条目、给出正确枚举——框架在 apply 阶段就拦住坏 schema,而不是等模型调用时炸。错误信息质量是框架成熟度的好指标,dsh 这关过了。

没有 API key,怎么验证工具真能被调用#

插件加载成功 ≠ 模型会用。我没有 DeepSeek API key,但官方仓库自带一个可脚本化的 mock LLM server,能指定它「假装调某个工具」:

Terminal window
pnpm mock:llm --port 8000 \
--sequence tool_call_success,success --repeat-last \
--tool-name web_fetch \
--tool-arguments '{"url":"https://example.com/"}' \
--success-text '我已经读取了该页面,标题是 Example Domain,内容是一个示例域名说明。'

它暴露 OpenAI 兼容接口。然后给 dsh 配一个自定义 provider——写进 $DSH_HOME/settings.yaml,不用点 UI:

llm-pi-ai:
providers:
mock:
api: openai-completions
baseURL: http://127.0.0.1:8000/v1
apiKeyEnv: MOCK_API_KEY
models:
- id: mock-model

启动 Web UI(MOCK_API_KEY=abc123 顺手设上),在模型选择器里切到 mock-model

模型列表里出现了自定义 provider mock 和 mock-model

然后开一个会话,发一句「帮我看看 https://example.com 这个页面讲了什么」。mock 模型按脚本调用了 web_fetch,真实轨迹长这样:

轨迹视图:Context injection → Fetch 工具调用 → 最终回答,下方是 turn/step/token 统计

轨迹里能看到三件事:

  • Context injection(system prompt 注入)被显式记录——「模型看到的都要进日志」这条原则是实打实的
  • Fetch · https://example.com/ 工具调用卡片,参数、耗时都在
  • 底部统计:1 turn · 2 steps · TTFT 0.1s · Input 6 tok · Output 43 tok——turn/step/token 三层度量,调试 agent 时这比什么都直观

一个发现顺便说:dsh 的 LLM 适配包叫 dsh-llm-pi-ai。点进去看,它「backed by @earendil-works/pi-ai」——就是 Pi 的多 provider SDK。也就是说 DeepSeek Harness 的模型接入层,底层用的是我们自己做的 Pi。这也是 Pi 作者会公开聊 Harness 的原因之一:看到自己维护的库被塞进一周 15 万 star 的项目里当底座,很难忍住不评论。

进阶:三角色能力缝#

官方 practice 的核心概念,也是「可替换」真正落地的设计:一个通用能力拆成三个角色。

┌─────────────┐ ┌──────────────────┐ ┌──────────────┐
│ dsh-shell │────▶│ dsh-bash-local │ │ dsh-tool-bash│
│ (定义) │ │ (提供者) │ │ (消费者/工具) │
└─────────────┘ └──────────────────┘ └──────────────┘
▲ │
└────────────────────────────────────────────┘
inject: ['shell']
  • Service Definition(如 dsh-shell):定义 service 接口和 Request/Result 类型,很少变
  • Service Provider(如 dsh-bash-local):具体实现,可独立演进、可替换
  • Consumer(如 dsh-tool-bash):把能力暴露成模型可调的工具

三者的依赖是单向的:Provider 和 Consumer 都只依赖 Definition,彼此不依赖。换一个执行后端(比如把本地 bash 换成远程沙箱),Definition 和工具都不用动,只换 Provider 那一行配置。官方 Bash 能力就是这么拆的,Windows 上还有一套 pwsh 的平行实现,同一份 patch 按平台 gate。

官方教程里有一段完整的三件套代码(MyCapService / MyCapLocal / tool-my-cap),结构很干净,值得照抄一遍。设计要点就三条:

  • 不要过早拆分。一个简单工具就是一个函数插件(比如本文的 web_fetch);只有角色需要独立演化时才分三个包
  • Definition 拥有 Request/Result 类型,别的角色只依赖它
  • 显式 > 隐式:默认值放进显式的 resolve() 步骤,别在 run() 里藏 ?? default

生态已经起来了#

上一篇结尾我说「下次它出 TUI 的时候,我会再花十分钟」。结果社区已经有人写了 dsh-TUI——Claude Code 风格的全屏终端 UI。这十分钟看来得提前花。

几个实打实的数据(8-18 查的):

  • GitHub dsh-plugin topic 下 7174 个仓库——发布五天,这是很夸张的生态速度
  • awesome-dsh-plugin 精选清单 20 个分类(UI、模型接入、记忆、工具、浏览器、语音、安全……)
  • dsh-market 插件市场dsh plugin --profile web add dshmarket,应用内一键装/升级插件、换主题
  • 官方分发格式是 dsh.bundle manifest,dsh plugin 命令底层直接转发给 pnpm

还有一条必须说的安全线。装插件 = 让第三方代码以你自己的权限跑在本机,能读你的文件、用你的凭证。工具审批(approval)管的是模型调工具,管不住插件代码本身。连 awesome 清单自己都写了:上榜不代表安全审查,装之前看源码,别在存着 key 的机器上试来路不明的插件。

我的建议#

  1. 先 patch 配置,再写插件--dump-config 看一眼启动树,很多定制只是换一行配置的事,根本不用写代码
  2. 简单工具一个函数插件就够,不要预拆三角色
  3. dev preview 会破兼容:接口边界放在 service definition 里,Consumer 别直接依赖 Provider 的实现
  4. 没 key 也能开发:官方 mock LLM 能脚本化工具调用,写工具插件时配合它做轨迹验证,比真模型更快更稳
  5. 插件放在独立机器或容器里试,尤其是带凭证的环境

参考资料#

给 DeepSeek Harness 写插件:从 hello-plugin 到一条能力缝
https://bangwu.me/posts/deepseek-harness-plugin-tutorial/
作者
棒无
发布于
2026-08-18
许可协议
CC BY-NC-SA 4.0