#TL; DR
本文尝试从编程语言视角(下文简称 PL)来解读 DeepSeek Harness(下文简称 DSH)背后,支撑起整个插件生态的元框架 Cordis。
你将不会看到,DSH 在通用 Harness 上的设计,如:上下文、多代理、动态工作流、Memory 等。如果你对以上领域感兴趣,抱歉可以不用往下看了。
你将会看到,Cordis 框架是如何尝试解决不同插件之间的副作用,如何使插件可以热更新而不影响整个系统。本人曾经做过一些 PL 与 IDE 插件的相关工作,属于入门者,但借 Fable 5 一起阅读了 Cordis 的整篇 Paper。对于不懂之处交叉核查,纯手写。希望可以抛砖引玉,如有不对之处,欢迎指正。
#1. Cordis 想解决的是什么问题?
VSCode 插件中如果含可执行代码(有 activate 入口、跑在 extension host 进程里),一旦激活就无法单独卸载。Top 100 扩展中有 87 个在移除或者 disable 时须重启整个 host。
难点在于插件的副作用不受任何作用域约束,两个插件之间会相互影响,A 和 B 的副作用交错之后,B 是否还可以直接卸载而不影响 A。
一个简单的例子:插件 A 和 B 都向宿主的同一张事件监听表注册了回调,B 还开了一个数据库连接、注册了一条 HTTP 路由。现在卸载 B — 需要从共享的监听表里精确摘掉 B 的那几个回调、关掉 B 的连接、注销 B 的路由,而 A 注册的东西原地不动。
#2. 为什么从 PL 角度看这个问题?
因为我先想到的是编程语言中的生命周期管理,像 Cpp 的 RAII,以及 Go 的 defer 语法。这一节将会从三个 PL 领域的运行时化延伸到 Cordis 中的几个概念,方便对比理解。
#2.1 析构函数的运行时化:可逆 effect
RAII:利用了 Cpp 析构函数在离开作用域时自动执行的特性,在词法世界中严格规定了资源释放的时机。绑定点是类型,触发点是作用域出口,在编译期定死。
Go 的 defer:清理逻辑绑定在调用点上,在获得资源时注册闭包,捕获当时的具体状态,函数退出时按 LIFO 顺序执行。相比析构函数,更加灵活,清理逻辑是操作的属性,而不是类型。
Cordis 中的可逆 effect:更进一步,将 defer 上的带执行清理栈,从调用栈上拆下来,变成一个函数值,可供一次外部调用(组件卸载)。
effect 超出析构函数的 3 处概念,也是数学介入的地方:
一是有正确性契约。析构函数没有任何语义约束,编译器只保证能成功调用。但是 effect 的逆函数要保证把状态恢复到操作之前(但这里不是完全相同,而是观测性相等)
二是 effect 复合本身也是一等公民,可以被存储、传递和部分应用,析构函数无法做到。
三是允许乱序,析构是严格的栈结构,effect 具有交换性,可以从中间抽走几个栈帧,而不影响其他帧。
以上三点,分别对应论文中的逆元条件、幺半群同态和交换性定理。
#2.2 结构化并发的运行时化:组件生命周期
结构化并发是指:子任务的生命周期必须嵌套在父作用域之内。
这个概念出自 njs 的一篇著名博客。go 语句(以及 spawn、回调、Promise)的问题在于:任务一旦开启就脱离了函数边界,资源释放和错误传播无人接管,也违背了可局部推理性质,简单来说必须读完函数内部才知道有没有东西逃逸出去。
nursery 是他给出的替代物:父任务必须先创建一个「托儿所」,才能往里 spawn 子任务。托儿所会接管作用域内的所有任务,等它们全部结束才释放。取消是协作式的:取消请求不立即生效,到下一个检查点才抛出,且托儿所会等清理逻辑完整跑完。错误处理上,子任务的异常抛给父,同时取消所有兄弟。
https://vorpus.org/blog/notes-on-structured-concurrency-or-go-statement-considered-harmful/
而 Cordis 的组件生命周期,和 nursery 逐条对应:
nursery 对象就是 Cordis 中的 context 对象,不经过 context 不得产生副作用。
「退出前等待」拆成了两个维度:一是父子关系(fiber 树),父组件卸载时把注册的子组件标记为退役,子再标记孙——是标记而非直接销毁,实际卸载由子自己的生命周期规则执行,清理不会被跳过;二是消费关系(谁依赖谁的 key),这是词法世界不存在的新问题,provider 要离开时先停止对外提供,等到没有依赖者了才执行自己的清理。一个等待点裂成两个,是因为并发任务只有一棵树,而组件是一棵树加一张依赖图。
检查点:组件的加载是一串 effect(形式上就是生成器的 yield 序列),取消只能落在两次 yield 之间的边界上,已开启的异步操作必须先落地,再由累加器按 LIFO 展开已完成的部分。
唯一反向的是错误处理。nursery 把异常抛给父,但一个坏插件不应拖垮宿主和其他插件,所以 Cordis 把失败记录在组件自己身上,兄弟照常运行。
#2.3 声明式 reconciliation 的进程内化:论文演算
论文把「插件需要什么」形式化为 coeffect,插件声明依赖的资源 key,运行时在上下文每次变化时判定依赖是否满足,而决定激活或者关闭插件。
这个判定并驱动的运转方式,很像 k8s 里的声明式 reconciliation:只声明期望状态,系统里有个循环一直在做三件事:观察实际状态 -> 与期望状态求差 -> 执行最小收敛动作。不管从什么状态出发,顺序交错,但最后都会到达同一状态。
论文演算的核心是这个循环的进程内版本。每个插件实例记录两样东西:target view(期望状态:按当前上下文,我该不该运行、依赖该由谁提供)和 committed view(实际状态:我激活时依赖实际由谁提供)。全部生命周期规则就是一句话:两个 view 不一致就触发迁移。
在此之上,论文证了两个元定理:
Progress 定理:系统必定收敛,不会卡死在中间状态(那个 provider 等依赖者退场的 guard 被证明不会死锁);
Confluence 定理:不管规则以什么顺序执行,最终配置唯一,而且等于把所有插件静态地一次性装配出来的结果,动态性不引入任何不确定性。
k8s 关于控制器的文档,参考阅读。
https://kubernetes.io/docs/concepts/architecture/controller/
#3. 工程实践与数学推导能否一致?
整套保证建立在三个无法机器检查的前提上。
一是逆函数写对了。框架内置的操作没有问题,注销监听的逆函数由框架自动生成。风险在自定义 effect:作者通过 ctx.effect() 注册副作用时,要自己写出对应的清理函数。比如打开了一个定时器和一个文件句柄,清理函数里只关了定时器,所以运行时照样把这个不完整的逆函数叠进累加器。
二是所有共享状态都经过 ctx。绕过 ctx 直接改全局变量、直接 require 别的模块,没有任何机制阻止,这些副作用就回到了 VSCode 的世界:无人认领。
三是 key 的交换性声明属实。声明可交换而实际不可交换时,乱序卸载的保证静默失效。
三个前提全靠开发者自觉或人工审查。
时间线上值得品味一番:Koishi 的插件生态先于形式化存在,数学是事后追认。但事后追认不是白做的,论文脚注提到,Cordis v4 正是因为形式化过程暴露了 v3 语义含糊之处而重新设计的。
实践上目前缺的是把前提变成门禁的工具。比如逆元契约完全可以做成自动化测试:随机生成上下文状态,执行插件的 effect 再立刻执行它交出的逆函数,检查前后状态是否可被观测区分。论文和目前 DSH 的技术预览版文档中都没有提供这类工具,可能是这套东西走向工业的较大劣势。
#4. 距离一个真正好用的 Harness 还有多久?
从第一反应上看,我觉得 DSH 如此设计,还是希望在以后可以让 Agent 自己迭代,并且给出了一个热插拔的强核心。但这样的生态比较依赖优质的社区开发者,在历史上或者更像 Vim 甚至是 Emacs。
也看到很多批评 DSH 的评论,说这玩意就是 AI 时代的 Emacs,PL 人的自嗨等等。目前看确实是这样,也许以后也会像 Emacs 一样变得极其小众。而必须承认的是,这个地基打好了,也会给以后更大的发展空间。
最有意义的场景应该是「在运行中替换组件,而不中断对话」这样的路径,但目前对很多人来说还是太小众了。
或许我们对 DeepSeek 的期待有些太高了,但我们作为使用者还是保持平常心就好,毕竟好用的开源 Harness 也不少,我们可以耐心等待 DSH 是否会进化成庞然巨物。
