92 页论文,讲给普通开发者听:DeepSeek Harness 的插件内核为什么是对的

精读对象:arXiv:2608.25512《A Programming Paradigm for Spatiotemporal Composability》

作者:Yifan Shi(北京大学 / DeepSeek-AI)、Wei Zhang(北京大学)、Tianyi Cui(DeepSeek-AI)

提交:2026-08-26 · 92 页 · 1 图 2 表 · cs.PL + cs.SE

前情提要:上一篇《DeepSeek Harness 精读》讲了 Cordis 怎么用,这一篇讲它为什么是对的

0. 一句话速览

这不是一篇 AI 论文,是一篇编程语言(PL)论文。 它给 DeepSeek Harness 的插件内核 Cordis v4 补上了完整的数学地基。

作者名单里的 Yifan Shi,就是 Koishi / Cordis 的作者 Shigma —— 现在在 DeepSeek。论文自己也交代了:Koishi 跑在 Cordis v3 上,论文呈现的是 v4,核心组合模型两版共享。

所以这不是「又一篇 Agent 论文」。它是一次工程实践的回头总结:一个从 2019 年一路长起来的框架、被 4000+ 社区插件压测了四年,终于有人把「它为什么 work」讲清楚了。

三个你在 dsh 里天天见到的东西,论文都给了正式答案:

你在 dsh 里看到的 论文里的名字 它证明了什么
ctx.effect() 返回 disposer,卸载自动撤销 可回滚效应
Revertible Effects
卸载一个组件时,它撤回的只有它自己的贡献
inject 声明依赖,服务就绪才启动 响应式余效应
Reactive Coeffects
provider 撤回绑定之前,所有依赖方一定已经停用
reload 不重启进程 合流定理
Confluence
中途怎么折腾都无所谓,最终状态只取决于最终配置

1. 问题:动态组合为什么一直没有理论

论文标题里那个拗口的词 —— 时空可组合性(spatiotemporal composability) —— 其实是把「运行时装卸组件」这件事拆成了两个正交的维度。

动态组合的两个正交维度 时间可组合性关注卸载时能否完整撤销副作用,空间可组合性关注组件间依赖的声明与解析 动态组合 时间可组合性 卸载时完整撤销副作用 空间可组合性 依赖的声明与响应式解析 VSCode 前 100 扩展 · 87 个含代码 VSCode 前 100 扩展 · 仅 7 个声明依赖

时间可组合性管的是:组件被卸载时,它对共享环境做的修改必须被完整、安全地撤销。每一次资源分配、事件注册、状态变更都要能被追踪和回收。

空间可组合性管的是:组件必须能声明、发现、解析彼此的依赖,并在依赖出现或消失时协调各自的生命周期。

静态场景下这两件事都有现成答案:时间维度退化成词法作用域(RAII、bracket 模式),空间维度退化成模块导入解析。但一旦组件能在运行时来去,两边同时崩掉 —— 没有哪个词法作用域能框住一个部署后才加载的插件,也没有哪个编译期上下文能预测运行时配置里冒出来的依赖。

论文拿 VSCode 开刀,数据很扎心

插件系统是动态组合最典型的场景。论文挑了 VSCode 做样本,因为它最普及、也最经典:

维度 VSCode 的现状
时间 所有扩展跑在同一个 extension host 进程里。安装量前 100 的扩展里 87 个含可执行代码,禁用或卸载任何一个,都得重启整个 host,影响所有已加载扩展。
空间 虽然提供了 extensionDependencies,但前 100 里只有 7 个声明了非内置扩展依赖。getExtension(...).exports 返回 any,完全没有类型契约。

论文对 deactivate 钩子的批评特别值得记一笔:它不只是「不彻底」,而是把效果的销毁(deactivate)和效果的创建(activate)拆到了两个地方,违反了关注的局部性 —— 于是「清理完整吗」这个问题,变成了一件无法验证的事。

顺带,论文也点了现有变通方案的本质:操作系统在「进程」粒度给了时间可组合性,容器编排器在「服务」粒度给了空间可组合性。 代价是每次重启丢掉全部进程内状态(缓存、连接、部分计算),重建要几秒到几分钟;容器编排还无法表达共享地址空间内组件之间的依赖,给本可以是本地函数调用的交互硬塞进一层网络开销。

论文给它起了个名字:粒度错配(granularity mismatch)。现代系统越来越在更细的粒度上组合,而现有机制只在进程和容器的边界上工作。

对 AI agent 来说这事更致命。论文把「自进化 harness」列为头号动机,并指出最危险的一条:没有时间可组合性,一次错误的自我修改可能让恢复所需的那个进程本身瘫痪。


2. 时间维度:让每个函数自带「逆」

经典 effect system 和 coeffect system 都是静态工具:效应在词法固定的作用域内被追踪、由编译期的 handler 处理;余效应注解针对执行前就确定的上下文验证。

论文的关键判断是:与其给静态类型系统继续加注解,不如把效应和余效应的概念结构「具体化」(reify),让运行时能直接操作它们。

落到时间维度上,成果简单到有点意外:一个效应不是「一个函数」,而是「一个函数 + 它自己的逆」。 类型写成这样:

e : Γ → Γ × (Γ → Γ)
      ↑     ↑
   新状态   撤销这次变换的逆

运行时把每一步返回的逆累积保存起来(论文叫它累加器)。于是:

「卸载一个组件」就退化成「把累积的逆跑一遍」。

可回滚效应:每个效应携带自己的逆 组件加载时依次执行效应,每个效应返回新上下文和它自己的逆;逆按应用顺序累积进累加器,卸载时按 LIFO 逆序执行即可回到初始状态 状态 0 状态 1 状态 2 状态 3 效应 1 效应 2 效应 3 累加器 = 逆 1 ∘ 逆 2 ∘ 逆 3 (每个效应自带逆,按应用顺序累积) 卸载 = 把累加器跑一遍,逆序执行自动回到初始状态

最漂亮的一点是:LIFO 顺序是白送的。逆按应用顺序累积、按相反顺序执行,正好每一次撤销都拿到「当初自己造成的那一个状态」。论文用一条定理把这个说死了(每步撤销都精确回到它自己的起点,且中间每个状态都满足可靠性不变量),不需要额外规则。

论文还点出一个我很喜欢的类比:效应迭代器本质上就是一个「具体化的定界续体」,也就是主流语言用 yield 暴露的那个结构。所以这套模型不是要发明什么新语言特性 —— 它直接落在语言已经给的 generator 上。

翻译成 Cordis 的写法,就是你在 dsh 里天天见的东西

ctx.effect(() => {
  const onSignal = () => shutdown()
  process.on('SIGINT', onSignal)     // ← 正向:做一件事
  // 这个返回值就是论文里的「逆」
  return () => process.off('SIGINT', onSignal)
})

你返回的那个 () => ...,在论文里是有严格类型的对象:运行时持有它,卸载时按 LIFO 跑它。所以你不需要写 uninstall 路径,也不需要记得清理 —— 这不是「方便」,是有证明的结构性保证


3. 空间维度:依赖操作本身就是效应

依赖这边,论文先用一张部分函数表来建模,也就是 key → 值 的映射,叫余效应上下文。用类型族保证每个 key 关联到具体的值类型,访问依赖时类型是安全的。

然后是全篇最「啊哈」的一步。注册一个依赖的操作签名长这样:

set(k, v) : Σ ⇀ Σ × (Σ ⇀ Σ)
              ↑        ↑
           新状态   撤销这次注册的逆

这个类型恰好就是第 2 节那个可回滚效应函数的类型。

也就是说:注册一个依赖,本身就是一次可回滚效应,它返回的逆就是「把 k 从表里删掉」。论文的结论是 —— 依赖注册的追踪和恢复,完全不需要新机制,直接从时间那一半白嫖过来。原文把这称为两种机制之间的 synergy:余效应操作就是效应,而效应是可回滚的。

在这之上才是「响应式」。每次上下文发生变化,都会被对照组件声明的依赖清单(specification)分类成三种之一:

分类 含义 后果
activating 依赖从「不满足」变成「满足」 执行组件的效应(自动被追踪)
deactivating 依赖从「满足」变成「不满足」 跑累加器回滚(自动撤销)
neutral 满足性没变 什么都不做

这就是为什么在 dsh / Cordis 里,插件不需要自己轮询依赖、也不需要自己接事件总线 —— 你 inject 声明一下,系统在依赖满足性变化的瞬间驱动你的生命周期。

论文还顺手加了两个机制,都很实用:

隔离(isolation):同一个 key,不同上下文解析到不同的值

论文说它本质上实现了一个运行时的 ad-hoc 多态系统 —— 而且这种多态可以在运行时动态调整。多租户、测试环境、组件沙箱都用得上。比如同一份「数据库」依赖,给社区插件注入只读实例,给核心插件注入完整权限实例,而两份代码都不用改。

拦截(interception):给依赖访问挂横切元数据

关键是它的合并规则是右偏的:外层上下文可以覆盖组件自己的声明。这意味着外层可以约束一个组件「如何使用」某个依赖,而不修改那个组件的代码。这个特性在权限控制里会变成主要卖点(第 6 节展开)。


4. 最深刻的一点:恢复不等于「原样恢复」

前面说「卸载把累加器跑一遍就回到初始状态」,论文很快就自己拆台了。

它举了两个例子:free 把内存块还给分配器,但不会恢复 malloc 之前堆的布局;一个「生成名」被丢弃后也恢复不了,因为下次创建会取一个新的。

物理状态根本没法原样恢复。所以论文做了一件很诚实的事:把「相等」降级成「观察等价」

定义是这样的:两个状态相关,当且仅当没有观察者能区分它们。而「观察者」的定义才是精髓 —— 观察一个值,就是跑它那个 key 提供的操作、读它们返回的结果。

于是这个等价关系是被接口本身生成的。推论非常实用:

接口发布什么决定了什么可回滚 只发布必要结果的接口让变更不可观察,因而可交换可回滚;多发布一个结果就会暴露顺序差异,破坏可交换性 可交换 · 可回滚 路由注册 / 事件监听器注册 mmap — 可返回任意未用地址 每次注册有自己的条目,顺序观察不到 不可交换 · 无法回滚 中间件有序链 open — 必须返回最低可用 fd 多暴露一个结果,顺序差异就可见 接口少发布一个结果 → 观察等价更粗 → 更多变更变得不可观察 接口设计因此可以直接当成可组合性设计来用

论文里那组 POSIX 对照我看了好几遍,实在太漂亮:

  • mmap 允许返回任何未使用的地址,所以没有操作能观察到「用了哪个地址」,两次分配就是可交换的。
  • open 必须返回最低可用的文件描述符 —— 单这一条要求,就让两次描述符分配不再可交换。

同一个内核里的两个系统调用,成败就取决于接口多发布了一个结果

论文还提到一个细节:关于「分配器发出的 handle」,如果它的接口不比较这些 handle,那么两个堆在「handle 重命名」下就是等价的 —— 这正是 CompCert 关联一个程序及其编译结果的内存状态的方式。

这条洞察可以直接拿去用:如果你在设计一个可热插拔的接口,「少发布一个返回值」不只是减少耦合,它直接把更多变更变成了「不可观察」,从而变得可回滚。


5. 从单个组件,抬到整个系统

单个组件能干净装卸还不够 —— 真实系统是一堆组件交错运行。论文为此搭了三层对象:

概念 是什么
组件(component) 一个三元组:它需要的依赖 d、它可能提供的 key p、以及带见证的效应函数 e
纤程(fiber) 组件的一次实例化,带自己的生命周期状态,和一份 committed view(记录它激活时每个 key 由谁提供)。你在 Cordis 里看到的 fiber 就是这个
注册表(registry) 所有纤程按名字挂载,父指针构成一棵树

有个设计细节特别能体现作者的谨慎:整个依赖表是「派生」出来的,不是存储的 —— 它就是所有处于激活状态的纤程共同提供的东西。而且不同纤程的「可提供集合」必须不相交,于是每个 key 恰好有一个 provider。

规则一共九条,分两类:编排规则是外部能请求的动作(插入 / 退休 / 移除),生命周期规则是只要前提成立就自发发生的步骤。

其中「退休」(Retire)和「移除」(Remove)是刻意分开的,理由很实在:退休是一个请求,所以无条件;而一个已退休但仍处于激活态的纤程,必须先被停用才能移除 —— 提前移除会丢弃累加器,直接泄漏。

这套演算最终换来六个结果,我用大白话列一下:

定理 大白话
Preservation 系统不会「跑坏」:良构性在每一步之后都保持
Recovery exactness 任意交错之后跑某个纤程的累加器,它撤回的只有它自己的贡献,别人的一丝不动
Ordering 纤程只在依赖就位的地方启动;provider 只在所有依赖方都停用之后,才撤回绑定
Resolution coherence 一次过渡所依据的依赖解析,不会在它自己脚下移动
Progress 不会死锁,而且一定会终止 —— 每个最长的步骤序列都终结于「静止状态」
Confluence 无论中途怎么折腾、什么顺序,最终静止状态只取决于最终配置

前两个就是「时间可组合性」和「空间可组合性」的全局版本 —— 从「一个组件自己成立」升级成「任意交错的一堆组件之间都成立」。

Confluence 是最有工程价值的那个,因为它直接回答了:「配置热更新为什么是对的?」 loader 在做增量调和的时候,中途怎么插入、怎么退休、以什么顺序执行,全都无所谓 —— 系统最终会停在「从零加载最终配置」本该在的位置。这就是 §5.2 那套实现的合法性来源。


6. 落地:理论 ↔ 代码的对照

Cordis 的实现分三层:核心库(效应 / 余效应原语)→ 组件加载器(声明式配置、调和、热模块替换)→ 应用框架(Koishi 在第三层)。

论文的 Table 2 是一张理论到代码的对照表,几个有意思的映射:

论文里的 Cordis 里的
累加器 fiber.dispose
committed view fiber.committed
插入 / 退休 ctx.use 及其回调的逆
生命周期状态 fiber.state(LOADING 对应 Reloading)

一个顺带做成的安全特性

访问依赖有两条路:ctx.get(key) 是反射式查找,永远不失败;而 ctx[key] 走 TypeScript 的 Proxy,沿纤程链向上解析 ——

  • 遇到第一个 committed view 里绑定了这个 key 的纤程 → 授权,返回
  • 遇到一个声明了但还没加载的纤程 → 访问失败
  • 一路走到 root 都没人声明 → 直接拒绝

本身就是一层能力式访问控制:组件只能访问它声明过的东西。而且因为声明是静态的,完整的权限集合在组件运行之前就已知 —— 编排器可以在加载的时候审查,而不是等到访问发生才发现。

Koishi 的四年,就是这套理论的对照组

  • 四年开发,积累了 4000+ 社区插件(IM 适配器、数据库驱动、管理控制台、终端功能)
  • 论文自己交代:Koishi 目前跑在 Cordis v3 上,论文里的 v4 精炼了效应/余效应语义并重写了 loader,核心组合模型两版共享
  • 最有说服力的验证是「时间可组合性没有认知开销」:因为效应被自动追踪、逆被自动组合,一个没经验的插件作者也能拿到有序清理,完全不用写 uninstall 路径

论文说,这才补上了 VSCode 那种「靠每个作者自己勤勉」的缺失 —— 正确性从「每个人的责任心」变成了由抽象一次性结清

空间侧的验证同样实在:Koishi 的生态里有真实的依赖拓扑。运行时切换存储后端,只会重新激活那些「解析结果真的变了」的依赖方;依赖暂时缺失的插件保持不活动、但不报错。而关键在于,这一切跨独立作者成立 —— 插件和它的依赖通常由不同人写,除了连接它们的那个 key 之外,谁也不认识谁。


7. 作者自己承认的局限

这部分我挺欣赏,写得非常实在,没有硬撑:

局限 具体说明
案例研究是「存在性」的,不是定量的 只有 Koishi 一个生态、TypeScript 一种宿主语言,是观察性的,没有受控对照。抽象带来的开销、以及对开发效率的影响,论文没有测量,明确留作未来工作
「无环」是假设,不是结论 Progress 和 Confluence 两条定理都建立在依赖优先关系无环的假设上。有环的后果只是相关组件永远不激活(好处是从声明就能提前预测,不像并发死锁必须在运行时检测),但「拆环」会让集成组件的数量随组件数二次增长
版本问题完全开放 形式模型只有按名字链接,没有版本或结构化链接。于是有接口漂移(provider 改了接口,消费者还声明同一个 key,依赖「满足」了但值不对)和 key 碰撞(两个独立 provider 用同名 key 表示无关接口,消费者不做检查就接受)。Cordis 现在靠 peer dependencies 兜着,但这依赖大家遵守 semver,而包管理器通常只解析单一版本
边界外的东西回不了滚 论文把环境划成边界内 / 外。一次外部操作分两个阶段:获取在边界内(装描述符、注册条目 —— 是可回滚效应),发射跨到边界外(写出去的字节、发出去的消息 —— 表现为恒等变换)。事后要恢复只能靠扣留(推迟发射,即经典的 output commit 问题)或补偿(退款、删文件),而补偿「元理论不传递」—— 交换性是针对观察等价证的,换成更粗的等价得重新证一遍
沙箱需要语言之外的机制 语言级的访问控制挡不住恶意组件(它能直接够到底层对象),必须靠软件故障隔离、独立运行时、沙箱进程或容器

8. 可借鉴清单

认知层

  1. 「注册即副作用,卸载即撤销」值得当成一条设计纪律。 不只是 Cordis 的做法,而是一个可以迁移的原则:任何「装上去」的东西,都应该在安装的那一刻就定义好「怎么拆下来」,并且让框架替你拆。凡是把清理代码写在另一个地方的设计,迟早会漏。
  2. 给「接口发布什么」这件事加一层思考。 想让一个组件更容易被替换、被回滚,第一步不是加抽象,而是检查它的接口有没有发布不必要的返回值。发布得越少,可观察的差异越少,能安全替换的实现就越多。POSIX 里 mmapopen 的对比是最好的教材。
  3. 依赖用「声明」而不是「查找」。 组件说「我需要什么」,而不是「我去哪里找什么」。声明可以静态审查、可以推导加载顺序、可以在依赖消失时自动停用 —— 查找做不到这些。

架构层

  1. 把「撤销」做成一等公民,而不是异常路径。 如果你的系统有任何形式的动态加载(插件、工具、技能、子 agent),先保证两件事:每次注册都返回一个 disposer,且框架在卸载时逆序执行它们。
  2. 「退休」和「移除」分开。 一个想下线的组件,应该先被标记、等它自然停用,再真正删除。直接删会丢掉它持有的撤销信息 —— 这是论文专门用一条规则强调的坑。
  3. 热更新要有「最终状态只取决于最终配置」这个性质。 如果你的增量更新做出来的结果,和「拿最终配置重启一次」不一样,那这个增量就是不可信的。Cordis 用一条合流定理把这个性质钉死了,自研系统至少应该把它当成测试断言。
  4. 能力集合在加载前可知,比在访问时才拦截强得多。 声明式的依赖清单天然就是一张权限表,可以在组件装载前审查。这是「声明式」相对「运行时反射」被低估的一个收益。

可以直接去读的东西

  1. 论文第 3.4.2 节关于「可交换性」的讨论,是全文最可迁移到日常接口设计的部分,配合 §6.6 一起读。
  2. Cordis 源码只有 9 个模块(context / registry / fiber / events / service / reflect / logger / utils / index),fiber.ts 是核心 —— 生命周期状态机加 effect 系统都在里面。
  3. 论文的 Table 2(理论↔实现对照表)值得单独保存,它是一张「读源码时该看哪里」的地图。

9. 风险与观望点

说明
论文没有性能数据 全文没有 benchmark。效应追踪和依赖解析的运行时开销是多少,只能自己压测
单一生态验证 所有实证来自 Koishi + TypeScript,范式本身和其他语言实现的优劣还没有横向对比
「无环」需要自己保证 它不是被证明的,是假设。你的组件设计得自己盯着别写出环
版本语义还缺一块 这是论文明确列为「开放问题」的部分。生态越大,接口漂移和 key 碰撞的代价越明显

10. 延伸阅读

  • 论文原文:arxiv.org/abs/2608.25512(92 页,但 §3 和 §4 的定理陈述部分读下来就够了)
  • Cordis 源码:github.com/cordiverse/cordis(MIT)
  • DeepSeek Harness 里 vendor 的版本:vendor/cordis,发布为 @deepseek-ai/cordis
  • 官方插件入门:docs/cordis-primer.zh.md
  • 前文:《DeepSeek Harness 精读:一个「一切皆插件」的 Agent 运行时》

一句话总结:它把 effect 和 coeffect 从编译期的类型注解,变成运行时可操作的一等对象,让「运行时装卸组件」第一次有了完整的理论保证 —— 而这一切,已经被一个 4000+ 插件的生产系统验证了四年。


已发布

分类

来自

评论

发表回复

您的邮箱地址不会被公开。 必填项已用 * 标注