给 DeepSeek Harness 写插件:从 hello-plugin 到一条能力缝
先说结论
上一篇我把 Harness 关了,因为它的 Web UI 不是我的菜;但「一切皆插件」这个架构我认。这篇把官方插件教程(basic → framework → practice)从头跑了一遍,而且没停在 hello-world:我写了一个真正有用的 web_fetch 工具插件,并用官方 mock LLM 跑出了真实的调用轨迹,截图为证。
给 dsh 写插件的门槛比想象低:一个导出 apply(ctx) 的 TypeScript 函数就是完整的插件。 真正要掌握的其实不是 API,而是三个心智模型:
- 一切注册皆可逆——通过
ctx注册的东西,卸载时自动回收 - 依赖驱动加载——
inject声明依赖,加载顺序由框架推导 - 能力缝 = 定义 + 提供者 + 消费者——可替换的能力边界
另外有个彩蛋放最后:dsh 的多模型适配层,底层就是 Pi(@earendil-works/pi-ai)。
文中所有命令和输出都是我 8 月 18 号在 Node v24.18 + pnpm 11.7 上实跑的,包括一个真实的报错和一次真实的踩坑。
准备:从源码跑
用 npx @deepseek-ai/dsh web 跑起来容易,但挂本地插件需要 --patch 指向本地文件,从源码检出最顺:
git clone https://github.com/deepseek-ai/deepseek-harnesscd deepseek-harnesspnpm installpnpm run buildpnpm dsh web # 开发模式,直接跑 TS 源码要求 Node ^22.19 || >=24。比我预想的轻:shallow clone 86M,install 约 21s,build 约 3s。
第一个插件:hello-plugin
插件就是一个导出 apply 函数的 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 层,把它插进启动树。插件路径必须是绝对路径:
- insert: - id: hello name: '/绝对路径/deepseek-harness/scratch-plugin/src/my-plugin.ts'启动时用 --patch 挂上:
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 ↘ FAILEDACTIVE → 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:
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(/ /g, ' ').replace(/&/g, '&') .replace(/</g, '<').replace(/>/g, '>') .replace(/"/g, '"').replace(/'/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,能指定它「假装调某个工具」:
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:

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

轨迹里能看到三件事:
- 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-plugintopic 下 7174 个仓库——发布五天,这是很夸张的生态速度 awesome-dsh-plugin精选清单 20 个分类(UI、模型接入、记忆、工具、浏览器、语音、安全……)- dsh-market 插件市场:
dsh plugin --profile web add dshmarket,应用内一键装/升级插件、换主题 - 官方分发格式是
dsh.bundlemanifest,dsh plugin命令底层直接转发给 pnpm
还有一条必须说的安全线。装插件 = 让第三方代码以你自己的权限跑在本机,能读你的文件、用你的凭证。工具审批(approval)管的是模型调工具,管不住插件代码本身。连 awesome 清单自己都写了:上榜不代表安全审查,装之前看源码,别在存着 key 的机器上试来路不明的插件。
我的建议
- 先 patch 配置,再写插件。
--dump-config看一眼启动树,很多定制只是换一行配置的事,根本不用写代码 - 简单工具一个函数插件就够,不要预拆三角色
- dev preview 会破兼容:接口边界放在 service definition 里,Consumer 别直接依赖 Provider 的实现
- 没 key 也能开发:官方 mock LLM 能脚本化工具调用,写工具插件时配合它做轨迹验证,比真模型更快更稳
- 插件放在独立机器或容器里试,尤其是带凭证的环境