Lucent's Blog

Lucent's Blog

当时明月在 曾照彩云归


人生不相见,动如参与商。


DeepSeek Harness Cordis:为动态 Agent 而生的组件模型

DeepSeek Harness Cordis:为动态 Agent 而生的组件模型

最近在看 DeepSeek Harness 的时候,我顺着它的底层框架 Cordis 找到了一篇论文:《A Programming Paradigm for Spatiotemporal Composability》。

这个标题第一次看有点劝退。"时空可组合性"听起来很像编程语言理论里的东西,论文也确实花了不少篇幅做形式化定义和演算。

但如果先把这些公式放一边,它研究的问题其实很工程:

一个正在运行的软件系统,怎么安全地安装、卸载和替换组件?

这件事在传统插件系统里已经做了很多年。但到了 Agent 系统,问题突然严重起来。

一个 Agent 运行时里可能同时存在 LLM Provider、Tool、MCP、Memory、Sandbox、RAG、Skills、事件监听器、Prompt 扩展。以后甚至可能出现 Agent 自己安装 Skill、替换工具、调整运行组件的情况。

这时候,"能加载插件"远远不够。

真正麻烦的是:插件能不能卸干净,以及插件之间的依赖发生变化后,系统还能不能保持正确状态。

这正是 Cordis 这篇论文研究的东西。

论文把问题拆成了两个方向:Temporal Composability 和 Spatial Composability,也就是时间可组合性和空间可组合性。作者进一步把传统的 Effect 和 Coeffect 概念变成运行时机制,再用一个统一的 Context 把它们组织起来。

先从最容易踩坑的那个说起。

插件真正难的地方不是加载,而是卸载

假设我们给一个 Agent 动态安装了 PDF 处理插件。

插件启动的时候做了这些事:

PDF Plugin
│
├── 注册 pdf_parse Tool
├── 注册 pdf_summary Tool
├── 监听 message/received
├── 创建临时目录
├── 启动文件 watcher
└── 注册 PDF Service

加载当然没什么难度。

麻烦出现在卸载的时候。

如果只是:

plugins.delete("pdf")

那基本什么都没解决。

之前注册到 Tool Registry 里的工具还在,事件监听器还挂着,watcher 继续工作,临时资源没有释放,其他组件手里甚至可能还拿着旧的 PDF Service 引用。

插件没了,但它留下的痕迹还在。

这类问题在大型系统里非常烦,因为错误通常不会立即出现。可能过几个小时,你才发现某个 listener 被重复注册了三遍;或者 HMR 十几次以后,一个请求会被处理十几次。

Cordis 给这个问题起了一个名字:

Temporal Composability,时间可组合性。

它要求组件被移除以后,它对运行环境造成的副作用能够被完整撤销。论文为此提出了 Revertible Effects,也就是"可逆 Effect"。运行时不只记录一个组件做了什么,同时还跟踪对应的逆操作。

在 Cordis 里,一个典型写法是:

ctx.effect(() => {
  const timer = setInterval(() => {
    // do something
  }, 1000)

  return () => {
    clearInterval(timer)
  }
})

前面的函数建立副作用,返回的 disposer 负责撤销。

插件被卸载时,不需要插件作者重新手动遍历自己注册过的东西,Cordis 会沿着生命周期把这些 effect 清掉。

实际开发时甚至不需要所有东西都手写 ctx.effect()

像:

ctx.on(...)
ctx.plugin(...)
Service 注册
Tool 注册

Cordis 和 DeepSeek Harness 已经把这些操作本身设计成 effect。插件卸载时,listener 会删除,子插件会递归卸载,Service 会解绑,Tool 也会从注册表里消失。对于 Cordis 不认识的 timer、连接、watcher 等资源,开发者才需要自己提供 disposer。

这有点像给插件生命周期加了一层事务。

只是这里的"事务"不是数据库事务,而是运行时资源的事务。

组件:

安装
 ↓
产生 effect A
产生 effect B
产生 effect C

卸载时:

撤销 C
撤销 B
撤销 A
 ↓
回到组件安装前的状态

我觉得这是整篇论文里最容易理解、也最实用的一部分。

只解决卸载还不够

再来看另一个问题。

假设 PDF 插件依赖 OCR:

PDF Plugin
    │
    └── OCR Service

传统的依赖注入系统通常在启动阶段把 OCR 传进去:

const pdf = new PdfPlugin(ocr)

程序正常启动以后,大家基本默认:

ocr 永远在那里

可动态插件系统不能这么假设。

运行过程中,OCR 插件可能被禁用。

也可能:

OCR V1
  ↓
OCR V2

被另一个实现替换。

如果 PDF 插件还握着原来的 OCR V1 对象,就进入了一种很尴尬的状态:

系统认为 OCR V1 已经不存在

但 PDF Plugin 还在调用 OCR V1

这种 bug 很难查。

Cordis 把这一类问题归到 Spatial Composability,也就是空间可组合性。

解决办法来自另一个概念:

Reactive Coeffects。

如果说 Effect 表达的是:

这个组件会对环境产生什么影响

那么 Coeffect 更接近:

这个组件运行需要什么环境

于是 PDF Plugin 不需要自己寻找 OCR,而是声明:

export const inject = ['ocr']

意思很简单:

没有 OCR,我就不应该运行。

Cordis 会让这个插件保持 PENDING,直到 OCR Service 出现。

OCR 不存在

PDF Plugin
    ↓
 PENDING

OCR 加载完成:

OCR Service
    ↓
PDF Plugin
    ↓
 ACTIVE

比较有意思的是,inject 不是启动时检查一次就结束。

如果运行过程中 OCR 消失,依赖 OCR 的插件也会被卸载;OCR 恢复以后,它们再重新启动。DeepSeek Harness 的 Cordis 文档明确说明,服务依赖会在运行过程中持续跟踪,而不是一次性的启动顺序检查。

于是替换实现也变得自然:

            OCR V1
              │
              ▼
          PDF Plugin

卸载 V1:

OCR V1 removed
      ↓
PDF Plugin unload
      ↓
撤销自己的 effects

然后加载 V2:

OCR V2 available
      ↓
PDF Plugin reload
      ↓
重新绑定当前 OCR Service

消费方不需要知道究竟是谁提供 OCR。

这就比我们常见的 DI 多了一层东西:

依赖关系本身也是动态的。

Effect 和 Coeffect 最终都落到了 Context

到这里,论文真正想构建的模型已经比较清楚了。

一个组件身上同时存在两个方向:

                 Component
                    │
          ┌─────────┴─────────┐
          │                   │
       Coeffect             Effect

       我需要什么           我改变什么
          │                   │
          ▼                   ▼
       Context             Context

组件从 Context 获取它需要的能力,也通过 Context 对系统产生影响。

论文做了一步更理论化的处理:把 Effect Context 和 Coeffect Context 统一成一个 Context Type,并在这个基础上定义 Component 和动态组合演算。

这部分是论文真正偏编程语言理论的地方。

作者关心的已经不是:

某一个 plugin 能不能正确 unload

而是:

Plugin A
Plugin B
Plugin C
Plugin D
...

不断交错加载、卸载、替换以后,整个系统是不是仍然保持那些性质。

论文通过动态组合演算和对应的元理论,把单个组件上的时空可组合性推到一组互相交错的组件系统上。

工程开发不一定需要读懂里面每一个证明,但它解释了 Cordis 为什么要把很多看起来"框架帮你做一下就行"的事情设计得这么严格。

因为 HMR、依赖替换、插件树、资源释放,其实都建立在同一套生命周期语义上。

Fiber:Cordis 怎么管理一个活着的插件

到了 Cordis 的工程实现里,每次插件挂载都会对应一个 Fiber。

Fiber 可以理解成:

某个插件实例在运行时的生命周期句柄

它大概会经历:

PENDING
   ↓
LOADING
   ↓
ACTIVE
   ↓
UNLOADING
   ↓
DISPOSED

如果依赖不满足,就留在 PENDING

如果 apply 执行失败,则进入 FAILED

这种状态机有一个很实用的好处:挂载插件和启动插件不再是同一个动作。

例如配置里写:

- name: './consumer.ts'
- name: './ocr.ts'

即便 consumer 写在 OCR 前面也没关系。

Consumer 声明:

export const inject = ['ocr']

它就会等。

什么时候启动不是 YAML 顺序决定,而是依赖关系决定。

这其实解决了插件系统里一个很古老的问题:启动顺序。

很多项目最后都会出现这种东西:

先初始化 database
再初始化 redis
然后 event bus
然后 tool registry
然后 llm
最后 agent

系统稍微复杂一点,就开始出现几十行初始化顺序。

Cordis 换了一种思路:

别告诉 runtime 谁先谁后。

告诉它:
我依赖谁。

剩下的让依赖图决定。

这样一来,HMR 也变得顺理成章

普通 HMR 最麻烦的地方就是旧模块留下的状态。

新代码加载进来了,但:

旧事件还在
旧 timer 还在
旧对象还在

于是 HMR 次数越多,程序状态越奇怪。

Cordis 的处理方式很直接:

发现 hello.ts 修改

        ↓

卸载 Plugin V1

        ↓

回卷 V1 所有 effects

        ↓

加载 Plugin V2

        ↓

重新执行 apply()

DeepSeek Harness 的 Cordis 教程就是这么描述 HMR 的:旧实例先卸载,effect 被回卷,再加载新的插件代码;配置文件变化时,Loader 还可以根据稳定 ID 只重新挂载发生变化的部分。

换句话说,HMR 并不是一套孤立功能。

它只是:

可逆生命周期 + 重新组合

产生的自然结果。

这一点我挺喜欢。

为什么这套东西放到 Agent 上突然变得很合适

如果只是普通 Web 后端,大部分组件其实不需要频繁动态变化。

数据库连接启动以后一般不会突然换实现,Spring Bean 运行到一半也很少自己重新组合。

Agent 不一样。

今天一个完整 Agent Runtime 里已经可能出现:

Agent
│
├── LLM Provider
├── Tool Registry
├── MCP
├── Skills
├── Memory
├── RAG
├── Sandbox
├── System Prompt
├── Session
└── Agent Loop

这些东西中的很多都天然适合动态安装和替换。

用户启用一个 Skill:

增加新的 Tool

连接一个 MCP Server:

增加一批能力

切换 Sandbox:

替换 shell Service

换模型:

替换 LLM Provider

甚至 Agent 自己以后都可能修改自身组件。

这时候 Agent Runtime 已经不像传统应用:

启动
  ↓
运行
  ↓
退出

而更像一个长期存在、不断改变内部结构的系统。

所以我觉得 Cordis 和 Agent 的结合并不是巧合。

DeepSeek Harness 本身就采用了 "everything is a plugin" 的设计。官方 README 直接写明,它由 Cordis 驱动,而 Cordis 的设计正来自这篇时空可组合性的论文。

甚至一个 Tool 也是普通插件。

DeepSeek 官方教程里的例子就是:

export const inject = ['tools']

export function apply(ctx: Context) {
  ctx.tools.register(
    defineTool({
      name: 'greet',
      // ...
    })
  )
}

这个插件依赖 tools Service。

加载以后 Tool 出现在 Agent 的工具注册表里。

插件卸载,Tool 自动注销。

这种模型扩展起来很舒服,因为 Tool、LLM、Shell、Memory 不需要被框架写死成几个特殊模块。

它们都只是 Component。

可逆也不是魔法

这里有个边界需要说清楚。

"Revertible Effect" 很容易让人产生一种错觉:

插件干了什么
框架都能自动 rollback

实际上没这么神。

比如:

发送了一封邮件
执行 DROP TABLE
调用支付接口
向远程服务器写入文件

这些事情发生以后,运行时不可能凭空把现实世界倒放。

Cordis 真正能保证的是它管理范围内的 Context 和生命周期副作用能够撤销。

对于框架不知道的资源,开发者仍然要提供 disposer:

ctx.effect(() => {
  const resource = createResource()

  return async () => {
    await resource.close()
  }
})

Cordis 文档也明确要求,框架没有管理的资源应该包装进 ctx.effect(),并提供释放逻辑。

所以这套范式没有消灭资源管理。

它做的是把:

"记得 destroy"

从一种开发约定,提升成运行时生命周期的一部分。

差别挺大。

我觉得这篇论文真正值得看的地方

读完以后,我对它印象最深的其实不是那些形式化定义。

而是它重新定义了"插件"。

我们以前理解插件,通常是:

一段可以动态加载的代码

Cordis 的 Component 更接近:

一个声明自己需要什么,
同时把自己产生的变化交给 runtime 管理的运行单元。

一旦这么定义,很多过去需要分别解决的问题就串起来了。

插件卸载依靠 Effect 回卷。

依赖管理依靠 Coeffect。

依赖消失会触发生命周期变化。

生命周期可靠,HMR 才能可靠。

HMR 可靠以后,运行时动态重新组合才真正可用。

最后才轮到 Agent 自己动态调整系统组成这种更激进的场景。

所以如果让我用一张图概括 Cordis,我可能会画成这样:

                  Context
                     │
        ┌────────────┴────────────┐
        │                         │
     Components                Services
        │                         │
        │ inject                  │ provide
        └──────────┐     ┌────────┘
                   ▼     ▼
                Dependency
                   Graph
                     │
              Context 发生变化
                     │
                     ▼
              重新计算生命周期
                     │
         ┌───────────┴───────────┐
         ▼                       ▼
      unload                   reload
         │                       │
     revert effects          apply effects

这套设计未必适合所有软件。

如果你的应用启动以后组件基本固定,那传统 DI、模块系统和明确的初始化/销毁流程已经够用了,引入 Cordis 反而会增加理解成本。

但如果系统本身就是动态的,尤其是 Agent Harness、插件平台、IDE、机器人框架这类长期运行又需要不断增删能力的软件,我觉得这篇论文提出的问题很准确。

现在很多 Agent 框架都在讨论怎么让 Agent 获得更多 Tool、更多 Skill、更多 MCP。

Cordis 往前多问了一句:

这些能力加进来以后,将来还能不能干干净净地拿出去?

等 Agent 真开始修改自己的运行时,这个问题恐怕会比"怎么加载一个 Tool"难得多。