Agent Harness 的热插拔难题:DeepSeek 论文揭秘 Cordis 如何管理可逆副作用与动态依赖

读《A Programming Paradigm for Spatiotemporal Composability》

今天的 Agent 已经不只是一个"模型 + Prompt"。一个可长期运行的 Agent harness,通常还要管理工具、记忆、权限、沙箱、会话状态、子 Agent 和任务编排。更进一步,未来的 Agent 可能会在服务请求的同时修改自己的工具链或运行时模块。

这会立刻带来一个问题:

如果组件可以在运行时被加载、卸载、替换和重配置,系统怎样保证状态不泄漏、依赖不失效、正在执行的任务不被无序打断?

论文《A Programming Paradigm for Spatiotemporal Composability》给出的答案,是把动态组合拆成两个维度,并把传统编程语言里的 effect 和 coeffect 提升为运行时机制。

本文不复述论文中的全部证明,而是从工程角度解释它的核心模型、实现方式,以及它对 Agent harness 的启发。

一、动态组合缺的不是加载,而是可控的卸载

传统软件组合大多在编译期完成:函数调用、模块导入、类继承都形成相对固定的结构。插件系统和自演化 Agent 则要求组件在运行时到来和离开。

论文认为,动态组合至少有两个正交维度。

很多系统采用粗粒度的替代方案:出问题就重启进程,服务依赖交给容器编排。这样当然能恢复,但代价是丢失进程内状态、打断进行中的请求,并把本来可以是进程内调用的依赖变成网络调用。

对自演化 Agent 来说,频繁重启尤其昂贵。Agent 可能正处于一个长任务中,拥有尚未持久化的上下文、缓存和连接;如果一次自修改必须让整个 harness 重启,修改本身还可能破坏负责恢复系统的代码。

图 1:动态组合同时关注组件生命周期和组件依赖拓扑。

二、从静态 effect/coeffect 到运行时上下文

经典 effect system 描述"一个计算会对环境做什么",coeffect system 描述"一个计算要求环境提供什么"。论文的关键动作不是再加一层类型注解,而是把上下文本身变成运行时的一等实体。

可以先把上下文 Γ 想成一个共享运行时环境:工具注册表、事件监听器、权限、连接、缓存和组件提供的服务都可以通过它访问。

论文的统一方向是:

  • effect 负责修改上下文,并且必须能撤销;
  • coeffect 负责声明依赖,并且在依赖变化时触发响应;
  • 所有组件和环境的交互都经过同一个 context。

这样,加载和卸载不再是两个容易漂移的生命周期回调,而是同一套上下文变换的正向和逆向。

三、可逆效果:每次修改都带着 undo

3.1 基本模型

普通的环境变换可以写成:

f : Γ -> Γ

论文要求一个 effect 同时返回新上下文和撤销函数:

e : Γ -> Γ × (Γ -> Γ)

也就是:

e(oldContext) = (newContext, undo)

例如,一个组件注册天气工具时,操作不是简单地执行:

set("weather", weatherTool)

而是同时提供:

undo(context) = delete("weather")

组件卸载时,运行时执行当时保存的 undo,而不是依赖开发者另外写一个可能遗漏清理动作的 deactivate

3.2 运行时怎样累计撤销操作

论文用 effect context 保存两部分状态:

∂Γ = Γ × (Γ -> Γ)

第二项是撤销累加器。假设组件依次执行:

A:注册工具 -> undo_A

B:注册事件监听器 -> undo_B

累加器会保存组合后的撤销函数,卸载时按逆序执行:

undo_B

undo_A

这和资源管理中的 LIFO 原则一致:后申请的资源先释放。

如果用伪代码表达,核心大致是:

async function effect(callback) {

const inverses = \[\]

for await (const inverse of callback()) {

inverses.unshift(inverse) // 后发生的效果先撤销

}

return async function dispose() {

for (const inverse of inverses) {

await inverse()

}

}

}

图 2:effect 不只修改上下文,还把对应的撤销操作交给运行时追踪。

论文中的实现 ctx.effect 还支持异步迭代:每完成一个 effect,就把它的 inverse 放入累加器;如果组件在中途被要求停止,只撤销已经完成的部分。

3.3 为什么 inverse 必须在运行时产生

很多操作没有一个对所有状态都适用的固定逆函数。

例如:

  • 分配资源后,逆操作需要知道本次分配得到的句柄;
  • 建立连接后,逆操作需要关闭这条具体连接;
  • 注册组件后,逆操作需要撤退刚刚生成的 fiber;
  • 创建临时文件后,逆操作需要删除这一个文件。

所以论文允许 effect 在实际执行时,根据当前状态生成自己的 inverse。

3.4 可逆不等于可以随便删除

单个组件按自己的逆序撤销通常没有问题。多个组件交错修改同一环境时,就必须讨论 independence,效果独立性

论文要求两个独立 effect 满足更强的条件:

  1. 双方的正向操作和逆操作可以交换;
  2. 一个 effect 不会因为另一个 effect 改变状态,就生成不同的 inverse。

如果两个组件都在修改一个有顺序的中间件链,先插入谁、后插入谁会改变行为,那么它们就不能被视为独立。此时只能依赖明确的顺序,不能任意卸载。

这也是论文一个很实用的分工:

effect 处理可以交换的修改;coeffect 处理必须保留的依赖顺序。

四、反应式共效果:依赖变化会驱动生命周期

4.1 依赖表和依赖规格

论文把 coeffect context 建模成一个带类型的有限表:

Σ = {

key_1 -> value_1,

key_2 -> value_2,

...

}

每个 key 对应自己的值类型。组件则声明一个依赖规格:

d = { memory, search, permission }

组件只有在所有 key 都存在时才满足依赖:

σ ⊨ d <=> d 中的每个 key 都在 σ 中

这比组件先启动、再在运行时调用一个不存在的服务安全得多。

4.2 三类通知

每次上下文发生变化,运行时都会根据组件的依赖规格重新分类:

activating 依赖从不满足变为满足

deactivating 依赖从满足变为不满足

neutral 满足状态没有改变

例如:

A 提供 weather

B 声明依赖 weather

当 A 激活并提供 weather 时,B 进入激活流程;当 A 开始撤销 weather 时,B 被通知并进入停用流程。

组件不会因为依赖暂时缺失而立刻报错,而是保持 inactive,等待依赖重新出现。

4.3 Provider 必须先停止提供,再等待消费者清理

这里有一个容易忽略的顺序问题:B 的 teardown 可能仍然需要访问 A 提供的 weather

因此论文把 provider 的停用拆成两个阶段:

  1. provider 先标记为 UNLOADING,从新的依赖解析中消失;
  2. 所有依赖它的 consumer 开始停用,但仍能读取自己已经提交的依赖视图;
  3. consumer 全部完成 teardown 后,provider 才执行自己的 inverse,真正删除绑定。

Cordis 的实现对应为:

mark provider as UNLOADING

notify dependents

await all(dependents become INACTIVE)

await provider.dispose()

图 3:deactivation 的关键是保留 consumer 已提交的依赖视图,并等待依赖方完成清理。

这比单纯调用一组同步 deactivate() 更强,因为异步清理也被纳入生命周期协议。

五、统一 Context:effect 和 coeffect 放在同一个运行时实体里

论文最终把上下文写成递归类型:

Γ∞ = μΓ. Γ × (Γ -> Γ) × Σ

可以把它理解成一个三元组:

  1. 当前上下文状态;
  2. 能恢复这一层 effect 的累加器;
  3. 携带依赖信息的 coeffect context。

递归结构支持嵌套上下文。一个父组件可以创建子组件,父级累积子级的 effect;卸载父级时,子级会被逐层撤退。

5.1 为什么不是要求物理状态完全相同

论文很诚实地指出,恢复通常不能保证物理表示逐字节相同。

例如:

  • free 释放内存后,堆分配器内部布局不会恢复到完全一样;
  • 删除一个生成的名字后,下次生成可能得到不同名字;
  • 文件描述符可能被操作系统重新分配。

因此论文采用 observational equivalence,观测等价:只要组件通过公开 coeffect 操作观察不到差异,就视为恢复成功。

这比"内存快照恢复"更符合实际系统,也说明了恢复保证的边界:它针对系统可观察行为,而不是所有底层物理细节。

六、从局部机制到全局组件演算

前面的定义只说明单个组件怎样可逆、怎样响应依赖。第 4 节把它们组合成动态组合演算。

6.1 Component、Fiber 和 Registry

一个组件由三部分构成:

Component = (dependencies, provisions, effects)

  • dependencies:组件需要读取什么;
  • provisions:组件可能提供什么;
  • effects:组件激活时执行的可逆效果。

组件的一次实例化叫作 fiber。fiber 除了携带组件本身,还记录:

  • 父 fiber;
  • 当前生命周期状态;
  • 自己的撤销累加器;
  • 激活时实际绑定到哪些 provider;
  • 是否已经被退休。

这里"实际绑定到哪个 provider"很重要。即使两个 provider 提供完全相同的值,只要 provider 身份变了,consumer 也能检测到依赖解析发生了变化。

6.2 生命周期状态

论文从简单的两态模型逐步扩展为:

INACTIVE

-> RELOADING

-> ACTIVE

-> UNLOADING

-> INACTIVE

还要处理:

  • Iteration:一次激活由多个 effect 步骤组成;
  • Asynchrony:某一步已经发出但尚未完成;
  • Failure:某一步失败时,必须撤销已经完成的前置步骤;
  • Withdrawal:provider 等待所有 consumer 清理后才真正撤销服务。

异步场景引入了 inertia:一个 transition 一旦开始,就必须先完成当前在途操作,再处理新的 target 变化。否则会出现一个 effect 按旧依赖启动、却在新依赖环境中落地的状态。

6.3 论文给出的全局性质

在若干条件成立时,论文证明了:

  • Preservation:生命周期转换保持系统结构和不变量;
  • Temporal composability:组件结束后,它的环境贡献被撤销;
  • Spatial composability:依赖总是在 provider 之后激活、在 provider 撤销之前完成停用;
  • Progress:没有违反条件的循环依赖时,系统不会因为撤销 guard 永久死锁;
  • Confluence:只要组件效果满足独立性,最终静止状态与组件经历过的动态加载顺序无关,等价于按最终配置从头组装。

这里必须强调"在若干条件成立时"。论文依赖的关键前提包括:

  • provider-consumer 依赖图无环;
  • 共享 key 的操作满足适当的可交换性;
  • 组件最终确实安装它声明的 provision;
  • 动态注册不会无限制地产生 fiber;
  • 所有需要纳入保证的共享位置都被 reify 成 context/coeffect。

七、Cordis:把理论落成一组 API

论文用 Cordis 实现了这套模型。它不是一个面向 Web、数据库或 UI 的应用框架,而是一个提供动态组合语义的 meta-framework。

核心 API 可以概括为:

ctx.effect(callback) // 运行可逆 effect,返回 dispose

ctx.set(key, value) // 提供一个依赖

ctx.get(key) // 读取依赖

ctx.use(component, config) // 实例化组件

ctx.isolate(key, realm) // 改变依赖解析域

ctx.intercept(key, meta) // 为依赖访问附加策略元数据

实现上,所有 context mutation 都经过 ctx.effect,因此 ctx.set、组件实例化和其他修改都自动进入同一个撤销体系。

7.1 声明式配置和增量 reconciliation

Cordis 还提供组件 loader。编排器维护一棵声明式配置树,每个 entry 描述:

  • 组件模块 URL;
  • 稳定 ID;
  • isolate 和 intercept 配置;
  • 组件 config;
  • 是否 disabled。

配置发生变化时,loader 不会粗暴地重启整个系统,而是根据字段选择最小更新:

  • idurl 变化:重建 fiber;
  • isolate 变化:重分配解析域;
  • intercept 变化:原地更新;
  • config 变化:交给组件做 diff;
  • disabled 变化:卸载或重新激活。

7.2 Hot Module Replacement

HMR 也可以被看作组件级的可逆替换:

  1. 找出受修改模块影响的组件;
  2. dispose 旧 fiber,恢复旧组件安装的 effect;
  3. 清理模块缓存并导入新模块;
  4. 用新模块创建新 fiber;
  5. 如果任何一步失败,恢复缓存并用旧模块重建。

因此 HMR 不需要每个组件额外声明一套接受边界,组件生命周期本身就是替换边界。

八、Koishi 案例说明了什么

Koishi 是一个基于 Cordis 的开源聊天机器人框架,论文提到其生态已经积累了 4000 多个社区插件,覆盖 IM 适配器、数据库驱动、管理控制台和用户功能。

这个案例主要说明三点:

  1. 可表达性:只靠通用 context 原语,就能承载一个完整生产系统;
  2. 通用性:同一套机制既能用于服务端机器人,也能用于浏览器 Web 控制台;
  3. 开放生态中的依赖协调:不同作者编写的插件可以通过 coeffect 连接,而不需要共享内部实现。

Koishi 中的插件可以被单独禁用,插件效果会在原地撤销;开发时,HMR 可以重新应用修改过的插件,同时保留系统其他部分的连接和状态。

不过,论文也承认这不是一个受控的性能实验:证据来自单一生态和单一 TypeScript 实现,尚没有和传统插件框架做系统性的开销或开发效率对比。

九、把这套模型放进 Agent harness

可以把一个 Agent harness 抽象成下面的依赖图:

memory-provider ─────┐

search-provider ─────┼──> planner / executor

permission-provider ─┘

└──> tool registry / sub-agent coordinator

在这个模型里:

  • 工具注册、事件监听、会话句柄是 revertible effects;
  • memory、search、permission 是 coeffects;
  • planner 声明自己需要哪些 coeffects;
  • provider 变化时,只有受影响的 Agent 组件进入 reload/unload;
  • permission 还可以通过 interception 附加只读、路径限制或租户信息;
  • service broker 可以让多个工具 provider 共存,并在后台做负载均衡和滚动替换。

一个更具体的例子是切换向量数据库:

  1. 新数据库 provider 先加载并完成健康检查;
  2. 依赖 memory 的组件解析到新 provider 后重新激活;
  3. 旧 provider 进入 UNLOADING,停止接受新绑定;
  4. 依赖旧 provider 的 teardown 完成;
  5. 旧 provider 撤销连接、注册表和事件监听。

整个过程不需要重启 Agent 进程,也不需要每个 consumer 自己实现一套"发现 provider 变化"的逻辑。

十、这套方案的边界

10.1 外部副作用不一定真正可逆

论文把系统边界定义得很清楚:只有系统能独占修改、并且能恢复的资源,才属于 context 内部。

例如:

  • 打开文件并记录文件描述符,通常可以跟踪;
  • 创建私有临时文件并删除,通常可以补偿;
  • 向外部邮箱发出的邮件,无法真正撤回;
  • 已经发出的支付请求或网络消息,不能靠普通 inverse 消除。

对于越过边界的 emission,系统只能:

  • 延迟输出,直到内部状态确定提交;
  • 提供业务层 compensation,例如退款、撤销订单或发送更正事件。

所以"完整恢复"不是对整个宇宙状态的承诺,而是对系统边界内、可被重新表示的行为的承诺。

10.2 依赖 key 还不够解决版本问题

形式化模型主要通过 key 身份连接 provider 和 consumer。这会遇到:

  • provider 升级后接口漂移;
  • 不同包使用同名 key 表示不同接口;
  • 独立构建的组件缺少结构兼容性检查。

Cordis 当前更多依赖包管理器的 peer dependency。更彻底的方案可能是命名空间 key、版本约束或结构兼容性检查,但语言无关的结构兼容性并不容易定义。

10.3 依赖环和组件粒度

如果 A 依赖 B、B 又依赖 A,两者的 satisfaction predicate 都无法成立,最终会永久 inactive。工程上需要拆分出更细的 integration component,把双向交互拆成两个单向绑定。

代价是组件数量和配置复杂度上升。论文建议用组件打包、约定式 wiring 和脚手架工具降低认知成本。

10.4 这不是完整的安全沙箱

依赖声明可以限制组件通过 context 能访问什么,但恶意代码如果直接拿到宿主运行时对象,语言级检查就可能被绕开。

真正的不可信组件仍然需要隔离进程、独立运行时、WebAssembly 或容器;context 机制负责控制沙箱边界上的依赖桥接。

十一、我的总结

这篇论文最重要的贡献,不是提出一种新的 Agent 推理算法,而是提出了一种可以支撑动态 Agent 系统的运行时组织方式:

Effect = 我对环境做了什么,以及如何撤销

Coeffect = 我依赖环境提供什么,以及变化时如何响应

Context = 让两者在同一个运行时实体中协作

Component = 依赖 + 提供 + 可逆效果

如果把 Agent harness 看作一个可以持续演化的程序,那么它需要的不只是"加载工具"的能力,还需要:

  • 工具卸载时不泄漏资源;
  • provider 替换时不让 consumer 读到混合状态;
  • 异步 teardown 能被等待;
  • 部分失败能撤销已经完成的步骤;
  • 最终状态与从头组装的配置一致。

这正是论文所谓的 spatiotemporal composability

在时间上,组件可以加入、离开并恢复自己的影响;在空间上,组件可以声明依赖并随依赖拓扑变化自动重组。

论文最后也把自演化 Agent harness 列为下一步验证方向。真正有意思的实验是:让 Agent 自动生成和替换自己的工具、记忆或权限组件,然后观察 Cordis 这类机制能否在高频拓扑变化中维持状态恢复和依赖协调。

参考

  • Yifan Shi, Wei Zhang, Tianyi Cui, A Programming Paradigm for Spatiotemporal Composability.