拓十年匠心定制 · 商业建站与技术教学双线并行 咨询热线:400-886-1026 service@lmnt.cn
ARTICLE DETAIL

资讯详情

深耕网站建设与运营推广的一线实战洞察。

从插件装载到热更新:一篇看懂 DSH Cordis 的运行机制

从插件装载到热更新:一篇看懂 DSH Cordis 的运行机制

假设我们正在构建一个 Agent。它需要模型服务、工具、日志、会话、权限校验等几十个插件,还要允许不同租户使用不同的模型配置。运行过程中,有些插件会被关闭,有些服务会被替换,开发时还希望修改代码后立即生效。

如果这些事情全部靠业务代码手工处理,很快就会遇到四类问题:

  • 插件卸载了,定时器、监听器或连接却没有释放;
  • 服务发生变化后,依赖它的插件仍拿着旧实例运行;
  • 插件之间互相直接调用,最终形成难以拆解的依赖网;
  • 改一处配置或代码,整个进程都要重启。

Cordis 就是为这类动态应用准备的运行底座。它并不直接提供 Agent 能力,而是规定插件如何进入系统、如何协作、如何退出,以及整个应用如何在运行中发生变化。

可以先把它概括成四个问题和四组机制:

问题Cordis 的回答
插件产生的资源怎么回收?Effect与 disposer
插件什么时候应该运行?Service、inject与响应式依赖
插件之间怎么通信?五种事件分发模式
大量插件怎么组织和更新?Loader、YAML、isolate、intercept与 HMR

理解这四组机制后,DSH 为什么能把大量能力做成可插拔组件,也就不难理解了。

本文讨论的是@deepseek-ai/cordis 4.0.1所体现的运行语义。它是 DSH 从 Cordis fork 并重新命名作用域后的版本,对应的上游核心 API 语义基本一致。

先建立一张运行时地图

Cordis 中最常见的入口是Context,插件则是一个可以被独立装卸的功能单元。最简单的插件只有一个apply(ctx):

importtype{Context}from'@deepseek-ai/cordis'exportfunctionapply(ctx:Context){console.log('plugin started')}

Context可以理解为插件看到的共享运行环境。插件通过它挂载子插件、注册服务、订阅事件,也通过它访问自己声明过的依赖。

不过,真正负责管理一次插件运行的并不是插件源码,而是Fiber。每调用一次ctx.plugin(),框架都会为这次挂载创建一个 Fiber。即使同一个插件被挂载三次,也会产生三个独立的 Fiber 实例。

因此需要区分三层:

  • Plugin 是功能定义,也就是“要运行什么”;
  • Context 是插件操作环境的入口,也就是“能看到什么”;
  • Fiber 是某次挂载的运行实例,也就是“当前运行得怎么样”。

这里的“Plugin”容易让人以为存在一个像 Context、Fiber 那样由 Cordis 创建的 new Plugin() 对象。这里指的是插件定义,在文中的例子里,就是导出 apply 的模块。

Fiber 会经历PENDING、LOADING、ACTIVE、UNLOADING、DISPOSED等状态,加载失败时还会进入FAILED。父插件挂载子插件后,Fiber 之间也形成父子关系,最终构成一棵插件树。

整个运行模型可以先简化成下面这样:

配置或代码 │ ▼ Context + Plugin │ ctx.plugin() ▼ Fiber 运行实例 ├── Effect:管理资源生命周期 ├── Service:提供具名能力 ├── inject:声明运行依赖 └── Event:完成插件间通信

整个过程涉及“挂载插件”和“运行插件”:

  • ctx.plugin(plugin, config) 是调用方使用的 API:把插件挂到插件树上,创建对应的 Fiber,并交给 Cordis 管理依赖、生命周期和卸载。
  • apply(ctx, config) 是插件作者提供的入口:当依赖满足、插件可以启动时,由 Cordis 调用,执行插件的初始化逻辑。它不是 ctx 上的方法。
const worker = { inject: ['db'], apply(ctx, config) { // db 就绪后,Cordis 才会执行这里 }, } const fiber = ctx.plugin(worker, { name: 'example' }) // 之后可以通过 fiber 卸载这个运行实例

补充:apply(ctx, config) 主要做的是启动这个插件的一次运行实例:读取配置,取得已声明的服务依赖,然后注册这个插件要提供的能力和要执行的工作,例如服务、事件监听器、定时器或子插件。

ctx.plugin(plugin, config) 挂载插件,交给 Cordis 管理 ↓ 依赖满足后 plugin.apply(ctx, config) 执行插件的初始化逻辑

下面从一个 Fiber 的生与死开始。

插件能加载,更要能干净地卸载

插件很少只做一次计算。它通常会启动定时器、打开连接、注册监听器,或者挂载更多子插件。这些操作都会改变外部环境,也就是产生副作用。

Cordis 的基本要求是:产生副作用时,同时描述如何撤销它。

exportfunctionapply(ctx:Context){ctx.effect(()=>{consttimer=setInterval(()=>{console.log('tick')},1000)return()=>clearInterval(timer)})}

这段代码里,setInterval()是向前执行的操作,返回的函数则是撤销路径。插件被卸载时,框架会主动调用这条路径。业务代码不需要在另一个角落记住定时器编号,也不需要另外维护一套停止流程。

这就是 Cordis 所说的“时间可组合性”:组件不仅可以加入系统,也可以把自己对环境造成的改变撤销掉。

很多 Cordis API 本身就是 Effect

实际开发中,并不需要把每个操作都手工包进ctx.effect()。Cordis 自己提供的注册型 API 已经带有 Effect 语义:

  • ctx.on()注册的监听器会在所属插件卸载时移除;
  • ctx.plugin()挂载的子插件会随父插件卸载;
  • ctx.provide()或Service注册的服务会在提供方退出时撤销;
  • 这些 API 返回的 disposer 都会归属到当前 Fiber。

只有数据库连接、文件句柄、原生定时器等 Cordis 无法自动认识的资源,才需要使用ctx.effect()明确提供释放逻辑。

在 Cordis 里,Effect 可以理解为“创建一项持续存在的影响,同时登记如何撤销它”。它通常不是一个需要 new Effect() 的对象,而是通过 ctx.effect() 体现的生命周期约定:

export function apply(ctx: Context) { ctx.effect(() => { const timer = setInterval(() => { console.log('tick') }, 1000) return () => clearInterval(timer) }) }

这里 setInterval() 注册了一个定时器;即使 apply() 已经返回,它仍会持续运行。返回的函数叫 disposer,告诉 Cordis 如何停止它。所属 Fiber 卸载时,Cordis 会调用这个函数。

为什么叫 Effect 为“副作用”

看同一个函数里,除了返回值还发生了什么:

let count = 0 function add(a: number, b: number) { count++ // 改变了函数外面的变量 return a + b // 返回值 }

调用 add(1, 2) 后,有两件事发生:拿到返回值 3,同时外部的 count 增加了。count 的变化就是这次调用的副作用。去掉 count++,返回值仍是 3,但副作用没了。

再放回 Cordis:apply(ctx) 注册监听器时,改变的是 ctx 所管理的监听器列表;即使 apply() 返回了,监听器仍在。这就是它产生的副作用。Cordis 的 Effect 机制负责记录“卸载时怎样移除这个监听器”。“副”只是相对于函数返回值而言,不代表它在插件里不重要。

所以 Cordis 插件的 apply() 常常正是为了产生这类变化:注册监听器、启动定时器、提供服务。Cordis 称它们为 Effect,是为了把变化和清理配成一对:装插件时增加了什么,卸插件时就移除什么。例如注册定时器是副作用,clearInterval() 是对应的清理。

卸载并不是简单地逐行反向执行

调用fiber.dispose()后,框架大致会走过这条链路:

fiber.dispose() │ ▼ _unload() │ ▼ DisposableList.clear() │ 按注册顺序逆序取出 ▼ Promise.all(...) 并发启动各个 disposer │ ▼ 单个 disposer 内部再逆序执行自己的清理步骤

这里有一个容易误解的细节:多个 disposer 会逆序启动,但外层使用了Promise.all()。如果清理函数包含异步操作,框架并不保证它们按逆序完成。

假设卸载时必须先停止轮询,再关闭连接,最后释放文件,那么这三步不应该拆成互相独立的 disposer。更稳妥的写法是放在同一条清理链中:

ctx.effect(()=>{constfile=openFile()constconnection=connect()constpolling=startPolling(connection)returnasync()=>{awaitpolling.stop()awaitconnection.close()awaitfile.close()}})

Cordis 负责兑现清理函数,却不能替开发者推断资源之间的业务顺序。它提供的是可靠的生命周期边界,而不是自动发现所有 JavaScript 资源。

插件不再等待“正确的启动顺序”

资源能够干净回收,只解决了单个插件的生命周期。真实系统里,一个插件往往依赖另一个插件提供的能力。

例如聊天插件需要一个模型服务。模型服务可能尚未加载,也可能在运行中被替换。传统做法通常是先确定启动顺序,再写大量“服务是否就绪”的判断。Cordis 换了一个思路:插件只声明依赖,框架负责决定它何时运行。

Service 负责提供能力

在 Cordis 中,Service 是注册在 Context 上的具名能力:

import{Service,typeContext}from'@deepseek-ai/cordis'declaremodule'@deepseek-ai/cordis'{interfaceContext{greeter:GreeterService}}exportclassGreeterServiceextendsService{constructor(ctx:Context){super(ctx,'greeter')}greet(name:string){return`Hello,${name}`}}

super(ctx, 'greeter')才是运行时注册服务的动作。TypeScript 的声明合并只负责让ctx.greeter获得类型提示,并不会产生运行时代码。

服务注册本身也是 Effect。因此,当提供服务的 Fiber 被卸载时,服务会自动从 Context 中消失。

inject 负责声明硬依赖

消费方只需要声明自己需要什么:

exportconstinject=['greeter']exportfunctionapply(ctx:Context){console.log(ctx.greeter.greet('world'))}

如果greeter尚未出现,这个插件不会贸然执行apply(),而是停在PENDING。服务就绪后,它才会被激活。

更重要的是,inject不是启动时只检查一次。Cordis 会持续追踪依赖:

  1. greeter v1上线,消费插件开始运行;
  2. greeter v1被撤销,消费插件自动卸载自己的 Effect;
  3. greeter v2上线,消费插件带着新实例重新执行apply()。

整个过程中,消费方没有编写启动、停止或重试代码。依赖关系本身就是编排规则。

响应式依赖的核心是一枚“依赖指纹”

当服务被提供或撤销时,Cordis 会通知所有声明了相关依赖的 Fiber。核心链路可以概括为:

provide / revoke │ ▼ notify │ 找到声明了该依赖的 Fiber ▼ _checkImpl │ 重新查询服务实例 ▼ _refresh │ 用服务实例 uid 计算依赖指纹 epoch ▼ _setEpoch ├── 依赖失效:_unload() └── 依赖恢复或更换:_reload()

每个 Fiber 都维护自己当前依赖的服务实例。只要缺少一个硬依赖,指纹就会变成不活跃状态;依赖重新满足后,框架会重新执行插件的初始化逻辑。

这里的“重新加载”不是把冻结的旧对象唤醒,而是基于新依赖重新执行一次apply()。所以插件内部的运行状态要么来自配置和服务,要么需要由更高层显式保存,不能假设重启后还能继续使用旧的局部变量。

  • 为什么需要 _checkImpl?

以消费插件声明 inject: [‘greeter’] 为例:_checkImpl 会从消费插件当前的 Context 重新查询“现在可见的 greeter 是谁”。它可能是原来的 v1、新提供的 v2,也可能已经不存在。Cordis 再把查询结果与这个 Fiber 之前记录的依赖实例比较,决定是否卸载或重新执行 apply()。

所以可以记成:查的是“我现在依赖谁”,记录和响应变化的是“我自己”。 查询还会受 isolate 影响,并不一定是全局唯一的同名服务。

  • uid 计算依赖指纹 epoch 怎么理解?

可以把 epoch 理解成:这个 Fiber 当前拿到的整组依赖的“版本标记”,不是时间戳。

假设插件声明 inject: [‘greeter’, ‘db’]:

当前依赖:greeter(uid=12) + db(uid=7) 依赖指纹:由 (12, 7) 得到的 epoch

Cordis 重新查询服务后,会用查到的实例 uid 再算一次:

  • greeter 换成新实例,uid 变为 19:指纹变化,插件需要用新依赖重新运行 apply()。
  • greeter 消失:硬依赖不满足,插件需要卸载。
  • 只是同一个 greeter 实例内部的数据变了:uid 没变,不会因此重启插件。
  • 别的作用域换了服务,但当前插件看到的实例没变:指纹也不变。

所以,uid 标识的是服务实例的身份;epoch 概括的是我这一组依赖目前绑定到了哪些实例。它用来判断是否需要重新运行插件,而不是记录服务内部每次状态变化。

并非所有依赖都应该写进 inject

如果某项能力只是锦上添花,缺少它时插件仍然可以运行,就不应该把它声明为硬依赖,而应在使用处探测:

constgreeter=ctx.get('greeter')if(greeter){greeter.greet('world')}else{console.log('greeter unavailable, skip')}

可以用一句话区分:

  • inject表示“没有它,我就不应该运行”;
  • ctx.get()表示“有它就增强,没有它也能继续”。

这套持续重新评估依赖的模型,是 Cordis 与许多传统 IoC 容器最明显的区别。传统容器通常擅长启动阶段的一次性装配,而 Cordis 从一开始就把依赖图当成会在运行中变化的对象。

依赖之外,插件还需要“说话”

依赖关系表达的是“我需要谁的能力”,事件表达的则是“我想告诉别人发生了什么”。

日志插件和统计插件都想知道用户发出了消息,但消息发送方不需要直接依赖这两个插件。这正适合事件系统。

Cordis 支持通过 TypeScript 声明合并定义事件签名:

declaremodule'@deepseek-ai/cordis'{interfaceEvents{'chat/message'(text:string):void}}ctx.on('chat/message',(text)=>{console.log('message:',text)})ctx.emit('chat/message','hello')

所有事件共享一个扁平命名空间,所以通常使用namespace/action的命名方式。类型声明让事件名和参数在编译期得到检查;ctx.on()又具有 Effect 语义,因此监听器会随插件自动卸载。

真正需要做选择的地方,是用哪一种分发模式。可以沿着三个问题判断。

是否需要监听器的处理结果? ├── 不需要 │ ├── 不等待完成:emit │ └── 等待全部完成:parallel └── 需要 ├── 谁能处理谁接手:同步用 bail,异步用 serial └── 层层加工或拦截:waterfall
场景模式执行语义
只广播通知emit按注册顺序同步调用,不等待 Promise,不收集结果
并发完成多个任务parallel并发调用并等待全部结束,错误汇总为AggregateError
异步寻找第一个处理者serial按顺序await,遇到有效返回值就短路
同步寻找第一个处理者bailserial的同步版本
拦截、包装或转换结果waterfall监听器通过next()形成洋葱式调用链

两种短路不能混为一谈

serial和bail的短路取决于返回值。只要监听器返回的值不是null、false或undefined,框架就认为这件事已经被处理,后面的监听器不再执行。

waterfall的短路则取决于有没有调用next():

ctx.on('chat/reply',(text,next)=>{constresult=next()return`[bot]${result}`})ctx.on('chat/reply',(text,next)=>{if(containsSensitiveContent(text)){return'content blocked'}returnnext()})

第一层调用next(),拿到下游结果后再包装;第二层在特定条件下不调用next(),因此直接截断后续处理。一个只想记录日志的 waterfall 监听器如果忘记调用next(),也会意外吞掉整条链路。

此外,ctx.once()适合只等待下一次事件,prepend可以把监听器插到队首。不过执行顺序最好主要依靠清晰的注册关系,只有确实需要抢占顺序时才使用prepend。

从源码角度看,五种事件并不是五套系统。它们都先通过dispatch()取得同一份监听器列表,差别只在如何遍历:同步map、并发等待、顺序短路,或者递归调用next()。复杂的使用语义,建立在一个很小的统一内核之上。

从手写挂载走向声明式插件树

Effect、依赖和事件解决了插件的运行问题。但当应用包含几十上百个插件时,继续在入口文件中逐行调用ctx.plugin(),仍会产生大量机械代码。

Cordis 的 Loader 把装配过程转移到 YAML。这里需要同时理解两棵树:

  • 配置树是cordis.yml中的声明,是应用的图纸;
  • 插件树是运行时产生的 Fiber 层级,是图纸对应的实物。

一个最小条目可能是:

-id:loggername:./plugins/logger.tsconfig:level:info

它的运行链路如下:

创建根 Context │ ▼ 挂载 Loader │ ▼ include cordis.yml │ ▼ 生成 Entry 配置节点 │ ▼ registry.plugin() │ ▼ 创建 Fiber,校验配置,执行 apply()

这条链路可以分成三段:准备运行环境 → 把 YAML 变成配置节点 → 把配置节点变成正在运行的插件。

层在做什么
创建根 Context建立整个应用的根运行环境。后续插件都从这棵 Context 插件树中获得服务、事件和生命周期管理能力。
挂载 Loader把 Loader 作为一个插件装上去。它负责读取配置,并根据配置管理其他插件。
includecordis.yml告诉 Loader 把这份 YAML 纳入管理;Loader 读取、解析其中的插件声明。
生成 Entry为每个配置条目建立内部节点,记录id、插件name、config、父子关系等。Entry 是配置树中的节点,还不是运行中的插件。
registry.plugin()根据 Entry 找到对应的插件模块,并发起挂载。这一步是从“配置声明”走向“运行实例”的桥梁。
创建 Fiber为这次挂载建立运行实例,管理状态、依赖和卸载时的清理。配置通过校验、硬依赖满足后,Cordis 才调用插件的apply(ctx, config)。

例如 YAML 里写了 name: ./logger.ts,Entry 表示“配置要求装一个 logger”;Fiber 表示“这个 logger 此次挂载的实际运行状态”。同一模块挂载两次,可以对应两个 Entry 和两个 Fiber。

图中最后的“创建 Fiber,校验配置,执行 apply()”是简写,不保证挂载后立刻执行 apply():条目被禁用时可能不挂载;配置校验失败会进入失败状态;声明的 inject 尚未满足时,Fiber 会等待依赖。

Loader 还会把 Entry 记录到 Fiber 上,使配置节点与运行实例能够持续对账。这一点不仅方便诊断,也是精准热更新的基础。

配置字段不是清单,而是运行规则

一份完整配置里,常见字段分别解决不同问题:

字段或能力作用
id条目的稳定身份,用于生成全名、配置对账和精准更新
name要加载的插件模块,可以是相对路径或包名
config传给插件apply()的配置对象
disabled保留配置但暂不挂载,父级停用时子条目也受影响
group把子条目组织成树干节点;组本身也是一个真实插件
!!js在config或disabled中惰性计算动态值
inject从配置层为插件追加硬依赖

插件还可以导出运行时 schema。Loader 会先校验和补齐默认值,再调用apply():

importzfrom'@deepseek-ai/schemastery'exportconstConfig=z.object({level:z.string().default('info'),targets:z.array(String).default(['world']),})

这样一来,业务代码收到的是一份完整且经过验证的配置。字段类型错误时,Fiber 会进入FAILED,并给出具体的ValidationError,而不是带着错误配置继续运行。

这里有两条很实用的原则:经常随环境变化的值放进 YAML,不要硬编码;需要严格约束的配置应在加载阶段响亮地失败,不要等到业务运行中再暴露问题。

同名服务如何服务不同租户

这一节可以用一个具体问题来理解:A、B 两个租户都运行同一份chat.ts,但 A 要用 mock 模型,B 要用真实模型。代码都写ctx.shell,怎么保证它们拿到的不是同一个服务?

先看一份完整的配置。为便于说明,shell.ts会注册名为shell的服务,chat.ts则声明inject: ['shell']并调用ctx.shell:

# cordis.yml-id:tenant-aname:'@deepseek-ai/cordis-plugin-group'group:trueisolate:shell:tenant-aconfig:-id:a-shellname:./plugins/shell.tsconfig:provider:mockmodel:mock-chat-id:a-chatname:./plugins/chat.ts-id:tenant-bname:'@deepseek-ai/cordis-plugin-group'group:trueisolate:shell:tenant-bconfig:-id:b-shellname:./plugins/shell.tsconfig:provider:openaimodel:gpt-4o-id:b-chatname:./plugins/chat.ts

chat.ts可以非常简单:

exportconstinject=['shell']exportfunctionapply(ctx){// 这里的 ctx.shell 由当前租户的 isolate 作用域决定ctx.shell.chat('你好')}

运行时可以把它想成两间互相隔开的房间:

租户 A 的房间 租户 B 的房间 ┌────────────────────┐ ┌────────────────────┐ │ a-shell │ │ b-shell │ │ mock / mock-chat │ │ openai / gpt-4o │ │ │ │ │ │ a-chat │ │ b-chat │ │ ctx.shell ──────────┘ │ ctx.shell ──────────┘ └────────────────────┘ └────────────────────┘

这里发生了四件事:

  1. 两个group分别创建租户 A、B 的插件作用域。
  2. A 的isolate.shell: tenant-a和 B 的isolate.shell: tenant-b,把同名的shell放进两个不同的服务“世界”。
  3. a-shell和b-shell虽然使用同一个插件文件,但因为配置不同,产生两个服务实例。
  4. a-chat、b-chat都只声明“我要shell”,不需要知道具体供应商;解析依赖时,A 只能看到 A 的shell,B 只能看到 B 的shell。

因此,isolate不会自动创建服务,也不会改变chat.ts的代码;它只决定“同名服务在哪个作用域内可见”。每个租户仍然要各自挂载自己的shell.ts。

intercept又解决什么问题

假设 B 仍然使用b-shell这个实例,但只想让 B 下面的某个子插件临时看到另一份模型配置,可以在该作用域加上intercept:

-id:tenant-bname:'@deepseek-ai/cordis-plugin-group'group:trueisolate:shell:tenant-bintercept:shell:model:gpt-4o-miniconfig:-id:b-shellname:./plugins/shell.tsconfig:provider:openaimodel:gpt-4o-id:b-chatname:./plugins/chat.ts

这时要区分两件事:b-shell服务实例仍然是原来的那个,原始配置仍是gpt-4o;但 B 这个作用域中的消费者读取配置时,会看到被覆盖的gpt-4o-mini。因此:

isolate = 把服务实例分到不同作用域(换一个“房间”) intercept = 只改变当前作用域看到的配置(在房间里换一张“配置便签”)

简单说:要让两个租户拥有不同的服务对象,用isolate;已经是同一个服务对象,只想让某个范围内的插件使用不同配置视图,才用intercept。

HMR 为什么能只替换发生变化的部分

插件树一旦具备明确的生命周期、稳定的身份和响应式依赖,就已经拥有局部更新所需的基础。

Cordis 的 HMR 有两条路径。

修改配置:按 Entry 更新

cordis.yml变化后,include 会根据稳定id对比新旧配置,只重启发生变化的条目:

配置变化 │ ▼ 根据 id 找到 Entry │ ▼ fiber.restart() │ ├── 卸载旧实例并回收 Effect └── 使用新配置重新执行 apply()

如果条目没有显式id,Loader 会生成随机身份。下次读取配置时,它就可能被识别成“删除旧条目,再新增一个条目”,相关子树也会被重新挂载。因此,稳定id是精准配置热更新的前提,而不只是为了让 YAML 更易读。

修改代码:按模块更新

插件源文件变化后,HMR 会清理模块缓存、重新import新代码,再使用原配置替换插件。共用同一个模块的多个实例会一起换装,而没有引用这个模块的其他插件不受影响。

所以两条路径的更新粒度不同:

  • 改配置以 Entry 为单位,只处理发生变化的条目;
  • 改代码以模块为单位,使用该模块的实例一起更新。

HMR 本身还依赖timer服务进行防抖。没有挂载 timer 时,HMR 会因为依赖不满足而停在PENDING。此外,它需要 Node 的内部模块加载能力,因此要使用--expose-internals,并注意不同 Node 版本的内部 API 差异。

HMR 看似是一项独立功能,实际仍在复用前面的机制:旧实例依靠 Effect 干净卸载,新实例依靠 Service 和inject重新就绪,Fiber 则提供重启与状态管理。Cordis 并没有为热更新重新发明一套生命周期。

把四套机制还原为一条运行链路

现在可以把整个 Cordis 运行过程收拢到一张图中:

YAML / ctx.plugin() │ ▼ Fiber │ ▼ apply(ctx) ┌────┼────────┐ ▼ ▼ ▼ Effect Service Event │ │ │ ▼ ▼ ▼ dispose notify dispatch │ ▼ unload / reload │ ▼ 插件树持续演化

这条链路揭示了 Cordis 最重要的设计取向:它不是把插件当成启动一次就永远存在的模块,而是把插件当成随时可能出现、消失、重新执行和改变依赖的运行单元。

因此:

  • Effect回答“这个插件离开时,怎样把环境恢复干净”;
  • Service / inject回答“依赖变化时,这个插件是否应该运行”;
  • 事件系统回答“没有直接依赖的插件,怎样按合适的语义通信”;
  • Loader、isolate、intercept和 HMR 回答“整棵插件树怎样被描述、分区和局部替换”。

写一个 Cordis 插件时,也可以用这四个问题自检:

  1. 我创建的每项外部资源是否都有 disposer?
  2. 必需能力是否声明为inject,可选能力是否使用ctx.get()?
  3. 插件通信究竟是广播、竞争,还是洋葱式加工?
  4. 配置是否有 schema,条目是否有稳定id,多租户是否需要isolate?

DSH 在 Cordis 之上构建模型、工具、会话等 Agent 能力。Cordis 自己并不理解模型推理,也不负责回答用户问题;它提供的是一套更底层的秩序:让能力能够以插件形式加入系统,让依赖和通信可以被声明,让资源可以被回收,并让运行中的应用能够安全地改变形态。

从这个角度看,Cordis 的核心并不是“插件很多”,而是插件无论来、去、协作还是替换,都处在同一套可推导的运行规则里。

返回列表