ByteNoteByteNote
Cordis 教程 3:插件服务与 inject
字

字节笔记本

2026年10月6日 · 约 6 分钟读完

Cordis 教程 3:插件服务与 inject

API中转
¥120

本文是 Cordis 插件框架实战教程的第 3 篇。Cordis 是 DeepSeek 开源 agent 框架 DeepSeek Harness(仓库 deepseek-ai/deepseek-harness,MIT 协议)底层的插件运行时,官方教程每章都是一个可运行的动手实验,本篇把其中「服务」一章整理成中文导读,聚焦这个框架最核心的抽象。

Cordis 服务机制总览:提供方注册、消费方注入与运行输出

服务:写在 ctx 上的具名能力

服务(Service)是由某个插件提供、其他插件通过 ctx 消费的具名能力。harness 里的 ctx.tools、ctx.llm、ctx.agents 都是服务。它解决的问题是解耦:消费方只写能力名的字符串,比如 'tools',并不导入提供方的代码,因此配置文件可以随时更换提供方,消费方一行代码都不用改。

提供服务:运行时注册与编译时声明缺一不可

官方教程用一个 greeter 示例讲清了两块配合:

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

declare module '@deepseek-ai/cordis' {
  interface Context {
    greeter: GreeterService
  }
}

export class GreeterService extends Service {
  constructor(ctx: Context) {
    super(ctx, 'greeter')
  }
  greet(who: string) {
    return `Hello, ${who}!`
  }
}

export const name = 'greeter'
export function apply(ctx: Context) {
  ctx.plugin(GreeterService)
}

运行时这一半靠 super(ctx, 'greeter'):它把实例以 greeter 为名注册进上下文,此后任何插件都能用 ctx.greeter 访问它。这个注册本身是一个 effect,即插件卸载时框架会自动撤销的登记项,所以提供方一被卸载,服务就随之消失。

编译时这一半是 declare module 块,用到 TypeScript 的声明合并:它把 greeter 字段加进 Context 接口,让 ctx.greeter 在任何文件里都能通过类型检查。这个块不生成任何代码,缺了它服务照样能跑,只是消费方失去类型提示。

还有一个省心的细节:Service 子类本身就是一个插件(类形态),所以 ctx.plugin(GreeterService) 像挂载普通插件一样挂载它,不需要额外的接线。

消费服务:inject 声明依赖,加载顺序无关

消费方只需导出一个 inject 数组:

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

export const name = 'consumer'
export const inject = ['greeter']

export function apply(ctx: Context) {
  console.log(ctx.greeter.greet('world'))
}

框架会把这类插件保持在 PENDING 状态,直到 inject 列出的每一项服务都真实存在,才执行它的 apply。因此在 apply 内部,ctx.greeter 一定已经就绪。cordis.yml 里两个插件的书写顺序因此无关紧要:决定插件何时启动的是依赖关系,不是文件先后。把两行配置对调,输出仍是 Hello, world!。

把 greeter 从配置里整个删掉会怎样?消费方停在 PENDING,什么都不打印,不崩溃,也不会跑一半。教程还点出一个容易踩的坑:PENDING 的 fiber 不会让 Node 事件循环保持活跃,如果组合里没有其他在运行的东西,进程会静默地以状态码 0 退出,看起来就像什么都没发生过,排查时容易一愣。

依赖在运行期仍被持续跟踪

inject 不是一次性的启动检查。应用运行中,如果所需服务消失,比如提供方被卸载或热替换,每个依赖它的插件也会被一并卸载,等服务恢复后再自动重新加载。配合 effect 机制(插件卸载时自动撤销它注册过的东西),这保证运行中的消费方不会攥着一个已不可用的服务引用:依赖消失时,它自己的注册也会被逐层撤销。

这正是「服务可以在配置里替换」能成立的原因:卸载 dsh-bash-local 这条配置项,挂上另一个 shell 提供方,所有注入 'shell' 的插件都会干净地重启到新实现上,不需要改任何消费方代码。

Cordis 服务依赖生命周期:PENDING 等待、卸载联动与可选依赖探测

可选依赖与命名建议

inject 表达的是硬依赖。如果某项能力缺席时插件也能活下去,就不要写 inject,改为在使用处探测:

ts
export function apply(ctx: Context) {
  const greeter = ctx.get('greeter')
  console.log(greeter?.greet('maybe') ?? 'no greeter available')
}

ctx.get 在没有提供方时返回 undefined,插件照常运行,用可选链兜底即可。

命名方面,每个应用里的服务名共用一个扁平命名空间,harness 已经占用了 tools、llm 这类普通名字,自己的服务应当加上有辨识度的前缀或命名空间。harness 文档的子系统页面上有一个由工具生成的 cordis-surface 区块,列出框架注册的每一个服务名,动手写插件前先查一遍,可以避免撞名。

整体看,这一章给出的模式相当实用:能力用名字暴露,依赖用数组声明,替换靠配置完成。对于想给 agent 框架写扩展的人来说,这是一套可以直接照搬的解耦思路。

相关文章

分享: