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。
这句话一成立,卸载清理和依赖通知就连起来了:
- 插件通过上下文注册服务;
- 注册服务是一次可逆 effect;
- 插件卸载时,effect 的 inverse 删除服务绑定;
- 绑定变化触发依赖者重新评估;
- 依赖者按生命周期规则激活、停用或保持不变。
这不是两个系统互相打补丁,而是一条机制自然传导。

这张图从注册一个 LLM 服务开始(论文记号是 set(k, v),Cordis 实现里叫 ctx.provide()):左边是 effect 账——卸载时按逆操作删除绑定;右边是 coeffect 账——绑定一变,依赖它的组件被通知、重新评估生命周期。两边记的是同一次注册。
在 Cordis 的实现里,这条链路都落在同一个上下文对象上:可逆副作用入口追踪清理动作,服务的注册和注销本身也是一次 effect,绑定变化时由运行时通知受影响的组件。甚至包括更高级的作用域隔离和访问拦截,也基于同一套绑定机制。
这就是为什么同一个上下文对象能把清理、依赖和生命周期通知放在同一本运行时账本上。不是因为 API 名字统一,而是因为这些动作都落在同一套可追踪、可撤销、可重新评估的机制里。
四、一个 ctx 的意义不是 API 简洁
如果把 effect 和 coeffect 拆成两个系统,当然也可以写出能跑的工程。
但你必须额外维护一套同步逻辑:服务删除以后,依赖者怎么知道?依赖者卸载时,提供者能不能先别彻底撤掉?如果还用上了作用域隔离和访问拦截,那么隔离域变化时哪些组件要重新评估?拦截策略变化时哪些路径会受影响?
这些问题加起来,就是 Cordis 论文想形式化的那部分。
所以一个 ctx 的意义不是”API 看起来简单”。它的意义是把两类事实放进同一个运行时账本:
- 组件对环境做过哪些可撤销修改;
- 组件从环境声明过哪些可观察依赖。
同一个账本,才更容易让卸载、依赖变化和生命周期转换落在一条链路上。
这篇论文真正有启发的地方也在这里。它不是教你把所有代码都写成插件,而是提醒你:一旦系统需要运行时换件,就不能只问”怎么加载”,还要问”怎么撤回”、“谁会被影响”,以及”影响发生时由谁重新评估”。
模型能调用工具,不代表 Agent 能长期运行。
长期运行靠的不是更多生命周期钩子,而是同一本账:每个组件改过什么、需要什么、撤掉以后谁受影响,系统都看得见。
