在上一章中,我们深入理解了插件的生命周期——每个插件都有自己独立的 Fiber,经历从 PENDING 到 DISPOSED 的完整旅程。你也知道了 inject 如何让插件等待依赖服务就绪后再加载。
但你有没有想过一个问题:这些服务本身是从哪里来的?
tools、llm、agents —— 我们在插件中反复使用的这些能力,它们本身也是由其他插件提供的。换句话说,服务本身就是一种特殊的插件——它以类(Class)的形式定义,可以被其他插件依赖和消费。
这一章,我们将学习如何创建自己的服务。你会理解:
服务和普通插件有什么区别。
如何用 Service 基类定义一个可被依赖的服务。
如何让其他插件通过 inject 来使用你的服务。
当服务提供方被替换时,依赖方如何自动响应。
掌握了这些,你就能够构建可扩展的插件体系——让不同的插件各司其职,有的提供服务,有的消费服务,彼此协作又互不耦合。
什么是服务?——挂在 ctx 上的命名能力
在 Harness 中,服务是一个很朴素的概念:服务就是把插件挂载到 ctx 上、供其他插件按名调用的功能。
我们来看看几个熟悉的面孔:
ctx.tools // 工具注册服务
ctx.llm // 大语言模型服务
ctx.agents // Agent 服务这些都是服务。它们有一个共同点:挂载在 ctx 上,通过一个名字(tools、llm、agents)来访问。
任何插件都可以提供服务,供其他插件使用。当你创建一个服务并把它挂载到 ctx 上之后,其他插件只需要在 inject 中声明服务名,就能在 apply 函数中通过 ctx.服务名 来访问它。
给初中级开发者的比喻:可以把服务想象成“插座”,把插件想象成“电器”。电器(插件)通过插头(inject)连接到插座(服务)上,获取电力(能力)。一个插座可以被多个电器同时使用,一个电器也可以同时连接多个插座(即一个插件可以注入多个服务)。
使用服务:inject 的完整语义
我们在之前的章节中已经用过 inject=['tools'],但当时我们只说了“框架会确保服务就绪”。
现在,结合“服务”这个概念,我们可以更精确地理解 inject 的完整语义:
export const inject = ['tools', 'llm']
export function apply(ctx: Context) {
// 此时 ctx.tools 和 ctx.llm 都已经就绪
ctx.tools.register(/* ... */)
}inject 声明了这个插件需要的所有服务。框架保证:在 apply 执行时,inject 中声明的服务已经全部处于 ACTIVE 状态。
如果某个服务还没准备好——比如它还在 PENDING 状态等待它自己的依赖——你的插件就会停留在 PENDING 状态,不会执行 apply。
这种“等待”是自动的、透明的。你不需要写任何轮询代码或回调函数。框架替你管理好了所有依赖关系。
必需依赖 vs 可选依赖
inject 声明的是必需依赖——如果服务不存在或加载失败,你的插件就不会加载。
但有时候,你可能想使用某个服务,但它不是必不可少的——如果它不存在,你的插件仍然可以正常工作,只是某些功能受限。这时可以使用 ctx.get() 来按需获取:
export function apply(ctx: Context) {
// 尝试获取 metrics 服务,如果不存在则返回 undefined
const metrics = ctx.get('metrics')
if (metrics) {
metrics.record('plugin_loaded', 1)
}
// 即使 metrics 不存在,插件也能正常加载
}ctx.get('服务名') 返回服务实例,如果服务不存在则返回 undefined。
给初中级开发者的建议:大部分情况下,你应该使用 inject 来声明必需依赖。这样做的好处是明确、安全——框架会在加载前确认一切就绪。只有在“这个服务有则更好、无则也行”的场景下,才使用 ctx.get()。
提供服务:用 Service 基类定义服务
现在来到本章的核心——如何创建一个服务。
在 Cordis 中,服务是通过继承 Service 基类来定义的。
让我们创建一个简单的 MetricsService,它提供一个 record 方法,用于记录事件:
import { Service, type Context } from '@deepseek-ai/cordis'
// 类型声明:让 TypeScript 认识 ctx.metrics
declare module '@deepseek-ai/cordis' {
interface Context {
metrics: MetricsService
}
}
export default class MetricsService extends Service {
// 这个服务本身也可以依赖其他服务——这里我们暂时不依赖
static inject: string[] = []
constructor(ctx: Context) {
// 'metrics' 是服务名,消费方通过 ctx.metrics 访问
super(ctx, 'metrics')
console.log('[metrics] service initialized!')
}
// 对外提供的方法:记录一个事件
record(event: string, value: number) {
console.log(`[metrics] ${event}: ${value}`)
}
// 另一个对外方法:快捷增加计数
increment(event: string) {
this.record(event, 1)
}
}加载这个插件后,消费方就可以通过 ctx.metrics 来访问它。
重要提示:服务名(super(ctx, 'metrics') 中的 'metrics')必须是全局唯一的。如果两个服务用了同一个名字,后加载的会覆盖先加载的。
服务的类形式 vs 函数的函数形式
你可能注意到了:服务是用类(Class)定义的,而我们之前写的插件都是用函数(Function)定义的。
这两种形式有什么区别?
维度 | 函数形式 | 类形式(Service) |
|---|---|---|
适用场景 | 不需要对外提供服务 | 需要对外提供服务 |
导出方式 | export function apply() | export default class XxxService extends Service |
被依赖方式 | 不能被其他插件 inject | 可以被其他插件 inject |
生命周期 | 由框架自动管理 | 由框架自动管理 |
简单来说:如果你的插件需要被别人依赖,就用类形式;如果不需要,就用函数形式。
函数形式和服务形式并不是互斥的——一个复杂的插件可以同时拥有一个服务类(供别人依赖)和一个函数形式的入口(做初始化工作)。但最常见的情况是:你只需要选择其中一种。
类型声明:让 TypeScript 认识你的服务
当你定义一个服务后,其他插件通过 ctx.metrics 来访问它。但 TypeScript 怎么知道 ctx.metrics 的类型是什么?
答案是:使用 TypeScript 的声明合并(Declaration Merging)。
在服务定义的同一个文件中,添加以下代码:
import { Service, type Context } from '@deepseek-ai/cordis'
// 声明合并:告诉 TypeScript,Context 上有一个 metrics 属性
declare module '@deepseek-ai/cordis' {
interface Context {
metrics: MetricsService
}
}
export default class MetricsService extends Service {
constructor(ctx: Context) {
super(ctx, 'metrics')
}
record(event: string, value: number) {
console.log(`[metrics] ${event}: ${value}`)
}
}这段代码做了什么?
declare module '@deepseek-ai/cordis' 表示我们要扩展 @deepseek-ai/cordis 包中的类型定义。
interface Context { metrics: MetricsService } 表示在 Context 接口上添加一个 metrics 属性,类型是 MetricsService。
有了这个声明,其他插件在使用 ctx.metrics 时,TypeScript 就能提供完整的类型提示和检查。
给初中级开发者的提示:声明合并是 TypeScript 的一个高级特性,但在这里它的用途很单纯——就是让类型系统知道你的服务存在。你只需要按照上面的模板照抄,把 metrics 换成你的服务名、把 MetricsService 换成你的服务类名即可。
依赖的行为:服务消失时会发生什么?
我们在上一章学过:当插件依赖的服务消失时,插件会被自动卸载。
现在我们可以更精确地理解这个行为:
如果应用运行期间,某项必需服务消失了(比如它的提供方被卸载或HMR了):
所有依赖它的插件会 自动 dispose(从 ACTIVE 直接进入 DISPOSED,走完清理流程)。
所有通过 ctx 注册的资源被释放。
当服务重新出现时,依赖它的插件会自动重新加载。
整个过程对开发者是透明的。你不需要写任何代码来“监听服务状态变化”或“重新初始化插件”。
// 插件 A:提供 metrics 服务
export default class MetricsService extends Service {
constructor(ctx: Context) {
super(ctx, 'metrics')
}
record(event: string, value: number) { /* ... */ }
}
// 插件 B:依赖 metrics 服务
export const inject = ['metrics']
export function apply(ctx: Context) {
// 如果插件 A 被卸载,插件 B 会自动 dispose
// 当插件 A 重新加载后,插件 B 会自动重新加载
ctx.metrics.record('plugin_loaded', 1)
}这防止了插件调用已不存在的服务。在传统的编程模型中,你可能需要在每次调用前检查 if (service),或者处理 null pointer 异常。在 Cordis 中,这些都不需要——框架通过生命周期管理,从根本上杜绝了“服务已消失但插件还在运行”的情况。
服务隔离:同一个服务,不同的实例
在某些场景下,你可能希望同一个服务名在不同的插件组中指向不同的实例。
比如,你有两组插件,每组都需要 Bash 执行能力,但希望它们使用不同的超时配置——一组用 5 秒,另一组用 60 秒。
Harness 通过 cordis.yml 中的 isolate 配置来支持这种场景:
- id: group-a
name: '@deepseek-ai/cordis-plugin-group'
group: true
isolate:
shell: true
config:
- name: '@deepseek-ai/dsh-bash-local'
config:
timeoutMs: 5000
- name: './src/plugin-a.ts'
- id: group-b
name: '@deepseek-ai/cordis-plugin-group'
group: true
isolate:
shell: true
config:
- name: '@deepseek-ai/dsh-bash-local'
config:
timeoutMs: 60000
- name: './src/plugin-b.ts'在这个配置中:
group-a 和 group-b 是两个独立的插件组。
isolate: { shell: true } 表示 shell 服务在每个组中被隔离——组 A 和组 B 各自拥有独立的 shell 服务实例。
组 A 中的 dsh-bash-local 超时是 5000ms,组 B 中的是 60000ms。
plugin-a.ts 和 plugin-b.ts 各自看到自己组内的 shell 实例,互不影响。
给初中级开发者的理解:可以把服务隔离想象成“每个部门有自己的打印机”——同样是“打印机”这个服务,财务部的打印机和研发部的打印机是不同的物理设备,配置(比如纸张大小、打印速度)可以各自独立。plugin-a 和 plugin-b 虽然都叫 shell,但它们是两个独立的实例,互不干扰。
服务隔离是一个高级特性,在大多数插件开发中你不需要主动使用它。但了解它的存在,能帮助你理解 Harness 是如何支持多租户、多环境等复杂场景的。
完整示例:从定义到使用
让我们把本章的所有知识点串起来,完成一个完整的服务开发流程。整个流程分为三个部分:定义服务、消费服务、运行验证。
为了保持和前面章节一致的开发习惯,我们继续使用 scratch-plugin 目录作为实验场地。
第一步:定义服务
首先,在 scratch-plugin/src/ 目录下创建 metrics-service.ts:
import { Service, type Context } from '@deepseek-ai/cordis'
// 类型声明:让 TypeScript 认识 ctx.metrics
declare module '@deepseek-ai/cordis' {
interface Context {
metrics: MetricsService
}
}
export default class MetricsService extends Service {
// 这个服务本身也可以依赖其他服务——这里我们暂时不依赖
static inject: string[] = []
// 用 Map 维护每个事件的累加计数
private counters = new Map<string, number>()
constructor(ctx: Context) {
// 'metrics' 是服务名,消费方通过 ctx.metrics 访问
super(ctx, 'metrics')
console.log('[metrics] service initialized!')
}
// 对外提供的方法:记录一个事件
record(event: string, value: number) {
console.log(`[metrics] ${event}: ${value}`)
}
// 另一个对外方法:快捷增加计数
increment(event: string) {
// 从 Map 取当前值,没有就从 0 开始
const current = this.counters.get(event) ?? 0
const next = current + 1
this.counters.set(event, next) // 存回 Map
this.record(event, next) // 传累加后的值
}
}注意:服务使用 export default class 导出,这是“类形式”插件——它本身就是一个可被框架加载的插件,同时也是一个可被其他插件依赖的服务。
第二步:消费服务
接下来,在 scratch-plugin/src/ 目录下创建 consumer-plugin.ts:
import type { Context } from '@deepseek-ai/cordis'
import { defineTool } from '@deepseek-ai/dsh-tools'
export const name = 'consumer-plugin'
// 声明依赖 metrics 服务
export const inject = ['tools', 'metrics']
export function apply(ctx: Context) {
// 此时 ctx.metrics 已经就绪,可以直接使用
ctx.metrics.increment('consumer_loaded')
// 注册一个工具,在工具执行时记录指标
ctx.tools.register(
defineTool({
name: 'greet_with_metrics',
description: 'Greet someone and record the interaction.',
parameters: {
name: {
type: 'string',
required: true,
description: 'The name to greet',
},
},
output: {
schema: { type: 'string' },
render: (_args, value) => [{ type: 'text', text: value }],
},
async execute(args) {
// 记录工具被调用了
ctx.metrics.increment('greet_tool_called')
return `Hello, ${args.name}!`
},
})
)
}这个消费方插件做了三件事:
声明依赖:inject=['tools', 'metrics'] 告诉框架,它需要 tools 和 metrics 两个服务都就绪后才能加载。
使用服务:在 apply 中直接调用 ctx.metrics.increment()。
注册工具:工具的执行逻辑中再次调用了 metrics 服务。
给初中级开发者的提示:inject 数组中的顺序不重要——框架会处理好所有依赖的就绪顺序。关键在于:被依赖的服务必须先被加载。
第三步:配置 cordis.yml
现在我们需要在 cordis.yml 中把服务提供方(metrics-service.ts)和消费方(consumer-plugin.ts)都加载进来。
打开 scratch-plugin/cordis.yml,修改为:
- insert:
- id: metrics
name: 'file:///E:/ProjectsAI/deepseek-harness/scratch-plugin/src/metrics-service.ts'
- id: consumer
name: 'file:///E:/ProjectsAI/deepseek-harness/scratch-plugin/src/consumer-plugin.ts'两个关键点:
路径必须是绝对路径:把 name对应的路径替换为你本地仓库的实际路径。注意保留 file:// 协议。
加载顺序很重要:metrics 必须在 consumer 之前。因为 consumer 依赖 metrics 服务。如果顺序反了,consumer 会停留在 PENDING 状态等待 metrics 就绪——虽然最终也能加载,但会有不必要的等待,日志也不够清晰。
第四步:运行并验证
一切准备就绪,启动 Web UI:
cd deepseek-harness/
pnpm dsh web --patch ./scratch-plugin/cordis.yml在启动日志中,你应该能看到类似这样的输出:
[metrics] service initialized!
[metrics] consumer_loaded: 1
dsh web: http://127.0.0.1:3080每次调用工具,greet_tool_called 的计数都会增加。你成功实现了:
一个服务提供方(MetricsService),对外暴露 record 和 increment 方法。
一个服务消费方(consumer-plugin),通过 inject=['metrics'] 依赖并使用这个服务。
服务在工具执行逻辑中也被正常调用。
完整文件结构
完成所有步骤后,scratch-plugin/ 的目录结构如下:
scratch-plugin/
├── cordis.yml # 配置文件
└── src/
├── metrics-service.ts # 服务提供方(类形式插件)
└── consumer-plugin.ts # 服务消费方(函数形式插件)Harness 内置服务
在实际开发中,你更多时候是在使用 Harness 提供的内置服务,而不是从头创建自己的服务。
Harness 内置了丰富的服务,包括但不限于:
服务名 | 用途 |
|---|---|
tools | 工具注册与管理 |
llm | 大语言模型调用 |
agents | Agent 编排 |
bash | Bash 命令执行 |
workspace | 工作区文件操作 |
这些服务的详细 API——包括服务名、公开方法和源码位置——由仓库自动生成在子系统页面中。
重要提示:开发插件时应以这些自动生成的区块和服务的 TypeScript 接口为准,不要维护另一份静态清单。文档是自动生成的,永远和代码保持同步——这是最可靠的信息源。
总结
本章我们学习了服务的完整生命周期——从定义到使用,从依赖到隔离。核心收获如下:
概念 | 说明 |
|---|---|
服务 | 服务就是把插件挂载到 ctx 上、供其他插件按名调用的功能 |
使用服务 | 通过 inject 声明依赖,框架保证服务就绪后才执行 apply |
提供服务 | 继承 Service 基类,在构造函数中调用 super(ctx, '服务名') |
类型声明 | 使用 declare module 进行声明合并,让 TypeScript 识别服务类型 |
必需依赖 | inject 声明的是必需依赖,服务不存在时插件不加载 |
可选依赖 | ctx.get('服务名') 按需获取,服务不存在时返回 undefined |
服务消失 | 依赖方自动 dispose,服务恢复后自动重新加载 |
服务隔离 | 通过 isolate 让不同插件组拥有独立的服务实例 |
服务是 Harness 插件体系的“骨架”。有了服务,插件之间才能协作——有的提供能力,有的消费能力,彼此独立又相互连接。理解服务,你就理解了 Harness 生态中“插件如何组成系统”的核心逻辑。
下一章,我们将学习插件之间另一种通信方式——事件——它让插件可以松耦合地“对话”,而不需要直接依赖彼此的服务。