零散插件为什么能组成完整系统?
插件彼此独立,谁来连接它们、检查依赖并管理生命周期?
上一课看到,启动器最终会把一棵插件配置树交给 Cordis。可“加载许多插件”并不等于“得到一套系统”。真正困难的是:这些插件怎样找到彼此,又怎样保证依赖完整?
先从一个失败的团队开始
假设公司组建项目组时,只做了一件事:把十个人拉进同一个群。
群里有人会前端,有人会测试,有人会运维,但大家不知道谁负责什么,也不知道自己应该把工作交给谁。人员虽然到齐,项目仍然无法开始。
把插件放进同一个进程也一样。如果模型插件、工具插件和 Session 插件只是被 import 进来,却没有共同的服务名称和依赖关系,它们依然是一堆零件。
Cordis Context 的作用,就是提供一块公共的“服务公告板”,同时管理插件的激活和卸载。
provide:插件先说明自己能提供什么
例如,Session 核心插件启动后,会在 Context 中提供 ctx.sessions。LLM 注册插件提供 ctx.llm,工具注册插件提供 ctx.tools。
其他插件不需要知道这些服务由哪个具体类、哪个 npm 包实现。它们只按稳定名称查找:
ctx.sessions
ctx.llm
ctx.tools
这就像公司规定:“所有报销都找财务岗位”,而不是规定“必须找张三”。张三离职以后可以换李四,使用方的工作方式不需要改变。
这也是为什么 Provider 可以替换:稳定的是服务职责,变化的是具体实现。
用一小段代码理解 provide
先看一个只保留核心意思的示例。假设我们写了一个时间服务插件,希望其他插件都能通过统一入口读取当前时间:
export function apply(ctx: Context) {
const clock = {
now: () => Date.now(),
}
ctx.provide('clock', clock)
}
为了把注意力留在 provide 和 inject 上,这里省略了完整插件中的 Context 类型扩展、配置校验和生命周期代码。
这里真正关键的只有一行:
ctx.provide('clock', clock)
clock 是服务名称,后面的对象是具体实现。执行以后,当前 Context 中就出现了一个稳定入口 ctx.clock。其他插件只需要依赖 clock 这个名称,不必知道它来自哪个文件,也不必自己创建这个对象。
上面是为了说明机制而写的最小示例。DSH 的核心服务通常会继承 Cordis 的 Service,由基类完成同样的注册工作。例如下面是 Agent 注册服务的核心片段。
源码相对路径:
packages/core/agent/src/index.ts
export class AgentRegistry extends Service {
constructor(ctx: Context) {
super(ctx, 'agents')
}
}
这里的 super(ctx, 'agents') 会把当前 AgentRegistry 实例注册成 agents 服务,使用方随后就能通过 ctx.agents 访问它。真实类中还有 Agent 存储、工厂注册和生命周期处理等大量代码,这里全部略去,只保留“服务怎样进入 Context”这一步。
inject:使用方公开声明自己缺什么
Agent Loop 要工作,需要访问 Agent 注册表、Session、LLM、Tools 和 System Prompt。它会通过 inject 声明这些依赖。
如果 ctx.llm 还没有出现,Agent Loop 不会抢跑。Cordis 会让它保持等待,等模型服务就绪后再激活。
inject 在代码里是什么样子
继续使用刚才的 clock 服务。一个使用时间服务的插件可以这样声明:
export const inject = ['clock']
export function apply(ctx: Context) {
const startedAt = ctx.clock.now()
// 使用 startedAt 继续工作
}
这段代码要分成两部分看:
inject = ['clock']声明“没有 clock 服务,我就不能激活”;ctx.clock.now()才是真正使用服务的函数调用。
因此,inject 本身不会传递业务数据,也不会调用某个函数。它只建立启动依赖:先保证服务存在,再允许插件执行 apply()。
如果插件使用 class 形式,inject 会写成静态字段。Agent Loop 就是一个真实例子。
源码相对路径:
packages/core/agent-loop/src/index.ts
export class AgentLoop extends Service implements AgentFactory {
static inject = [
'agents',
'sessions',
'llm',
'tools',
'systemPrompt',
]
constructor(ctx: Context, config: Config) {
super(ctx, 'agentLoop')
ctx.effect(() => ctx.agents.setFactory(this))
ctx.systemPrompt.variable('model', context => context.agent?.options.model)
}
}
这段代码经过了简化,但保留了真实依赖和真实调用方式。Cordis 先检查 agents、sessions、llm、tools、systemPrompt 是否都已提供;全部满足后,才创建 Agent Loop。进入构造函数以后,它就可以直接使用 ctx.agents 和 ctx.systemPrompt。
把 provide 和 inject 连起来看,实际发生的是:
AgentRegistry --provide agents--> ctx.agents
↑
AgentLoop --------inject agents-------┘
服务名称 agents 是两边能够对上的关键。提供方可以更换实现,只要继续提供同名、符合约定的服务,Agent Loop 就不需要跟着改。
和 Java Spring 的依赖注入有点像
熟悉 Java 的同学,还记得 Java Spring 中大名鼎鼎的依赖注入。开发者不必在每个类里手动 new 出所有依赖对象,而是先把对象交给 Spring 容器管理,再由容器根据依赖关系把它们组织起来。
Cordis 插件框架在某种程度上和 Java Spring 的依赖注入有相似之处:两者都让使用方依赖稳定的服务职责,而不是由使用方自己创建和绑定具体实现。
| Cordis | 可以类比到 Spring |
|---|---|
Cordis Context | ApplicationContext,保存和管理可用服务 |
ctx.provide('agents', service) | 向容器注册一个 Bean |
inject = ['agents'] | 声明当前组件依赖某个 Bean |
| Cordis 等待依赖就绪后激活插件 | Spring 解析依赖后创建 Bean |
| 替换同名服务的具体实现 | 接口不变,替换 Bean 实现 |
不过两者并不完全相同。Spring 常见的做法是通过构造函数或 @Autowired,把 Bean 直接注入对象字段;Cordis 的 inject 更像一份“激活前置条件”,负责告诉框架这个插件必须等哪些服务。插件真正使用服务时,通常还是通过 ctx.agents、ctx.llm 这样的 Context 入口访问。
另外,Cordis 特别强调插件的动态生命周期。插件可以随自己的 Fiber 加载和卸载,provide、事件监听和 effect 也会跟着撤销。这一点更贴近可动态重组的插件系统,而不是只在应用启动阶段创建一次的传统 Spring 容器。
所以,熟悉 Spring 的读者可以先把 Cordis 理解成“面向插件生命周期的轻量 IoC 容器”,再记住一个区别:inject 主要控制插件何时可以激活,ctx 才是插件实际取得服务的地方。

图 4-1:插件提供稳定服务,Agent Loop 注入所需依赖;缺少任何必须服务,Loop 都不会正常激活。
所以 inject 连线不是在表达“LLM 把任务提交给 ctx.agents”。它只表达一件事:这个插件在启动和运行时需要访问这些服务。
任务的真实流向要看函数调用和事件;inject 解决的是“谁必须先准备好”。
把 provide 和 inject 放进同一张图,会更容易看清:左边的插件把能力登记到 Context;Agent Loop 只声明自己需要哪些稳定服务。当五项依赖都出现,它才从“等待”进入“激活”。
没有固定槽位,为什么系统仍然完整
我们之前用“槽位”帮助理解,是因为 Agent Loop 周围确实存在一组稳定职责。但源码并没有一个写死的框架,要求每套 Harness 都必须填满同一张表。
真正形成完整系统的是三层约束:
- Profile 与 Bundle 决定本次究竟加载哪些插件;
- 插件通过 provide 和 inject 形成服务依赖图;
- 启动审计检查所有启用插件是否成功激活。
不同运行形态需要的完整性也不一样。Web Profile 必须有 Web 服务;headless Profile 不需要浏览器,却必须有一次性任务入口。
因此“完整”不是全世界统一的一张槽位表,而是:当前配置选择的这棵插件树,所有声明的必需依赖都能得到满足。
Context 还负责管理生命周期
插件启动以后通常会注册工具、监听事件、打开文件监听器或者建立连接。如果插件卸载,这些副作用也必须一起撤销。
Cordis 把注册行为做成可逆 effect。插件卸载时,它注册的工具和监听器随之清理,不会留下半死不活的全局状态。
这对开发时热更新、测试隔离和运行时重组都非常重要。
可以把它理解成租办公室:员工入职时领取门卡、电脑和权限;离职时系统按同一份记录自动收回,而不是依靠大家记得手工清理。
事件让相邻插件扩展流程
服务适合回答“我能调用谁”,事件适合回答“流程走到这里时,谁想参与”。
例如工具执行前后会发出工具事件。审批插件可以在执行前询问用户,权限插件可以拒绝危险操作,遥测插件可以记录耗时。它们不必修改 Agent Loop,也不必互相引用。

图 4-2:服务连接主要模块,事件让审批、权限和遥测等插件从侧面参与流程。
这就是“在旁边挂一个插件”的真实含义:找到合适的服务或事件扩展点,把行为接进去,而不是去改 Loop 的内部代码。
用一句话重新理解 Cordis
Cordis 不是 Agent 的第五层,也不是装工具的容器。
它更像整栋办公楼的物业系统:
- Context 保存公共服务;
- provide 公布服务;
- inject 声明依赖;
- Loader 按依赖激活插件;
- event 传递运行过程;
- effect 管理可撤销的副作用。
有了这套机制,彼此独立的插件才会从“同处一个进程”变成“共同组成一套系统”。
下一课继续向上看配置:Profile、Bundle 与 Patch 为什么要分成三层,而不是只维护一份巨大的 cordis.yml?
Profile、Bundle 与 Patch 是什么关系?
三种配置名词分别解决什么问题,又是怎样层层合并的?