乐于分享
好东西不私藏

一切皆插件:DeepSeek Harness 爆了

一切皆插件:DeepSeek Harness 爆了

一切皆插件:DeepSeek Harness 爆了

写在前面

AI Agent 真正进入工程现场以后,开发者迟早会遇到一个问题:光会调模型不够,还得会给 Agent 做工具。

比如你想让 Agent 查端口、读内部系统、调用审批流、触发构建、跑一段业务诊断脚本,最后都绕不开插件体系。插件写得好,Agent 就像多了一套稳定的手脚;插件写得烂,轻则工具不出现,重则服务撞名、配置失效、会话污染,排查起来很磨人。

DeepSeek Harness 的有趣之处在于,它几乎把整套系统都压成了插件模型。@deepseek-ai/dsh 0.1.0-rc.6 的安装目录里,195 个 @deepseek-ai 包都是 Cordis 插件:工具、LLM 适配器、会话持久化、Web 服务、前端 UI、沙箱策略,全走同一套组合和生命周期。

对开发者来说,理解 Harness 插件开发,重点不是背 API,而是先搞清楚三个判断:插件放在哪个平面,是否发布服务,模块字符串按什么基准解析。想清楚这三件事,后面的代码骨架反而很固定。

Harness 的底层逻辑:没有内核加插件,只有组合树

很多系统会分成“内核能力”和“插件能力”。Harness 的心智模型更激进:能力就是往组合里加一行,行为修改就是覆盖已有的一行。

底层框架是 Cordis。它提供显式依赖注入、作用域服务、生命周期清理和组合 patch。你自己写的插件,和官方的 dsh-tool-bash、LLM 适配器、会话模块在机制上没有本质区别。

这带来两个结果。

第一,扩展能力很统一。你不需要同时理解“插件 API”和“内核 API”,只要能把插件行挂进正确的组合树,就能让能力进入运行时。

第二,错误也更工程化。插件不出现,不一定是代码没跑;可能是硬依赖停在 PENDING,可能是放错 realm,可能是 patch 只浅覆盖导致配置字段丢了,也可能是 preset 锁定在旧会话里。

四个具名导出,就是插件的骨架

Harness 插件最稳定的部分只有四个具名导出:

/** 诊断信息里显示的名字 */
exportconst name = 'my-plugin'

/** 硬依赖的服务;不满足时插件停在 PENDING 不执行 */
exportconst inject = ['tools']

/** schemastery 配置 schema,框架据此校验组合里的 config */
exportconst Config = z.object({ greeting: z.string() })

/** 唯一入口。在这里注册一切副作用 */
exportfunctionapply(ctx, config{}

这里有一条硬规则:不要用 export default

Loader 的 unwrapExports 会把默认导出折叠成插件本体,inject 等具名元数据可能被静默丢掉。最麻烦的是,插件看起来仍然加载了,但依赖声明没了,行为会变得很诡异,而且未必有明确报错。

也就是说,工具可以复杂,业务逻辑可以复杂,配置可以复杂,但插件入口最好保持朴素:nameinjectConfigapply

Fiber 和三种依赖:到底该等,还是该降级

ctx.plugin() 启动插件后会返回一个 Fiber。可以把 Fiber 理解为插件的运行时实例:它持有依赖状态、校验后的配置、注册过的副作用和卸载清理逻辑。

服务挂在 Context 上,比如 ctx.toolsctx.agentsctx.sessions。获取服务有三种方式:

写法
语义
适合场景
inject = ['x']
硬依赖。服务不存在时插件等待,服务出现后重新激活
没有这个服务插件就不能工作
ctx.get('x')
可选依赖。不声明、不等待,可能返回 undefined
能降级运行的增强能力
ctx.inject(names, cb)
局部硬依赖。服务出现时才执行回调
主流程先跑,增强逻辑等依赖就绪

判断标准很简单:服务缺失时,插件应该等,还是应该继续跑。

如果必须等,就放进 inject。如果只是锦上添花,就用 ctx.get 做空值判断。不要为了少写一个 undefined 判断就把所有东西都塞进 inject,那会让插件更容易卡在 PENDING

Isolate realm 是最容易踩坑的地方

Harness 的服务实现存在 Context 的符号表里:服务名对应一个 symbol。ctx.isolate(name, label) 会给这个服务名换一个私有 symbol。服务归属判断,本质上就是比较 symbol。

Preset 是每个会话挂载一份的。如果 preset 里某一行发布服务,却没有 isolate realm,服务就会注册到进程全局 realm。第二个会话再挂载同一份 preset 时,同名服务就可能撞车。

-id:delegation
name:cordis:group
group:true
isolate:
workflows:true
config:
-id:workflow-worker-thread
name:'@deepseek-ai/dsh-workflow-worker-thread'
-id:tool-workflow
name:'@deepseek-ai/dsh-tool-workflow'

反过来也会出事。把纯消费者单独包进 isolate group,它会去一个空的私有 realm 里找服务,找不到 host 提供的实例。结果可能是“挂载成功,但什么都不贡献”,还没有明显报错。

所以判断一行该不该进 realm,不看名字,看它是否发布服务。工具消费者通常别乱隔离;会发布服务、且每个会话都要独立一份的行,才需要隔离。

事件和清理:所有副作用都要能卸载

插件停止、更新、移除以后,它贡献的一切都应该消失。事件监听、定时器、外部订阅、工具注册都不能留脏东西。

Harness 提供了几类清理感知 API:

API
用途
ctx.on(...)
事件监听,随 Fiber 自动移除
ctx.effect(fn, label)
托管一个返回 disposer 的外部订阅
ctx.timeout / ctx.interval
定时器,需要声明 timer 服务
注册 API 的返回值
register
 等通常返回 disposer

Waterfall 事件还要特别小心。监听器最后一个参数是 next,不调用 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 })

禁止把副作用写在模块作用域。模块只 import 一次,但插件可能挂载、卸载很多次。模块级 setInterval 永远不会被 Fiber 清掉。所有副作用都应该在 apply 里通过 ctx 建立。

Patch 不是深合并,配置没生效先 dump

Harness 的配置树从空根开始,按顺序叠加 patch 层:profile bundles、profile 目录的 cordis.patch.yml、home 级 patch、命令行 --patch

排查配置问题时,第一步不是猜,而是 dump:

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

Patch 主要是三类操作:覆盖、插入、禁用。这里最容易误解的是覆盖:它是浅层键赋值,不是深合并。

你在 patch 里写了 config,就会整体替换原来的 config。原本有五个字段,你只写一个,另外四个就没了。要保留,就一起写全。

新增行必须用 insert。如果想新增却写成覆盖,会看到 patch: entry "X" not foundinsert 带标识时,目标必须是 group;不是 group 就会报 entry "X" is not a group

还有几个习惯很有用:

  • Patch 按列表顺序应用,前面插入的行,后面可以继续覆盖。
  • 非 insert patch 里建议写 name 做守卫,避免标识漂移后误改。
  • 匹配不到通常只警告,不一定让启动失败,所以要看警告输出。

Host 和 Agent preset:别把服务放错平面

Harness 有两个很重要的平面:Host 组合和 Agent preset。

一个实用判断是:只要某个服务有 Agent 平面之外的消费者,就不要移进 preset。

几类东西通常不该移进 preset:

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

模块解析也要分清。Preset 行里的裸包名,不按 preset 目录解析,而是按 profile 目录解析。插件文件内部自己的 import 仍然走标准 Node 规则,从插件文件所在目录向上找 node_modules

所以本地开发时通常有三种布局:

  • npm 包加裸包名:适合发布给别人用,包名和路径解耦。
  • 绝对路径:适合本机快速迭代,改完即生效,但绑定机器路径。
  • 放进 preset 目录用相对路径:只适合零外部依赖的极简插件,因为插件自己的裸导入容易失败。

看到 Cannot find package 时,先看报错里的 imported from,它会告诉你实际解析基准。

从零写一个 hello_world 工具

最小可用插件可以从一个工具开始。包结构就是 package.json 加 lib/index.js,并且 type: module 必须有,因为 Harness 全线 ESM。

import z from'@deepseek-ai/schemastery'
import { defineTool } from'@deepseek-ai/dsh-tools'

exportconst name = 'hello'
exportconst inject = ['tools']
exportconst Config = z.object({ greeting: z.string() })

exportfunctionapply(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) {
return { message: `${greeting}${args.who}!` }
    },
  }))
}

description 很关键。它是模型判断“什么时候该调用这个工具”的主要依据。参数 schema 告诉模型怎么调,description 才告诉模型何时调、边界在哪、和相近工具有什么区别。

参数里的 default 也别误会。它更多是给模型看的注解,不会自动填值。真正执行时,execute 里仍然要自己兜底。

安装和挂载是两件事:

cd dsh-plugin-hello && npm install
dsh plugin --profile web add ./dsh-plugin-hello

dsh plugin add 不会替你安装被 link 包自己的依赖,也不会自动把包变成 profile 层。它只是让裸包名更容易被 preset 行解析到。

Preset 建议从已有配置复制再改,不要从零写。很多人一上来自己写组合,最常漏的就是 group realm 或消费者行。

# agent.cordis.yml 末尾增加
-id:hello
name:dsh-plugin-hello
config:
greeting:你好

还有一个细节:preset 在会话创建时锁定。给已有会话换工具,通常看不到新能力。想验证新工具,开新会话。

真实工具开发:port_check 暴露的八个工程细节

hello_world 只证明能挂载。真实工具会遇到数组参数、结构化输出、并发、取消、限额、错误文案和模型行为控制。

端口探测工具 port_check 是一个很好的练习。它的骨架仍然是四个导出,复杂度全部进入工具定义。

几个规则值得直接记下来:

  • 数组入参的 items 也要写 description,这是模型理解数组元素含义的位置。
  • 返回结构化规范值,别只返回字符串;output.schema 会帮你在开发期抓返回值错误。
  • render 是给模型看的文本投影,不是随便格式化;先摘要,再明细,信息密度要够。
  • isConcurrencySafe 只有精确返回 true 才会并行;未知、非法、抛错都走独占,这是保守保护。
  • exec.signal 是真实取消,长任务要监听 abort、关闭 socket、摘监听器。
  • 错误文案会进入模型对话历史,要稳定、可操作、尽量前置。
  • 限额放进 config,不要硬编码,因为部署方才知道运行环境。
  • “端口没开”不是工具错误,而是工具的正常答案;抛错会诱导模型重试,正常返回会让模型继续推进。

实测输出可以长这样:

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

这类工具开发,最能体现 Agent 工具和普通脚本的区别。普通脚本只要跑完;Agent 工具还要让模型理解结果、理解失败、知道下一步该怎么做。

验证不要靠感觉,用探针看运行时

有些状态只存在于活的运行时里,比如某个 preset 的 standing key。做法是写一个一次性探针插件,通过 --patch 挂进 host 组合,跑完打印结果就退出。

exportconst inject = ['agentPresets''tools']

exportfunctionapply(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

如果 preset scope 里有你的工具,说明注册成功,schema 也通过了编译。如果 global scope 里出现了本该属于会话的工具,说明平面放错了。

探针是诊断工具,不是交付物。查完就删,最终能力要落回组合文件里。

排错表:比猜更快

现象
先查什么
service "x" is not declared
用了 ctx.x 但没声明 inject,改成 ctx.get 或声明真实硬依赖
cannot get property "timer"
定时器是服务,声明 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 service
preset 里发布服务的行没有 isolate realm
has been registered at ...
和 host 已有服务撞名,该服务可能应该留在 host 平面
挂载成功但工具没出现
用探针查 tools.schemas(key),看是否放错 realm
改配置没生效
--dump-config
 看最终树,注意 config 是整体替换
行为诡异且无报错
检查有没有 export default
新会话没有新工具
preset 在会话创建时锁定,确认新开会话且选对 preset
headless 下 preset 不生效
headless profile 没有 agent-presets 行,用 --patch 插入插件行

发布和分发:普通工具别碰 Bundle

Harness 当前没有插件市场。插件清单只是 Loader 树的只读投影,不是注册中心。分发主要靠 npm 包加 dsh plugin add

发布前先判断插件属于哪个平面:

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

普通工具插件老老实实走普通依赖包。Bundle 的权限很大,它的 patch 能直接改 host 组合树,甚至覆盖已有行、关闭沙箱行。这是部署方能力,不适合一般工具插件滥用。

普通 npm 包发布时注意:

  • 去掉 private,否则 npm 拒绝发布。
  • files 只列 lib,别把 node_modules、源码测试都发出去。
  • Harness 相关包更适合作为 peerDependencies,避免装出第二份实例。
  • 版本区间跟着 Harness 走,当前还是开发者预览期,要给破坏性变更留余地。

README 至少写清楚四件事:插件应该放 preset 还是 host,要不要 realm,是否发布服务,完整 config 字段和支持的 dsh 版本区间。最好给一段可以直接粘贴的 YAML。

和 AI 编程工作流怎么接起来

Harness 插件开发不是孤立技能,它本质上是在给 Agent 做可控工具层。

如果你已经在用 Claude Code、Codex、Cursor 这类 AI 编程工具,插件开发会让你从“让模型写代码”走到“给模型布置工作环境”:哪些系统能查,哪些命令能跑,哪些操作必须审批,哪些结果应该结构化返回。

DeepSeek Harness 本身可以通过 npm 安装,0.1.0-rc.6 仍是开发者预览阶段;真正的模型调用成本取决于你背后接的模型和 API。国内开发者如果要把 Claude、GPT、Gemini 等模型能力接到日常 CLI 或 Agent 工作流里,又不想反复折腾海外支付和网络环境,可以看看 Code80,真实订阅帐号转 API,换个 endpoint 就能用。

常见问题

DeepSeek Harness 插件最小要写什么?

最小骨架就是四个具名导出:nameinjectConfigapply。工具注册、事件监听、定时器、外部订阅都放进 apply,并通过 ctx 的清理感知 API 建立。

为什么不能用 export default?

默认导出可能让 Loader 折叠插件本体,导致 inject 等具名元数据丢失。最麻烦的是它未必立刻报错,而是出现依赖没声明、行为异常这类隐性问题。

插件应该放 preset 还是 host?

只被某个 Agent 会话使用的工具,通常放 Agent preset。会被 host 或跨会话消费者使用的注册表、持久化、sandbox、approval 等能力,应该留在 host 平面。

什么时候需要 isolate realm?

看插件是否发布服务,而不是看名字。Preset 里会发布服务的行通常需要 isolate realm;纯消费者乱隔离,反而可能解析到空服务表,出现挂载成功但工具没贡献的情况。

写 Agent 工具时最重要的不是代码吗?

代码只是底层。对模型来说,description、参数 schema、结构化输出、稳定错误文案和 render 投影同样重要。它们决定模型何时调用工具、如何修正参数、看到失败后会不会乱重试。

国内怎么更方便地把模型能力接进 Agent 开发?

Harness 插件本地开发按 npm 和 dsh 流程走即可;如果要稳定使用 Claude、GPT、Gemini 等模型 API,国内用户可以通过 Code80 更省事地接入。