DeepSeek Harness背后的论文:让Agent在运行中改写自己
编辑|Panda
昨天,DeepSeek 接连发布了 DeepSeek V4 Pro 正式版模型和 DeepSeek Harness 开发者预览版,AI 社区宛如过年,褒奖、评测、争吵接连不断。截至本文发稿,开源仅一天的 DeepSeek Harness,star 数已接近 9.5 万,fork 数也达到了 8.8k,并仍在持续上涨。

https://github.com/deepseek-ai/deepseek-harness
是的,DeepSeek Harness 非常受欢迎。而它受欢迎的一大原因,藏在一个很多人还没注意到的名字里:Cordis。
先说 Harness 本身。它是一个跑在本地的编程智能体,可简称 dsh,读文件、跑命令、改代码、查资料,差不多就类似于 Claude Code。
真正让它区别于同类的是设计方式:一切皆插件。
模型接入、工具注册表、会话日志、审批策略,连驱动智能体运转的主循环本身,全都是插件。
想换搜索引擎、接自己公司的模型服务,改配置就行,不用动框架代码;给系统加功能就是挂一个新插件,卸载时注册的一切自动撤销,不留残余。
支撑这件事的就是 Cordis。它是一套以依赖注入和可逆副作用为核心的插件与上下文内核,来自 Koishi 生态,只负责插件的加载、卸载和依赖关系。dsh 的所有具体组件都是不同插件,靠服务与事件协作,在配置层自由组合。
这套底座的一大用法,从界面的模式选择器里就能看到。
四档模式里,标准模式功能最全,PTC 模式让模型生成一段代码来组合多轮工具调用,极简模式只留一个终端和一个文本编辑器、系统提示词只有一句话,适合跑基准测试。它们都是在既有的插件树上做加减法。
而第四档「创造模式」不一样,它是一组自指的 Cordis 工具,是高级入口:选择这一预设后,Agent 可以检查当前运行时的插件树,并动态挂载或卸载临时插件。模型可以临时写一个事件监听器、注册一个新工具、提供一个服务,任务完成后再把它卸掉。
DeepSeek Harness 默认的四个模式以及使用创造模式构建的 Obsidian 笔记模式这听上去有点像让汽车在高速公路上给自己换发动机,所以项目没有默认打开它,信任等级被标注为等同于 shell 访问权限。临时插件只存在于进程内存里,不写文件、不装包、不改配置,重启即消失。
自修改式 Agent 的难点之一是写完之后怎么把组件干净地拿下来:一次自我修改留下的事件监听器、打开的连接、注册进去的服务,如果没有一条明确的回收路径,系统运行几个小时就会变成一堆无人认领的残留。dsh 的做法是把动态插件仍然放进已有的插件生命周期里,跑在 Cordis 的 Context 和 Effect 机制下,注册项有确定的清理路径。
这套机制的设计依据,已经写在 DeepSeek 同步发布的一篇论文里:《A Programming Paradigm for Spatiotemporal Composability(时空可组合性的一种编程范式)》。作者包括北京大学的 Yifan Shi(同时属 DeepSeek)、张伟(Wei Zhang),以及 DeepSeek 的崔添翼(Tianyi Cui)。这不是一篇 Agent 论文,甚至几乎不谈模型,它是一篇编程语言理论的论文,八十页,正文里有二十多个定理和证明。

论文地址:https://github.com/cordiverse/paper
软件工程的基石是组合
论文开篇提出的问题已经颇有历史:软件工程的基石是组合,把复杂系统拆成简单部件再拼起来。但传统的组合是静态的:函数调用、模块导入、类继承,编译期就确定,运行期不再变化,这套东西有极其丰富的形式化基础。
而现代软件越来越需要动态组合:组件在运行时被加载、卸载、重新配置。
论文的判断是,相比静态组合,动态组合方向的理论基础仍然欠缺,工业界的通行做法是绕开它。
作者拿 VSCode 做了实证。VSCode 的所有扩展跑在一个共享进程里,虽然扩展可以动态安装,但这个 Host 没有提供任何在运行时卸载单个扩展代码的机制。一旦某个扩展的 activate 执行过,禁用或卸载它就要重启整个 Host,影响所有已加载的扩展。论文统计了安装量前 100 的扩展,其中 87 个包含可执行代码,也就是说移除它们都需要一次重启。
VSCode 确实提供了 deactivate 钩子,但它只是 Host 进程终止时的优雅关闭回调,无法用于在线移除;而且这个钩子把副作用的销毁和副作用的产生(在 activate 里)分开了,破坏了关注点的局部性,完整清理很难被验证。
依赖那一侧也很稀薄。VSCode 提供了 extensionDependencies 用于声明扩展间依赖,但前 100 个扩展里只有 7 个对非内置扩展声明了它。更关键的是,扩展间交互没有结构化契约:通过 getExtension(...).exports 拿到的返回值默认是 any,消费方无法依赖一个受检查的接口。
那为什么还没有改?因为操作系统和容器编排提供了一个粗粒度的替代品。操作系统在进程粒度上提供时间维度的可组合性,容器编排在服务粒度上提供空间维度的可组合性。模块出问题就重启进程,服务依赖交给编排器。
论文对这个替代方案提出了具体的批评:每次重启都会丢弃全部进程本地的累积状态(缓存、连接、部分计算结果),重建它们要花数秒到数分钟;要在这期间维持可用性就得跑冗余副本,为了无法恢复单个组件而付出整机的资源开销。空间上,容器级编排无法表达共享同一地址空间的组件之间的依赖,还会给本可以是本地函数调用的交互引入网络开销。两种机制都工作在进程和容器的边界上,而现代系统的组合粒度正在不断变细。
这个粒度错配,正是自演化 Agent harness 会撞上的墙。
论文在动机部分写道:一个未来的 harness 可能在持续服务请求的同时,生成并部署对自身组件的修改,每一次这样的修改都是一次动态组合。没有时间可组合性,每次自我修改都要一次全量重启,在高频率下累积的不可用时间相当可观,进行中的任务被反复打断;更糟的是,一次有缺陷的自我修改可能让恢复所需的那个进程本身失效。没有空间可组合性,每个模块都要自己去检测所依赖的模块何时出现、消失或换了身份,而且只能用临时手段;一个天真的代码替换策略可能悄悄弄坏依赖方,或者引入只在重载时才暴露的循环依赖。
把编译期的概念搬到运行时
论文的技术路线可以一句话概括:把效应(effect)和余效应(coeffect)这两个类型系统里的经典概念,从编译期的静态标注提升为运行时的机制。
效应系统描述一个计算对环境做了什么,余效应系统描述一个计算需要环境提供什么。
前者的谱系从 Moggi 的单子、Plotkin 与 Power 的代数效应一路到 Koka、Eff 和 OCaml 5;后者从 Uustalu 与 Vene 的余单子到 Petricek 等人的余效应统一静态分析。
论文指出,这两套系统恰好对应动态可组合性的两个维度,但它们都是静态工具:效应在词法固定的作用域内被追踪、由编译期的 handler 处理,余效应标注是对执行前就确定的上下文做验证。而部署之后才加载的插件,没有任何固定的词法作用域能圈住它。
于是论文把这两个概念具体化(reify)成运行时可以直接操作的对象。
可逆副作用处理时间维度。一个副作用被建模为 Γ → Γ × (Γ → Γ) 类型的函数:作用于当前上下文,返回修改后的上下文,同时返回一个显式的逆函数。运行时把这些逆函数不断累积到一个称为累加器的复合函数上,卸载组件时把累加器整个作用一次,上下文就回到组合之前的状态。因为逆函数按施加顺序前置累积,累加器天然以后进先出的顺序回收,与资源获取的嵌套结构一致。

这里有两个设计选择值得一提。
第一,逆函数由调用方在施加副作用的那一刻提供,而不是事后回忆,也不是由系统推导。这条看似朴素的约束,把它和可逆计算传统区分开了。Janus 那样的可逆语言、以及 Heunen 等人用 dagger arrow 建模可逆效应的工作,要求整个计算全局可逆,可逆性由构造保证;论文只要求每个原子副作用有一个单侧逆,且这个逆是被写出来交给运行时的。运行时不验证它——论文表示,逆函数确实撤销了它所伴随的副作用,这是组件作者的义务,不是运行时检查的性质。

第二,复合的逆由组合自动得出。开发者只为每个原子操作写逆,任何由这些操作拼起来的复合操作,它的逆自动跟着出来。用论文的说法,一个组件的拆除过程是从它的加载过程派生出来的,而不是与之并列手写的。这正是 VSCode 的 deactivate 做不到的事:那个钩子把销毁和产生分开写,谁忘了一行,就漏一份资源,而且这种漏没有任何机制能发现。
论文点名了结构上最接近的对照物:React 的 useEffect。它同样让副作用返回一个 cleanup,由运行时在重新执行前和卸载时调用,是少有的把副作用和它的逆结构性配对的设计。它的短板在可组合性:hook 只能写在组件或另一个 hook 的顶层,永远不能放进条件、循环或嵌套函数,effect body 既不接受 async 函数也不接受迭代器。副作用因此无法由其他副作用装配而成,也无法与控制流交错,也就没有任何东西可以让复合的逆从中派生。Cordis 的副作用没有这些限制:它们是可以自由组合、可以异步执行的普通操作。
反应式余效应处理空间维度。一个组件把自己需要的依赖声明为一份规格 d,上下文每发生一次变化,系统就对照这份规格把这次变化分类为激活、去激活或中性三种之一。依赖没有就绪时组件不激活,而不是乐观地去访问然后在缺失时报错;依赖被撤走时,对应的组件被反应式地去激活。

在这个基础上论文加了两个机制。
余效应隔离让同一个键在不同上下文里解析到不同的值,用于多租户、测试环境和组件沙箱,它的实现是在键和值之间插一层「隔离域」映射,本质上是一套运行时的特设多态。
余效应拦截则给依赖访问附加横切元数据,不改变一个键解析到什么,只改变解析到的东西怎么被使用。论文举的例子是文件系统依赖携带路径白名单,或者给社区组件只读的数据库权限而核心组件保留完整权限;因为拦截元数据挂在上下文上而不在双方任何一方的代码里,编排器可以在不修改提供方也不修改消费方的前提下调整它,而且由于它只影响调用方式、不影响依赖是否被满足,安装、重配或移除它都不会触发任何重载。
最难的半个定理
空间可组合性有两半,一半容易,一半很难。

容易的一半是激活顺序:消费方要在提供方之后激活。这个直接由满足性前提保证;声明了某个键的组件,在没有组件提供该键之前根本无法激活。
难的一半是撤销顺序:提供方要在它的消费方全部去激活之后,才能撤回自己提供的东西。

论文强调,这一半要交付的东西比「状态变化的先后顺序」更多。一个因为提供方要走而正在被拆除的组件,它自己的拆除代码可能正需要那个即将消失的依赖:关闭一个连接池通常意味着把连接交还给提供它们的东西。所以需要保证:消费方在自己整个去激活过程中都还能读到那个键,而提供方对该键的撤回只在这之后生效。
朴素的形式化做不到这一点,因为它把「移除提供」和「运行逆函数」放在同一步里,中间没有留出任何区间给消费方的拆除代码。
论文的解法是把这一步拆成两条规则,中间插入一个 UNLOADING 生命周期状态:第一条规则只记录「决定去激活」这个事实,让该组件立即停止对外提供余效应,但保留自己和所有人已经提交的依赖视图;第二条规则才真正运行累加器,并且带一个守卫条件:只要还有任何组件把某个键解析到它,这一步就不许执行。
这类守卫通常会死锁。论文的论证是它不会:一旦第一条规则标记了某个组件,它的表就退出了共享的余效应上下文,任何目标视图都不可能再指向它,所以每一个曾经依赖它的组件自身也都在离开的路上。这个论证后来被写成进展性定理,证明守卫总会释放。

元理论:动态历史不留痕迹
有了组件和 fiber(组件的一次实例化)的概念,论文给出一个动态组合演算,十条规则,四个生命周期状态,还专门处理了三件真实运行时无法回避的事:一次转换不是原子的(可以在迭代边界被打断并部分回滚)、不是即时的(异步操作一旦发起就必须落地,论文称之为惯性)、不是必然成功的(失败被路由成一次去激活,组件带着错误结果回到未激活状态,不影响它的兄弟组件)。

在此之上是五个定理:保持性、全局时间可组合性、全局空间可组合性、进展性,以及汇流性。
分量最重的是最后一个。汇流性说的是,无论一个运行中的系统经历了怎样一串激活与去激活,它最终静止时所处的状态,等同于把同样的插入与退役指令一次性执行、每个最终处于激活状态的组件按依赖顺序加载一次且从未卸载所得到的状态。一个编排器增添一个组件、移除它、替换某个提供方、再撤销这次替换,最终到达的状态与一开始就写下最终组合完全一致。
论文把这称作增量计算里「与从头计算保持一致」这条性质在动态组合上的对应物。
它的实际含义是:推理一个 Cordis 应用可以当它是静态装配的来推理。组件作者想知道自己能拿到哪些余效应,只需要看静止状态,不必在脑子里回放整部加载历史。dsh 的插件树能被一层层叠加的配置(bundle、profile、patch)任意覆盖而不产生顺序依赖的怪象,靠的就是这条。

结语
论文的结论部分,作者自己点了未来验证方向的名:超越人工策划的插件生态。其中一个有说服力的方向是自演化的 Agent harness,即 AI Agent 持续地生成并替换自己 harness 的组件,人类监督很少。
把 Cordis 用在这样的场景里,可以验证快速组件替换下完整恢复的时间性保证,以及拓扑频繁变化下依赖协调的空间性保证。
而 dsh 的创造模式就是第一个产品形态。它还远谈不上安全无忧。论文中就说了,语言级的访问控制在恶意组件面前不成立,真正的沙箱需要语言之外的执行边界;dsh 也把它的信任等级标在 shell 访问权限上,并且默认关闭。
但它展示了这套架构真正想抵达的地方:智能体不只是使用能力,也能在受控边界内重新组合自己的运行时。
更多详情请参阅原论文。
