DeepSeek Harness -- Cordis:为什么“一切皆插件”没有变成一团乱麻,从问题到机制的完整推演
上一章讲的是“把什么装进一次 DSH 运行”。但启动完成之后,另一个更难的问题才真正出现:
100 多个能力包同时存在时,模型适配器、会话、工具、权限、前端和子 Agent,怎样既能协作,又不会互相绑死?
如果答案只是“大家都写成插件”,系统往往会变成另一种混乱:插件 A 直接 import 插件 B 的实现;插件 B 卸载后,A 还持有旧引用;为了控制顺序,又在入口里堆出长长的手工启动列表。
这篇文章不是 Cordis 的 API 文档,也不是 DSH 的使用教程。它要回答一个更底层的问题:
一个长运行、可扩展的 Agent 系统,需要什么样的插件模型?
DSH 选择了 Cordis 作为底层框架。本文的目的不是吹捧这个选择,而是把 Cordis 的设计拆开,看它到底解决了哪些工程问题,以及这些问题为什么在 Agent Runtime 里至关重要。
为什么要研究这个问题?
大多数开发者对“插件系统”的理解停留在“加载一堆代码”的层面。这种理解在短生命周期应用里够用,但在 Agent Runtime 里会遇到三个致命挑战:
| 挑战 | 具体表现 |
|---|---|
| 热更新 | Agent 需要 7x24 运行,修改配置或升级插件不能靠重启 |
| 依赖动态性 | 插件之间相互依赖,但启动顺序不能靠人工维护 |
| 资源泄漏 | 插件卸载时,它注册的监听器、定时器、子进程必须被完整清理,否则系统会逐渐腐烂 |
这三个挑战指向同一个结论:插件不是“能加载就行”,而是“加载、运行、卸载的全生命周期都要被管理”。
Cordis 提供了一个答案。它用五个核心机制——Fiber、Context、inject、provide、effect——构建了一个让插件可组合、可替换、可撤销的运行时环境。
各节之间的递进关系
整个系列从“工具到底依赖谁”这个具体问题出发,逐层深入,最终回到 Agent 系统的全景。每一节都建立在前一节的基础上:
| 节 | 标题 | 核心问题 | 与前文的关联 |
|---|---|---|---|
| 1 | 一句话模型 | Cordis 的插件模型在根本上是什么? | 全文的总纲,提出“能力单元”和“归属权”两个核心概念 |
| 2 | 工具到底依赖谁? | 插件如何声明自己需要什么,而不绑定具体实现? | 用 greet 工具这个最小例子,引出 inject 和 service seam |
| 3 | Context:带作用域的服务容器 | 服务怎么被提供出来?ctx.tools 是怎么来的? |
回答第 2 节留下的问题:提供者通过 provide() 注册,框架负责存、绑、发通知、管撤销 |
| 4 | Fiber:一台小型状态机 | 插件在依赖缺失或变化时如何保持正确状态? | 第 3 节的“发通知”具体怎么落地:状态机管理插件的 PENDING → ACTIVE → UNLOADING 转换 |
| 5 | Effect:可逆副作用 | 插件卸载时,谁负责清理它注册的东西? | 第 4 节的“UNLOADING 状态”具体执行什么:Fiber 遍历 effect 列表,逐个撤销 |
| 6 | Event:不共享实现也能协作 | 如果插件之间不直接调用,如何协作? | 第 5 节后,读者已有“副作用可撤销”的认知,自然延伸到“事件监听器也是副作用,也可撤销”;同时五种分发模式填补了“服务注入”覆盖不到的协作场景 |
| 7 | 投射回通用 Agent Runtime | 这些机制在真实的 Agent 系统里到底对应什么? | 用一张全景图把 2-6 节的抽象机制映射到 DSH 的具体组件上,完成从“框架原理”到“业务场景”的收口 |
这个系列适合谁?
适合正在构建或评估 Agent 框架的开发者,尤其是对以下问题有切身感受的人:
- “换一个模型实现,为什么要改 10 个文件?”
- “热更新后,旧的事件监听为什么还在执行?”
- “插件加载顺序调不对,只能靠试?”
如果你遇到过这些问题,这个系列会帮你看到它们的共同根源:依赖了具体实现,而不是依赖了抽象边界。
Cordis 提供的不是“更快的插件加载”,而是一套让这些工程问题在架构层面被系统化解决的方案。
1. 一句话模型:插件不是代码块,而是受生命周期管理的能力单元
先给出本篇最重要的一句话:
在 Cordis 中,插件不是一个代码块,而是一个受生命周期管理的能力单元。它启动后得到一个带作用域的 Context,通过它来声明需要什么、提供什么、注册什么;所有注册行为都归属于当前插件的生命周期,插件卸载时,这些注册会被自动撤销,不需要开发者手动清理。
把这句话展开,就是下面五个概念:
注意图中最关键的箭头:副作用不属于“全局应用”,而属于 Fiber。这个归属关系让热更新、替换 provider、关闭应用都不必靠开发者逐个猜测该清理什么。
这五个概念分别是什么?
- Plugin(插件):一个功能单元。在 DSH 里,模型适配器、文件系统、Agent 循环都是插件。
- Fiber(生命周期句柄):插件的“时间线”。从启动到卸载,框架通过它追踪这个插件的整个生命周期。
- Context(作用域):插件启动后拿到的“工作台”。每个插件在自己的 Context 里操作,不会污染全局。
- inject 与 provide(依赖与服务):插件通过
inject声明自己需要什么,通过provide声明自己提供什么。依赖关系由框架解析,不靠硬编码。 - on / effect(可逆副作用):插件注册事件监听、工具、定时器等操作时,这些“副作用”会被记录在当前 Fiber 名下。当插件卸载时,框架自动撤销所有副作用,不需要开发者手动清理。
为什么这个设计重要?
最核心的设计点是:副作用的归属权属于 Fiber,而不是全局应用。
这意味着:
- 插件 A 卸载时,只有它注册的副作用会被撤销,不影响插件 B
- 热更新时,旧版本的所有副作用会被完整清理,新版本在干净状态下启动
- 开发者不需要去“猜”有哪些遗留监听器、残留定时器
作用域边界 + 可逆副作用,共同构成了 Cordis 支持热更新、安全替换 Provider 的基础。
补充说明
- 文中提到的设计概念,分别对应 Cordis 源码中的
fiber.ts(生命周期管理)、service.ts(依赖注入)、events.ts(事件系统)和reflect.ts(元编程支持)。 - 想深入了解的话,可以阅读
docs/cordis-primer.zh.md。
2. 从一个真实矛盾进入:工具到底依赖谁?
这一节用一个具体的例子,来理解 DSH 中“可替换能力”是如何设计的。
场景:给 Agent 加一个 greet 工具
假设我们要给 Agent 加一个最简单的工具——打个招呼。
很多人的第一反应是写一个函数,然后把它注册到工具列表里:
import { tools } from './tools-implementation' // 从某个具体文件里导入工具列表
tools.register(/* 注册 greet 工具 */) // 直接往那个列表里塞
这段代码能工作,但它有三个隐患:
| 隐患 | 它到底什么问题? |
|---|---|
| 1. 绑死了具体实现 | 你 import 的是本地硬盘上的 ./tools-implementation。如果将来想换成“远程工具中心”或“云端工具库”,你必须改这行代码。 |
| 2. 热替换时旧引用失效 | 如果运行时工具注册表被替换了(比如热更新),你之前拿到的那个 tools 对象已经过期了,但你的代码还在往旧的上面注册。 |
| 3. 执行时机靠运气 | tools 这个服务准备好了吗?如果你的 greet 工具先启动,tools 还没准备好,注册就会失败。你只能靠配置顺序去“碰运气”。 |
这些问题的根源都是同一个:你的代码依赖了一个“具体的东西”(具体文件、具体对象),而不是依赖了一个“抽象的东西”(服务名、接口)。
DSH 的做法:不 import,靠注入
DSH 教程给出的写法是这样的:
export const inject = ['tools'] // ① 声明:我需要一个叫 'tools' 的服务
export function apply(ctx: Context) { // ② 框架启动时,把 ctx 传进来
ctx.tools.register(/* 注册 greet 工具 */) // ③ 通过 ctx 拿到 tools,注册工具
}
这段代码里藏着两个关键设计:
① inject 是“硬依赖声明”
它明确说:“我这个插件需要 tools 这个服务。如果当前环境里没有,就别启动我。”
这解决了上面第 3 个隐患——执行时机由框架保证,不靠配置顺序。
② 用的是 ctx.tools,而不是直接 import
它不关心 tools 具体来自哪个文件、哪个 npm 包。它只要求“当前作用域里有一个叫 tools 的服务,让我能调用它的 register 方法”。
这两个设计透露了两个边界
| 边界 | 是什么意思? |
|---|---|
工具插件只要求“当前作用域存在名为 tools 的服务” |
它不关心这个 tools 是谁提供的、从哪来的、是本地的还是远程的。它只关心“有没有”。 |
inject 是硬依赖,不满足时不启动 |
如果 tools 没准备好,你的插件压根不会启动。不会出现“启动了一半发现缺东西”的尴尬状态。 |
“可替换能力”的最小形态
capability seam(能力接缝)。它由三个角色组成:
| 角色 | 它是什么? | 在这个例子里对应什么? |
|---|---|---|
| Service Definition(服务定义) | 能力叫什么、长什么样 | ctx.tools 这个稳定的名字,以及它上面的 register 方法 |
| Provider(提供者) | 谁来实现这个能力 | 某个具体的 tools 服务(本地的、远程的、都行) |
| Consumer(消费者) | 谁在使用这个能力 | 你的 greet 工具插件、Agent 循环、其他需要注册工具的插件 |
“接缝” 就是这三者之间的那条边界——Provider 和 Consumer 在接缝的两侧,互不直接接触。Consumer 只知道 Service Definition 的存在,不知道 Provider 是谁。只有把这三者同时摆出来,才能说一项能力真的“可替换”。
为什么这个设计对 Agent Runtime 尤其重要?
Agent 的场景里,模型、工具、沙箱、会话这些能力,在不同产品入口(Web / CLI / ACP)下可能需要完全不同的实现。如果每换一种入口就要改一遍代码,维护成本会爆炸。
把每一项能力都定义成一个“可替换的服务”,让 Consumer 依赖服务名而不是具体实现——这样换入口只需要换 Provider 的配置,所有 Consumer 的代码保持不动。
这正是 DSH 用 Cordis 作为底层框架的核心原因。
DOC:docs/cordis-tutorial/07-into-the-harness.zh.md 的 greet-tool.ts 使用 inject: ['tools'] 和 ctx.tools.register()。
INFERENCE:这比“插件之间直接 import”更适合 Agent Runtime,因为模型、工具、沙箱和会话都需要被不同产品入口重新组装。
3. Context:不是全局单例,而是带作用域的服务容器
上一节我们看到了 greet 工具通过 ctx.tools.register() 来注册自己。这一节要回答一个更底层的问题:ctx.tools 这个东西到底是怎么来的?它凭什么能出现在 ctx 上?
先给个最直观的结论:
ctx不是一个大号全局对象,而是一个“带作用域的服务窗口”。每个插件看到的ctx都是它自己的视角,不是所有人的公共视角。
3.0 最可能的疑问
“我写代码时,ctx.tools 这个 tools 是哪来的?谁把它挂到 ctx 上的?”
答案分两步:
- 有人先定义了一个叫
tools的服务——通常是一个 Provider 插件在启动时调用super(ctx, 'tools'),告诉框架“我要提供tools这个服务”。 - 框架把
tools挂到ctx上——之后任何插件通过ctx.tools就能访问到这个服务。
关键在于:这个行为不是“把东西塞进一个全局大字典”,而是“在当前的 Context 视图里注册一个名字”。
3.1 为什么说 Context 不是全局单例?
全局单例的意思是:所有人都访问同一个对象,你改我也改,全世界共享。
Cordis 的 Context 不是这样的。每个插件启动时,框架会给它创建一个从父 Context 扩展出来的子 Context:
this.ctx = this.context = parent.extend({ fiber: this })
翻译成人话:
每个插件拿到的
ctx,是它“自己所在位置”的一个视图。它能看到父级可见的服务,也可以在自己的作用域里提供或覆盖服务,但这不影响父级,也不影响兄弟插件。
类比:你在公司里有一个“工作台”(Context)。你可以看到公司公共资源(父作用域的服务),也可以在自己的工作台上放自己的工具(注册本作用域的服务)。你放了什么,隔壁工位的同事看不到;你把工具换了,也不影响其他人的工作台。 这就是“带作用域”的含义——不是全公司共享一个大仓库,而是每个人有自己的工作台视图。
3.2 Service 是如何进入 ctx.xxx 的?
这是最核心的机制。首先我们划清 三个角色的职责边界:
- 提供者(Provider):提供服务的人
- 消费者(Consumer):使用服务的人
- 框架(Framework / Cordis):在中间做协调和管理的系统
第一步:提供者(Provider)做了什么?
提供者的全部工作,就是下面这段代码:
// 这是【提供者】写的代码
import { Service, Context } from 'cordis'
export class ToolsService extends Service { // ① 继承框架给的 Service 基类
constructor(ctx: Context) {
super(ctx, 'tools') // ② 调用父类,传一个名字
}
register(tool: any) {
// 业务逻辑
}
}
提供者的职责到此为止,就两件事:
| 动作 | 人话解释 |
|---|---|
① extends Service |
“我这个类要接入框架的管理体系” |
② super(ctx, 'tools') |
“我向框架报告:我要提供一项服务,名字叫 tools” |
提供者只负责“举手报告”,不负责“注册流程”。它甚至不需要知道 provide() 这个函数的存在——因为 super() 已经把控制权交给框架了。
第二步:框架(Framework)做了什么?
当提供者执行 super(ctx, 'tools') 时,控制权彻底交给了框架(Cordis 核心库)。框架在 Service 的构造函数里执行了真正的注册逻辑:
// 这是【框架】的代码(在 cordis/src/service.ts 里)
class Service {
constructor(ctx: Context, name: string) {
// 框架在这里做真正的注册工作
ctx.reflect.provide(name, this)
}
}
provide() 执行了完整的四步注册流程:
| 步骤 | 框架具体做了什么? | 用“入职”来比喻 |
|---|---|---|
| 1. 记录服务 | 把服务实例存到 Context 内部的“服务仓库”里,标记为 tools |
HR 把员工档案录入系统,标记为“技术部” |
| 2. 绑定归属 | 记录“当前这个 Fiber(插件生命周期)”是这个服务的拥有者 | HR 记录“这个员工属于张三经理(Fiber)的团队” |
| 3. 通知消费者 | 扫描所有声明了 inject: ['tools'] 的插件,告诉它们“你要的服务现在有了” |
HR 发邮件通知各部门:“技术部员工已到岗” |
| 4. 注册撤销逻辑 | 在当前 Fiber 上挂一个“清理函数”,未来卸载时自动执行 | HR 预设“离职流程”:一旦离职,自动回收工位、权限、门禁卡 |
框架的职责就是这四件事:存档案、绑归属、发通知、管撤销。 提供者不参与其中任何一个环节,消费者也不参与。
第三步:消费者(Consumer)做了什么?
消费者的全部工作,就是一句话:
// 这是【消费者】写的代码
export const inject = ['tools'] // ① 声明依赖
就这一行。它不做任何主动注册,也不调用任何框架 API。它只是告诉框架:
“我需要
tools这个服务。等服务来了,框架会通知我启动。”
消费者的职责就是“等着”。 框架会在 tools 可用时,自动去启动这个消费者插件,并把 ctx.tools 注入给它。
这个 provide() 干了四件事,不只是一句简单的 ctx.tools = xxx:
| 步骤 | 它在做什么? |
|---|---|
| 1. 记录服务实现 | 把服务实例存到当前 Context 的服务存储中,这样后续通过 ctx.tools 才能取到它 |
| 2. 建立归属关系 | 让当前 Fiber 成为这个服务的 owner——谁提供的,记在谁名下 |
| 3. 通知依赖方 | 遍历所有声明了 inject: ['tools'] 的插件,告诉它们“你要的服务现在有了” |
| 4. 注册撤销逻辑 | 在 Fiber 上注册一个 disposer,当 provider 卸载时,删除服务并再次通知依赖方 |
第四步非常关键:provide() 自身也是一个“可逆副作用”——它被 Fiber 追踪,卸载时会自动撤销,并通知所有消费者“这个服务没了”。
“服务注册属于 effect” 这句话的技术含义就是:
provide()不是永久性的全局赋值,而是 Fiber 生命周期内的一次可逆操作。
总结三个角色
| 角色 | 代码中做了什么? | 调用了什么? | 依赖谁? |
|---|---|---|---|
| 提供者 | extends Service + super(ctx, 'tools') |
调用了框架的 Service 构造函数 |
依赖框架接收它的注册请求 |
| 框架 | 执行 provide()(存、绑、通知、设撤销) |
主动完成所有注册和通知逻辑 | 不依赖任何人,自己完成一切 |
| 消费者 | export const inject = ['tools'] |
没有调用任何函数,只是声明一个数组 | 完全依赖框架把 tools 送上门来 |
所以“框架是谁”?
框架就是 Cordis 的核心库,具体指:
Service基类(你继承的那个)Context和Reflect(用来存储和通知服务)Fiber(用来管理生命周期和撤销)
你写 super(ctx, 'tools') 时,框架替你完成了剩下的一整套流程。你不是在“调用一个注册函数”,而是在“触发框架的自动处理机制”。
3.3 inject 不是“只检查一次”,而是“持续盯住”
上一节的 inject: ['tools'],很多人会误以为是:
“启动前检查一下
tools存不存在,有就继续,没有就报错。”
实际上它做的是:
“把这个依赖关系记录下来,然后持续监听。
tools出现时,通知我;tools消失时,通知我;tools被替换时,也通知我。我会根据通知重新评估自己该不该启动。”
用流程图看这个过程:
这就是教程里提到的场景:当你把 provider 从配置中删掉时,依赖它的 consumer 会保持 PENDING 状态,而不是带着一个失效的引用继续运行。
3.4 回到上一节的例子:greet 工具到底依赖了谁?
现在我们可以准确地回答上一节的问题了:
| 你之前可能以为是 | 实际上它依赖的是 |
|---|---|
greet 工具依赖了 ./tools-implementation 这个文件 |
greet 工具依赖的是 ctx.tools 这个服务名 |
| 依赖关系是“import 那一刻”确定的 | 依赖关系是 从启动到卸载持续追踪的 |
| 换掉 tools 实现需要改代码 | 换掉 tools 实现只需要改配置,greet 会自动重载 |
inject: ['tools'] 确保了 greet 工具只依赖“tools 这个服务名”,而不依赖“谁实现了 tools”。而 ctx.tools.register() 确保了 greet 工具的注册行为是可撤销的副作用,会在 tools 服务消失时自动清理。
服务提供、注册、发现、调用收尾
inject: ['tools']不是在说“我启动前检查一下”,而是在说:“我要盯住tools这个服务。它来了,我就启动;它走了,我就把自己清理干净,等它再来。”
源文件对照:vendor/cordis/src/service.ts 中 Service 构造函数调用 ctx.reflect.provide();vendor/cordis/src/reflect.ts 的 provide() 用 ctx.fiber.effect() 包裹服务登记与撤销。docs/cordis-tutorial/03-services.zh.md 说明服务消失时依赖插件会卸载,并在服务恢复后重新加载。
4. Fiber:插件不是函数调用,而是一台状态机
前两节我们回答了:
- 第 2 节:插件怎么表达“我需要什么”(
inject) - 第 3 节:服务怎么被“提供出来”(
provide)
这一节要回答一个更深入的问题:在依赖随时可能变化、插件可能被热替换的环境下,如何保证每个插件始终处于正确的状态?
答案就是:Fiber 不是简单的函数调用,而是一台状态机。
4.0 可能已有的疑问
看到“状态机”这个词,你可能第一反应是:“这不是杀鸡用牛刀吗?插件不就是加载代码、执行、结束吗?”
对于普通的脚本程序,确实如此。但在 DSH 这种长运行、热更新的 Agent Runtime 里,情况完全不同:
- 插件 A 启动时,依赖的服务
tools可能还没加载 - 插件 B 在运行时,它依赖的
llm服务可能被热替换了 - 用户修改了配置文件,某些插件需要重新加载,但其他插件要保持运行
在这些场景下,插件不是一个“执行完就结束的函数”,而是一个 “从启动到卸载,状态不断变化的长生命周期单元”。状态机就是用来管理这种变化的标准模型。
你甚至不需要知道状态机在计算机科学里的严格定义。你只需要知道三件事:
- 插件会经历几个固定的阶段(状态)
- 插件会根据条件(如依赖是否满足)自动从一个阶段进入另一个阶段(状态转移)
- 插件的当前状态决定了它能做什么、不能做什么
4.1 状态机长什么样?
看着复杂,实际上插件的一生只有五个核心状态:
| 状态 | 人话解释 | 比喻(员工的工作状态) |
|---|---|---|
| PENDING | 已注册,但条件不满足,暂停等待 | 员工已入职,但还没配电脑和工位,暂时无法干活 |
| LOADING | 条件满足了,正在执行 apply() 初始化 |
员工正在领电脑、开通权限 |
| ACTIVE | 初始化成功,正在工作中 | 员工正常办公,能干活了 |
| UNLOADING | 正在卸载(条件不满足了,或被要求重启) | 员工正在办离职交接,交还电脑和工位 |
| DISPOSED | 已彻底销毁 | 员工已离职,档案已封存 |
关键规则:
ACTIVE的插件一定满足它的所有inject依赖PENDING的插件至少有一个inject依赖尚未满足- 插件只能通过状态机定义的路径转移,不能“跳转”
- 状态转移由框架自动触发,插件自身不控制
4.2 PENDING 是一种正常状态,不是错误
这是 Fiber 设计里最不直觉但最关键的一点。
大多数框架的做法:依赖缺失 → 抛异常 → 启动失败 → 人工排查 → 调整配置顺序 → 重启。
Cordis 的做法:依赖缺失 → 停在 PENDING → 等依赖出现 → 自动转入 LOADING。
这意味着:
- 你的
greet工具(消费者)可以写在配置文件的任何位置,不需要确保它排在tools(提供者)后面。 - 即使配置顺序是消费者先于提供者,系统也能正常工作。
- 真正决定插件启动顺序的是依赖图,而不是配置文件的书写顺序。
在 DSH 中,Profile、Bundle 和 Patch 的组合方式千变万化,如果要求运维人员手工维护一个永远正确的启动顺序,既不现实也不经济。让框架通过依赖图自动编排,是唯一可行的工程方案。
4.3 provider 替换是“卸载再激活”,不是“修改内部字段”
这是另一个关键设计。
当 tools 的提供者从 A 换成 B 时(比如本地实现换成远程实现),Fiber 不会简单地“把 ctx.tools 的指针从 A 改成 B”。它是这么做的:
- 旧的消费者插件完整卸载(
ACTIVE→UNLOADING),清理掉它注册的所有副作用 - 在新条件下重新加载(
PENDING→LOADING→ACTIVE)
这样做看起来比“只改一个指针”更重,但好处巨大:
| 做法 | 结果 |
|---|---|
| 改指针 | 消费者内部可能还缓存了旧对象的引用;旧对象注册的事件监听还在;旧定时器还在跑 → 内存泄漏 + 行为异常 |
| 卸载再激活 | 旧的全部清理干净,新的在干净状态下启动 → 状态边界清晰,无陈旧引用 |
Fiber 用“服务实现 Fiber 的 uid”组成 activation epoch。 当注入的 provider 变了,即便服务名仍然叫 tools,epoch 也会变化。旧插件先清理自己登记的副作用,再在新的 Context 条件下加载。
简单说:换人,就重新走一遍完整的入职流程,而不是只在工牌上改个名字。
5. Effect:解决“注册了,谁负责撤销”的根问题
5.0 这一节回答什么问题?
前几节我们解决了:
- 插件怎么表达依赖(
inject) - 服务怎么被提供出来(
provide) - 插件在不同条件下如何保持正确状态(状态机)
这一节回答:当插件被卸载时,它注册的那些东西——事件监听、定时器、子进程、工具注册——到底谁来清理?
5.1 没有 Effect 会怎样?
假设你要写一个带定时器的插件:
const timer = setInterval(() => console.log('tick'), 1000)
这行代码的问题很明显:谁负责 clearInterval?没人知道。
Agent Runtime 里比这复杂的资源多得多:文件 watcher、WebSocket 连接、子进程句柄、事件监听器、工具 schema……如果每个插件都手工维护一张清理清单,热更新一次就足以制造:
- 重复监听(旧的没移除,新的又加了一个)
- 幽灵工具(工具列表里还挂着已卸载插件的工具)
- 残留进程(子进程在后台继续跑)
这就是“副作用”问题——插件做了某件事、改变了系统状态,但插件消失时,这个改变没有跟着消失。
5.2 Effect 如何解决?
Cordis 的约束非常直接:副作用必须挂在 Fiber 名下。
ctx.effect(() => {
const timer = setInterval(tick, 1000)
return () => clearInterval(timer)
})
effect() 做了三件事:
| 步骤 | 发生了什么 |
|---|---|
| 1. 立即执行主体 | 创建定时器(或注册监听器、启动子进程等) |
| 2. 收集 disposer | 主体返回的清理函数被框架记录下来 |
| 3. 归属 Fiber | 这个 disposer 挂在当前插件的 Fiber 上 |
当 Fiber 进入 UNLOADING 状态时,框架遍历该 Fiber 名下所有 disposer,逐个调用。撤销不依赖开发者的记忆,而是框架自动完成。
如果手工提前调用 disposer 也可以,但重复调用会成为 no-op(安全)。
5.3 很多操作已经是自动的 Effect
你不必手写 ctx.effect() 包裹每一个注册操作。Cordis 的框架 API 已经内置了 Effect:
| 操作 | 何时撤销 |
|---|---|
ctx.plugin(child) |
父 Fiber 卸载时,子 Fiber 一起卸载 |
ctx.on(event, listener) |
Fiber 卸载时,监听器自动 off |
new Service(ctx, name) |
Fiber 卸载时,服务自动从 Context 中注销 |
ctx.tools.register(...) |
Fiber 卸载时,工具自动从注册表中移除 |
这就是第 2 节 greet 工具不需要手写“卸载时删除工具”的原因——ctx.tools.register() 已经通过 Effect 机制把注册行为绑定到当前 Fiber。插件卸载时,框架自动撤销注册,开发者不需要额外操心。
5.4 清理顺序的一个细节
Fiber.effect() 对同一个 effect 收集的多个 disposer,按逆注册顺序执行(后注册的先清理)。
但如果一个 Fiber 下有多个独立的 effect,它们的 disposer 是并发清理的。因此,如果“先停生产者,再关连接”有严格的顺序要求,不能依赖多个独立 effect 的偶然完成顺序,而应把这几个步骤放进同一个 disposer 内部顺序等待。
5.5 核心结论
Effect 是一个让“创建”和“撤销”保持对称的机制。插件在 Fiber 名下注册的一切,都会被同一 Fiber 追踪;卸载时,框架自动调用 disposer,撤销所有副作用。开发者不需要手写清理清单,也不需要担心残留。
这也意味着:Cordis 里的热更新不是“把旧对象替换成新对象”,而是“旧 Fiber 完整卸载 → 新 Fiber 干净加载”。 Effect 机制保证了卸载一定是完整的,加载一定是在干净状态上进行的。
SOURCE:vendor/cordis/src/fiber.ts 的 effect() 记录 disposer;_unload() 使用 Promise.all() 清理 Fiber 下的 effects。
DOC:docs/cordis-tutorial/02-lifecycle-and-effects.zh.md 明确区分“同一 effect 内逆序”和“多个异步 disposer 并发”。
6. Event:不共享实现,也能协作
6.0 为什么要研究事件机制?
前几节我们花了大量篇幅讨论“服务”(Service)——通过 inject 声明依赖、通过 provide 提供服务,框架负责把能力注入到需要它的地方。
但“服务”解决的是垂直的能力供给:A 提供能力,B 消费能力,一对一,强依赖。这是一种紧密耦合的协作方式,适用于“B 明确需要 A 才能工作”的场景。
而 Agent Runtime 里存在大量无法用“服务”解决的协作场景:
场景一:触发者不需要知道响应者
工具执行完成时,系统需要记录日志、更新指标、触发审计。如果让工具插件去直接调用日志插件、指标插件、审计插件,工具就和它们牢牢绑定——每加一个新观察者,就要改工具代码。
场景二:核心逻辑需要被包裹
Agent 发起请求前,可能需要经过鉴权、限流、日志、审计等步骤。如果让 Agent 核心直接调用这些策略,策略的增删改都要动核心代码,不同环境的策略组合也无法灵活切换。
场景三:一对多通知,不关心结果
当会话接近上下文窗口上限时,系统需要通知压缩器、告警器、监控面板等多个组件。如果会话管理器直接调用它们,会话管理器必须知道所有可能相关的组件,还要处理“某个调用失败但其他仍需执行”的复杂逻辑。
这些场景的共同特征是:触发者不关心谁在响应,也不关心响应结果。 在这种场景下,“直接调用”是不恰当的——它把“知不知道”和“调不调用”强行绑定,让系统丧失弹性。
事件机制的价值在于:它是“水平的能力扩展”,而不是“垂直的能力供给”。
对于 DSH 这类长运行、可扩展的 Agent 系统,事件机制是“解耦的最后一公里”。 没有它,所有扩展都必须被核心引擎“知晓”并“硬编码调用”,系统就谈不上可插拔。
这也是为什么 Cordis 的事件系统不只是简单的“触发-监听”,而是提供了五种分发模式(emit、parallel、serial、bail、waterfall)——不同的协作场景需要不同的响应语义,而事件机制的设计决定了系统能支持多复杂的协作模式。
6.1 服务和事件分别解决什么问题?
| 协作方式 | 适用场景 | 例子 |
|---|---|---|
| 服务(Service) | “我需要直接调用某项能力” | ctx.tools.register():明确要注册工具 |
| 事件(Event) | “我发生了一件事,不关心谁要处理” | 工具执行完成 → 触发 tools/result,谁想记录日志谁就监听 |
6.2 那么,事件怎么落地?
讲完了“为什么要用事件”,接下来自然的问题是:在代码里,事件到底怎么用?
在 Cordis 中,事件机制由两个核心动作组成:
| 动作 | 谁做的 | 代码长什么样 |
|---|---|---|
| 触发事件 | 事件的发起者 | ctx.emit('tools/result', data) |
| 监听事件 | 事件的响应者 | ctx.on('tools/result', (data) => { /* 处理 */ }) |
看起来很简单。但这里有一个容易被忽略的问题:监听器注册了,谁来清理?
如果你在第 5 节理解了 Effect 机制,应该马上意识到:ctx.on() 注册一个监听器,本质上也是一种“副作用”——它在事件总线上留下了一个回调函数。如果插件卸载时没有移除这个监听器,就会出现幽灵监听器:插件已经没了,但它的回调还留在事件总线上,每次事件触发都会执行一次。
这就是 Cordis 为什么要把 ctx.on() 和 Effect 机制打通的原因。
6.3 ctx.on() 为什么能自动撤销?
技术上,ctx.on() 的底层实现被 ctx.fiber.effect() 包裹了。
插件加载 → ctx.on('tools/result', listener)
→ 监听器被挂在当前 Fiber 的 effect 列表中
插件卸载 → Fiber 进入 UNLOADING
→ 遍历 effect 列表,调用 disposer
→ 监听器从事件总线移除
用人话说:监听器不会成为永久全局回调,而是与当前插件共同生灭。
这对 Agent 系统来说是一条重要的安全线:替换一个日志、遥测或策略插件时,旧版本的监听器不会继续留在总线上执行,避免出现“重复日志”或“幽灵回调”的诡异问题。你不需要手动 off(),框架替你做了。
6.4 五种分发方式解决五类协作
事件机制不只是“触发-监听”这么简单。不同的协作场景需要不同的响应方式,Cordis 提供了五种分发模式:
| 模式 | 做了什么? | 什么时候用? |
|---|---|---|
emit |
触发事件,不等待监听器返回 | “通知一下,不需要反馈”(如日志记录) |
parallel |
所有监听器并发执行,等待全部完成 | “多方都要完成”(如多个插件同时处理同一份数据) |
serial |
监听器依次执行,任意一个失败就停止 | “按顺序尝试,谁行谁上”(如多个认证策略,一个通过就行) |
bail |
监听器依次执行,任意一个返回非空就停止并返回 | “按顺序尝试,谁先给结果就用谁的”(如多个推荐算法,先算出结果就采用) |
waterfall |
监听器依次执行,每个接收上一个的返回值,可改造或短路 | “层层包裹,逐级传递”(如请求拦截、策略包装) |
前四种比较容易理解,waterfall 最值得展开说明。
6.5 重点:waterfall 的危险之处
waterfall 的监听器会接收一个 next() 函数:
ctx.on('agent/request', (payload, next) => {
// 做点自己的事...
const result = await next() // ← 调用 next,继续往下传
// 拿到下游返回的结果,可以做点修改...
return result
})
关键规则:如果不调用 next(),下游所有监听器都不会执行,连内置默认逻辑也被跳过了。
用更直观的方式理解:waterfall 里,每个监听器都像一个“中间件”:
- 你调用
next(),相当于把请求传给下一个处理者 - 你不调用
next(),相当于直接拦截请求,自己做了最终决定
这也是为什么DOC文档特别强调:“这不是编码细节,而是 Runtime 的权限语义。” 忘记调用 next() 不是代码错误,而是你的插件主动声明了“我来终结这件事”。
SOURCE:vendor/cordis/src/events.ts 中 waterfall() 按注册顺序将监听器包裹在最内层 next 外;监听器不调用 next() 时,后续监听器和内置逻辑都不会执行。
DOC:docs/cordis-tutorial/04-events.zh.md 用可运行示例说明“观察型 waterfall 监听器必须委托”。
7. 把它投射回通用 Agent Runtime
7.0 这一节在做什么?
如果把前面 2-6 节的内容比作“Cordis 框架的使用说明书”,那第 7 节就是“看完说明书后,把工具放回真实工作台”。它回答了终极问题:我们花了这么多篇幅讨论 inject、Fiber、Effect、Event,在 DSH 这个 Agent 系统里,它们到底对应什么?
7.1 图里画的是什么?
这张图把 Cordis 的机制映射到了具体的 Agent 组件上:
| 图中的元素 | 对应什么 | 由什么机制支撑 |
|---|---|---|
| LLM Provider、Session Provider、Tools Provider、Prompt Provider | Agent 的四个核心能力——谁提供模型、谁管会话、谁提供工具、谁管提示词 | 第 3 节:provide() 注册服务 |
工具插件 inject tools |
一个具体工具(比如 greet)声明“我需要 tools 服务” |
第 2 节:inject 声明依赖 |
策略插件 waterfall |
鉴权、限流等策略通过事件包裹 Agent 请求 | 第 6 节:waterfall 事件拦截 |
| 持久化插件监听 Session Event | 会话变化时自动保存,触发者不关心谁在存 | 第 6 节:emit / on 观察者模式 |
| 虚线箭头 | 插件卸载时,Fiber 撤销它们注册的所有东西 | 第 4、5 节:状态机 + Effect |
图中的核心信息:Cordis 没有替代 Agent Loop,也没有替代 Session Event Log。它只做一件事——让这些子系统可以被组合、替换和撤销。### 7.0 这一节在回答什么问题?
前面 2-6 节我们花了大量篇幅讨论 Cordis 的机制:inject、provide、状态机、Effect、Event。
这些机制本身是抽象的——它们描述的是“框架怎么管理插件”,而不是“Agent 怎么工作”。
第 7 节要做的事:把这些抽象机制,放回 DSH 这个真实的 Agent 系统里,看它们到底对应什么。
7.1 一张图看懂:Cordis 机制在 Agent 中的映射
这张图的核心信息是:Cordis 没有替代 Agent 的任何核心能力。它只做一件事——让这些能力可以被组合、替换和撤销。
图中的几个关键映射:
| 图中的元素 | 对应前面哪节的机制 | 在 Agent 里的作用 |
|---|---|---|
| LLM / Session / Tools / Prompt Provider | 第 3 节:provide() 注册服务 |
Agent 的四个核心能力来源 |
工具插件 → inject tools |
第 2 节:inject 声明依赖 |
具体工具连接到工具注册表 |
策略插件 → waterfall |
第 6 节:事件拦截 | 鉴权、限流等策略包裹 Agent 请求 |
| 持久化插件 → 监听 Session Event | 第 6 节:emit/on 观察者 |
会话变化时自动保存 |
| Fiber 虚线箭头 | 第 4、5 节:状态机 + Effect | 插件卸载时自动撤销注册 |
7.2 从这里自然引出一个问题:图中“事件”到底在指什么?
在上面的图里,“事件”出现了两次:
- 策略插件通过
waterfall事件包裹 Agent Loop(实时控制) - 持久化插件通过监听 Session Event 来保存状态(状态记录)
这两者虽然都叫“事件”,但性质完全不同。它们分别对应 Agent 系统中两种完全不同的协作模式。
7.3 两种事件:控制事件 vs 状态事件
第一种:控制事件(Control Event)
用途: 影响“当前这次请求”怎么处理。
特点: 实时决策,用完即弃,不持久化。
典型例子:
- 鉴权插件拦截请求:这个用户有没有权限?
- 限流插件决定:这次请求要不要放行?
- 审计插件记录:这次请求的来源和目标是什么?
这些决策发生在请求处理的过程中,影响的是“现在怎么办”。它们是实时控制流的一部分,一旦请求处理完毕,这些决策就失去了意义。
第二种:状态事件(State Event / Session Event Log)
用途: 记录“已经发生了什么”,用于恢复和回溯。
特点: 必须持久化,重启后需要回放。
典型例子:
- 用户说了一句:“帮我查一下天气”
- Agent 调用了
get_weather工具 - 工具返回了:“今天晴,25°C”
这些是会话历史的一部分。如果 Agent 重启,它需要回放这些事件才能恢复到之前的状态。它们不是“决策”,而是“事实”。
7.4 为什么必须区分?
因为如果把两者混在一起,会出现严重的逻辑问题:
| 场景 | 如果混为一谈会怎样? |
|---|---|
| 鉴权插件拒绝了一个请求 | 这个“拒绝”要不要写进会话历史?如果写了,回放时会再次执行鉴权逻辑,可能因为状态变化导致结果不同 |
| 限流插件决定放行 | 这个决策是“当前状态下的判断”,回放时不应该再重新判断一次 |
| 工具调用结果被记录 | 这是“已经发生的事实”,回放时必须保留,不能重新执行(比如“发送邮件”不能发两遍) |
正确认知:
- 控制事件(
waterfall、bail、serial)是“请求处理流程中的临时决策”,属于实时控制流,走事件机制,不持久化。 - 状态事件(会话历史)是“已经发生的事实”,属于持久状态流,走 Session Event Log,必须持久化。
两者在代码里可能都叫“事件”,但它们的用途、生命周期、存储方式完全不同。
8. 本机实验:依赖安装的真实状态,以及下一步怎么验证
实验记录:安装锁定依赖
前置条件:Windows、Node v24.15.0、pnpm 11.19.0;仓库根声明 pnpm@11.7.0,Node 要求为 ^22.19.0 || >=24.0.0。
命令:pnpm install --frozen-lockfile。
实际结果:锁文件通过供应链策略校验,pnpm 开始处理 923 个包;但从 npm registry 下载多个 tarball 时连续得到 EACCES,在超时窗口内未生成完整 node_modules/.modules.yaml。之后调用 pnpm exec 会再次触发未完成安装。
证明范围:本机环境的版本前置满足;当前网络/安装链路无法完成依赖闭环。它不证明 Cordis 教程失败,也不构成 DSH 运行时缺陷。
依赖恢复后应跑的最小验证
不要一开始就启动真实模型。先在可销毁目录完成三个无密钥实验:
- 按
docs/cordis-tutorial/02-lifecycle-and-effects.zh.md跑 timer 插件,观察Fiber.dispose()是否会先清理子插件 effect; - 按
03-services.zh.md先保留 provider,再移除 provider,观察 consumer 从ACTIVE回到PENDING; - 按
04-events.zh.md跑 waterfall 示例,给第二个 listener 的next()加断点,确认默认逻辑被短路。
在仓库级别,优先运行与结论一一对应的测试,而不是笼统执行全量测试:
pnpm exec vitest run packages/boot/app-boot/tests/app-boot.spec.ts
pnpm exec vitest run packages/boot/app-boot/tests/config-reload.spec.ts
这些测试覆盖了真实 Loader 启动、未解析依赖、激活失败、配置替换和失败回滚等边界。它们能验证 DSH 对 Cordis 生命周期的产品级使用;Cordis 内部 Fiber 的具体状态转移仍应以 vendor/cordis/src/fiber.ts 为源码真源。
9. 本篇四个核心判断
- 插件数量不是可扩展性的证据。 只有 Consumer 依赖稳定 Service、Provider 可以替换、注册会随 owner 撤销,插件化才有意义。
inject是持续依赖图,不是启动顺序提示。 它让 provider 的出现、消失和替换都能驱动 consumer 的状态变化。- Fiber 是 DSH 热更新与替换安全的最小所有权单元。 不理解 Fiber,就无法解释“为什么旧工具、监听器和资源不会残留”。
- waterfall 是决策权,不是普通广播。 调用
next()是委托;不调用是短路。策略插件必须明确自己是否拥有这个权力。
10. 闭卷复述
合上文章后,尝试回答:
ctx.tools的 Definition、Provider、Consumer 在 DSH 中分别是什么?- 为什么
inject: ['tools']比“按 YAML 顺序先启动工具服务”更可靠? - provider 卸载后,consumer 为什么不会继续拿着旧服务引用工作?
ctx.effect()与ctx.on()的关系是什么?多个异步 effect 是否保证按注册顺序依次清理?- waterfall 监听器不调用
next()时,具体截断了什么?
能把这五问讲清楚,再回到 vendor/cordis/src/fiber.ts 追一次 _refresh() → _setEpoch() → _unload()/_reload(),这一篇才算真正内化。
下一篇:一条任务怎样从 followup() 走到 turn/end
Cordis 解释了“能力怎样被组织”。下一篇进入 DSH 的第一个业务核心:当输入来到 Agent 后,谁领取消息、怎样组 prompt、如何请求模型、工具为什么会驱动下一步,以及何时把一次工作真正结束为 turn/end。
届时,Cordis 的 service、effect 和 waterfall 将不再是抽象概念,而会出现在 Agent Loop 的真实调用链里。
更多推荐
所有评论(0)