在上一章中,我们深入理解了插件的生命周期——每个插件都有自己独立的 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了):

  1. 所有依赖它的插件会 自动 dispose(从 ACTIVE 直接进入 DISPOSED,走完清理流程)。

  2. 所有通过 ctx 注册的资源被释放。

  3. 当服务重新出现时,依赖它的插件会自动重新加载。

 

整个过程对开发者是透明的。你不需要写任何代码来“监听服务状态变化”或“重新初始化插件”。

// 插件 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 的计数都会增加。你成功实现了:

  1. 一个服务提供方(MetricsService),对外暴露 record 和 increment 方法。

  2. 一个服务消费方(consumer-plugin),通过 inject=['metrics'] 依赖并使用这个服务。

  3. 服务在工具执行逻辑中也被正常调用。

完整文件结构

完成所有步骤后,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 生态中“插件如何组成系统”的核心逻辑。

下一章,我们将学习插件之间另一种通信方式——事件——它让插件可以松耦合地“对话”,而不需要直接依赖彼此的服务。