【DeepSeek Harness 插件】01:从零写第一个插件
你大概用过某个 AI 助手,然后冒出一个念头:"要是它能帮我做某件具体的事就好了。"在 DeepSeek Harness 里,这个"某件事"通常就是一个插件。
这篇文章我们亲手从零写一个插件:让助手学会一句"你好,XXX!"的问候。先把它建出来、跑起来、亲眼看到模型真的调用它,再回过头把"为什么能跑"这件事一层层讲透。读完你既能复述插件是什么,也能照着写出一个自己的。
下面的例子假设你已经在本地按官方文档搭好了 DeepSeek Harness 的源码运行环境。代码本身不依赖任何私有仓库,新建几个文件就能跟着敲。
一、动手:从零把它造出来
1. 先建个目录
在任意方便的位置建一个放本地实验插件的目录:
mkdir -p scratch-plugin/src2. 写插件本体
新建 scratch-plugin/src/my-plugin.ts,把下面这段写进去:
// 一个最小的插件:注册一个名为 greet 的工具,让模型能向某人打招呼。
import type { Context } from '@deepseek-ai/cordis'
import { defineTool } from '@deepseek-ai/dsh-tools'
// 插件名,只用于日志输出。
export const name = 'hello-plugin'
// 声明依赖 tools 服务,框架会等它就绪,再调用我们的 apply。
export const inject = ['tools']
export function apply(ctx: Context) {
console.log('[hello-plugin] 插件已加载!')
// 通过 tools 服务注册工具,注册后模型即可在会话中调用它。
ctx.tools.register(defineTool({
name: 'greet', // 模型调用这个工具时用的名字
description: '向某人打招呼。', // 模型据此判断要不要调用
parameters: { // 参数声明,和下面 execute 解构出来的字段一一对应。
name: {
type: 'string', required: true, description: '要打招呼的对象名字'
},
},
output: {
schema: { type: 'string' }, // 返回值长什么样
// render 把规范值转成返回给模型的工具结果内容。
render: (_args, value) => [{ type: 'text', text: value }],
},
// execute 是真正的执行体。
async execute(args) {
return `你好,${args.name}!`
},
}))
}现在不用急着弄懂每一行,先把它存好,后面我们逐个拆开讲。
3. 写叠加层文件
插件写好了,框架还不知道它存在。再建一个 scratch-plugin/cordis.yml:
- insert:
- id: hello
name: '/absolute/path/to/scratch-plugin/src/my-plugin.ts'把里面换成你机器上的真实绝对路径。注意:叠加层只描述"要挂什么";加载器解析模块路径时用的基准目录不会因它而改变,所以相对路径会指到别处,路径必须写全、用绝对路径(官方教程同此要求)。
4. 跑起来,看它工作
启动 Web 界面时,把这个文件作为**叠加层(overlay)**挂上去:
pnpm dsh web --patch ./scratch-plugin/cordis.yml打开 http://127.0.0.1:3080,在对话框里说一句"向 Ada 打个招呼",模型会自己判断该调用 greet 工具,然后回你"你好,Ada!"。终端里则多了一行 [hello-plugin] 插件已加载!。
就这样,三十来行代码,你就给助手添了一项可调用能力。接下来我们拆开看,它到底是怎么做到的。
二、回过头看:它为什么能跑
插件到底是什么
抛开术语,一个插件就是一个 TypeScript 模块,关键在于它导出一个 apply 函数;我们例子里还顺手导出了 name 和 inject,后面会逐个看。框架加载这个插件时,会调用 apply,并交给它一个 ctx,也就是整个应用的"公共黑板",所有能力都登记在这上面。
所以写插件不是"写一个程序",而是"往一块公共黑板上贴一张便签,告诉系统我提供点什么"。上面那段代码贴的便签就是:ctx.tools.register(...) 把 greet 挂到"工具"那一栏里。
Harness 自身也是一堆这样的插件拼起来的:工具、模型适配器、文件访问、终端界面,全都是挂着 apply(ctx) 的模块。你的插件和它们运行在同一个 ctx 里,彼此平等、互相找得到。
于是有一条朴素的规矩:包与包之间不要互相 import 对方的实现,要谁的能力,就去 ctx 上按名字取,这样包之间才不会互相纠缠。
三种写法,先用简单的那种
插件允许有三种形态,把它想成"由浅入深"的三档:
- 函数形态:直接
export function apply(ctx) {}。我们刚才写的就是它,最常见,也是默认选择。 - 对象形态:把
name、inject、apply收进一个export default的对象里,适合想把这三样集中写在一处的场景。 - 类形态:写个继承
Service的类,用来在ctx上暴露一个新服务给别人用,比如你想造一个ctx.myService让别的插件来消费。
经验法则很直白:先用函数形态,直到你确实需要"暴露一个服务给别的插件",再升级到类形态。
前两种对应"函数插件",最后一种对应"Service 类插件"。两者不能混着写:如果同时写默认导出的类、又写命名导出的函数,加载器会丢掉函数那份,能力就悄悄没了。我们的例子只用了 name/inject/apply 三个命名导出,没有任何默认导出,正是安全写法。
name 只是个诊断用的标签,出现在日志里,叠加层并不靠它定位插件。
挂上去这件小事
回看刚才的 cordis.yml:它列出要挂进去的插件条目,name 指向模块的位置。注意,这是模块路径,和插件里 export const name 那个只用于日志的标签不是一回事;insert 是一个 patch 动作,意思是"把这个本地插件加进启动组合"。
它是一个叠加层:像一张透明片,叠在 Harness 基础组合之上,而不是替换它。所以那条启动命令 --patch ./scratch-plugin/cordis.yml,本质就是:"按基础组合启动,再额外叠上我的本地插件。"
两个容易踩的坑:
- 如果
apply里抛了错,进程会直接崩掉,错误当场暴露出来,而不是被悄悄吞掉(工程里叫 fail loud); - 如果
name指向的文件路径拼错了、找不到,框架会通过日志服务上报,但启动太早时这条日志可能在控制台就绪前就丢了。所以插件"毫无反应"时,第一件事就是检查叠加层里的路径。
谁先跑,谁来定
注意代码里的 export const inject = ['tools']。这行不是装饰,它说:"我这个插件依赖 tools 服务。"
有了这句,框架会等 tools 服务就绪之后再调用我们的 apply,于是 ctx.tools 一定已经可用。tools 本身又是另一个插件挂在 ctx 上的服务,我们只是它的消费方。
这里有个反直觉但很重要的事实:叠加层里的条目是并发启动的,书写顺序并不代表 apply 被调用的先后。真正的顺序由服务依赖算出来:你声明依赖谁,框架就保证谁先就位。所以你不用操心"把 tools 排在前面",只要 inject 写对,排队的事交给框架。
插件怎么清理干净
通过 ctx 注册的一切,比如事件监听、工具、定时器,都会随插件卸载自动清理,你不必手写 removeListener 或 clearInterval。我们那个 ctx.tools.register(greet) 正是如此:插件被卸载(比如热重载或应用关停)时,greet 自动从工具表里消失。
遇到必须显式清理的资源,比如一个网络连接,用 ctx.effect() 告诉框架"怎么拆":
export function apply(ctx: Context) {
ctx.effect(() => {
const timer = setInterval(() => console.log('heartbeat'), 5000)
return () => clearInterval(timer) // 插件卸载时运行
})
}传给 effect 的函数,其返回值会在卸载时执行,这就是"拆除说明书"。一句话:能走 ctx 注册的就走 ctx,否则就包进 effect,这样卸载才不会漏。
那个工具到底长什么样
我们真正干的活,是用 defineTool 造一个模型可调用的工具。它里面的 parameters、output、execute 可以理解成三段:输入管模型填什么(输入约定)、执行管怎么算(业务逻辑)、输出管算出什么、模型会读到什么样的结果文本(输出约定)。

对着问候代码看这三段:
- 输入(parameters):声明模型要填的参数。
defineTool据此推断并校验入参,所以执行体能直接拿到类型正确的args;必填项没填,框架不会放行。 - 执行(execute):真正的业务逻辑,返回规范值,必须匹配输出约定,否则被校验拦下。
- 输出(output):
schema约束返回值的样子,render把规范值转成模型读到的文本。问候里render把"你好,Ada!"包成一条文本块,模型看到的工具结果就是它。
这里有个最容易被忽略、却最关键的区别:执行体返回的规范值,和模型读到的工具结果,是两样东西。
- 规范值:执行体的返回值,先校验、再冻结成不可变值。它是机器可读的原始数据,留在结果里供策略钩子和界面卡片使用;
- 结果内容:
render把规范值转成的文本块,返回给模型的工具结果消息就是它。模型读到的是渲染后的文本,而不是execute返回的那个值本身。
所以 schema 管"值对不对",render 管"模型读到什么":把"算出来的值"和"读到的文本"分开,正是 defineTool 的核心。比如一个工具返回结构化 JSON,render 把它渲染成一句易读说明,模型读到的是这句话,而策略钩子和卡片仍能从规范值拿到完整 JSON 做判断、画卡片。
注册完,模型就能在会话里听懂"向 Ada 打个招呼"这类话、自己调起 greet,并把"你好,Ada!"当工具结果。这一整套就是普通插件"贡献能力"的标准做法:你不用自己写 HTTP 接口、不用自己接对话循环,只要把能力挂上 ctx.tools,剩下的交给 Harness。
三、再往前想
什么时候该拆,什么时候不用
我们写的只是个单一用途的小东西:一个 greet 工具,一个包搞定,这就挺好。
只有当你要做可替换的能力时,才值得把它拆开:把服务定义(Service Definition)、提供方(Service Provider)、消费方(Consumer)拆成独立包,但这只在它们各自独立演进时才有意义。
这里澄清一个常见误会:能力接入点(capability seam)不是"又一种插件类型",而是一种组合模式。三者齐备才算一个接入点,单独一个角色不算。所以别把"我要做个提供方"误当成"我要发明一种新插件"。
拆开长什么样:把 greet 真拆一次
光说不练不算懂,我们把 greet 真拆一次。在 scratch-plugin 旁边新建 scratch-seam/,下面四个目录各放一个文件:
scratch-seam/
├── greeting-definition/ 能力定义:纯契约库,不出现在叠加层里
├── greeting-provider-local/ 提供方(口语版)
├── greeting-provider-polite/ 提供方(敬语版):与 local 二选一
├── greeting-tool/ 消费方:注册 greet 工具
└── cordis.yml 只挂提供方与消费方两条定义只声明契约:一个抽象类通过 super(ctx, 'greeting') 把服务名定为 greeting,挂在 ctx 上用的就是这个名字;输入输出都以命名类型定义在契约里,没有任何实现,它是库,不是插件:
// scratch-seam/greeting-definition/src/index.ts(节选)
import { Context, Service } from '@deepseek-ai/cordis'
declare module '@deepseek-ai/cordis' {
interface Context { greeting: GreetingService }
}
export interface GreetRequest { readonly name: string }
export interface GreetingMessage { readonly text: string }
export abstract class GreetingService extends Service {
constructor(ctx: Context) { super(ctx, 'greeting') }
abstract greet(request: GreetRequest): GreetingMessage
}提供方子类化契约、实现 greet,以类插件的形式挂载后自动注册为 ctx.greeting。polite 版是同一契约下的第二个实现,只改 greet 返回的文本:
// scratch-seam/greeting-provider-local/src/index.ts
import type { Context } from '@deepseek-ai/cordis'
import type { GreetRequest, GreetingMessage } from '../../greeting-definition/src/index.ts'
import { GreetingService } from '../../greeting-definition/src/index.ts'
export default class LocalGreeting extends GreetingService {
constructor(ctx: Context) { super(ctx) }
greet(request: GreetRequest): GreetingMessage {
return { text: `你好,${request.name}!` }
}
}消费方和第 1 节的 greet 工具几乎一样,只有两处变化:inject 里多声明了一个 greeting 服务;execute 不再自己拼字符串,而是调用 ctx.greeting.greet(...)。它对提供方不做任何 import,只 import type 契约:
// scratch-seam/greeting-tool/src/index.ts(节选)
import type {} from '../../greeting-definition/src/index.ts'
export const inject = ['greeting', 'tools']
export function apply(ctx: Context) {
ctx.tools.register(defineTool({
// name/description/parameters/output 与第 1 节完全相同
async execute(args) {
return ctx.greeting.greet({ name: args.name }).text
},
}))
}跑起来效果和第 1 节一模一样:
pnpm dsh web --patch ./scratch-seam/cordis.yml现在能亲手体会拆分的意义了:把叠加层里提供方那条的路径换成 greeting-provider-polite 再重启,同样的提问会得到"您好,Ada!很高兴见到你。",而 greeting-tool/ 一行没改,因为它只认识契约。两个提供方不能同时挂载:同一个 ctx 下重复注册 greeting 服务会抛错,这正是"一个 key 一个实现"这条约束在起作用。
三块怎么摆:定义居中,提供方与消费方互不依赖
拆开之后,三块的依赖不是等边三角形,而是以定义为中枢的星型:

三条依赖方向:
- 定义是中枢:提供方和消费方都依赖它,它自己不依赖任何一方。抽象类在构造函数里用
super(ctx, 'greeting')定下服务名,再用声明合并把ctx.greeting的类型合进Context;两者合起来,才让ctx.greeting既是义同又有类型。 - 提供方之间互不相干:两个提供方都只继承那个抽象类,谁也不认谁。同一个
ctx下只能挂一个(重复注册greeting会抛错),但仓库里可以并排存在多个提供方包,按需换挂。 - 消费方对提供方不做任何
import:消费方只import type定义包来拿到ctx.greeting的类型,从头到尾没引入任何提供方实现。
这套摆法的收益是结构性的:
- 可替换:消费方只认契约,把
cordis.yml里提供方那条的local换成polite,greeting-tool/一行不动,这正是能力接入点存在的意义。 - 独立演进:两个提供方各自改实现,互不拖累;消费方长期稳定。
- 可测:消费方能用桩提供方(甚至只挂定义)单独测,不必拉起真实实现。
两条红线反过来界定"独立"的边界:
- 定义必须独立成库,不能塞进某个提供方包。否则别的提供方和所有消费方都得依赖那个提供方包,可替换性也就没了。
scratch-seam把greeting-definition/单独成包、且不写进cordis.yml(只挂提供方与消费方),守的就是这条。 - 消费方不能
import提供方实现。一旦import,换提供方就得改消费方,能力接入点就退化成普通耦合,可替换性随之消失。
从本地试玩到成为正式的工作区包
上面的 scratch-plugin 和 scratch-seam 都只是 packages/ 之外的本地实验:靠 --patch 临时挂进 Web,不纳入工作区、不经过任何质量检查,玩完随时删。"工作区"指由 pnpm 统一管理、收在 packages/ 目录树里的那批包;一个包只有真正放进 packages/ 才叫工作区包。
想把插件并进仓库、随官方包一起被维护,才要提升成工作区包,也就是把目录搬进 packages/、和官方包做邻居,并与它们过同一套质量约束:
- 文件骨架:放进
packages/<组>/<包>/,package.json要守工作区约束的一堆规矩:必须声明为私有包、版本号与根package.json一致、"type": "module"、入口指向lib/目录等。 - 注册进根配置:在
tsconfig.host.json的references里补一条。 - 质量检查要过:
pnpm run constraints、pnpm run typecheck、pnpm run lint、pnpm run build等。
四、收个尾
普通插件说穿了就一句话:一个 export function apply(ctx) 的 TypeScript 模块,通过 ctx 注册能力,再由叠加层把它组合进应用:临时用 --patch,常驻就收进组合包。
我们亲手从零走通了这条最简路径,也顺带印证了三件事:函数形态最常用、函数插件用命名导出而非默认导出、inject 声明的依赖决定启动顺序,这是动手前最该想清的前提。
想系统深入,建议顺着官方文档这条链读下去:插件开发的《第一个插件》→《开发一个工具》→《插件配置》。