Agent 框架里有一只薛定谔的猫:一个插件,你卸掉了它,但系统的某些路径里还残留着它注册过的服务、监听器或状态——它既被拆掉了,又还活着。

这不是比喻。插件系统里有两个最容易被低估的问题。

第一个问题:插件注册了文件监听器、WebSocket(长连接协议)连接和定时器,卸载时怎么保证都被撤销?

第二个问题:插件声明”我依赖 LLM(大语言模型)服务”,这个服务消失时它要停下来,服务回来时它要恢复,谁来通知它?

常见工程做法会把这两件事拆开:清理由生命周期管理器负责,依赖由依赖注入容器负责。

这样当然能工作,但代价是中间必须有胶水:服务被撤销以后,谁负责通知依赖它的组件?通知以后,谁负责安排依赖组件先停下来?停下来的过程中,它还能不能访问自己清理时需要的资源?

DeepSeek 随 Harness 一起发布的那篇近百页论文,讨论的正是一个叫 Cordis 的运行时框架:它不负责让模型更会回答问题,而是负责管理 Agent 系统里的插件、服务、依赖和卸载。

这篇论文给出的答案是:不要把它们拆成两套系统。

它们是同一个 Context——同一个运行时上下文——的两个方向。

一、effect:你改了什么,就要知道怎么撤销

论文里的第一个概念是 revertible effect(可逆副作用),可以先粗略理解为”可撤销的环境修改”。

一句人话:插件在被安装的那一刻,就要同时铺好自己的退路。

一个插件加载时会改环境:注册工具、挂事件监听、启动后台任务、安装服务。Cordis 的要求是:这些修改不能只是”做了”,还要同时留下撤销办法。

工程上可以把它理解成一条纪律:启动文件监听器时,要同时登记”怎么停掉监听”;打开 WebSocket 时,要同时登记”怎么关闭连接”;启动定时器时,要同时登记”怎么清掉定时器”。

核心不是具体 API 长什么样,而是安装动作和撤销动作要绑在一起,并且交给运行时记录。

如果三个 effect 依次注册,卸载时通常要反序恢复:先清定时器,再关 WebSocket,最后停文件监听。反序不是写作修辞,而是因为后注册的东西可能依赖先注册的东西。

论文把这个动作写成一个累积过程:每次 effect 返回一个 inverse(逆操作),运行时把它们合并进同一个 disposer(统一清理函数),卸载时反着执行。

论文在这里证明的是模型内的性质:只要每个 effect 都真的提供了能恢复它的 inverse,运行时按记录顺序反向执行,就能恢复到对应的前序状态。

注意条件:运行时能追踪 inverse,也能保证同一个 disposer 最多执行一次;但 inverse 是否真的撤干净,仍然取决于组件作者是否把可撤销的边界写对。

所以更准确的说法不是”框架自动让所有副作用都安全”,而是:

框架把清理从分散的记忆,变成集中追踪的运行时纪律。

熟悉 React 的读者会想到 useEffect——它也让副作用返回一个清理函数。但 useEffect 的清理函数不能像普通函数那样自由组合,多个副作用的复合清理仍然要手写拼凑;它也不支持异步清理和嵌套 effect。论文要的是一个可自由组合的版本:复合操作的逆,从各部分的逆自动构成。

这是账本的第一笔:谁改了什么,怎么撤回来。

二、coeffect:你需要什么,就让系统看见

第二个概念是 reactive coeffect(反应式余效应),可以先粗略理解为”可观察的环境依赖”。

如果 effect 是”我对环境做了什么”,coeffect 就是”我从环境依赖什么”。

论文把依赖环境抽象成一张表。组件可以从表里读一个 key,也可以往表里写一个 key。落到工程上,可以把它们理解成”消费依赖”和”提供服务”。

这张表带来两个能力。

第一,依赖满足以后再启动。

一个组件声明自己需要哪些 key。按论文模型,只有这些 key 都出现在上下文里,组件才进入加载和运行。它不是先跑起来、失败了再说,而是先检查依赖是否满足。

第二,依赖变化以后重新评估。

当上下文里的绑定通过这些操作发生变化,运行时会按组件的依赖声明重新分类:以前不满足、现在满足,就触发激活;以前满足、现在不满足,就触发卸载或停用;前后都满足或都不满足,就保持中性。

这是账本的第二笔:谁需要谁,变化了怎么通知。

三、关键一步:服务注册本身就是 effect

现在看统一点。

写入一个服务绑定,做了什么?它把一个 key 写进依赖表。

这是不是在修改环境?是。

既然是修改环境,它就应该有 inverse。这个 inverse 是什么?把这个 key 从表里删掉。

所以论文里最关键的一句可以翻译成:提供服务这种 coeffect 写入操作,本身就是一种 effect。

这句话一成立,卸载清理和依赖通知就连起来了:

  1. 插件通过上下文注册服务;
  2. 注册服务是一次可逆 effect;
  3. 插件卸载时,effect 的 inverse 删除服务绑定;
  4. 绑定变化触发依赖者重新评估;
  5. 依赖者按生命周期规则激活、停用或保持不变。

这不是两个系统互相打补丁,而是一条机制自然传导。

服务注册同时进入 effects 与 coeffects 两本运行时账

这张图从注册一个 LLM 服务开始(论文记号是 set(k, v),Cordis 实现里叫 ctx.provide()):左边是 effect 账——卸载时按逆操作删除绑定;右边是 coeffect 账——绑定一变,依赖它的组件被通知、重新评估生命周期。两边记的是同一次注册。

在 Cordis 的实现里,这条链路都落在同一个上下文对象上:可逆副作用入口追踪清理动作,服务的注册和注销本身也是一次 effect,绑定变化时由运行时通知受影响的组件。甚至包括更高级的作用域隔离和访问拦截,也基于同一套绑定机制。

这就是为什么同一个上下文对象能把清理、依赖和生命周期通知放在同一本运行时账本上。不是因为 API 名字统一,而是因为这些动作都落在同一套可追踪、可撤销、可重新评估的机制里。

四、一个 ctx 的意义不是 API 简洁

如果把 effect 和 coeffect 拆成两个系统,当然也可以写出能跑的工程。

但你必须额外维护一套同步逻辑:服务删除以后,依赖者怎么知道?依赖者卸载时,提供者能不能先别彻底撤掉?如果还用上了作用域隔离和访问拦截,那么隔离域变化时哪些组件要重新评估?拦截策略变化时哪些路径会受影响?

这些问题加起来,就是 Cordis 论文想形式化的那部分。

所以一个 ctx 的意义不是”API 看起来简单”。它的意义是把两类事实放进同一个运行时账本:

  • 组件对环境做过哪些可撤销修改;
  • 组件从环境声明过哪些可观察依赖。

同一个账本,才更容易让卸载、依赖变化和生命周期转换落在一条链路上。

这篇论文真正有启发的地方也在这里。它不是教你把所有代码都写成插件,而是提醒你:一旦系统需要运行时换件,就不能只问”怎么加载”,还要问”怎么撤回”、“谁会被影响”,以及”影响发生时由谁重新评估”。

模型能调用工具,不代表 Agent 能长期运行。

长期运行靠的不是更多生命周期钩子,而是同一本账:每个组件改过什么、需要什么、撤掉以后谁受影响,系统都看得见。