DeepSeek Harness(DSH)发布后在社区激起了巨大反响:主仓库上线两天,star数迅速超过10万,成为Github历史上最快突破10万star的项目(截至 2026-08-21 的 GitHub 实时数据,deepseek-ai/deepseek-harness 的 star数已超过17万,挂上 dsh-plugin 标签 的插件仓库已突破 1 万个)。伴随DSH发布的,还有一篇Deepseek与北大合写的论文——A Programming Paradigm for Spatiotemporal Composability(《一种面向时空可组合性的编程范式》),从数学上形式化了 DSH 底层插件运行时 Cordis 的设计原理。
DSH 的架构设计思想可以用一句话来概括:一切皆插件,插件皆可逆。这是真正创新的设计吗?社区对此的反应并不统一,有些人兴奋,有些人困惑,质疑的声音也很多,常见的有以下几种:
- "一切皆插件"是没有必要的统一——Agent Loop 和 Memory 差距那么大,把它们统一成同一种"插件"有什么意义?
- “一切皆插件”不是 DSH 的发明,现在哪个 Agent 框架不是高度可配置的?Pi Agent 不也是插件化的吗?
- "可逆插件"是什么意思?副作用是不可避免的,怎么可能可逆?
- 动态更新插件有什么必要?版本升级不就可以了吗?
对于这些疑问,DSH 的论文其实已经做了解答,但是它的表述是从范畴论/函数式的角度做形式化,一般的开发人员很难越过数学符号理解到它的真实含义。这篇论文中还反复强调"可逆"二字,因此知乎上有同学@我,问它的概念与我提出的可逆计算理论是否是类似的。
本文就从可逆计算理论出发,对Cordis 的设计原理做一个通俗讲解,同时也对比分析它和可逆计算理论的区别与联系。具体结论是:
- Cordis 可以看作可逆计算理论在运行时结构空间的一种具体应用——"插件"在这里不只是功能模块,而是真正在数学上可以被严格定义的差量单元;
- 但要完整地实现可逆性,还需要补上可逆计算在编译期结构空间已经形式化的一整套做法—— DSH 的具体实现中有部分体现(声明式配置、运行时 reconciliation),但 Cordis 论文对这一层只有机制描述,没有形式化为差量代数。
- Cordis中的可逆指的不是逆向运行,而是逆转对运行时结构空间的变更——如果把这种变更看作是副作用,则Cordis并不是在管控一切副作用,而只是某种特殊的、影响到多个插件复合交互的副作用。
一、一切皆插件 => 一切皆差量
一切皆插件的概念可以看作是如下公式:
App = Base + Plugin1 + Plugin2 + ....
也就是大量功能特性不内置在base产品中,都通过插件提供。在这个层面上,DSH的做法和Pi Agent类似,都可以看作是核心提供最小特性集,然后大量功能通过扩展来提供。
在可逆计算理论中,上面的做法对应于如下数学公式的一个具体实现
Structure = Base + Delta1 + Delta2 + ...
一个Plugin可以看作是一个功能增量。这看起来像是一句废话,增量式开发在软件工程领域早就是老生常谈的事情了。但是,本质性的区别在于,在传统软件工程的语境中看待上面的公式,不过是一个启发式的描述,而Cordis的论文以及可逆计算理论是要把它落实为一个真正的数学公式。
这就涉及到如下问题:
- 参与运算的基本单元,也就是Plugin的具体的结构形态是什么?
- 这个加号在数学上如何精确的定义?
Base + Plugin1 + Plugin2和Base + Plugin2 + Plugin1是等价的吗?- 在没有Base的情况下,
Plugin1 + Plugin2能定义吗?它们的运算结果是什么?
Cordis的论文本质上就是在建立一个数学空间,然后在这个空间中规定各个对象之间的运算规律,从而精确的回答上面的问题。对于Cordis的详细形式化定义我们后面会进行分析。这里先看一个有趣的问题:一切皆插件包括这个Base吗?
Base + Plugin中的这个Base显然不是Plugin,因此很多程序员的第一个潜在反应就是一切皆插件必然是一个夸大的说法。但是从可逆计算的角度去理解,这件事情就是很自然的:A = 0 + A,一切全量都可以看作是与0结合的差量,因此一切皆差量是在数学上精确的说法。
这里有一个微妙的地方,差量是定义在支持差量运算的结构空间中的,因此一切皆差量是针对这个已经存在的空间而言的。构造这个空间其实需要做相应工作,是需要成本的,但是这个基础空间并不是差量运算的Base。这就好比是应用程序是在操作系统之上运行,我们需要先构造操作系统作为应用程序得以实际运行的基础空间,但是我们一般并不会把操作系统本身看作是应用系统的Base部分。
Cordis中一切皆插件,当一切插件都不存在的时候,整个系统没有任何业务侧可见的功能,此时对应于这个数学空间中的零。但是这个零并不意味着一无所有。这就类似于物理世界中的真空。按照现代物理学的理解,真空并不是真的空空如也:它是量子场的基态,内部存在着隐秘的结构——零点涨落、QCD 凝聚、拓扑构型,并且拥有非零的能量密度(宇宙常数),在极端条件下还能被极化甚至"击穿"(Schwinger 对产生)。
二. 插件皆可逆 => Delta可正可负
很多人看到可逆这两个字,望文生义产生的第一个认知就是逆向运行。还有一些人从副作用的角度出发去理解,实际系统中执行的插件必然有大量副作用,这些副作用怎么可能被逆向撤销?
这些都是对可逆计算的常见误解。实际上,可逆计算中的可逆既不是逐个步骤的逆向运行,也不是对所有副作用的补偿撤销,它的可逆指的是结构空间中发生的Delta变化可正可负。
先精确化一下"结构空间"这个词。软件世界实际上存在两个性质不同的空间,合在一起构成软件的"相空间":
- 编译期结构空间:程序、模型、DSL、配置所构成的空间——这是"软件是什么"的层面,代码是编译期被读取、被合并、被生成的结构化对象;
- 运行时结构空间:进程状态、内存、依赖图、生命周期所构成的空间——这是"软件在做什么"的层面,状态在运行期被创建、被修改、被销毁。
可逆计算理论明确指出:可逆并不要求全量——不要求在整个相空间的每个层次上都可逆。Nop 平台的实践就是这样:它的主体可逆发生在编译期结构空间——所有 DSL 支持差量合并 x-extends和差量拆分 x-diff,差量运算发生在编译期和模型加载期,完全不触及运行时的执行轨迹。而 dsh 选择了一条互补的路径:在运行时空间实现可逆——为每个 effect 显式配备逆函数,由运行时跟踪并组合这些逆。理解了这两个空间的划分,上面所说的"可逆指的是结构空间中发生的 Delta 变化可正可负"就有了准确的含义——对 dsh 而言,这个"结构空间"特指运行时结构空间。
App = Base + (Plugin1, 逆转Plugin1对运行时结构空间的变更)+ (Plugin2,逆转Plugin2对运行时结构空间的变更)+ ...
每一个plugin在激活的时候都会产生一个配对的撤销函数,这个撤销函数的作用就是逆转plugin在激活过程中对于运行时结构空间所做的变更。插件卸载的时候将会按照一定的顺序执行这些撤销函数。
在概念层面上,这相当于
Structure = Base + Delta1 + Delta2 - Delta2 + Delta3 = Base + Delta1 + Delta3
Cordis的论文证明了安装插件1,2,3之后,如果卸载插件2,则等价于从零开始安装插件1和3。
以上说法可能有些抽象,我们来看一个具体的dsh的插件示例。
// tool-stats.ts —— 一个会话统计插件
import { defineTool } from '@deepseek-ai/dsh-tools'
import type { Context } from '@deepseek-ai/cordis'
export const name = 'tool-stats'
export const inject = ['tools'] // 声明依赖:tools 服务就绪后本插件才会被激活
export function apply(ctx: Context) {
// 1. 注册一个模型可以调用的工具
ctx.tools.register(defineTool({
name: 'stat_summary',
description: '输出本次会话的调用统计',
parameters: {},
output: {
schema: { type: 'string' },
render: (_args, value) => [{ type: 'text', text: value }],
},
async execute() { return summarize() },
}))
// 2. 监听 LLM 请求失败事件(waterfall 模式,必须调用 next() 委派下游)
ctx.on('agent/request-error', async (payload, next) => {
countFailure(payload)
return next()
})
// 3. 一个手动管理的资源:心跳定时器
ctx.effect(() => {
const timer = setInterval(heartbeat, 60_000)
return () => clearInterval(timer) // 配对的撤销函数
})
}
插件被激活(apply 被调用)时做了三件事:往工具注册表里挂了一个工具、往事件表里挂了一个监听器、启动了一个定时器。表面上看这只是三个普通的注册动作,但关键在于——这三件事全部是以 effect 的形式登记的。这里的机制是:插件实例(fiber)把自己的 effect 方法直接暴露为 ctx.effect(二者是同一个方法),ctx.on、ctx.tools.register 这些注册 API 内部最终都落到它上面——effect 体执行真正的注册动作,并在当场返回撤销函数;运行时自动把这个 effect 记录到当前 fiber 的 disposables 列表上。作者无需手动登记任何东西,每次注册在完成的同时,就把它的撤销函数交到了运行时手里:
| 注册动作 | 内部调用链 | 配对的撤销函数 |
|---|---|---|
ctx.on(...) | register() → ctx.fiber.effect(...)(dsh/vendor/cordis/src/events.ts) | 从该事件名的监听器数组中移除这个 listener |
ctx.tools.register(...) | register() → layers.effect(...) → ctx.effect(...)(dsh/packages/core/tools/src/index.ts、dsh/packages/core/scope/src/store.ts) | 从当前 scope 的工具层中移除该工具 |
ctx.effect(...) | 就是 ctx.fiber.effect(...)(dsh/vendor/cordis/src/fiber.ts) | 插件作者当场写下的 () => clearInterval(timer) |
ctx.effect与fiber.effect是同一个方法——fiber 通过 mixin 把它暴露为 ctx 的属性,ctx 是插件拿到的界面,fiber 是真正执行登记与清理的主体;layers.effect则是工具注册表在ctx.effect之上的一层领域封装,负责"注册到全局层还是当前 scope 的专属层、空层自动回收、可见性变更通知"这些与工具域相关的逻辑,最终仍落到ctx.effect。)
运行时把这些撤销函数按顺序累积在 fiber 的 disposables 列表中。插件被卸载(fiber.dispose())时,_unload() 会清空这个列表并执行其中的撤销函数——同一个 effect 内部按注册的相反顺序,像退栈一样(disposables.splice(0).reverse())。于是:监听器被注销、工具从注册表消失、定时器被清除。整个运行时结构空间回到了插件安装之前的状态,没有重启,也没有残留。
撤销函数的执行也并不只发生在卸载时——作者随时可以手动调用注册 API 返回的 disposer 提前注销(例如 llm-retry 在自己的撤销逻辑里先手动注销监听器,再 abort 并排空进行中的重试);热重载(fiber.restart())、被注入的服务被撤走(依赖丧失触发 _refresh → 卸载重载)也同样会先执行撤销、再重新加载。可逆不是"临终清算",而是随时可减。
还有一个容易混淆的问题:撤销函数记在"哪个 fiber"的账本上。插件代码可以在一个进程里被实例化多次,每次 ctx.plugin() 挂载都产生一个独立的 fiber——自己派生的 ctx 链、自己的 disposables、自己的生命周期。dsh 的每个 agent 作用域就是这样做出来的:createScope 内部就是 ctx.plugin(scope) 挂载一个专用 fiber(dsh/packages/core/scope/src/index.ts),作用域内注册的一切(工具、守卫、监听器)都记在这个 fiber 上,scope.dispose() 即可独立清理——"一个插件代码、多个实例"的隔离,靠的正是 fiber 的独立生命周期。而 ctx.isolate(name) 只更换服务的存储槽位(同名服务可并存、互不冲突),不产生新 fiber——在 isolate 子 ctx 上注册的内容仍跟随原 fiber。所以 ctx 链(extend 原型链)同时决定两件事:可见性(沿链读到哪个服务的实现)与归属(ctx.fiber 决定注册记到哪个账本、级联卸载到哪个子树),服务配置覆盖(intercept)也沿这条链继承。
用公式表达,这个插件的安装相当于
App = Base + (Plugin, 逆转Plugin对运行时结构空间的变更)
= Base + Δ(工具 + 监听器 + 定时器) + Δ⁻¹(撤销函数集)
卸载就是施加 Δ⁻¹,等价于对运行时结构空间做了一次减法:App = Base + Δ - Δ = Base。注意被撤销的始终是结构而不是业务数据:stat_summary 执行时读到的日志、countFailure 累加的计数,都不会被"逆转"——dsh 的可逆针对的是运行时结构空间(谁注册了什么、谁监听了什么、谁占用了什么资源),而不是业务状态。这正是"Delta 可正可负"的精确含义:正负运算的对象是结构,不是状态。
一个细节:dsh 的"逆序"保证只在单个 effect 内部是严格的 LIFO;fiber 顶层的多个 effect 卸载时是并发清理的——它们的贡献彼此独立,本就不需要排序。若拆除步骤有先后要求,官方指南的建议是把它们写进同一个 effect,在同一个撤销函数里依次 await。
有了这个例子,论文中"安装插件 1、2、3,卸载插件 2,在观测层面上等价于从零开始安装插件 1 和 3"的结论就变得直观了:插件 2 的所有变更都登记在自己的 fiber 上,卸载时被全部逆转;插件 1 和 3 的贡献与它相互独立——依赖关系通过 inject 显式声明,插件 2 卸载时会通知依赖它的插件重新解析,不会留下悬空引用。因此卸载之后系统的运行时结构空间,与"只安装插件 1 和 3"得到的结果等价——就像执行一组相互独立的语句 A; B; C 之后撤掉 B 的效果,剩余状态与直接执行 A; C 相同。只不过 dsh 把"撤掉 B 的效果"从一句口头承诺,变成了由运行时保证的、可验证的机制。
再往前走一步:既然安装与卸载都是对运行时结构空间的加减法,"动态更新插件"就成了自然的推论——对旧插件施加 Δ⁻¹,再对新插件施加 Δ:
App = Base + Δ(旧) - Δ(旧) + Δ(新) = Base + Δ(新)
这就是 dsh 热更新(HMR)与运行时自我修改在机制层面的全部秘密:不需要重启,因为结构空间的变化本来就是可逆的。dsh 中这个模式的生产级实例是 llm-retry 插件(dsh/packages/llm/llm-retry/src/index.ts):它监听 agent/request-error 事件实施重试策略,并在 ctx.effect 中登记"abort 所有进行中的重试并等待其排空"作为撤销函数——与上面的例子是同一个骨架。
最后值得点明的是,在dsh中,这个"减法"之所以成立,不是因为运行时自主推断出如何撤销,而是因为每个插件在安装时就把自己的撤销函数写好交出去了——"Δ 出生即带逆",逆是插件作者在 apply 中写下的契约,不是运行时推导出的性质。论文对此说得很清楚:运行时并不验证撤销函数是否正确(那是组件作者的义务),它保证的只是——你的撤销函数会被保存、会被执行、不会被执行两次。
三. Pi Agent 与 DSH 的区别
Pi Agent 也是高度插件化的,所以"一切皆插件"如果只是指"框架支持很多扩展点",那确实不是 DSH 的发明。两者的真正差别不在插件数量的多少,而在一个问题:
扩展坐标由核心预定义,还是可以由插件通过 service/inject 关系持续生成?
"坐标"是理解 DSH 的关键词,我们先把它的含义展开。
3.1 ctx 是运行时结构空间的界面
DSH 的运行时结构空间由三部分构成:
- 一张共享的坐标表。整个运行时只有一张服务表(根 ctx 的 ReflectService,所有子 ctx 沿原型链共享它),表中的每个槽位由"服务名 + 隔离标签"这对坐标定位,槽位里记录实现值和它的归属 fiber(
dsh/vendor/cordis/src/reflect.ts)。 - fiber 树。每次
ctx.plugin()挂载一个插件,就产生一个 fiber(插件实例)和它专属的派生 ctx(parent.extend({ fiber: this }),dsh/vendor/cordis/src/fiber.ts)。fiber 是结构空间的生命周期单元:依赖何时满足、何时激活、卸载时哪些资源跟着收回,都由它说了算。 - ctx 链。插件拿到的 ctx 其实是一个 Proxy——属性读写被拦截后翻译成坐标查询(
dsh/vendor/cordis/src/context.ts)。每个 ctx 带一张"标签视图"(服务名 → 隔离标签),查询ctx.database时先用当前 ctx 的视图把服务名翻译成表里的槽位;子 ctx 没有改动的名字,沿原型链继承父 ctx 的视图。一个直观的例子:父级 Agent 的ctx.shell指向本地环境,子 Agent 的ctx.shell指向受限沙箱——工具 Consumer 仍然调用同一个 key,解析到的实现却不同。
所以整个运行时结构空间可以看作是多个Delta层叠加,ctx是对外暴露的结构空间的界面(Proxy 视图);fiber 是空间中负责动态生命周期管理的单元;"服务名 + 隔离标签 + 视图链"是坐标;注册项是坐标上的值。 插件的全部动作——注册工具、订阅事件、提供服务——都是往某个坐标上写值,并且当场把"擦除这个坐标上的值"的撤销函数交给运行时。工具注册表就是这样一张坐标表:每个工具以 name 为 key 占一个槽位——同一层内同名注册直接报错(duplicate error,dsh/packages/core/scope/src/store.ts 的 NamedEntries.insert),跨层同名则近层遮蔽远层(dsh/packages/core/tools/src/index.ts);事件以事件名为 key,服务以服务名为 key……"一切皆插件"所统一的对象,正是一个键值化的坐标空间。
3.2 ctx 链 对比 delta 层
对比 Nop 的 delta 文件系统:同一个资源路径在多个层目录里各自存在一份,查找时沿层从高到低取第一个命中——高层的文件整个遮蔽低层的文件。DSH 的 ctx 链:每个 ctx 贡献一张"服务名 → 隔离标签"的映射,子 ctx 没改过的名字沿原型链继承父 ctx 的映射,最终都指向同一张共享的服务表——读 ctx.database 时沿链取最近的标签映射。两者是同一个"分层命名空间 + 就近优先"模式,只是物理布局稍有差异:Nop 的层携带内容(文件在层里,查找跨层进行),DSH 的层携带路由(值在共享表里,层只决定查哪个槽位)。x:extends 也是类似——它的语义骨架"最具体的层覆盖、其余内容保留回退",与原型链查找是同一种东西。
但是它们之间也存在如下区别:
- 合并粒度。
x:extends是节点级合并:base 与当前文件按子节点身份匹配,属性级覆盖,base 中未被覆盖的内容保留在结果里,还能用x:override="remove"删除指定节点。DSH 的 provide 是整值遮蔽——子 ctx 的同名服务把父实现整个盖住,父实现仍在表里活着,但在子视图中完全不可见(没有"保留父实现的一部分"这种操作)。DSH 的"保留与合并"只出现在值是数据的场合:intercept 配置沿链按序合并(后代覆盖祖先,类似x:extends的属性级覆盖);工具注册表按层做并集联合(各层条目都可见,同名就近遮蔽,类似 delta 目录合并)。而值是对象(服务实现、监听器)时不存在合并——两个闭包无法合并,只能替换,或通过事件 waterfall 把它们串成洋葱。 - 时间性。Nop 的 delta 层在装配期确定,除了nop-dyn模块提供的租户隔离层之外,一般不会动态变化;DSH 的每一层绑定一个 fiber,可以在运行时被创建、重启、卸载,卸载会触发依赖方重新协调(reconciliation)。所以ctx 链不仅是空间分层,还是时间分代:空间上决定坐标可见性,时间上决定生命周期归属。论文里的"时空可组合性",落到源码里就是这两个正交的维度:插件位于哪个 ctx/realm,能看到哪一层服务;依赖何时满足、fiber 何时激活退出、退出时哪些资源跟着收回。
- 逆的承载形式。DSH 在 ctx.effect 登记时当场返回 disposer(闭包函数),撤销 = 调一次函数;Nop 的逆是结构差量(x-diff 事后提取或作者预写),撤销 = 写/用一份新 delta。两者的差别是 不透明的修改函数 vs 声明式的结构差量。
顺带说明 DSH 具有真正对应 Nop 编译期结构空间的部分——它的配置树(Profile → Bundle → patch → 命令行):配置覆盖是"整段替换、不做深合并",比 x:extends 的节点级合并弱得多。此外Nop 的 dump 可以逐项显示每个选择来自哪个 delta 层,而 DSH 的配置树展示的是叠加后的最终结果,这也显示出Nop平台提供了更加精细的Delta合并处理机制。
3.3 扩展坐标可以由插件生成
Pi的插件化表面上看起来也可以写成如下公式:
Agent = AgentLoop + Extension1 + Extension2 + ...
但是如果借助可逆计算理论的结构坐标系的观点进行分析,我们就可以很清晰的发现,Pi Agent本质上是先规定“系统是一个 Agent”,并由核心 Agent Loop 定义主要控制流,然后在 Loop 的生命周期和数据通路上开放扩展点。
固定的 Agent Loop
├─ model/provider
├─ tools
├─ prompt/context
├─ events/hooks
└─ UI / session extensions
也就是说,Pi 的扩展空间是 Ext(AgentLoop):tools、hooks、providers、context 都是 Agent Loop 预先定义好的槽位,插件只能往槽里填。反观DSH,它的扩展空间是 Compose(Plugin₁, …, Pluginₙ):插件可以 provide 任意服务,其他插件 inject 它——于是新的扩展点不是被核心"设计"出来的,而是被插件生态"涌现"出来的:
Δ₁ = provide(ServiceA) # 插件1:定义一个新坐标
Δ₂ = inject(ServiceA) + provide(ServiceB) # 插件2:消费它,再定义下一个坐标
Δ₃ = inject(ServiceB) # 插件3:继续消费
组合结果是一张开放的依赖图 ServiceA → Plugin2 → ServiceB → Plugin3,而不是围绕 Agent Loop 的放射状结构。差量不仅能填充已有坐标,还能引入新的坐标——这是"DSH 比 Pi Agent 更自由"的精确含义。
当然,自由不等于没有底线,实际存在如下限定:
- Cordis 内核仍然是元层:Context、service 标识、provide/inject、生命周期、作用域、reconciliation 这些组合规则由它定义。它限定的是"如何形成结构",不是"允许形成哪些业务结构";
- tools、session、llm 等服务仍是官方预定义的——但它们是"通过插件机制建立的事实标准",原则上可替换,不是内核硬编码的槽位;
3.4 结构空间的两种变化路径
在DSH中,服务的Provider 变化时,运行时按影响面分两条路处理:
- 轻路径(注册表替换):无状态能力(如 Web Search)的 Provider 登记在 Map 里,调用时才按当前配置选择实现(
dsh/packages/web/web/src/index.ts)——撤销旧注册即可,使用者无需重启; - 重路径(依赖收敛):被长期持有的 Service 消失时,依赖它的消费者退出、旧 fiber 清理,新服务出现后再按新关系激活消费者——这就是 reconciliation。
轻路径体现的就是结构空间坐标定位,而这里的重路径则充分体现了 DSH 论文中特别强调的"时空可组合性"(spatiotemporal composability):Provider 消失时,依赖它的 Consumer 主动退出、旧 fiber 清理完,新 Provider 出现后再按新关系激活。空间(谁在哪个 ctx 看到哪个服务)和时间(依赖何时满足、fiber 何时激活退出)由同一机制联动。
Pi 的生命周期管理比这粗糙得多,可以说它只建模了自身AgentLoop的生命周期和扩展点。/reload 时,Pi 会向旧 Extension runtime 发 session_shutdown,重新加载设置、Extension、Skill 和提示词,构建新 runtime,再发 session_start(reason: "reload")。旧命令的调用栈不会因为 reload 自动消失——await ctx.reload() 之后继续使用旧的 ctx,就可能撞到已经失效的运行时。官方文档因此要求把 reload 当作当前 handler 的终点,长期资源在 session_start 创建、在 session_shutdown 幂等清理。
Pi对于Extension之间的关系没有建模,它们之间如何协调完全依赖约定、测试和代码审查维护。对于Extension 内部的生命周期更没有统一治理。session_start 与 session_shutdown 是分离的两个事件回调,副作用与清理逻辑写在两处,完整清理无法被结构性地保证。
综上所述,Pi和DSH的区别可以表述为:
- Pi:以 Agent Loop 为中心的开放式框架;
- DSH:以插件组合为基础的运行时结构空间。
四. 可逆计算角度的进一步分析
(广义)可逆计算理论(Generalized Reversible Computation,GRC)是我在2007年左右提出的关于软件构造演化的一个底层理论。它的核心思想可以表述为一个公式:App = F(X) ⊕ Δ——X 是另一个域的结构定义,F 是将X转换/生成到当前域的算子,Δ 是结构化差量,⊕是某种满足差量结合运算。落地为具体的技术实现,它可以被表达为:
App = Delta x-extends Generator<DSL>
Nop平台是我在2023年发布的可逆计算理论的一个参考实现,它通过一个DeltaLoader,将编译期结构空间的可逆计算封装为一个Loader接口:
Loader :: Path -> Model
DeltaLoader :: Possible Path -> Possible Model
Possible Path = DeltaPath + StdPath
DeltaLoader<DeltaPath + StdPath> = DeltaLoader<DeltaPath> x-extends DeltaLoader<StdPath>
DeltaLoader<PossiblePath> = DeltaModel x-extends BaseModel
也就是说,只要把自己应用中的JsonLoader/WorkflowModelLoader等外部描述文件的加载器替换为Nop平台的DeltaLoader,就可以自动获得可逆计算支持。
可逆计算理论是关于Delta差量和差量运算的一个普适的底层理论,所有关于Delta差量的实践都可以在这个理论框架内进行解释和分析。在我此前的文章中,已经对Docker/React/Kustomize/Git等多种技术进行过对应解析。
比如,Docker镜像的构造结构可以看作是
DockerImage = DockerBuild<DockerFile> overlay-fs BaseImage
而Kustomize可以看作是Docker分层文件系统的思想在应用配置层面的一个应用
K8sConfig = KustomizeBuild<KustomizationYaml> patch-merge BaseYaml
根据此前章节的分析,DSH的实践同样可以纳入到结构空间的差量合并这一可逆计算图像,只是它的定义空间是具有生命周期管理的运行时结构空间。下面我们将结合DSH的论文进行进一步的理论分析。
4.1 可逆需要定义在结构空间中
可逆一词在计算机领域存在着多种不同的语境,每种语境中的含义也有不同:
| 语境 | 逆的对象 | 代表 |
|---|---|---|
| 可逆计算机 | 计算过程(逻辑门的逆向执行) | Landauer 可逆计算 |
| 事务/补偿 | 业务副作用 | 数据库 ROLLBACK、Saga |
| Cordis/DSH | 对框架托管的运行时结构的变更 | effect 的撤销函数 |
Cordis 的可逆是第三种:服务提供、依赖绑定、事件订阅、拦截链、生命周期关系都是框架"登记过、看得见"的结构变更,撤销它们不等于倒放执行历史,也不等于回滚业务数据。可逆的对象始终是结构,不是状态。
在DSH的论文中,明确引入了对于结构空间的形式化定义,本质上就是一张带坐标的表(table),由三部分构成:
- coeffect 上下文 Σ(Definition 22):一张从 key 到值的部分函数表
σ: K ⇀ V_k——keyk是坐标,V_k是绑定在该坐标上的内容,整张表就是运行时结构空间本身。读写坐标只有两个原语(Definition 23):get(k)(前置条件k∈dom(σ))读坐标,set(k,v)(前置条件k∉dom(σ))写坐标——后者正是"单写者"的形式化:一个坐标只能被写一次,且 set 本身是带逆的效应函数(set(k,v) ∈ 𝔈Σ*),逆(撤销)与坐标读写合一。 - isolation realm 表 ρ: K⇀R(Definition 28):坐标翻译层——解析走
k → ρ(k) → σ(ρ(k))两层,同一 key 经不同 realm 解析到不同绑定,这是"坐标命名空间/多租户"的机制。 - registry 与 committed view(Definition 45/46):registry 是以 fiber 名为 key 的表(生命周期单元的坐标);committed view
ω: d → N把组件声明的每个 key 解析到提供它的 fiber(坐标解析)。
组件被定义为三件套 (d, p, e)(Definition 43):d 声明它读哪些坐标、p 声明它写哪些坐标、e 是带逆的效应函数;Definition 58 第 2 条要求不同 fiber 的 p 两两不相交——"单写者"由此落实到组件层面。
这里要分清"单写"的两层含义,以免把它误读成"每个坐标上只能有一个贡献者":单写锁死的是键的绑定归属——每个坐标由一个提供者 fiber 提供一次;而当这个坐标的值恰是一张表(事件监听器、工具表)时,多个 fiber 仍可在这张表上各做各的增删,各自的操作带各自的逆、卸载时只撤自己那一条(论文 §3.3.2 的原话:"either registration can be withdrawn while the other stands")。真正被"单写"锁死的,是"删掉别人的贡献"这件事——这正是 4.4 要讲的"无删除"。
与可逆计算理论相比,DSH的这种设计明显是结构空间的一个特例,在更多应用场景中,完全可以放松单写者要求,也完全可以扩展到tree结构坐标,而不仅仅是当前的平铺映射坐标。
可逆是不是一定要引入结构坐标系?这在我此前的理论文章《写给程序员的可逆计算理论辨析》中已经论证过:一,差量的独立存在,隐含要求原系统存在一个明确的坐标系统——差量要能独立存放、独立管理、最终与基础合并,就必须精确知道每个局部变化应作用到哪个位置,需要能够透明地穿越一切结构障碍、直接应用到扰动实际发生的坐标处;所有具有差量特性的设计,第一个要求都是系统内部存在系统化的、带有唯一性的定位机制。二,坐标系必须是稳定的——Docker 的文件路径是稳定坐标(新增/删除一个文件不影响其他文件的坐标),Git 的行号是不稳定坐标(新增/删除一行会让后续所有行号漂移);不稳定的坐标无法支撑差量的独立存在与自由组合,正因为坐标系稳定且精确,才能在其上定义数学上精确的差量运算(增删改、合并、逆转),在混乱的文本差异上这是做不到的。
我在此前的文章中曾把坐标抽象为最小定义:"任何支持 get(path)/set(path,value) 的唯一标识都是一个坐标"。DSH 论文的形式化恰好与这个最小定义逐字对应:Definition 22 的 coeffect 上下文就是一张 key→value 的表,Definition 23 的 get(k)/set(k,v) 正好就是这两个运算,且 key 是稳定的命名坐标(新增一个 key 不影响其他 key 的坐标),而不是 Git 那种位置坐标——DSH的论文从另一条理论路径到达了同一个结论:先有坐标系,差量才有意义。
运行时空间中坐标系的概念其实并不新鲜,Dijkstra 在他的经典论文《Go To Statement Considered Harmful》中就曾经点明:他反对 goto 的科学理由不是"代码不美观",而是无约束的 goto 使得 "it becomes terribly hard to find a meaningful set of coordinates in which to describe the process progress"——而按结构化编程写出的代码,其文本会天然构成一个独立于程序员的客观坐标系(文本索引 + 循环动态索引 + 嵌套深度),借助它可以直观地把静态程序(文本空间)与动态执行过程(时间空间)对应起来,在思维中定位到运行时行为正在哪一点(详见我此前的文章《从零开始编写的下一代逻辑编排引擎 NopTaskFlow》)。这里的区别在于,结构化编程提供的坐标系只能辅助理解运行时行为,却不能支持更进一步的演算——你不能对执行轨迹施加差量、逆转、合并。可逆计算对 Dijkstra 思想的深化正在于此:不仅要有一个坐标系,还要让这个坐标系支持差量运算,真正把它用于软件构造。
4.2 单个坐标上的差量代数:交换律与逆
定义了坐标系之后,我们需要定义每个坐标点上的运算。这里首先需要引入坐标点的独立性
不同坐标处的运算天然独立。 在DSH的论文中,Theorem 40的证明极其紧凑——核心只是一句:不同 key 上的操作 "each reading and writing one key alone and the two keys differing":各自只读、只写自己坐标上的值,坐标不同则两两独立——变换可交换、互不扰动对方的逆。这就是"不同坐标处互不打扰"的形式化,是整张结构表上一切代数结论的地基。
然后要考虑的是单坐标上的运算是否满足交换律。论文为每个坐标单独定义自己的运算与等价关系(Definition 24),再据此判定可交换性(Definition 39):一个 key 可交换(commutative),当且仅当它上面的任意两个操作两两独立。"独立"按观测等价来读——两个顺序的结果若只差在该 key 的 ≃_k 不可区分的值上,仍算可交换(论文的分配器例子正是靠 ≃_k 的松紧来划定这条线的)。所以判据只有一个:该坐标的运算能否把两种先后顺序区分开,而不是先验地假定顺序无关。代入两类典型坐标:
- 值是一张表的 key 可交换——注册类操作是代表案例:论文举 route 与事件监听两个注册,先后任意,留下的都是同一张表、任一可单独撤销(工具注册在结构上与之同构,见 3.1);
- 值是一条有序链的 key 不可交换——"middleware inserted before another sees a different request":插在别人前面的中间件看到的请求,和插在后面看到的请求不同,先后顺序真实可区分。
套到 dsh 的事件系统上,"顺序是否无关"取决于派发模式:
ctx.emit:同步通知全部监听器、忽略返回值——监听器彼此独立,先后顺序真实无关,注册与撤销都可交换;ctx.waterfall:把监听器串成洋葱中间件链,每个监听器调用next()才放行下游——这正是论文列为不可交换反例的"有序链",一个忘了next()的监听器会吞掉下游行为。顺序在这里真实存在、真实影响结果,dsh 不把它当可交换处理,而是交给下述机制治理。
不满足交换律时的处理:顺序由 coeffect 承载。 论文把系统切成两半:可交换部分由 effect 承载(可任意顺序撤销),顺序敏感部分由 coeffect 承载(§3.3.2),在三个位置施加顺序:
- 单个 fiber 内部:累加器按 LIFO 撤销(Theorem 16),逆序撤销不需要任何额外假设;
- 跨 fiber:通过声明的 coeffect 排序——激活要求依赖已满足(提供者先于消费者),提供者要等所有消费它的 fiber 退出后才允许卸载(Theorem 63 的 ordering 与 guard);
- 元理论层:progress 与 confluence 以依赖关系无环为前提(Definition 65 的 ≺,Theorem 66/73)。
不同事件类型 = 不同坐标 = 各自的差量代数(Definition 24)。 每个 key 携带三元组 (V_k, ≃_k, A_k):值类型、观测等价、操作集。agent/request-error、tools 事件词汇表中的 pre-execute 这类不同事件类型就是不同坐标——在 dsh 中,这个"坐标的代数"由事件声明上的契约给出:每个事件必须声明一种派发模式(@mode:emit/parallel/serial/bail/waterfall;尾参是 next 即 waterfall),五种模式即五种组合语义——emit/parallel 为并集广播(论文"表"判据)、serial/bail 为顺序短路、waterfall 为有序洋葱链("链"判据),所以一个坐标只有一种结合运算。为确保契约不被破坏,dsh 用工具校验而非自觉:目录生成器强制每个事件必填 @mode、且签名与模式自洽,生成的"事件-生产者-消费者"矩阵逐事件核对声明模式与实际派发方法,doc-sync 门禁对"代码 ↔ 声明"做再生成 diff——"一事件一结合律"由此是"声明 + 校验"共同保证的,而非运行时临时选择。论文的证明因此是模块化的:每个坐标内部的代数单独验证(Definition 39 → Theorem 42:只要涉及的所有 key 都可交换,效应函数就两两独立),坐标之间的独立性自动成立(Theorem 40),再经 Corollary 21 组合成整个系统的可逆性(Theorem 61 Recovery exactness、Theorem 73 Confluence)。"先按坐标区分、再分别论证交换律/结合律",正是可逆计算理论完成模块化证明的标准方案。
而对于坐标点上的逆运算,DSH的假设是逆的存在是无条件的——每个 effect 函数自带逆(𝔈Γ* 的定义),逆由构造给定、与交换律无关(这正是上文第 1 点 LIFO 撤销"不需要任何额外假设"的原因)。交换律解决的不是"能不能逆",而是逆以什么顺序执行、能否与其他 fiber 的逆自由交错:坐标可交换,逆就能任意顺序执行、可与别人的逆交错(Corollary 21);坐标不可交换,逆就只能 LIFO、或交给 coeffect 排序。
4.3 动态资源的统一管控
运行时结构空间面临的一个基本问题是动态资源的管控:定时器、连接这类东西散布在进程里,框架无法枚举——一旦随插件卸载泄漏,"回到原状"就是空话。dsh 的解法不是追踪资源本身,而是要求一切受管动态资源在创建时转化为句柄、以差量条目的形式登记到坐标系统上:ctx.effect(() => { const timer = setInterval(heartbeat, 60_000); return () => clearInterval(timer) })——effect 体当场创建资源并返回销毁动作,运行时把它连同 label 记入当前 fiber 的 disposables 账本:一个槽位就是一个坐标,label 是可读名(如 'tools.register()',缺省 'anonymous'),fiber.getEffects() 随时可枚举(dsh/vendor/cordis/src/fiber.ts)。这正是把 fiber 的 disposables 当一张句柄管理表来设计:坐标(槽位)+ 名称(label)+ 可枚举性(getEffects)——否则进程里不可枚举的动态资源根本无从管控。
转化完成之后,这个条目与 listener、service 的条目没有任何本体差异——同样是一个"坐标位 + 值 + 摘除语义"的差量;ctx.on、ctx.tools.register 的底层实现本来就是 ctx.effect(注册动作当场返回注销函数,见第二节的调用链表)。唯一的区别在摘除那一刻:注册型条目的值还落在共享表里(事件 hooks 数组、工具层),从表中移除即是撤销,撤销后可以查表验证条目消失;资源型条目在坐标系统里只有这个句柄本身,从坐标系统中摘除时关联执行一个销毁动作(clearInterval、关闭连接),框架保证句柄被保存、被执行一次,但不验证销毁是否正确——那是组件作者的契约(见第二节末尾论文的诚实声明)。也正因如此,论文才说这类效应与其他效应一样被跟踪,只是不能被其他组件在规范中命名,因而不参与依赖排序。完全隐式的副作用(改了一个外部变量、不留任何条目)连转化都没发生,框架无从观测、无从逆转。这正是 Cordis 把可逆性牢牢限定在"受管操作"上的原因:可逆性的前提是坐标化——一切受管动态资源必须转化为坐标上的句柄差量;转化之后它与任何坐标条目同类,差别只剩"摘除时是否附带销毁动作"。
4.4 单写者 + 累加 + 无删除——有逆而无独立的负元素
dsh 的运行时叠加遵循三条约定,可精确表述为"单写者 + 累加 + 无删除"。单写者——4.1 已论证它约束的是键的绑定归属,在实现上 dsh 把这条落到运行时检查:同层同名注册直接报错(NamedEntries.insert 的 duplicate error),跨层也不改写别人的层,只是各写各的层、读取时就近遮蔽。累加——注册操作只做插入,运行时结构随注册单调增长,组合语义是(贡献, 逆)对的 twisted composition。无删除——运行时不提供"删掉别人的贡献"的原语,唯一的移除方式是执行自己的逆:事件条目即如此,每个条目只归其注册者、注销必须持有 listener 引用——删除是能力式的,拿着句柄才能撤;你能减掉的,只有你自己加上的。换句话说,dsh 在运行时坐标上有与创建伴生的逆(dispose),但没有独立的负元素(无法表达"减去别人的贡献")。这个看似限制的设计,恰好反推出一个原则性结论:插件级 delta 定制本质上绕不开结构空间的差量代数。原因在于,差量合并要求被修改的对象拥有结构坐标系——否则无法定位"在哪一点上施加差量"。dsh 的插件内部是命令式 TypeScript 代码(ctx.on/ctx.provide 在 apply 回调中执行),命令式代码的"结构"是控制流与闭包,没有稳定可寻址的坐标。这正是 dsh 的 patch 只能停留在 entry id 级的根本原因:插件内部没有坐标,所以插件内部没有差量。
要让插件内部支持 delta 定制,只有两条路:一是插件内部声明式化——把插件的贡献表达为 DSL 结构,建立字段级坐标,然后用 x-extends 那套机制做任意深度的增删改(Nop 的路线);二是在运行时结构空间上定义差量代数——但这与 dsh 的"单写者 + 累加"设计冲突,要做运行时的负元素,就必须回答论文回避的问题:谁能删除谁的贡献?删除是否满足结合律?所以结论是:插件是结构(可定制)还是代码(只能替换),决定了定制能力的上限——Nop 的Delta 包本身就是结构,所以可定制到字段级;dsh 的插件是代码,所以只能停留在"代码的入口"。
4.5 从 Generator 到 Applier:apply 的双重身份
可逆计算的核心公式是 App = F(X) ⊕ Δ:F 是一个生成器(Generator),把某个域的输入 X 构造为结构,并叠加差量 Δ。对照 DSH 的实现,我们会发现插件的 apply(ctx) 函数在功能上兼具 Generator 与 Delta Applier 的双重作用:它根据配置描述等输入,动态产生对运行时结构空间的修改,并在同一个执行步骤中直接应用这些修改。
每一次 ctx.on / ctx.provide / ctx.effect 都在运行时产生一个可逆的结构变更条目——注册一个监听器、提供一个服务、启动一个定时器,同时将该操作的逆函数(撤销函数)交付给运行时。因此,apply 函数的语义可以被理解为“定义自己向当前 Context 贡献什么”,且该定义在执行时被即时实现为具体的结构变更。
apply 的当前设计,本质上是将“差量的生成”与“差量的应用”合并为一步——插件代码就地执行注册,而不是先返回一份声明式的 Delta 描述、再由框架统一应用。我们也可以设想一种功能等价但机制不同的设计:ΔP = setup(config); C' = apply(C, ΔP),即插件先返回一份声明式 Delta,框架再将其应用到上下文。这种设想正是 Nop 平台所走的路线——它将差量的生成(x:gen-extends)与应用(x-extends 合并)分离为两个步骤,外部引擎完全接管差量运算。此时撤销的结构正确性由合并代数保证——只要差量定义本身合法,逆向提取(x-diff)一定回到原状。Cordis 则因为逆向函数的不透明性,只能选择信任插件作者提供的执行内容。
function setup(config): Delta {
return {
provide: [{ service: ServiceA, value: impl }],
inject: [{ service: ServiceB, consumer }],
}
}
apply把生成与应用混合为一步,这一步执行要求应用窗口内"时间静止"。如果注册动作在展开 delta 的同时就地应用,要保证"应用之后可恢复到未应用状态",就必须保证该 registration 执行期间没有其他变化发生——否则逆函数捕获的"应用时状态"可能已被并发修改污染。这就是为什么 dsh 的异步 effect 展开在每一步都检查 runner.epoch 是否变化(依赖一变立即中止展开),以及 inertia 机制保证一次转换原子完成——两者合起来,正是"时间静止"的工程化实现:在单个 registration 的展开窗口内冻结相关坐标的变化。论文从数学侧面回应了这一工程选择:Theorem 61(Recovery exactness)与 Lemma 71(Transposition)共同依赖一个事实——步骤携带的迭代器与累积器由该步与当时的状态共同固定、且在每个状态上都有定义,因而可以在离开当前位置的状态上求值(论文 §4.4.2);而 dsh 的 epoch 检查选择把同类问题挡在发生之前——依赖一变立即中止展开,宁可直接作废被污染的半程结果,也不依赖定理在干扰下的宽容。dsh 选择了一步执行 + 局部时间静止(epoch 检查 + inertia),Nop 选择了编译期合并(天然时间静止,因为根本没有运行并发)——两种选择殊途同归,都是在保证"应用窗口内无干扰"。
4.6 哪些Delta必须定义在运行时结构空间
对比Nop平台中的已有实现,我们发现有很多注册都可以前提到编译期结构空间进行描述。比如xbiz.xdef中
<!-- 基础模型:声明式注册业务动作,name 即坐标(xdef:key-attr="name"),when 表达运行期条件 -->
<biz x:schema="/nop/schema/biz/xbiz.xdef" xmlns:x="/nop/schema/xdsl.xdef" xmlns:c="c">
<actions>
<action name="computeQualityScore" when="tenant == 'A'">
<arg name="entityData" type="io.nop.biz.crud.EntityData"/>
<arg name="svcCtx" kind="ServiceContext"/>
<source>
<c:script><![CDATA[
// 动作实现
]]></c:script>
</source>
</action>
</actions>
</biz>
<!-- Delta 定制:不修改基础模型,在 delta 层按同一坐标覆盖实现或整体移除 -->
<biz x:extends="super" x:schema="/nop/schema/biz/xbiz.xdef" xmlns:x="/nop/schema/xdsl.xdef">
<actions>
<action name="computeQualityScore" x:override="remove"/>
</actions>
</biz>
如果要动态判断是否需要注册,则可以增加when描述条件来表达运行期判断。实际上业务 action、工作流的 step/listener/action、任务流的 step 在Nop平台中全部都是编译期结构空间中的声明式节点,以 name/id 为坐标,"注册什么"的信息全部在结构空间以 DSL 表达,delta 合并(x-extends)在编译期/加载期完成,运行时只是加载展开后的模型并解释执行。展开动作(x:gen-extends 生成 + delta 合并)收敛在模型加载器(ResourceComponentManager)中。
"注册什么"一旦成为声明式的结构定义,delta 定制(组合、撤销、逆向提取)就都成为结构空间运算,运行时只需应用展开结果。如果DSH引入了类似Nop的设计,就可以实现对插件本身的定制,也就是在完全不修改核心以及插件代码的前提下,通过Delta来定制插件内部的细节功能。目前DSH的做法只能是通过配置来控制或者对插件整体替换,并不能实现插件自身的进一步差量化。也就是说,Plugin是差量,但是差量的差量还没有合适的技术载体。而在可逆计算理论中,差量的差量也是一个普通的差量,可以利用同一个机制去实现。
这里的关键是可逆计算理论本身是允许正元素和负元素聚合成为一个新的合法元素,比如Delta =(+字段A,-字段B),这是可逆计算支持细粒度定制的理论基础。但是DSH的运行时结构空间是不支持负元素的,因此可逆计算可以看作是提供了一个更大的解空间,而DSH相当于是提供了这个解空间中的一个特定的解。
Nop 的"编译期展开"假设结构在加载后基本不变(运行中变化靠重新加载模型实现);而如果结构需要高频动态变化(自修改 harness 场景),运行时结构空间的可逆机制(逆跟踪 + 响应式触发)是一种必要的补充。理想形态是两者的结合:结构空间负责"注册什么"的差量代数(Nop 已证明),运行时结构空间负责"结构如何激活/卸载"的可逆管理(dsh 已形式化)。
这个边界的两侧,对应着两种组合拓扑。dsh 的插件之间不直接组合:Plugin1 与 Plugin2 谁也没把谁当作操作数,它们都垂直地组合到运行时坐标系上——星型拓扑,kernel 是事实上的幺元,所以"没有 Base 的组合"在 dsh 里是平凡的:零就是空坐标系。而 Nop 的差量链是链型拓扑:Δ₃ ⊕ Δ₂ ⊕ Δ₁ ⊕ Base,任何一段差量链本身就是合法的差量(结合律保证),可以脱离具体 Base 先行组合、按需挂接。
那么,是否一切注册都可以前提到结构空间?不能。 存在一类注册,其内容依赖运行时才知道的信息——最典型的例子是 dsh 的 agent 级 scope:同一插件代码在不同 agent 的 scope 中执行时,注册的具体内容可能完全不同(tools.presentAs 只对当前 scope 生效、tools.guard() 只给当前 agent 增加守卫)——注册什么,取决于这个 agent 是谁,而这些信息在编译期并不存在。scope 链的解析顺序是 agent → preset → global(近者遮蔽远者)。所以分界线不是由"能不能前提前"的偏好决定,而是由**"注册内容何时可知"的认识论边界决定**:凡是在结构空间中可确定的内容,就提到结构空间;凡是在运行期才可确定的内容,就留在运行时并用可逆机制管理。
4.7 可逆性(能力)与响应式(治理)正交
还有一个重要的概念需要澄清:动态检查依赖、主动触发激活/停用,这个操作与可逆性本身无关。DSH的论文强调时空可组合性可能会误导人认为响应式是整个设计体系中一个不可分的部分。但是从可逆计算理论来看,这是一个独立的问题。在Nop的实现中,DeltaLoader是一种被动式的依赖追踪:加载模型之后会自动记录所有依赖的资源文件的修改时间,当依赖文件发生变化时会导致模型缓存失效。在Nop的实现中,是当每次获取模型的时候检查模型缓存是否失效。很容易想见,也可以将它改造成一个主动更新的机制:每当资源文件更新就自动更新对应缓存,甚至推送修改到对应使用者。
从 dsh 的实现也可以看得很清楚:_refresh(reactive 依赖计算)与 _unload(revertible 卸载)是相互独立的方法——reactive 机制只负责"何时触发",revertible 机制负责"如何执行"。即便完全没有响应式机制,可逆性依然成立:手工调用 fiber.dispose() 同样会反序执行全部逆。响应式只是给"触发"加了一个自动化的策略。
那么自动触发的价值是什么?它保证的是运行时的有效性(validity),而不是可逆性(revertibility)。可逆性保证"撤得回来",响应式保证"不会跑到无效的运行时状态"——当某个依赖失效时,依赖它的 fiber 若继续运行,就会读取不存在的服务、进入结构上无效的状态。自动触发在依赖失效的瞬间主动卸载依赖者,确保任何运行中的 fiber 都处于依赖满足的合法状态。论文 Theorem 63 把这种合法性落成可证明的次序保证:提供者严格晚于依赖者卸载(ordering),任何消费者都先于它依赖的提供者退出;Theorem 66 进一步保证级联卸载的守卫最终一定释放(guard always releases)——依赖协调的终止性由定理保证,而不是凭经验祈祷。这也解释了为什么 Nop 不需要响应式而 dsh 需要:Nop 的结构空间是静态的,合成结果要么合法要么编译失败,不存在"运行中突然失效"的中间态;而 dsh 的运行时结构空间是动态的,依赖消失是真实且高频的事件,必须主动治理。可逆性是能力(能否撤销),响应式是治理(何时撤销);能力保证撤得回来,治理保证不该跑的时候不跑。
4.8 观测等价性
执行逆操作之后是否保证恢复到原始状态?在实际实现中,这只能是一种理想化:物理状态不可能被完全恢复——free 把内存块还给分配器,却不会还原 malloc 之前的堆布局。因此DSH的论文明确要求把所有状态等式都理解为"观测等价":两个状态等价,当且仅当没有观察者能够区分它们(Definition 33)。观测等价不是从外部引入的近似,而是由坐标自带的比较标准装配出来的:每个 key 携带自己的 ≃_k(Definition 24 三元组的第二元,决定"该坐标上的两个值算不算一样"),上下文级 ≃ 是各 ≃_k 的组装(Definition 33);而"观测"的边界由操作集 𝒜_k 限定——≃_k 不能比该坐标上的操作所能区分的更多(论文原话:"calling the relation observational is a claim about each ≃_k, namely that it separates no more than the operations of k can tell apart"),Definition 34 的不可区分性 ≈_𝒜 正是以操作集为测试词的形式化。
观测等价是"可逆"在现实系统中的可操作定义:我们要求的是结构可恢复,而不是物理状态可恢复——这正好对应可逆计算理论中"差量作用于结构坐标系"的立场(4.1 所述:可逆的对象是结构,不是状态)。
等价性在可逆计算理论中是一个专门的话题。一是表象转换等价性:同一信息可以有不同的表示,表示之间的等价是信息层面而非字面层面的(如 XML/JSON/AST 的等价转换)——观测等价的 ≃_k 就是"按信息内容比、不按表示细节比"的形式化。二是往返变换等价性:范畴论中的伴随函子可以看作可逆运算概念的一种推广——一对函子 L 与 R 并不直接互为逆,而是通过自然同构把往返结果"修正"回去(导入引擎解析 Excel 得到对象数据,然后再通过报表引擎生成Excel,此时样式信息可能会有变化);GRC 的实操体系中则把"透镜/伴随函子"列为保证"语义往返"可行性的具体数学模型。观测等价就是这个"修正"在软件构造中的现实落点:往返不需要是强等式,只需要回到观测等价的类里——Nop 的 x:gen-extends 生成与 x-diff 逆向提取的往返如此,dsh 的"安装-卸载"同样如此(Corollary 21 说卸载后回到初态,这个"回到"在 ≃ 的意义下成立)。用伴随函子的语言说,可逆计算要求的不是互逆,而是"伴随":一个经过观测等价这一"自然同构"修正的准逆。
五. 可逆的哲学思考:有效控制熵增
DSH的代码实现和论文都不是很复杂,并不难理解。但是可逆这个概念对大多数人来说有点陌生,动态更新这个应用的必要性似乎也不高,那它是不是一种过度设计呢?
如果我们把视野拓宽,不仅仅局限于计算机领域中广为人知的传统观念,立刻就会发现可逆性在科学和哲学层面都关系到我们这个世界最底层的运作规律。应该说,目前计算机领域对于可逆性的认知还处于非常初级的阶段,对于它的应用的挖掘还远远不够成分。Nop平台是借助于可逆计算理论解决了大型软件系统粗粒度复用的问题,而DSH是借助于可逆插件尝试去解决Agent自驱动演化的问题,它们都只是一个开始。
我们所处的物理世界受到热力学第二定律(熵增定律)的支配,这个定律表明系统自发演化时熵永不减少——若过程可逆则熵不变,若不可逆则熵增加。软件演化服从同样的规律:大型软件普遍经历从有序到混乱的历程,增加功能越来越难、修改 bug 引入新 bug,最终推倒重建。可逆计算由此得到它的核心命题:熵保持不变就意味着可逆——如果系统的每一次演化都是可逆的,则合起来看,系统始终处于确定、可回退的状态,演化并不积累混乱。DSH 的运行时结构空间正是在追求这种确定性的演化:装上什么就能完整卸下什么,装了 N 个插件再卸掉任意一个,系统与"只装其余插件"从观测上不可区分——每一步都不留残余,熵不增加。
但现实世界中熵增无法逃避:业务数据要写、外部副作用要发生、代码里总有少量隐式耦合,强行要求"全部可逆"只是让不可逆性改头换面。可逆计算的态度是:控制不了熵增,但可以选择熵增发生的地方——把不可逆性集中到显式边界,让它发生在差量层而不是核心架构里。Nop 把偶然的、随机的客户需求集中到可抛弃的 delta 层,交付新客户时始终从一个低熵状态重新开始;DSH 则把不可逆内容(业务状态、外部副作用)限制在受管边界与 scope 之外,机制结构(坐标、差量、逆)保持可逆,演化只发生在可卸载的注册差量上。核心架构不受侵蚀——这是熵增被"管住"的唯一可行方式。
由此可以给出"可逆"的信息学实质:可逆 = 信息可追溯 + 信息可分离。在DSH中,可追溯意味着每个差量都带着自己的逆,运行时记录着"谁加过什么、逆在哪里",随时可以枚举并反序执行;可分离——差量以 fiber/scope 为边界登记,任何一部分都可以被完整拿走而不波及其他。两者合起来,就为软件工程中那个一直说不清的关注点分离提供了可操作的版本:到底分离到什么程度算是真正分离了? 可逆计算的回答是:分离到可逆的程度。任何增加的功能都要有配对的撤销机制,任何输入系统的信息都要有自动反向提取的方法。分离得好不好,看它能不能被完整拿走。
可逆性的另一个工程价值在于它可以复合:如果每个局部都可逆、且局部之间的结合关系也可逆,那么整体就可逆——逐步构建大型可逆系统因此成为可能,正如一个个带 execute/undo 的 Command 可以复合为一个整体上仍然可逆的 BatchCommand。DSH 的可逆性正是按这个方式组合出来的:每个插件在受限 scope 内可逆(卸载 = 反序执行自己的全部逆),插件之间只通过声明式接口(service/inject、事件坐标)结合,结合本身的规则(激活顺序、卸载守卫)由运行时保证——于是任意插件组合的整体都可逆,装多少、卸多少都不留残余。这也正是"有效控制系统复杂性边界"的机制基础:复杂性的增长被限制在可分离、可逆的差量层,而不是弥散在全局状态里;系统可以承担更多的组合,因为每一次组合都建立在一个可逆而确定的基础上。
熵增不可回避,但熵增的位置、以及可逆的边界,都可以被设计。软件的可演化性从来不是"不设限",而是把限制设计在正确的位置上——哪个层次可逆、哪个层次允许不可逆、越界之后怎么办。DSH 的坐标、差量、逆、scope、边界与观测等价,回答的都是这些问题;它把这种"约束的艺术"从经验法则变成了可形式化、可校验的运行时机制。回到开头的疑问——可逆是过度设计吗?它不是为"动态更新"这个场景发明的修辞,而是顺着熵增定律为软件演化指出的路径:无法消除混乱的累积,就把每一次变更都做成可逆的,把不可逆的部分集中到边界上——这正是本文全部机制分析背后的逻辑。
六. 结语
借助于可逆计算理论的分析框架,我们可以跳出具体的业务需求,从理论层面非常清晰直观的看出DSH的设计为什么可以起作用,它和Pi Agent这种可扩展框架的本质区别是什么,以及未来它还可以在哪些方面进行拓展。而对Nop平台来说,此前它的可逆计算机制主要集中在编译期模型构建,在运行期规划了plugin机制,但一直没有进行详细设计,Cordis的设计正好补足这个部分,后续会在Nop平台中引入类似机制,进一步完善Nop平台在运行时的动态合成能力。
参考文章
- 可逆计算:下一代软件构造理论:可逆计算理论的概要介绍,阐述了基本原理、核心公式,以及与图灵机、Lambda 演算这两种传统计算世界观的区别——本文行文所立足的理论框架。
- 广义可逆计算: 一个软件构造范式的正名与阐释:为"广义可逆计算"(GRC)正名,阐释其核心思想——以"差量"(Delta)为第一类公民,系统性地管理软件构造过程中的可逆性与不可逆性。
- (广义)可逆计算理论速览-统一软件构造与演化的新范式:对(广义)可逆计算理论的快速概览,总结了核心公式
App = Delta x-extends Generator<DSL>与关键技术实现。 - 写给程序员的可逆计算理论辨析:本文4.1节所引文章,论证了"差量的独立存在隐含要求原系统存在一个明确的坐标系统"等核心命题。
- 写给程序员的可逆计算理论辨析补遗:对《辨析》一文的补充说明。
- 写给程序员的差量概念辨析,以Git和Docker为例:差量概念与坐标稳定性的通俗讲解——Git的行号是不稳定坐标,Docker的文件路径是稳定坐标,本文4.1节的坐标论证即源于此。
- 可逆计算理论中的可逆到底指的是什么?:厘清"可逆"在多种语境中的不同含义,与本文第二节"Delta可正可负"及4.8节"观测等价性"的讨论直接对应。
- 从可逆计算看Delta Oriented Programming:对比可逆计算与学术界的面向特征编程(FOP)、面向差量编程(DOP),指出"场"与"坐标系"观念的价值——对应本文"一切皆插件=一切皆差量"的论述。
- 从可逆计算看Kustomize:Kustomize补丁机制的可逆计算解析——本文第四节开头所列举的既有分析之一。
- 从React Hooks看React的本质:React本质的可逆计算解析——本文第四节开头所列举的既有分析之一。
- 从零开始编写的下一代逻辑编排引擎 NopTaskFlow:本文4.1节所引文章,其中包含对Dijkstra坐标系思想的深化讨论(从"辅助理解"到"支持演算")。
- 模型驱动架构的数学内核:统一生成与演化的 Y = F(X) ⊕ Delta 不变式:对可逆计算核心公式
App = F(X) ⊕ Δ的数学化阐释——对应本文4.5节"插件的apply函数可以看作是一种Generator"。 - 让演化可编程:XLang 与可逆计算的结构化范式:XLang如何在结构空间中实现"演化可编程"——对应本文关于编译期结构空间可逆的论述。
- 如何在不修改基础产品源码的情况下实现定制化开发:Delta定制化开发的实践路径——对应本文4.6节"在不修改插件代码的前提下实现插件内部定制"的讨论。
本文分析的 DSH 论文:A Programming Paradigm for Spatiotemporal Composability