DeepSeek Harness 写第一个插件:让 Agent 调用你的工具
生命不息,折腾不止。这篇带你真正动手给 DeepSeek Harness 写第一个插件——从空文件一路写到「模型能点你写的工具」,看完你会明白「一切皆插件」到底是怎么一回事。
前面几篇我们把 DeepSeek Harness(简称 dsh)装起来、接上了中转站,模型能跑、工具能用。但那都是别人的插件。今天兑现一句早就埋下的伏笔:自己写一个插件。
dsh 这套框架最有意思的地方,就是那句「Everything is a Plugin」——工具是插件、模型适配器是插件、连 agent 循环本身都是插件。理解了「插件」怎么写,你就拿到了整个框架的万能钥匙。下面跟着我一步步来,全程不用 API Key,五分钟就能跑通。
一、先把概念捋顺:插件到底是个啥
dsh 底层的插件框架叫 Cordis。在 Cordis 眼里,一个插件最核心的就是一个导出函数 apply,它接收一个上下文对象(ctx)。插件通过这个 ctx 把自己贡献的所有东西注册进去。
三种插件形态,从简到繁:
1 | import { Service, type Context } from '@deepseek-ai/cordis' |
日常 90% 的活儿,用第 1 种函数插件就够了。只有要对外提供「服务」(一个别的插件能通过 ctx.xxx 调用的具名能力)时,才升级到第 3 种类插件。后面到了「注册工具」那一步,你会看到 ctx.tools、ctx.llm 这些其实都是服务。
另外还有个可选的 name 导出,纯粹是给诊断信息用的标签,写上没坏处。
二、准备环境(真·三分钟)
官方文档把教程例子放在仓库内的 tmp/ 目录(已经 git 忽略,随便折腾)。前置是 Node.js + pnpm,跟着跑:
1 | git clone https://github.com/deepseek-ai/deepseek-harness.git |
每一章都用同一条命令启动:
1 | node --import tsx ../../vendor/cordis/bin.js |
这个 bin.js 是仓库自带的单文件启动器:它会创建一个根 Context、挂载 Loader 插件,然后读取当前目录下的 cordis.yml 来组合整个应用。--import tsx 让 Node 直接跑 TypeScript,不用先编译。
关键认知:你的插件文件里没有任何「启动代码」,它只描述自己贡献了什么;真正把一堆插件拼成应用的是 cordis.yml。这跟传统「写 main 函数」的思路完全不一样。
三、第一个插件:让控制台喊一嗓子
在 tmp/cordis-tutorial 下新建 hello.ts:
1 | import type { Context } from '@deepseek-ai/cordis' |
再建一个 cordis.yml,告诉 Loader 挂这个插件:
1 | - name: './hello.ts' |
运行:
1 | node --import tsx ../../vendor/cordis/bin.js |
预期输出:
1 | hello from my first plugin |
没别的东西需要跑,进程自己就退出了。整个过程是:启动器建根 Context → 挂 Loader → Loader 读 cordis.yml 找到 ./hello.ts → Cordis 调用你的 apply(ctx)。就这么简单。
顺手做个反向实验:把 apply 里改成抛异常:
1 | export function apply(ctx: Context) { |
再跑一次,进程会直接报错退出。插件加载失败会明确报错,不会悄悄跳过——这是好事,出事你能第一时间知道。
⚠️ 但有个例外要记住:如果 cordis.yml 里的模块路径或包名拼错了(没法解析),Cordis 只会通过 logger 报告,不会让进程崩溃,而且这条报告在启动早期很可能还没被 console 输出器看到就丢了。所以**「新增的插件好像没生效」,第一件事先检查拼写**。
四、让插件活起来:生命周期 + 配置
光会喊一嗓子不算本事。真实的插件会开定时器、建连接、占资源,那这些东西怎么保证「不用了能干净地关掉」?答案是一个词:effect。
Cordis 自己管理的注册(比如后面要讲的 ctx.on、服务注册)会自动在插件卸载时撤销。但它管不到的资源(定时器、网络连接、文件 watcher),你得手动包进 ctx.effect(),返回值就是释放函数:
1 | import type { Context } from '@deepseek-ai/cordis' |
几点值得记下来:
ctx.plugin(heartbeat)能把一个来自代码的函数直接挂成子插件,跟 YAML 里写配置项效果一样,返回的fiber就是这个已加载实例的运行时句柄。- effect 主体在加载时执行,返回的 disposer 在卸载时执行。生命周期和插件一致的资源,你基本用不着手动调释放。
- 每个插件实例背后都有一个 fiber 状态机,在
PENDING → LOADING → ACTIVE → UNLOADING → DISPOSED之间流转,中途可能掉进FAILED。
再说配置。插件可以在 cordis.yml 里带一块 config,配一个 schema 让 Cordis 在跑 apply 之前先校验。dsh 用 Schemastery 定义 schema:
1 | import type { Context } from '@deepseek-ai/cordis' |
对应的 cordis.yml:
1 | - name: './config-demo.ts' |
运行输出:
1 | Hello, alpha! |
greeting 没填,schema 默认值自动补上——apply 拿到的永远是一份完整、通过校验的配置。如果你塞个错误类型(比如把 targets 写成字符串),会得到一条精准的 ValidationError,插件进 FAILED 状态,绝不带病上岗。还可以用 !!js 标签写运行时才计算的配置值,比如 greeting: !!js process.env.DEMO_GREETING ?? 'Hello'。
五、真家伙:注册一个模型能调用的工具
前面都是铺垫,这才是今天的重头戏——写一个模型真能点名的工具。运行时靠 ctx.tools.register() 把一个工具注册进去,工具本身用官方 @deepseek-ai/dsh-tools 的 defineTool 来定义:
1 | import type { Context } from '@deepseek-ai/cordis' |
逐行拆一下:
inject: ['tools']:告诉 Cordis「我这个插件依赖 tools 服务」,工具注册表就绪之前我会一直停在 PENDING,apply里就能放心用ctx.tools。这也解释了为什么cordis.yml里的顺序不重要——决定启动顺序的是依赖关系,不是文件顺序。ctx.tools.register(...):注册的 disposer 会自动挂到插件上,插件卸载时工具跟着注销,不用手动管。defineTool把parameters规约转成展示给模型的 JSON Schema,还会在execute运行前校验模型塞进来的参数;output.render是把结果渲染成模型能读的文本块。
想让工具收参数,把 parameters 写出来就行(拿到参数的例子参考下面的 greet 工具)。配套再写一个「观察者」,监听每次工具调用结果:
1 | import type { Context } from '@deepseek-ai/cordis' |
组合并运行,cordis.yml 长这样:
1 | - name: '@deepseek-ai/dsh-system-prompt' |
1 | node --import tsx ../../vendor/cordis/bin.js |
输出:
1 | [tool-logger] get_time -> 2026年9月5日星期六 20时00分00秒 GMT+8 |
注意 @deepseek-ai/dsh-system-prompt 必须列上——工具要把 schema 贡献进系统提示词,得先把 systemPrompt 服务的提供方挂上,否则工具插件会一直 PENDING(「为什么我的插件没输出」最常见的答案就是这个)。
到这里你其实已经看到完整链路了:模型发起一次工具调用 → execute 干活 → tools/result 事件广播 → 结果喂回模型。一个真实的 agent,就是这套组合再叠上 LLM 适配器、agent loop、持久化和 UI 入口。顺着这个思路,你甚至能在 execute 里 fetch 任意 HTTP 接口——想接另一个模型走 OpenAI 兼容协议,跟上一篇接中转站的套路是一模一样的,只是把「接模型」这一层包成了插件里的一个工具。
六、写插件最常踩的三个坑
插件静默没输出:八成是
PENDING状态。要么inject声明的服务没人提供(比如漏了@deepseek-ai/dsh-tools或@deepseek-ai/dsh-system-prompt),要么cordis.yml里路径、包名拼错。先查拼写,再查依赖。服务命名撞车:所有服务名共享一个扁平命名空间,
tools、llm这种通用名已经被 harness 占了。自己定义服务记得加前缀或命名空间,别裸用一个time、user之类的词。disposer 顺序想当然:disposer 按注册的逆序触发,但多个异步 disposer 是并发跑的。如果拆除步骤必须严格按顺序,就把它们塞进同一个 disposer 里依次
await,别指望注册顺序能保证先后。
官方仓库里这套教程一共七章(第一个插件 → 生命周期 → 服务 → 事件 → 配置 → 组合与热重载 → 接入 harness),今天这篇把最核心的第 1、2、3、5、7 章串成了一条能跑通的实战链路。剩下的「事件」「组合与 HMR」值得你去原仓库 docs/cordis-tutorial 补完,尤其热重载——改完插件浏览器里刷一下就生效,开发体验很爽。
生命不息,折腾不止。下一篇我们玩点大的:DeepSeek Harness 多 Agent 协作实战——一个任务拆给多个 Agent 并行干活,主 Agent 派生子代理、跨代理通信,看它怎么用
dsh-plugin-subagent把「一个人干不完的活」拆成流水线。