【DeepSeek Harness 插件】01:从零写第一个插件

AIToolsDeveloper Tools

你大概用过某个 AI 助手,然后冒出一个念头:"要是它能帮我做某件具体的事就好了。"在 DeepSeek Harness 里,这个"某件事"通常就是一个插件。

这篇文章我们亲手从零写一个插件:让助手学会一句"你好,XXX!"的问候。先把它建出来、跑起来、亲眼看到模型真的调用它,再回过头把"为什么能跑"这件事一层层讲透。读完你既能复述插件是什么,也能照着写出一个自己的。

下面的例子假设你已经在本地按官方文档搭好了 DeepSeek Harness 的源码运行环境。代码本身不依赖任何私有仓库,新建几个文件就能跟着敲。

一、动手:从零把它造出来

1. 先建个目录

在任意方便的位置建一个放本地实验插件的目录:

bash
mkdir -p scratch-plugin/src

2. 写插件本体

新建 scratch-plugin/src/my-plugin.ts,把下面这段写进去:

typescript
// 一个最小的插件:注册一个名为 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:

yaml
- insert:
  - id: hello
    name: '/absolute/path/to/scratch-plugin/src/my-plugin.ts'

把里面换成你机器上的真实绝对路径。注意:叠加层只描述"要挂什么";加载器解析模块路径时用的基准目录不会因它而改变,所以相对路径会指到别处,路径必须写全、用绝对路径(官方教程同此要求)。

4. 跑起来,看它工作

启动 Web 界面时,把这个文件作为**叠加层(overlay)**挂上去:

bash
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() 告诉框架"怎么拆":

typescript
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!"包成一条文本块,模型看到的工具结果就是它。

这里有个最容易被忽略、却最关键的区别:执行体返回的规范值,和模型读到的工具结果,是两样东西。

  1. 规范值:执行体的返回值,先校验、再冻结成不可变值。它是机器可读的原始数据,留在结果里供策略钩子和界面卡片使用;
  2. 结果内容: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/,下面四个目录各放一个文件:

plaintext
scratch-seam/
├── greeting-definition/        能力定义:纯契约库,不出现在叠加层里
├── greeting-provider-local/    提供方(口语版)
├── greeting-provider-polite/   提供方(敬语版):与 local 二选一
├── greeting-tool/              消费方:注册 greet 工具
└── cordis.yml                  只挂提供方与消费方两条

定义只声明契约:一个抽象类通过 super(ctx, 'greeting') 把服务名定为 greeting,挂在 ctx 上用的就是这个名字;输入输出都以命名类型定义在契约里,没有任何实现,它是库,不是插件:

typescript
// 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 返回的文本:

typescript
// 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 契约:

typescript
// 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 节一模一样:

bash
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 的类型,从头到尾没引入任何提供方实现。

这套摆法的收益是结构性的:

  1. 可替换:消费方只认契约,把 cordis.yml 里提供方那条的 local 换成 polite,greeting-tool/ 一行不动,这正是能力接入点存在的意义。
  2. 独立演进:两个提供方各自改实现,互不拖累;消费方长期稳定。
  3. 可测:消费方能用桩提供方(甚至只挂定义)单独测,不必拉起真实实现。

两条红线反过来界定"独立"的边界:

  • 定义必须独立成库,不能塞进某个提供方包。否则别的提供方和所有消费方都得依赖那个提供方包,可替换性也就没了。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 声明的依赖决定启动顺序,这是动手前最该想清的前提。

想系统深入,建议顺着官方文档这条链读下去:插件开发的《第一个插件》→《开发一个工具》→《插件配置》。

评论

登录后参与评论 登录

加载中…

返回博客列表