DeepSeek Harness 插件开发全指南
本文面向具备 Node/TypeScript 基础的工程师,详解 DeepSeek Harness 插件开发机制。内容基于 @deepseek-ai/dsh 0.1.0-rc.6 源码与实际执行结果,涵盖插件架构(一切皆插件)、四个具名导出规范、Fiber 状态机与依赖获取方式、Isolate realm 机制、事件与清理纪律、配置树 patch 语义、Host/Agent 两个平面划分、模块解析基准、动手实践流程及排错方法论。
一切皆插件
Harness 没有「内核 + 插件」的分层。安装目录下的 195 个 @deepseek-ai 包全部是 cordis 插件 —— 包括工具、LLM 适配器、会话持久化、Web 服务器、前端 UI,甚至沙箱策略。
加能力 = 往组合里加一行。没有第二套配置语言,也没有「插件 API」和「内核 API」的区别。你写的插件和官方的 dsh-tool-bash 地位完全相同。
改行为 = 覆盖已有的行。想换掉某个官方实现,就在 patch 层用同一个标识覆盖它的 config,或者关掉再插自己的。
底层框架是 cordis —— 一个显式依赖注入 + 作用域服务 + 生命周期清理的插件框架。
插件的形状:四个具名导出
/** 诊断信息里显示的名字 */
export const name = 'my-plugin'
/** 硬依赖的服务;不满足时插件停在 PENDING 不执行 */
export const inject = ['tools']
/** schemastery 配置 schema,框架据此校验组合里的 config */
export const Config = z.object({ greeting: z.string() })
/** 唯一入口。在这里注册一切副作用 */
export function apply(ctx, config) { }
绝对不要用 export default。Loader 的 unwrapExports 会把默认导出折叠成插件本体,inject 等具名元数据会被静默丢弃 —— 插件照常加载,但依赖声明没了,行为诡异且没有报错。官方为此写过事故复盘。规则:只用具名导出。
这四个导出是整套体系里唯一不变的部分。后面无论工具变得多复杂,骨架一行都不会动。
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)?
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: true
isolate: workflows: true # 每个挂载方一份私有 realm
config: - 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 节)。
事件与清理纪律
事件有两种派发模式,监听器签名不同。普通事件各监听器独立执行;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 建立。
配置树与 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 会当作守卫,与目标不符则跳过并警告 —— 建议写上,防止标识漂移后误改
▸ 匹配不到只警告、不启动失败,所以一定要看警告输出
两个平面与模块解析

判断规则只有一条:一个服务,只要有 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 挂掉的正是后者。
动手:从零到挂载
写一个注册 hello_world 工具的插件
包结构就是 package.json 加一个 lib/index.js。type: module 必须有 —— harness 全线 ESM。
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 function apply(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 }]
}, },
async execute(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 —— 因为该会话的历史是在原来那套工具下产生的。所以想看到新工具,必须开新会话。
进阶案例 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 的正常结果返回。因为「端口没开」正是这个工具要回答的问题。抛错应该留给「工具没能完成它的工作」,而不是「工作做完了,答案是否定的」。这直接影响模型行为:抛错让它倾向重试或换方法,正常返回则让它继续推进。
骨架一行没变。这就是这个体系的形状:插件的复杂度全部落在工具定义里,注册和生命周期永远是那四个导出。
验证方法论与探针技术

层级 3 的 standingKeyFor 只存在于活的运行时里。办法是用 --patch 把一个一次性探针插件挂进 host 组合,跑完就退出。
export const inject = ['agentPresets', 'tools']
export function apply(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 OK
tools 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。
探针是诊断工具,不是交付物。查完就该删,能力必须落在组合文件里。另外:跑一次成功的挂载会安装一个常驻代际,活到进程退出,所以它适合作为改完之后的最终检查,而不是每改一行就跑。
排错对照表
收藏这一节,遇到现象直接查
| 现象 | 先查什么 |
|---|---|
| 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 group | insert 带标识时目标必须是 group |
| Cannot find package '…' | 插件目录没 npm install;或裸包名没装进 profile。看报错里的 imported from 确认基准 |
| N row(s) did not activate | 该行的硬依赖没人提供。常见于消费者被误包进 isolate realm |
| published process-global service | preset 里发布服务的行没有 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 把插件行插进它的树 |


JOTO 企业落地观察
- DeepSeek Harness 的“一切皆插件”架构,对企业部署意味着无需区分核心与扩展能力,所有功能模块均通过统一契约接入。这降低了企业构建垂直领域智能体时的架构决策成本,但要求团队具备更强的插件生命周期管理能力,尤其在多租户会话隔离与服务作用域控制方面。
- Isolate realm 机制暴露了企业级 RAG 知识工程中的关键取舍:若将知识检索服务置于 preset 中,则无法支持跨会话的全局知识聚合;若置于 host 层,则需额外设计权限与缓存策略。企业需根据知识更新频率、访问粒度与安全边界,在两个平面间明确划界。
- Fiber 状态机与严格的清理纪律,对企业 AI 安全治理提出刚性要求:任何插件若未通过 ctx.effect 等 API 建立副作用,其残留资源将逃逸出管控范围。这意味着企业必须将插件开发纳入安全准入流程,对第三方插件实施静态扫描与运行时资源审计。
- 配置树的 patch 语义(浅层覆盖而非深合并)对企业智能体工程构成隐性风险:当多个团队协同维护同一 profile 时,单个 patch 的 config 字段遗漏将导致其他字段意外丢失。企业需配套建设配置变更的可视化 Diff 工具与自动化校验流水线。
立即咨询 JOTO
JOTO 提供覆盖企业智能体规划与搭建、AI 平台私有化部署、RAG 知识工程、AI 安全治理、FDE 驻场共创及持续运营优化的全周期 AI 落地服务,帮助企业把验证中的 AI 能力转化为安全、可控、可持续迭代的生产力。 联系 JOTO 获取 AI 落地咨询
想把这些做法用到你的业务里?
留下你的场景和痛点,我们帮你判断从哪一步开始。
联系我们


