1. 从"能跑"到"跑得稳":Cordis 插件架构到底解决了什么
第一次接触 DeepSeek Harness 的人,十有八九会把它当成一个"命令行版的对话工具"——装上、配好模型、敲几句话,能用就行。但只要你在真实项目里连续用上一周,就会撞到同一堵墙:功能全堆在一个进程里,提示词优化、代码回退、LLM Wiki 检索、离线局域网适配,这些需求彼此独立,却被迫共享同一套生命周期。改一处提示词逻辑,可能把代码回退的钩子弄挂;想临时关掉某个实验性插件,只能去翻主程序源码。
Cordis 插件架构就是冲着这个痛点来的。它把 Harness 的能力拆成一个个可独立加载、独立卸载、独立声明依赖的插件单元,每个单元通过统一的上下文(Context)拿到自己需要的能力,而不是直接去 import 主程序的内部模块。用一句话概括:它让 Harness 从"一个程序"变成"一个可以拼装的运行时"。
这套架构的核心价值不在于"插件"这个词本身——很多工具都有插件——而在于它把依赖注入、生命周期管理、服务隔离这三件事做进了 TypeScript 的类型系统里。你写一个插件,编译器会告诉你缺了哪个依赖;你卸载一个插件,它注册的服务会自动从上下文里消失,不会留下悬空引用。这对长期维护的工程来说,价值远超"能扩展"这三个字。
适合读这篇的人分三类:一是想把 Harness 接进自己工作流、需要写自定义插件的开发者;二是正在设计类似插件系统、想参考一套成熟 TypeScript 实现的人;三是被"插件加载顺序""服务找不到""热重载失效"这类问题折磨过、想搞清底层机制的人。下面我会从架构分层讲到 Fiber 调度,再落到实际写插件的每一步,尽量把"为什么这么设计"讲透。
2. Cordis 的分层设计:Context、Service 与 Plugin 三者如何咬合
2.1 Context 不是全局变量,而是可派生的能力容器
很多人第一次看 Cordis 代码,会把Context理解成一个全局单例——毕竟它长得就像一个到处都能拿到的对象。这是个危险的误解。Cordis 的 Context 是可派生的:每个插件拿到的是从根 Context 派生出来的子 Context,子 Context 继承父级的所有服务,但自己新注册的服务只对子树可见。
这个设计直接决定了插件的隔离性。举个例子,插件 A 注册了一个logger服务,插件 B 在 A 的子树里就能拿到这个 logger;但如果 B 是根 Context 的直接子节点,它就拿不到 A 的 logger。这种"作用域链"式的服务查找,和浏览器的事件冒泡、Node 的模块解析是同一类思路——就近查找,逐级向上。
实际写代码时,你会看到这样的模式:
import { Context } from 'cordis' export const inject = ['config', 'logger'] export function apply(ctx: Context, config: PluginConfig) { ctx.logger.info('plugin loaded with config: %o', config) ctx.set('myService', { doSomething() { /* ... */ } }) }注意inject这个导出——它是 Cordis 依赖声明的关键。你声明了inject = ['config', 'logger'],Cordis 就会在加载这个插件前,确保这两个服务在当前 Context 链上可用。如果不可用,插件根本不会被实例化,而不是等到运行时才抛undefined is not a function。把依赖检查从运行时提前到加载时,这是 Cordis 相比手写插件系统最实在的改进。
2.2 Service 的注册与消费:为什么用 ctx.set 而不是直接挂属性
初学者最容易犯的错,是直接往 ctx 上挂属性:ctx.myService = {...}。这在 TypeScript 里能过编译(如果类型宽松),但会破坏 Cordis 的服务追踪机制。正确做法是ctx.set('myService', value),配合模块声明合并来补类型:
declare module 'cordis' { interface Context { myService: MyServiceType } }为什么要这么绕?因为 Cordis 需要在服务被替换、被卸载时通知所有依赖方。直接挂属性,框架感知不到变化;用set,框架能记录"谁注册了什么服务、谁消费了什么服务",从而在插件卸载时做级联清理。这就像 React 的 state 更新必须走setState而不是直接改对象——框架需要拦截变更点,才能做依赖追踪。
提示:如果你在插件里看到
ctx.foo = bar这种写法,先别急着抄。确认一下foo是不是已经在别处通过set注册过。混用两种方式,会在热重载时出现"服务还在但实现已经过期"的诡异现象。
2.3 Plugin 的三种形态:函数、对象与类
Cordis 允许插件以三种形式存在,各有适用场景:
| 形态 | 写法 | 适用场景 | 注意点 |
|---|---|---|---|
| 函数式 | export function apply(ctx, config) | 无状态、逻辑简单的插件 | 最常用,热重载友好 |
| 对象式 | export default { apply, inject, name } | 需要声明元信息(名称、依赖) | 元信息集中,便于工具扫描 |
| 类式 | class MyPlugin { constructor(ctx, config) } | 需要维护内部状态、多个方法 | 注意实例生命周期与卸载钩子 |
函数式是默认推荐,因为它天然无状态,卸载时不需要额外清理。类式插件则必须实现dispose或在ctx.on('dispose', ...)里释放资源,否则每次热重载都会泄漏一个实例。我在早期项目里用类式插件管理一个 WebSocket 连接池,忘了写清理逻辑,结果热重载十几次之后端口被占满——这个坑值得所有人警惕。
3. Fiber:Cordis 里最容易被忽略却最关键的调度单元
3.1 Fiber 是什么,为什么需要它
如果说 Context 是"能力容器",那 Fiber 就是"执行单元"。每个插件被加载时,Cordis 会为它创建一个 Fiber,记录这个插件的状态:是否已加载、依赖是否满足、注册了哪些服务、监听了哪些事件。你可以把 Fiber 理解成插件在运行时的身份证 + 生命周期账本。
为什么不能直接用插件对象本身来追踪?因为同一个插件可能被加载多次(不同配置、不同父 Context),每次加载都是一个独立的 Fiber。插件代码是"类",Fiber 是"实例"。这个区分在调试时特别重要——当你看到日志里两个 Fiber 都叫prompt-optimizer,别慌,它们只是同一份代码的两个运行实例。
Fiber 的状态机大致是这样的:
pending → loading → active → disposing → disposed ↓ failed(依赖缺失或初始化抛错)理解这个状态机,能帮你快速定位大部分加载问题。插件没生效?先看 Fiber 停在哪个状态。停在pending,多半是依赖没满足;停在failed,去看初始化时的报错;停在disposing迟迟不到disposed,说明你的清理逻辑里有异步操作没 await。
3.2 依赖解析的顺序:为什么插件加载顺序不能靠"猜"
Cordis 的依赖解析是拓扑排序:先加载没有依赖的插件,再加载依赖已满足的插件,逐层推进。这意味着你不需要手动指定加载顺序——只要inject声明正确,Cordis 会自己算出正确顺序。
但这里有个反直觉的点:循环依赖不会被自动打破,而是直接报错。插件 A inject B,插件 B inject A,Cordis 会检测到环并拒绝加载两者。这是好事——循环依赖在运行时几乎必然导致初始化顺序问题,早报错早省心。如果你真的遇到两个插件互相需要对方的能力,正确做法是抽出一个第三方插件,把共享能力下沉到它里面,让 A 和 B 都依赖这个第三方。
实际排查依赖问题时,我习惯在启动日志里打开 Cordis 的 debug 输出,它会打印完整的依赖图和解析顺序。看到某个插件排在预期之外的位置,基本就能顺藤摸瓜找到那个多声明或少声明的inject。
3.3 热重载背后的 Fiber 重建
Harness 支持插件热重载,改完代码不用重启整个进程。这个能力的底层就是 Fiber 的重建:Cordis 检测到插件文件变化,先 dispose 旧 Fiber(触发所有清理钩子),再用新代码创建新 Fiber,重新走一遍依赖解析。
热重载最容易出问题的地方是副作用没清理干净。比如你在插件里setInterval了一个定时器,dispose 时没 clear,热重载几次之后就有好几个定时器同时在跑。Cordis 提供了ctx.on('dispose', callback)让你注册清理逻辑,但框架没法替你判断哪些副作用需要清理——这永远是插件作者的责任。
注意:热重载不是万能的。涉及原生模块、文件锁、外部进程的插件,热重载往往比重启还慢还容易出错。我的经验是,纯逻辑插件用热重载,涉及 IO 和外部资源的插件老老实实重启。
4. 手写一个 Cordis 插件:从声明依赖到注册服务
4.1 最小可运行插件的完整骨架
光讲概念没用,直接上一个能跑的插件。假设我们要写一个"提示词优化"插件,它依赖配置服务和日志服务,对外提供一个optimize方法:
import { Context } from 'cordis' export const name = 'prompt-optimizer' export const inject = ['config', 'logger'] export interface Config { maxTokens: number style: 'concise' | 'detailed' } export function apply(ctx: Context, config: Config) { ctx.logger.info('prompt-optimizer loaded, style=%s', config.style) const optimize = (raw: string): string => { if (config.style === 'concise') { return raw.replace(/\s+/g, ' ').trim() } return raw } ctx.set('promptOptimizer', { optimize }) ctx.on('dispose', () => { ctx.logger.info('prompt-optimizer disposed') }) } declare module 'cordis' { interface Context { promptOptimizer: { optimize(raw: string): string } } }这个骨架包含了一个合格插件的全部要素:元信息(name)、依赖声明(inject)、配置类型(Config)、初始化逻辑(apply)、服务注册(set)、清理钩子(dispose)、类型合并(declare module)。少任何一项,插件要么跑不起来,要么跑起来但类型不对,要么卸载时留垃圾。
4.2 配置从哪来:插件配置的加载与校验
上面代码里的config参数不是凭空出现的,它来自 Harness 的配置文件。Cordis 本身不做配置校验,它只负责把配置对象传给apply。校验得你自己做,或者引入 schema 校验库。
我强烈建议在插件入口做一次显式校验,而不是信任配置:
function validateConfig(raw: unknown): Config { if (typeof raw !== 'object' || raw === null) { throw new Error('prompt-optimizer: config must be an object') } const c = raw as Partial<Config> if (typeof c.maxTokens !== 'number' || c.maxTokens <= 0) { throw new Error('prompt-optimizer: maxTokens must be a positive number') } if (c.style !== 'concise' && c.style !== 'detailed') { throw new Error('prompt-optimizer: style must be concise or detailed') } return c as Config }为什么值得多写这十几行?因为配置错误是插件问题里最难排查的一类——它不会在加载时报错,而是在某个特定调用路径上突然炸掉。提前校验,把错误暴露在加载阶段,Fiber 会直接进入failed状态,日志里一目了然。
4.3 服务注册的粒度:一个插件该暴露多少能力
新手常犯的另一个错,是把插件写成一个"大杂烩"——一个插件注册五六个服务,什么都往里塞。这违背了 Cordis 的设计初衷。一个插件应该只暴露一组内聚的能力,服务名要能准确描述它的职责。
判断粒度是否合适,有个简单标准:如果这个插件的某个服务被单独依赖、而其他服务没人用,那说明它该拆。比如"提示词优化"和"代码回退"是两个完全独立的能力,就不该塞进同一个插件。拆开之后,用户可以只装提示词优化、不装代码回退,依赖图也更清晰。
反过来,拆得太碎也有代价——每个插件都有加载开销,插件间通信也要走 Context 查找。我的经验是:按"用户会不会单独启用/禁用"来划分插件边界。会单独开关的,拆;永远一起用的,合。
5. 插件间通信与依赖注入的实战边界
5.1 通过服务通信 vs 通过事件通信
Cordis 提供两套插件间通信机制:服务(service)和事件(event)。选错了会让架构变味。
服务适合"请求-响应"式的同步调用:A 提供optimize方法,B 调用它拿结果。事件适合"广播-监听"式的异步通知:A 发出prompt:optimized事件,B、C、D 都可以监听,A 不关心谁在听。
一个常见的误用是把所有通信都做成事件,结果调试时根本追不到"这个事件到底触发了哪些逻辑"。我的原则是:需要返回值、需要保证执行顺序的,用服务;只是通知、允许多方独立响应的,用事件。提示词优化这种需要拿到结果的,必须是服务;"插件已加载完成"这种通知,用事件。
5.2 可选依赖:inject 的 optional 用法
有时候一个插件"最好有"某个服务,但没有也能降级运行。Cordis 支持可选依赖:
export const inject = { required: ['config'], optional: ['metrics'] }这样metrics服务存在时插件能用它上报指标,不存在时插件照常工作。这个能力在写"增强型"插件时特别有用——比如一个日志增强插件,有 metrics 服务就顺便上报,没有就只打日志。
但可选依赖有个陷阱:你不能在 apply 阶段就假设 optional 服务存在。正确做法是在真正用到它的地方做判空,或者监听服务的注册事件:
ctx.on('internal/service', (name) => { if (name === 'metrics') { // metrics 服务上线了,可以开始上报 } })5.3 服务替换与优先级:多个插件提供同名服务怎么办
如果两个插件都set('logger', ...),后加载的会覆盖先加载的。Cordis 不会报错,因为"覆盖"在某些场景下是合理需求(比如用自定义 logger 替换默认 logger)。但这意味着服务名冲突是静默的,排查起来很痛苦。
我的做法是给服务名加命名空间前缀,比如myapp:logger而不是logger。这样既避免了意外覆盖,又让服务归属一目了然。如果确实需要替换默认服务,在插件名和文档里明确写清楚"本插件会覆盖 X 服务",让使用者有心理准备。
6. 那些文档不会告诉你的踩坑记录
6.1 依赖声明写错,插件静默不加载
最坑的一类问题:inject里写了一个不存在的服务名,插件不会报错,只是永远停在pending状态,日志里连个警告都没有。我在这上面浪费过整整一个下午,最后靠打印 Fiber 状态才定位到。
后来我养成了一个习惯:新插件第一次加载时,先确认它的 Fiber 到了active状态。没到,第一件事就是检查inject里的每个名字是否真的有人注册。Cordis 的 debug 日志会列出当前所有可用服务,对照一下就知道哪个名字写错了。
6.2 异步初始化:apply 不是 async 的
Cordis 的apply函数是同步的,你不能写export async function apply(...)。如果插件初始化需要异步操作(比如读文件、连数据库),正确做法是在 apply 里启动异步任务,但不要 await:
export function apply(ctx: Context, config: Config) { const ready = (async () => { const data = await loadData(config.path) ctx.set('myData', data) })() ctx.on('dispose', () => { /* 清理 */ }) }但这样有个问题:在ready完成之前,myData服务还不存在,其他插件如果 inject 了它会一直等。这时候要么用可选依赖,要么提供一个"就绪"事件让消费方监听。异步初始化的插件,一定要想清楚消费方怎么知道它准备好了,这是设计问题不是编码问题。
6.3 热重载时服务引用失效
热重载会重建 Fiber,但已经持有旧服务引用的代码不会自动更新。比如插件 B 在初始化时把ctx.promptOptimizer存到了一个闭包变量里,插件 A 热重载后,B 手里的还是旧引用。
解决办法是不要缓存服务引用,每次用时从 ctx 取。ctx.promptOptimizer这个属性访问本身很轻,没必要为了省这点开销引入失效风险。如果确实需要缓存(比如高频调用路径),就监听服务的变更事件,在变更时刷新缓存。
6.4 插件卸载顺序与资源释放
Cordis 卸载插件时,会先卸载依赖它的插件,再卸载它本身——和加载顺序相反。这个顺序保证了卸载过程中不会出现"依赖方还在用、被依赖方已经没了"的情况。
但如果你在插件里注册了全局副作用(比如修改了某个单例对象、注册了进程级信号处理),Cordis 管不到这些。凡是"全局"的东西,都要在 dispose 钩子里手动还原。我见过最离谱的案例是一个插件修改了Array.prototype,卸载后没还原,导致后续所有代码都受影响。虽然这是极端情况,但道理是通用的:插件应该尽量不产生全局副作用,产生了就要负责清理。
7. 从 Cordis 看插件架构的通用设计取舍
写完几个 Cordis 插件之后,我回头想这套架构到底好在哪、又牺牲了什么,对做类似系统设计的人应该有参考价值。
它换来的:依赖显式化(inject 声明)、生命周期可控(Fiber 状态机)、作用域隔离(Context 派生)、类型安全(declare module 合并)。这四点让大型插件生态成为可能——几十个插件互相依赖,还能保持可预测的加载和卸载行为。
它牺牲的:上手成本。相比"直接 import 主程序模块"的土办法,Cordis 要求你理解 Context、Fiber、服务注册这一整套概念。写第一个插件时,你会觉得"我就想加个功能,怎么这么多规矩"。但当你写到第十个插件、开始需要组合和替换时,这些规矩就变成了救命的护栏。
一个值得借鉴的设计细节:Cordis 把"依赖声明"和"依赖使用"分开了。inject声明依赖,ctx.xxx使用依赖,两者通过类型系统关联。这种"声明与使用分离"的模式,在配置管理、权限系统、ORM 关联查询里都能看到影子。它的好处是让框架能在"使用之前"就介入——检查、排序、报错,而不是等到运行时才发现问题。
如果你正在设计自己的插件系统,Cordis 这套思路值得抄作业的地方,不是具体的 API 名字,而是把依赖关系变成一等公民这个决策。一旦依赖关系是显式的、可被框架读取的,加载顺序、循环检测、热重载、按需卸载这些能力就都能顺理成章地实现。反之,如果依赖关系藏在代码的 import 语句里,框架就只能干瞪眼。
最后分享一个我自己的小习惯:每写一个新插件,先在纸上画出它的依赖箭头——它 inject 谁、它 set 的服务被谁 inject。画完如果发现有环,或者有箭头指向一个"什么都不是"的节点,那基本就是设计有问题,趁早改比写完再改省事得多。这套方法在 Cordis 上尤其管用,因为它的依赖模型足够简单,简单到可以用一张图说清楚。