作者:互联网 时间: 2026-08-25 20:38:54
面向已有 Node.js / TypeScript 基础的开发者。读完本文,你可以独立完成:开发一个 DSH 插件 → 本地调试挂载 → 发布到社区并被他人安装。

版本说明:DeepSeek Harness 目前处于开发者预览(Developer Preview)阶段,迭代很快,官方明确声明会有兼容性破坏变更。文中机制基于 @deepseek-ai/dsh 0.1.0-rc.x 时代的公开资料整理,动手前请以官方仓库文档(GitHub:deepseek-ai/deepseek-harness)和 dsh --dump-config 的实际输出为准。
DeepSeek Harness(命令行简称 dsh)是 DeepSeek 开源的 Agent 框架(agent harness),架构上「一切皆插件」:模型适配器、工具注册表、会话日志、甚至 Agent 主循环本身都是插件,整个产品就是启动时从若干配置层组合出来的一棵插件树。
对开发者友好的几点现状:
dsh-plugin 话题(Topic),就会被社区聚合目录收录。安装目录下的约 195 个 @deepseek-ai/* 包全部是 Cordis 插件——工具、LLM 适配器、会话持久化、Web 服务器、前端 UI、沙箱策略都不例外。你写的插件和官方的 dsh-tool-bash 地位完全相同,没有「插件 API」和「内核 API」之分。
| 想法 | 含义 |
|---|---|
| 插件 | 一个实现了 Service 的对象:最常见是带 apply(ctx) 的函数,也可以是带 inject 的对象或 Service 子类 |
| 上下文(Context) | 服务的仓库。服务挂到稳定的 ctx.(如 ctx.tools、ctx.llm、ctx.sessions),插件之间通过 key 找服务,不 import 具体实现 |
| inject | 声明插件需要的必需服务。loader 会等待这些服务存在后再执行插件,加载顺序由依赖决定而不是文件顺序 |
| 类型化事件 | 服务通过声明合并定义事件,用 emit / waterfall / parallel / serial 分发给监听者 |
| 可逆的注册 | 工具 schema、监听器等都通过 ctx.effect() / ctx.on() 注册;插件卸载(HMR、热重载、关停)时一切自动回滚 |
扩展点(事件 / 服务)就是 dsh 的「API」。改行为时优先挂在扩展点上,不要去改主循环。
| 概念 | manifest | 回答的问题 |
|---|---|---|
| bundle(插件分发单元) | dsh.bundle(指向 patch 文件) | 「这个包贡献什么」——一个配置层(cordis.patch.yml),由 npm 包分发 |
| profile(可运行组合) | dsh.profile(bundles 列表) | 「哪些 bundle 按什么顺序组成这个运行实例」 |
bundle 是作者分发的单元,profile 是用户启动的单元,dsh plugin 命令负责维护 profile。
启动时配置层的叠加顺序(后层覆盖前层):
cordis.patch.yml$DSH_HOME/cordis.patch.yml(对本机所有 profile 生效)--patch 覆盖(按 argv 顺序)查看你的机器实际组合出的插件树:
dsh --profile web --dump-config
打印出来的任何一行,都可以用你自己的 patch 替换。 patch 按行的 id 定位:要么整行替换其 config(不是深合并),要么插入新行。
Node.js:官方声明范围 ^22.19.0 || >=24.0.0,不确定时直接用 Node 24。
pnpm:dsh plugin 子命令会把参数原样转发给 profile 目录里的 pnpm,没有 pnpm 会直接报错:
npm install -g pnpm
DeepSeek API Key(运行真实模型时需要):把 DEEPSEEK_API_KEY 放进根目录 .env,pnpm dsh 会自动加载。没有 Key 也可以先写代码、跑单元测试和 --dump-config 验证。
# 方式一:直接从 npm 运行(推荐普通开发者)npx @deepseek-ai/dsh web # 默认在 http://127.0.0.1:3080 启动 Web UI# 方式二:克隆源码开发(推荐要深度调试的开发者)git clone https://github.com/deepseek-ai/deepseek-harness.gitcd deepseek-harnesspnpm installpnpm run build # 不要省!只装依赖不构建会导致 Web 页面缺产物pnpm dsh web
开发期间用一个独立 profile(如 --profile dev)安装开发中的插件,日常使用的 web profile 保持稳定,两者互不干扰。
创建 hello.ts:
import type { Context } from '@deepseek-ai/cordis'export const name = 'hello'export function apply(ctx: Context) { ctx.logger.info('hello from my first plugin')}再创建 cordis.yml:
- name: './hello.ts'
在仓库内可以用 vendored 的 Cordis 启动器直接跑通最小挂载链路(不需要 API Key):
node --import tsx ../../vendor/cordis/bin.js
import { Service, type Context } from '@deepseek-ai/cordis'// 1. 函数插件(最常见,推荐默认用它)export function apply(ctx: Context) {}// 2. 对象插件:带 apply 方法的对象export const objectPlugin = { name: 'object-plugin', apply(ctx: Context) {},}// 3. 类插件:Service 子类(适合对外提供一个 ctx. 服务)export class MyService extends Service { constructor(ctx: Context) { super(ctx, 'myService') }} 一个正式的函数插件通常导出四个东西:
import type { Context } from '@deepseek-ai/cordis'import z from '@deepseek-ai/schemastery'/** 插件显示名,仅用于诊断。 */export const name = 'my-plugin'/** 声明依赖的必需服务;loader 会等它们存在再执行 apply。 */export const inject = ['tools']/** 部署期配置的 schemastery 校验 schema(可省略)。 */export interface Config { greeting: string}export const Config: z = z.object({ greeting: z.string(),})/** 插件主体:注册一切贡献,并只注册为可逆 effect。 */export function apply(ctx: Context, config: Config) { ctx.logger.info(config.greeting)} 要点:
inject只声明必需服务;可选服务用 ctx.get(name) 读取。inject 元数据。apply 签名:有 Config 导出时是 (ctx, config),没有时是 (ctx)。这一节汇总官方文档与社区实践中反复强调的规则,违反任何一条都可能导致插件加载失败或行为异常。
ctx.effect() / ctx.on() 等机制完成,插件卸载时自动回滚。ctx.effect() 中注册的东西必须有 teardown,否则重载或切换 profile 时会留下重复监听器或资源。SessionEventMap 加一种新事件类型、从日志渲染,而不是绕过日志。turn/*、step/*、tool/* 等)追加进会话日志,重启后可重建;live 事件(agent/*、tools/*)只做运行期协调。两者分工不能乱。cordis.yml 的 !!js 只允许出现在插件 config 和条目 disabled 下;按环境选插件要用 overlay,不要滥用 !!js。id。defineTool 会在 execute 前校验模型生成的参数。output.schema 定义返回值;抛异常 = isError;领域内的失败结果(如非零退出码)也要放进规范值返回。exec.signal:取消信号触发时必须中止进行中的工作。output.render 决定,UI 卡片由 presentCall / presentResult 返回渲染意图(generic / terminal / diff)。ctx.jobs.start() 注册,模型侧返回带 jobId 的规范句柄,且开关必须由部署配置控制。tools/pre-execute 等 waterfall 事件的监听器收到 (...args, next):调用 next() 才把结果传给下一个监听器;不调 next() 直接 return 就是短路,截断整条链。这是写钩子插件时最容易犯的错。
工具是插件最常见的用途。工具注册在 ctx.tools 上,schema 会自动进入 prompt 组装,模型就能「看到」它。
import { readFile } from 'node:fs/promises'import type { Context } from '@deepseek-ai/cordis'import { defineTool } from '@deepseek-ai/dsh-tools'export const name = 'demo-tool'export const inject = ['tools']export function apply(ctx: Context) { ctx.tools.register(defineTool({ name: 'read_file', description: 'Read a file from disk.', // 模型看到的能力描述,要写清前置条件与副作用 parameters: { path: { type: 'string', required: true, description: 'Absolute path' }, limit: { type: 'number' }, // 可选参数 }, output: { schema: { type: 'string' }, render: (_args, value) => [{ type: 'text', text: value }], }, async execute(args, exec) { // args 已被 defineTool 按 schema 校验并推导类型 return readFile(args.path, { encoding: 'utf8', signal: exec.signal }) }, }))}工具描述(description)的写作要求:说明何时调用、必要前置条件、失败语义与副作用。
不需要新工具、只想在某个环节插一脚时,用事件监听器。主循环是事件驱动的,钩子插件就是在这些事件上挂监听器。
权限门示例——在 tools/pre-execute 上拦截每一次工具调用:
import type { Context } from '@deepseek-ai/cordis'import type { PreToolDecision, ToolExecution } from '@deepseek-ai/dsh-tools'declare function isAllowed(exec: ToolExecution): Promiseexport const name = 'permission-gate'export function apply(ctx: Context) { ctx.on('tools/pre-execute', async (exec, next): Promise => { if (!(await isAllowed(exec))) { return { kind: 'deny', reason: 'Denied by policy.' } } return next() })} 常用扩展点速查:
| 你要做的 | 用哪个 |
|---|---|
| 允许 / 拒绝 / 询问工具调用 | tools/pre-execute,返回 {kind:'deny'} / {kind:'ask'} |
| 工具调用必须被最终否决、不可撤销 | ctx.tools.guard() |
| 包裹工具执行生命周期(超时/重试/指标) | tools/execute |
| 显式改写工具结果或呈现内容 | tools/post-execute |
| 只观察最终结果(审计/捕获) | tools/result |
| 改写模型请求配置 | agent/request(waterfall) |
| 改写/拒绝进入 step 的消息 | agent/pre-step(waterfall) |
用 @deepseek-ai/schemastery(它也是 Cordis 的校验器),类型和运行时校验合一:
import z from '@deepseek-ai/schemastery'export interface Config { allowParallelInProgress: boolean}export const Config: z = z.object({ allowParallelInProgress: z.boolean().required(),}) - id: todo name: '@deepseek-ai/dsh-tool-todo' config: allowParallelInProgress: true
| 路径 | 适用场景 | 做法 |
|---|---|---|
| 外置插件(推荐大多数场景) | 自研、开源、单独发布 | 独立 npm 包,用 dsh plugin add 安装进 profile;package.json 声明 dsh.bundle 可自动进 bundle 层 |
| 临时 overlay | 调试、演示 | dsh --profile |
| 仓库内包 | 给 dsh 本身贡献代码 | 放 packages/(预览期核心仓库暂不接受外部 PR) |
dsh plugin 把参数原样转发给 profile 目录里的 pnpm,动词在最后:
dsh plugin --profile web add /path/to/my-plugin # 本地路径dsh plugin --profile web add github:you/my-plugin # Git 仓库dsh plugin --profile web add my-plugin # npm 包dsh plugin --profile web add ./my-plugin-0.1.0.tgz # tarballdsh plugin --profile web remove my-plugin # 卸载
dsh.bundle 的,会自动追加进该 profile 的 dsh.profile.bundles 层栈;没声明的包只会作为普通依赖安装并收到警告。# my-overlay.yml- insert: - id: my-plugin name: '/绝对路径/my-plugin/index.js'
dsh --profile headless --patch ./my-overlay.yml "任务"
dsh --profile web --dump-config # 应看到 # == your-plugin 层和对应 id 行dsh plugin --profile web why# 确认依赖关系
DSH 没有专门的插件注册中心——发布 DSH 插件 ≈ 发布一个 npm 包,只是包内容遵循插件约定。官方提供三种分发途径,核心区别在于是否分发预构建产物:
| 方式 | 用户安装命令 | 安装到的是什么 | 是否需要构建授权 |
|---|---|---|---|
| npm 发布 | dsh plugin add your-package | 预构建的 lib/ 代码 | 不需要 |
| tarball 交付 | dsh plugin add ./hello-0.1.0.tgz | pnpm pack 打出的包 | 不需要 |
| Git 安装 | dsh plugin add github:you/repo | 源码(不是构建产物) | 需要(pnpm ≥ 10) |
# 1. 准备 npm 账户并登录npm login# 2. 先构建再发布(prepublishOnly 里做构建也行)pnpm buildnpm publish # 或 pnpm publish# 3. 验证:在某个 profile 里安装,确认能挂载dsh plugin --profile dev add your-plugindsh --profile dev --dump-config
发布前检查:入口正确导出 name / inject / apply;inject 里依赖的服务提供方要声明进 package.json;版本从 0.x 起步并遵循语义化版本;选择明确的开源协议(MIT / Apache-2.0 常见)。
# 作者侧:打出 tgzpnpm pack# 用户侧:直接安装 tarball 文件,零授权dsh plugin add ./hello-plugin-0.1.0.tgz
Git 安装拉取的是源码,没有任何环节替你运行 build 脚本——TypeScript 包到手没有 lib/ 输出,加载会失败。所以:
prepare 脚本(pnpm 在 git 安装后运行它完成构建)。它不能假设仅开发环境存在的上下文(比如旁边有一份 monorepo checkout)。pnpm-workspace.yaml 里添加 allowBuilds 授权(按报错提示复制 key 即可)。这等于允许该包的代码在你机器上执行。dsh plugin add github:you/repo#<完整commit-sha>,避免后续推送改变实际运行的代码。官方指定的发现渠道非常简单——给插件仓库加上 GitHub 话题 dsh-plugin。加了话题的仓库会被社区聚合目录(awesome 清单、各类插件索引站)自动收录。
其他渠道:
dsh-plugin 话题自动索引)一个标准 DSH 社区插件包(bundle)的结构:
your-plugin/ package.json # 声明 "dsh": { "bundle": { "patch": "./cordis.patch.yml" } } # main/types/exports 指向真实生成的 lib/ # files 只收录运行入口/声明/许可证/README/组合层 # Cordis 与 Service Definition 包放 peer + dev deps,自有实现放 dependencies cordis.patch.yml # bundle 的 patch 层:按行 id 插入插件行,插件按包名解析 src/index.ts # 函数插件:命名导出 name/inject/Config/apply README.md # 服务 API、事件、扩展点、安装命令、Known Limitations LICENSEpackage.json 关键片段:
{ "name": "dsh-your-plugin", "version": "0.1.0", "type": "module", "main": "./lib/index.js", "types": "./lib/index.d.ts", "exports": { ".": "./lib/index.js" }, "dsh": { "bundle": { "patch": "./cordis.patch.yml" } }}cordis.patch.yml:
- insert: - id: your-plugin name: 'dsh-your-plugin' # 用包名,不要用 checkout 相对路径
发布前检查清单(可直接复制进 PR 描述):
name/inject/Config/apply 命名导出完整;inject 的服务提供方已声明依赖execute() 契约遵守;output.render 与 UI 卡片分离pnpm pack 产物在干净 profile 里能 dsh plugin add 成功并出现在 --dump-config 中在仓库内开发时,新增/修改包后逐级往上跑(本地只跑受影响的,CI 才全量):
pnpm run constraints # workspace 约束pnpm run typecheck # strict 类型检查,无 any 逃逸pnpm run lint # oxlintpnpm run buildpnpm run hygiene # knip + publint + NodeNext 消费检查pnpm run test # vitest 单元测试
测试方针要点:
cordis.yml,而不是只用手搭的 ctx.plugin(...) 单测。Q:dsh plugin报错找不到 pnpm?
dsh plugin 是把参数转发给 profile 目录里的 pnpm 执行的。先 npm install -g pnpm。
Q:Git 安装的 TypeScript 插件加载失败?
Git 安装拉的是源码,没人替你跑 build。插件作者要提供自包含 prepare 脚本;用户侧 pnpm ≥ 10 还需要在 profile 的 pnpm-workspace.yaml 里加 allowBuilds 授权。不想折腾就改用 npm 包或 tarball。
Q:怎么确认插件真的挂载了?
dsh --profile ,应该能看到 # == your-plugin 层和你的插件行 id。
Q:插件异常导致 dsh 无法启动,如何临时禁用?
在 profile 的 cordis.patch.yml 里加一行即可,无需卸载:
- id: your-plugin disabled: true
Q:patch 覆盖了配置但没生效?
patch 是整行替换而非深合并——覆盖时必须保留行的 id,且被替换字段要全部重述。
Q:bundle 插件安装后还要手动 insert 吗?
不要。声明了 dsh.bundle 的包会被自动注册进 bundle 层栈;再往 profile 的 cordis.patch.yml 手动 insert 同 id 会报 duplicate loader entry id 导致无法启动。
Q:核心 API 会变吗?
会。开发者预览阶段官方明确声明有兼容性破坏变更。建议:pin 住你实验用的仓库 commit 或包版本;以官方文档和 --dump-config 实际输出为准。