课程总纲 / 阶段 3 · 插件开发
L16

服务插件与能力缝实战

写一个 Service 子类,把工具升级成完整的三角色能力。

预计阅读 35 分钟难度 ★★★★★本课 hardest,慢慢来

一、什么时候需要服务插件When a Service

函数插件的 apply 装完就完;服务插件把一个有名有姓的能力挂上 ctx 插座板供其他插件长期消费——toolsllmagents 全是这么来的。判据:你写的东西会被别的插件 inject 吗?会,就写成服务。

二、写一个 Service 子类A Service Subclass

import { Service, type Context } from '@deepseek-ai/cordis'

// ③ 类型登记:让 ctx.metrics 有类型提示(声明合并)
declare module '@deepseek-ai/cordis' {
  interface Context {
    metrics: MetricsService
  }
}

export default class MetricsService extends Service {
  static inject = ['llm']              // ② 服务自己也可以依赖别的服务

  constructor(ctx: Context) {
    super(ctx, 'metrics')             // ① 服务名 = 插座名
  }

  record(event: string, value: number) {   // 公开方法 = 服务能力
    /* ... */
  }
}

消费方:

export const inject = ['metrics']

export function apply(ctx: Context) {
  ctx.metrics.record('tool_call', 1)   // 插座上有电了
}
导出规则(postmortem/0001):服务包默认导出 Service 子类;函数插件命名导出三件套、无默认导出。混用则 Loader 静默丢弃函数插件的命名空间、inject 失效——"inject 好像没生效"先查导出形式。

三、必需 vs 可选依赖Required vs Optional

必需:inject

export const inject = ['tools']
// 没有就不加载,卸载时自动销毁

可选:ctx.get(使用处取)

const m = ctx.get('metrics')
m?.record('x', 1)  // 有则用之

服务消失时:必需依赖它的插件自动销毁、回来自动重载,永不对空插座发电。可选必须用 ctx.get:属性代理 ctx.metrics 对拓扑敏感,严格读全局服务存储的 ctx.get 才是安全通道。

四、三角色实战:把能力拆成三个包Three Roles in Practice

造一个"my-cap"能力,复刻 L06 的 Bash 三角色结构。

Step 1:Service Definition(合同包)

// packages/my-cap/my-cap/src/index.ts
import { Service, type Context } from '@deepseek-ai/cordis'

declare module '@deepseek-ai/cordis' {
  interface Context { myCap: MyCapService }
}

export abstract class MyCapService extends Service {
  constructor(ctx: Context) { super(ctx, 'myCap') }
  /** 执行该能力 */
  abstract execute(request: MyCapRequest): Promise<MyCapResult>
}

export interface MyCapRequest  { input: string }
export interface MyCapResult   { output: string }

要点:抽象类只定义形状不给实现;Request/Result 类型归合同包所有

Step 2:Service Provider(施工队)

// packages/my-cap/my-cap-local/src/index.ts
import { MyCapService } from '@deepseek-ai/dsh-my-cap'

class MyCapLocal extends MyCapService {
  async execute(request) { return { output: request.input.toUpperCase() } }
}

export const name = 'my-cap-local'
export function apply(ctx) { ctx.plugin(MyCapLocal) }   // 挂上实现

Step 3:Consumer(工具包)

// packages/my-cap/tool-my-cap/src/index.ts
import { defineTool } from '@deepseek-ai/dsh-tools'

export const name = 'tool-my-cap'
export const inject = ['tools', 'myCap']          // 同时依赖注册表和合同

export function apply(ctx: Context) {
  ctx.tools.register(defineTool({
    name: 'my_cap',
    description: 'Execute my capability.',
    parameters: { input: { type: 'string', required: true } },
    output: { schema: { type: 'string' }, render: (_a, v) => [{ type: 'text', text: v }] },
    async execute(args) {
      const result = await ctx.myCap.execute({ input: args.input })
      return result.output
    },
  }))
}
# cordis.yml 组合(三行,换 Provider 换第一行)
- name: '@deepseek-ai/dsh-my-cap-local'
- name: '@deepseek-ai/dsh-tool-my-cap'

换个把 input 转小写的 Provider?只动第一行——L06 的承诺,现在亲手验证。

不要过早拆分:三角色是为"确实需要多实现/独立演化"的能力准备的;简单工具(L13 的 greet)一个包就好——拆分是手段,不是仪式感。

五、服务隔离:一组一份实例Isolation

- id: group-a
  name: '@deepseek-ai/cordis-plugin-group'
  group: true
  isolate:
    shell: true              # shell 服务在本组内单独一份
  config:
    - name: '@deepseek-ai/dsh-bash-local'
      config: { timeoutMs: 5000 }
    - name: './src/plugin-a.ts'
- id: group-b                # 另一组,同样的服务,另一份实例、另一套配置
  ...

isolate 让同名服务在组内各有一份:两组各见各的 Bash 实例、各用各的超时,互不串门。

六、验收:不变量测试Invariant Testing

仓库要求每个注册贡献证明可销毁:销毁插件 fiber(fiber = 插件的运行实例),断言注册的东西真的消失(工具移除、监听器不再触发)。这把"注册即效果"变成 CI 硬约束;你的插件照此验收:HMR 干净 = 不变量成立。

Service 子类
继承 Service 的类插件:有名插座 + 公开方法。
ctx.get(name)
取可选服务的安全通道,缺席得 undefined。
isolate 隔离
group 内让某服务各有一份实例。
不变量测试
销毁 fiber 后断言注册消失的 HMR 安全测试。
✏️ 动手练习
  1. 照第二节写 MetricsService(record 先 console.log),挂进 scratch-plugin 验证可注入。
  2. 故意把函数插件写成带默认导出的混合形态,看 inject 是否"神秘失效"。
  3. 进阶:按 Step 1–3 把 greet 拆成三角色,配两套 Provider 用配置切换。
📝 自测(点击展开答案)
1. 函数插件和服务插件的判据是什么?
是否被其他插件 inject 消费:会→Service 子类;只是自己注册东西→函数插件。
2. 为什么 Request/Result 类型必须放在 Definition 包里?
Provider 和 Consumer 都只依赖 Definition;类型散在 Provider 处会破坏可替换性——类型是合同条款,跟合同走。
3. 必需依赖和可选依赖在代码上分别怎么写?
必需:inject 数组声明,缺席不加载、消失自动销毁。可选:不写 inject,使用处 ctx.get(name)(可能 undefined)。
4. Consumer 的 inject 为什么是 ['tools', 'myCap'] 两个?
tools 是注册工具的注册表(出口),myCap 是被消费的能力合同(来源)——Consumer 同时站在两个插座上。
5. "不变量测试"验证什么?为什么它是硬要求?
销毁 fiber 后注册的工具/监听器确实消失。没有它,HMR 会悄悄漏资源(幽灵注册),代码评审很难看出。
权威出处:framework/service.zh.md(本课主源) · practice/index.zh.md(三角色教程) · packages/AGENTS.md · postmortem/0001