您的位置:首页 > 手游攻略 > DeepSeek Harness 插件开发全指南

DeepSeek Harness 插件开发全指南

作者:互联网  时间: 2026-08-17 08:39:56  

面向有Node/TypeScript基础的工程师,DeepSeek Harness插件开发全指南,助你独立开发、排错,基于官方源码实践。
核心内容:
1. Harness插件架构:无内核插件分层,195个包均为cordis插件
2. 插件核心结构:四个具名导出(name/inject/Config/apply)的规范与重要性
3. 插件运行时:Fiber实例与三种依赖获取方式

DeepSeek Harness 插件开发全指南

面向已有 Node / TypeScript 基础的工程师。目标是读完能独立写出一个被真实挂载、工具出现在会话里的插件,并且能自己排错。

全文的机制结论均来自 @deepseek-ai/dsh 0.1.0-rc.6 安装目录下的源码与实际执行结果。

195

个包全是插件

4

个具名导出

0.1.0-rc.6

开发者预览

// 环境前置

npm install -g @deepseek-ai/dsh   # 需要 Node 22+dsh --version                      # 0.1.0-rc.6

配合阅读:安装目录里有两份更详细的官方技能文档 —— cordis-plugin-development(动态插件,420 行)与 editing-cordis-compositions(组合与 realm,154 行)。

01

一切皆插件

Harness 没有「内核 + 插件」的分层。安装目录下的 195 个 @deepseek-ai 包全部是 cordis 插件 —— 包括工具、LLM 适配器、会话持久化、Web 服务器、前端 UI,甚至沙箱策略。

加能力 = 往组合里加一行

没有第二套配置语言,也没有「插件 API」和「内核 API」的区别。你写的插件和官方的 dsh-tool-bash 地位完全相同。

改行为 = 覆盖已有的行

想换掉某个官方实现,就在 patch 层用同一个标识覆盖它的 config,或者关掉再插自己的。

底层框架是 cordis —— 一个显式依赖注入 + 作用域服务 + 生命周期清理的插件框架。

02

插件的形状:四个具名导出

/** 诊断信息里显示的名字 */export const name = 'my-plugin'/** 硬依赖的服务;不满足时插件停在 PENDING 不执行 */export const inject = ['tools']/** schemastery 配置 schema,框架据此校验组合里的 config */export const Config = z.object({ greeting: z.string() })/** 唯一入口。在这里注册一切副作用 */export functionapply(ctx, config) { }

绝对不要用 export default

Loader 的 unwrapExports 会把默认导出折叠成插件本体,inject 等具名元数据会被静默丢弃 —— 插件照常加载,但依赖声明没了,行为诡异且没有报错。官方为此写过事故复盘。规则:只用具名导出。

这四个导出是整套体系里唯一不变的部分。后面无论工具变得多复杂,骨架一行都不会动。

03

Fiber 与三种依赖获取方式

ctx.plugin() 启动一个插件,返回一个 Fiber —— 插件的运行时实例,持有依赖状态、已校验的 config、生命周期副作用和清理逻辑。它的状态机决定了你的代码到底跑没跑。

服务是挂在 Context 上的具名对象(ctx.tools、ctx.agents、ctx.sessions…)。获取方式有三种,语义完全不同

inject = ['x']硬依赖。框架保证 apply 执行时服务已就绪;不存在则停在 PENDING 等待,事后出现会重新激活插件
ctx.get('x')可选依赖。不声明、不等待,可能返回 undefined,必须自己处理缺失
ctx.inject(names, cb)局部硬依赖。回调拿到子 Context,只有服务出现时才执行 —— 核心能力硬依赖、增强能力软依赖的标准写法

Guard 会拒绝未声明的访问:直接写 ctx.someService 而没在 inject 里声明,会报 service "someService" is not declared。但也不要为了省一个 undefined 判断就滥用 inject

判断标准:这个服务缺失时,插件是应该「等」(inject),还是应该「降级运行」(ctx.get)?

04

Isolate realm:最容易出事的机制

服务实现存储在 Context 的一张符号表里:服务名 → symbol。ctx.isolate(name, label) 创建一张遮蔽表,给这个名字换一个新的私有 symbol;服务归属判断就是比较这个 symbol。传同一个 label 可以让两个 isolate 作用域合流,不传则每次都是全新的私有 symbol。

Preset 是每个会话挂载一份的。如果 preset 里某一行发布了服务却没有 isolate realm,这个服务就注册进了进程全局 realm —— 第二个会话挂载同一个 preset 时同名服务撞车,挂载直接被拒。

// 官方 standard preset 里的真实例子

- id: delegation  name: cordis:group  group: trueisolate:     workflows: true# 每个挂载方一份私有 realmconfig:     - id: workflow-worker-thread      name: '@deepseek-ai/dsh-workflow-worker-thread'     - id: tool-workflow        # 消费者也在 group 里name: '@deepseek-ai/dsh-tool-workflow'

反向的坑同样致命:把一个纯消费者单独包进 isolate group,它会去解析一个空的私有 realm,找不到 host 提供的实例 —— 结果是「挂载成功,但什么都不贡献」,且没有任何报错。官方 standard 里的 tool-bash、tool-jobs、tool-goal 就是刻意平铺在顶层的。

培训要点:判断一行该不该进 realm,看它发布什么,不看它叫什么。名字看不出来,就用探针查(见第 09 节)。

05

事件与清理纪律

事件有两种派发模式,监听器签名不同。普通事件各监听器独立执行;waterfall 事件的最后一个参数是 next

ctx.on('agent/pre-step', async ({ agent, turn, step }, next) => {  const decision = await next()   // 必须调用并返回,除非有意截断if (decision.kind === 'reject') return decision  return { kind: 'enter', messages: [...decision.messages, extra] } }, { prepend: true })              // 插到监听链最前面

不调 next() 就等于截断了整条下游链。写之前一定要先确认目标事件是哪种模式 —— 猜错了要么监听器不生效,要么把别人的逻辑全掐了。

核心纪律:插件停止 / 更新 / 移除后,它贡献的一切必须消失。框架提供了这些清理感知 API:

ctx.on(...)事件监听,随 fiber 自动移除
ctx.effect(fn, label)托管一个返回 disposer 的外部订阅
ctx.timeout / interval定时器(需要 inject 里声明 timer
各注册 API 的返回值register 等返回 disposer,需要时保存

ctx.effect 的语义:

▸ execute 立即执行

▸ 产生的 disposer 在「返回的 disposer 被调用」或「fiber 卸载」中较早的那个时机逆序执行

▸ 重复调用 disposer 是 no-op

▸ 在已销毁的 fiber 上调用会抛 INACTIVE_EFFECT

禁止模块级副作用。模块只会被 import 一次,但插件可能被挂载多次、卸载多次。写在模块作用域的 setInterval 永远不会被清理 —— 所有副作用都必须在 apply 里通过 ctx 的 API 建立。

06

配置树与 patch 语义

配置树从空根开始,按顺序叠加 patch 层:profile bundles → profile 目录的 cordis.patch.yml → home 级 patch → 命令行 --patch 指定的覆盖层。随时可以离线检查结果,不启动应用:

dsh --profile web --dump-default-config  # 不含用户层dsh --profile web --dump-config          # 完整组合结果

培训要点:任何「我改了配置但没生效」的问题,第一步都是 --dump-config 看最终树,而不是猜。

patch 只有三种操作:覆盖(写标识 + 要改的字段)、插入(用 insert)、禁用(其实就是一次普通覆盖,写 disabled)。

覆盖是浅层键赋值,不是深合并

源码就是一层 for 循环逐键赋值。所以 patch 里写 config 会整体替换原有 config。原本有五个字段,你只写一个,另外四个就没了 —— 必须把要保留的字段一并写全。

加新行必须用 insert。想加新行却用了覆盖语法,会得到 patch: entry "X" not found。insert 不带标识则追加到顶层,带标识则插进该 group 的 config(目标必须是 group)。

另外三个行为细节值得记:

顺序:patch 按列表顺序应用,插入的行会被立即索引,后面的 patch 可以再覆盖它

name 字段:非 insert 的 patch 里写 name 会当作守卫,与目标不符则跳过并警告 —— 建议写上,防止标识漂移后误改

匹配不到只警告、不启动失败,所以一定要看警告输出

07

两个平面与模块解析

整个体系最需要先想清楚的决策

判断规则只有一条:一个服务,只要有 agent 平面之外的消费者,就不能移进 preset。

官方给的例子是 subagents:这个注册表要回答来自 host api-proxy 的跨会话查询。放进 preset 会同时坏两件事 —— host 那一行永远等不到服务,第二个会话挂载时又撞名。正确做法是注册表和后端留在 host,preset 只贡献委派工具。

不能移进 preset原因
agent-loop只注册一个 agent 工厂,第二次会抛错
各注册表它们负责每会话分层,自身不能是每会话的
会话持久化移进去会导致会话列表碎片化
sandbox / approval刻意的边界:让 preset 放宽自己的限制等于取消限制

四种解析基准,容易踩空

同一个字符串,写在不同位置,解析基准完全不同。最反直觉的一条是:preset 行的裸包名不按 preset 目录解析,而是按 profile 目录解析。

原因写在源码注释里:本地编写的 preset 位于用户 home,Node 向上找 node_modules 永远够不到 harness 的依赖,所以框架覆写了 import(),改用 host 组合的 base。那条链上有两级都能命中 —— profile 自己的 node_modules,以及 profile 初始化时建立的共享目录。

排错技巧:看报错里的 imported from,它直接告诉你实际基准是什么,不用猜。

三种布局的取舍(均已实测):

方案 A · npm 包 + 裸包名:包名与路径解耦,换机器不用改组合。要发布给别人用,只有这条路

方案 B · 绝对路径:不用装进 profile,改完即生效,适合本地快速迭代,缺点是绑死机器路径

方案 C · 放进 preset 目录用相对路径:行本身能挂载,但插件自己的裸导入会失败,只适合零外部依赖的极简插件

两种「基准」不要混淆:行的 name 怎么解析是一回事;插件文件自己的 import 语句怎么解析是另一回事 —— 后者永远是标准 Node 规则,从插件文件所在位置向上找 node_modules。方案 C 挂掉的正是后者。

08

动手:从零到挂载

写一个注册 hello_world 工具的插件

包结构就是 package.json 加一个 lib/index.js。type: module 必须有 —— harness 全线 ESM。

// lib/index.js

import z from'@deepseek-ai/schemastery'import { defineTool } from'@deepseek-ai/dsh-tools'export const name = 'hello'export const inject = ['tools']export const Config = z.object({ greeting: z.string() })export functionapply(ctx, config) {  const greeting = config.greeting ?? 'Hello'    ctx.tools.register(defineTool({     name: 'hello_world',     description: 'Greet someone by name. Use this to verify plugin loading.',     parameters: {       who: { type: 'string', required: true, description: 'The name to greet.' },     },     output: {       schema: {         type: 'object',         additionalProperties: false,         properties: { message: { type: 'string', required: true } },       },      render(_args, value) {        return [{ type: 'text', text: value.message }]       },     },    asyncexecute(args, exec) {      return { message: `${greeting}, ${args.who}!` }     },   })) }

关于 description:这是模型判断「什么时候该调这个工具」的唯一依据。参数 schema 只告诉模型怎么调,不告诉它何时调。写清楚使用场景、边界、和相近工具的区别,比任何代码优化都更影响实际效果

default 是非校验注解。参数 spec 里的 default 定义在 ValueSchemaAnnotations,注释写明它不会自动填值,只是投影到 JSON Schema 给模型看的提示。所以 execute 里必须自己兜底:args.host ?? defaultHost

接下来是两件不同的事,都要做:插件目录里的 npm install 装自己的依赖;dsh plugin add 把包装进 profile,让 preset 行能用裸包名引用它。

cd dsh-plugin-hello && npm install         # 插件自己的依赖dsh plugin --profile web add ./dsh-plugin-hello  # 让裸包名可解析(可选)

注意 dsh plugin add 没做什么:不会把包变成 profile 层(那需要 bundle 声明);用 link 链接本地目录时,它不安装被链接包自己的依赖

然后造 preset。从复制开始,不要从零写 —— 从零写的组合几乎总会漏掉 group realm 或某个消费者行。目录标识必须匹配小写字母数字与连字符。

// agent.cordis.yml 末尾加一行

# 只消费 host 的 tools 注册表、不发布任何 service → 必须平铺在顶层- id: hello  name: dsh-plugin-hello  config:     greeting: 你好

绝对不要编辑随部署发行的 preset(standard / code / minimal / cordis)。升级会覆盖它们,而改坏 cordis 会让 preset 编写能力本身失效。

broken 字段不是验证。它只做浅层形状检查。解析错误、config 非法、行没激活、service 泄漏这四类失败全都能通过 broken 检查

最后一步:Preset 在会话创建时锁定,host 会拒绝给已存在的会话换 preset —— 因为该会话的历史是在原来那套工具下产生的。所以想看到新工具,必须开新会话

09

进阶案例 port_check 教会你的八件事

hello_world 只覆盖了最小可用面。真实的工具要处理数组入参、结构化输出、并发、取消、输入校验和部署限额。第二个案例是探测 TCP 端口 —— 骨架完全不变,变的只有工具定义本身

做成第二个独立的包,不要改 hello。一个 preset 可以挂任意多个自定义插件行。三个名字要分开:包名 dsh-plugin-portcheck、插件名 port-check、工具名 port_check。

① 数组入参要给 items 写 description

items 省略时接受任意无损 JSON 元素。给 items 也写 description —— 它会进入模型看到的 schema,是告诉模型「数组里该放什么」的地方。

② 返回结构化规范值,别返回字符串

output.schema 会对每个成功返回值强制校验。字段拆得越清楚,越能在开发期抓住返回值构造错误。注意 additionalProperties 在对象节点上是必填的 —— 规范里写明这是刻意设计,openness 必须显式。

③ render 做的是投影,不是格式化

模型看到的是这段文本,不是 JSON,所以要考虑信息密度:先给摘要,再给明细,对齐用 padStart。render 还必须是纯函数 —— 它会在回放历史时被重新调用,不能依赖外部状态。

// 实测输出

2/3 open on 127.0.0.1    8518  open    5ms      22  open    3ms    9999  closed  3ms (ECONNREFUSED)

④ isConcurrencySafe 的失败保护

defineTool 会包装这个分类器并先校验参数。规范写的是「只有精确的 true 才并行;未知、隐藏、未声明、非法或抛错的分类器一律独占」。你返回 true 不代表调用一定并行 —— 参数没通过校验时框架不会信任它,这是刻意的保守设计。

⑤ exec.signal 是真实的取消

长任务必须观察取消信号,三个细节容易漏:

幂等哨兵:connect / error / timeout / abort 四条路径都可能先到,只能结算一次

显式 destroy:Promise resolve 不会自动关闭 socket

摘监听器:once 只在真的触发时自动摘除,正常结算路径要手动摘,否则一次调用泄漏一个监听器

⑥ 错误文案是接口的一部分

execute 抛出的 message 会原样进入模型的对话历史。三条原则:

稳定 —— 同样的错误永远同样的措辞,否则破坏 KV cache 前缀且模型学不会规避

可操作 —— 说清哪个值错了、正确范围是什么,模型才能自己改对重试

前置 —— 在开任何 socket 之前校验完,别做一半再失败

分工:schema 校验(类型、必填、枚举)由框架做,业务约束要自己做 —— 「1-65535」「不为空」「不超过上限」都不是 JSON Schema 能表达的。

⑦ 限额交给 config,不要硬编码

这类策略取决于工具本身观测不到的运行时情况,应该交给组合决定。注意这里也要用空值兜底 —— Config 里的字段没写 default 时组合省略就是 undefined,而参数 spec 里的 default 又是非校验注解。两处默认值都得自己兜。

⑧ 失败不是错误

连接被拒、超时都不抛错,而是作为 open 为 false 的正常结果返回。因为「端口没开」正是这个工具要回答的问题。抛错应该留给「工具没能完成它的工作」,而不是「工作做完了,答案是否定的」。这直接影响模型行为:抛错让它倾向重试或换方法,正常返回则让它继续推进。

骨架一行没变。这就是这个体系的形状:插件的复杂度全部落在工具定义里,注册和生命周期永远是那四个导出。

10

验证方法论与探针技术

层级 3 的 standingKeyFor 只存在于活的运行时里。办法是用 --patch 把一个一次性探针插件挂进 host 组合,跑完就退出

// probe.js(核心部分)

export const inject = ['agentPresets', 'tools']export functionapply(ctx) {  void (async () => {    const key = await ctx.agentPresets.standingKeyFor('hello')     console.log('preset scope:', ctx.tools.schemas(key).map(s => s.name))     console.log('global scope:', ctx.tools.schemas().map(s => s.name))     process.exit(0)   })() }
dsh --profile web --patch ./probe.yml --port 0# --port 0 让 OS 随机选端口,不会撞上正在跑的服务===== PRESET PROBE =====standingKeyFor('hello'): MOUNTED OKtools in preset scope: bash, hello_world, str_replace_editor tools in global scope: (none)

这个输出要这么读:

preset scope 里有你的工具 → 注册成功,且 schema 通过了编译

global scope 是 none → 印证了平面规则:preset 注册的工具挂在会话自己的层里,不污染 host 全局注册表。如果你的工具出现在 global 里,说明放错了平面

同样的模式可以查任何东西 —— 某个服务由哪个 fiber 提供(判断某行要不要 realm)、某个事件有哪些监听器、工具的完整 schema。

探针是诊断工具,不是交付物。查完就该删,能力必须落在组合文件里。另外:跑一次成功的挂载会安装一个常驻代际,活到进程退出,所以它适合作为改完之后的最终检查,而不是每改一行就跑。

11

排错对照表

收藏这一节,遇到现象直接查

现象先查什么
service "x" is not declared用了 ctx.x 但没声明 inject。改成 ctx.get 加缺失判断,或声明真实硬依赖
cannot get property "timer"定时器是服务不是全局。声明 inject 里的 timer,用 ctx.timeout / interval
patch: entry "X" not found想加新行却用了覆盖语法。改用 insert
entry "X" is not a groupinsert 带标识时目标必须是 group
Cannot find package '…'插件目录没 npm install;或裸包名没装进 profile。看报错里的 imported from 确认基准
N row(s) did not activate该行的硬依赖没人提供。常见于消费者被误包进 isolate realm
published process-global servicepreset 里发布服务的行没有 isolate realm
has been registered at …与 host 已有服务撞名。该服务应该留在 host 平面
挂载成功但工具没出现探针查 tools.schemas(key);检查是否被误包进 realm 导致解析空注册表
改了配置没生效--dump-config 看最终树。注意 patch 的 config 是整体替换不是合并
行为诡异且无报错检查有没有 export default —— inject 被静默丢弃
新会话里没有新工具preset 在会话创建时锁定,必须开新会话;确认选的是对的 preset
headless 下 preset 不生效headless profile 没有 agent-presets 行,它不读任何 preset。用 --patch 把插件行插进它的树

12

发布与分发

没有插件市场。插件清单只是当前 Loader 树的只读投影,给设置页展示用,不是注册中心,也没有安装路径。分发靠 npm 包 + 一条 dsh plugin add。发布前先决定插件属于哪个平面,它决定了包的形态。

形态 A · 普通依赖包形态 B · Bundle
声明无特殊字段package.json 里声明 bundle patch
谁引用它使用者自己的 preset 行自动成为 host 组合的一层
平面Agent presetHost
适合工具插件(每会话一份)注册表、服务、host 级能力
使用者要做plugin add + 编辑自己的 preset只需 plugin add

形态 A 打包的几个决定要点:

去掉 private 标记,否则 npm 拒绝发布

files 只列 lib,别把 node_modules 和源码测试发出去

harness 的包应该是 peerDependencies,不是 dependencies —— 它们必须和使用者装的 harness 是同一份实例,装成普通依赖会拉进第二份副本,类型和实例判断都可能对不上

版本区间跟着 harness 走,当前是开发者预览期,官方明确会有破坏性变更

README 必须写清楚的四件事

使用者没法从包名看出这些,全靠你写:

① 平面与放置位置 —— 放 preset 还是 host 组合,要不要 realm

它发布服务吗 —— 决定使用者要不要给它包 isolate group

③ 完整的 config 字段 —— 名字、类型、默认值、是否必填

④ 支持的 dsh 版本区间

建议直接给一段可粘贴的 YAML 片段,省掉使用者猜的过程。

Bundle 的权限很大。它的 patch 直接改 host 组合树 —— 能覆盖任何已有行、能关掉沙箱行。这是给部署方的能力,不是给普通工具用的。工具插件老老实实走形态 A。

写在最后

整套体系其实只有三个决策点。

这东西该放哪个平面?它发布服务吗(决定要不要 realm)?这个字符串按什么基准解析?想清楚这三件事,剩下的就是把复杂度写进工具定义 —— 骨架永远是那四个具名导出。

参考来源

· @deepseek-ai/dsh 0.1.0-rc.6 安装目录源码与实测结果

· 官方技能文档 cordis-plugin-development / editing-cordis-compositions

· cordis 框架仓库:github.com/cordiverse/cordis

如果你觉得内容还算实用,欢迎点赞分享给你的朋友,在此谢过。

如果你想更快的看到后续内容的更新,请戳 “点赞”、“分享”、“喜欢” ,这些免费的鼓励将会影响后续有关内容的更新速度。如果有任何问题欢迎加wx交流!

完整演示视频请移步B站:老吴聊技术

登录查看剩余 70% 内容

最新游戏

更多

Copyright©2010-2019. All rights reserved | 波波三国游戏官网|[email protected]

备案编号:湘ICP备2022015115号-4