这里的 AI 用的是 GLM 5.2。下面的内容里,每段是鸭哥的 user prompt,来自语音识别,所以可能有错别字。AI 的回复折叠起来了,感兴趣可以展开看。
https://github.com/cordiverse/paper
看看这个paper讲的是啥
这篇论文叫 “A Programming Paradigm for Spatiotemporal Composability”(时空可组合性的编程范式),cordiverse 团队写的一份 preprint(2026-08-13 草稿,仍在修订)。
核心问题:现代软件——从插件系统到自进化的 agent 框架——越来越依赖动态组合,但形式化基础一直欠缺。论文把问题拆成两个正交维度:
解决思路是把经典的 effect / coeffect 理论提升为运行时机制:
实现落地为 Cordis——一个时空可组合性的 meta-framework,提供 effect 跟踪 + coeffect 解析的核心库,以及一个声明式组件加载器,支持配置 reconcile 和热模块替换(HMR)。
一句话:它给”可插拔、可热替换、可回滚”的动态软件组合提供了从理论到实现的完整范式。仓库里只有
README.md 和 paper.pdf,要看细节得读 PDF。
那你他妈的去把这个PDF下下来啊,读啊,这就这几行字还要我来让你读吗?
PDF 我这边模型不支持直接读图。我用命令行提取文本再读。
读到这里已经能看到全文骨架了。让我再读几个核心技术章节确认细节。
已经掌握了核心。再扫一眼 calculus 和实现/case study 章节,确认落地形态。
已经吃透全文。下面给你一个完整的解读。
标题:A Programming Paradigm for Spatiotemporal Composability 作者:Yifan Shi、Wei Zhang(北大),Tianyi Cui(DeepSeek-AI) 性质:preprint,2026-08-13 草稿,仍在修订。配套实现是开源的 Cordis 框架,case study 用的是基于 Cordis 构建的聊天机器人框架 Koishi(已有 4000+ 社区插件)。
现代软件越来越需要动态组合——在运行时装载、卸载、重配组件。典型场景有两类:插件系统(如 VSCode 扩展)和自进化的 agent harness(运行时自己生成并部署新组件)。但现状是:理论跟不上实践,大家只能用”粗粒度 workaround”——进程重启、容器编排——来凑合。重启会丢掉所有进程内累积状态(缓存、连接、部分计算),而且粒度错配:现代系统是在组件级组合,OS/容器却在进程/服务级操作。
论文把动态组合拆成两个正交维度:
VSCode 是它反复用来论证”两个维度都做不到”的例子:top 100 扩展里 87
个含可执行代码,卸载必须重启整个 extension
host;extensionDependencies 几乎没人用(100 个里只有 7
个声明),且 getExtension(...).exports 返回
any,没有结构化契约。
论文的关键 move 是把经典的 effect / coeffect 系统从”编译期、词法作用域内”的提升为”运行时、可直接操作的”机制。
Revertible
Effects(可逆效应)。把每个副作用建模成一个 context 变换
Γ → Γ,并配一个显式的逆 Γ → Γ。用 twisted
composition (f1,g1)∘(f2,g2) = (f1∘f2, g2∘g1) 把这些
(f,g) 对组成一个 monoid 𝔗Γ。再把上下文本身升级为
effect context
∂Γ = Γ × (Γ→Γ):左边是当前状态 γ,右边是
accumulator
φ——把至今所有效应的逆按相反顺序复合起来的函数,也就是”按一下就能把上下文恢复到初始态”的函数。track
把一个 (f,g) 作用到 ∂Γ 上:前进状态、把
g 接到 accumulator 上;recover 把 accumulator
作用回当前状态并清零。Theorem 7 保证:只要每个 g
在它实际作用到的那个状态上是 f 的左逆,recover
一定回到初始态。这套机制给出了局部时间可组合性。
但 track/recover 有两个不足:逆是先验固定的,而且 recover 是
all-or-nothing。所以论文进一步引入 effect function
𝔈Γ = Γ → Γ×(Γ→Γ)——在施加效应的地方就地返回逆,让逆可以逐状态选取;并定义新的复合
⋄(Theorem 10、11 证明仍是
monoid,且见证过的逆在复合下保持)。再定义 effect 把 𝔈Γ
提升到 𝔈∂Γ(Theorem
13/14/15/16),从而支持选择性撤销——撤销一个效应本身也是一个
effect,可以只回滚某一个而不动其余。这正对应”卸载一个插件,不影响其他插件”。
Reactive
Coeffects(响应式协效应)。对偶地,coeffect
不再是编译期标注,而是运行时的依赖声明。每个组件声明一个
coeffect specification d(它从环境读什么),每条 key 可以带
metadata。上下文带一个 coeffect context:一个 realm 表
ρ: K→R 和一个 store σ: (r:R) ⇀ V,两层解析
k → ρ(k) → σ(ρ(k))。每次 binding 变化,notify
会遍历所有活着的 fiber,看它声明的 key 是否落在变化的 key
上且解析到同一个 realm,是就 refresh
它——这就是响应式分类:一个变化把某个 fiber
的依赖从满足翻到不满足就激活/停用,否则 neutral 不动作。论文还定义了
isolation(把某个 key 重定向到新
realm,实现作用域隔离,父子互不污染)和
interception(给 key 叠加
metadata,调整”怎么用”而非”解析到什么”)。这给出了局部空间可组合性。
Context Paradigm(统一上下文)。把 effect context 和 coeffect context 合并成一个 context type——这被论文明确称为一种编程范式,而不只是一套库。关键桥梁是 observational equivalence:两个 coeffect binding 只要 observational 等价,effect 就该把它们看作同一个——这条等价关系反过来给 effect 提供了 independence(效应之间互不干扰的条件)。论文在 3.3.2 用等价关系系统刻画了 effect 的可替换性,并把它和模块系统、面向对象、actor 模型、代数效应等做了对照定位(3.3.3)。
理论构造最终汇入 component
的概念:ℭΓ = 𝔇Γ × 𝔓Γ × 𝔈Γ∗——一个三元组
(d, p, e),分别是它声明的依赖、它提供的 key
集合、它的可逆效应函数。provision 要求 disjoint(同一 key 在一个
registry 里只有一个 provider),这让它有唯一身份。
reload ↔︎ unload 互相 chaining
实现)、Failure(出错时把已执行的 inverse 按 LIFO 回滚)。落地为 Cordis,一个”时空可组合性 meta-framework”:
ctx.effect(效应跟踪 +
自动 inverse
复合)、ctx.get/set/isolate/intercept(协效应解析、provision、隔离、拦截)、ctx.use(组件实例化为
fiber,作为父上下文的一个普通 tracked
effect——所以卸载父组件会级联卸载子组件)。refresh 算法实现 inertial
state machine,reload/unload 互递归链式切换。核心点是:binding
的身份用 provider fiber 的 uid 而非值标识,所以”同一个 key 换了
provider”一次比较就能检出,而”provider 自己就地覆盖同一个
binding”不被观察——想让替换传播就得先撤再装。它把 effect/coeffect
这套类型论工具从编译期搬到了运行时,用
∂Γ = Γ × (Γ→Γ) 的”状态 +
累积逆”结构给”可插拔、可热替换、可回滚”的动态软件组合提供了从范畴式理论、操作语义到
TypeScript 实现的完整范式;Koishi
是它的存在性证明。核心新颖点不是”效应可逆”本身(transaction/saga
早有),而是把它和响应式 coeffect
统一成一个可传递到系统级的 context
type,并给出整套元理论。
OK,谢谢。但是我觉得它这个讲的是不是太理论了?你再调研一下 DeepSeek 的 harness,叫 DSH,今天刚刚发布的。它也 heavily depend on this plugin system,你能不能把它扣下来看看它是怎么实现的,跟这个东西对照一下。
先激活环境并搜一下 DSH。
uv 的 workdir 参数没生效。直接切到那个目录装。
搜索结果不够精准,让我换关键词试试,也直接查 GitHub。
找到了。DSH 就是
deepseek-ai/deepseek-harness,今天(8/13)public
beta,“everything is a plugin”,powered by
Cordis(就是那篇论文的实现)。让我把仓库克隆下来看实际实现。
仓库很大。“everything is a plugin” + vendored Cordis。让我重点看几个关键文件:架构文档、AGENTS.md、core 包、loader、boot、以及 vendor 里的 cordis 实现。
这是架构的核心文档。让我接着看 Cordis primer 和 boot 实现。
9 个文件,正好对应论文里的核心概念。让我一次性读核心的几个。
这是论文里 fiber 生命周期的直接实现。让我再看 reflect.ts(effect/coeffect 解析)和 registry.ts(plugin 注册),以及 boot 流程。
现在看 DSH 自己怎么用这套机制搭出 agent harness 的——看 boot 流程和一个具体插件(比如 tools 或 agent-loop)。
已经看清楚整条链路了。让我再看一个具体的 agent-loop 插件,确认”everything is a plugin”在实际代码里长什么样。
够了。我现在掌握了完整的图景:论文理论 → Cordis vendored 实现 → DSH 用插件树搭出 agent harness。让我再快速扫一眼一个最简单的插件例子,确认”插件写起来长什么样”。
现在我有完整的对照材料了。下面把论文理论和 DSH 实现逐点对照。
DSH 今天(2026-08-13)开放公测,仓库
deepseek-ai/deepseek-harness,12.3k stars。README
第一句就写明:“uses an architecture where everything is a
plugin, and is powered by Cordis, whose design
is described in A Programming Paradigm for Spatiotemporal
Composability”。所以这篇论文不是纯理论——它就是 DSH 的设计文档,DSH
是它的工业级落地。下面把论文里的每个核心构造对应到 vendored Cordis
源码和 DSH 业务代码里。
Context 类(context.ts)论文把 effect context ∂Γ = Γ × (Γ→Γ) 和 coeffect context
统一成一个 context type。实现里这就是 Context 类:一个被
Proxy 包裹的依赖容器,同时承载三样东西:
[symbols.isolate]:realm 表
ρ: K→R(reflect.ts:238 _getImpl 里
this.ctx[symbols.isolate][name]),对应论文 Section 3.2 的
coeffect 隔离层;[symbols.intercept]:拦截表 ι,对应论文
Definition 31 的 intercept;fiber:当前 fiber 引用,承载 effect 跟踪的
accumulator(fiber._disposables)。isolate(name) 和 intercept(name, config)
都是通过 extend() 创建原型链子上下文,不
mutate 父级——这正是论文 3.2.3 “isolation
的恢复是隐式的:丢弃子上下文即可,无需显式逆”。对照论文 Algorithm 2
ctx.isolate(key, realm) /
ctx.intercept(key, metadata),代码几乎是 1:1 翻译。
fiber.effect() +
DisposableList(fiber.ts)论文的 effectΓ : 𝔈Γ → ∂Γ → ∂²Γ 在实现里是
Fiber.effect(execute, label)(fiber.ts:418)。核心机制:
φ:论文里是
Γ→Γ 的复合逆函数;实现里是
this._disposables: DisposableList——一个有序的 disposer
列表。每注册一个 effect,collect 把它 push
进去(fiber.ts:230)。effect() 立即执行
execute(),把返回的 disposer 收集进
disposables(fiber.ts:366-399)。对应论文 Definition 12
effectΓ 的前半段。_unload() 里
this._disposables.clear().map(...) 按
LIFO(reverse) 顺序跑所有 disposer(fiber.ts:676
.clear() 返回的列表 + fiber.ts:431
.reverse())。这就是论文 Theorem 16
的”按应用的反序撤销,每个逆遇到的是它自己产生的状态”。Γ → Γ×(Γ→Γ));实现里
execute() 返回的 disposer 是个闭包,捕获了当时的状态——比如
provide() 的 disposer 闭包里持有 key,卸载时
delete this.store[key](reflect.ts:297-303)。这正是”逆按状态选取”。effect() 返回的
wrapper 本身就是一个 disposer,调用它只撤销这一个 effect
而不动其余(fiber.ts:504-514)。对应论文 3.1.2
“可以只回滚某一个而不动其余”。this.inertia: Promise 是 in-flight transition 的
handle,_setEpoch 检查
if (this.inertia) return(fiber.ts:629)——有 transition
在跑就先不启新的。_reload/_unload 完成后检查
epoch 是否变了,变了就链式切到对面(fiber.ts:665-672 /
688-695)。这就是论文 Algorithm 5 的 reload ↔︎ unload 互递归
chaining。provide() + notify() +
_refresh()(reflect.ts + fiber.ts)论文的 coeffect 解析是两层
k → ρ(k) → σ(ρ(k))。实现里:
ctx.provide(name, value) 在
ReflectService.provide(reflect.ts:277)里
this.store[key] = impl,key 来自
this.ctx[symbols.isolate][name](realm symbol)。对应论文
Algorithm 2 ctx.set(key, value):写 store +
notify([key])。_getImpl(name, strict)(reflect.ts:237)先查
isolate 拿 realm symbol,再查 this.store[key],再检查
impl.fiber.state === ACTIVE——这最后一检查就是论文里”binding
只在提供它的 fiber ACTIVE 时才对依赖可见”。Proxy handler 的
get trap(reflect.ts:136-170)沿 fiber
父链向上查找,对应论文的 provided by 关系。ReflectService.notify(names, filter)(reflect.ts:314)遍历所有
runtime 的所有 fiber,对每个 fiber 检查
name in fiber.inject 且
filter(fiber.ctx, name)(isolate realm 匹配),命中就
fiber._checkImpl(name) +
fiber._refresh()。这是论文 Algorithm 3 notify
的直接实现。Fiber._refresh()(fiber.ts:611)重新解析所有
inject 的 key,算出一个 epoch 字符串(':' + impl.fiber.uid
拼接)。这个 epoch 就是论文里 fiber 的 target
digest——用 provider 的 uid 而非值标识 binding,所以”同 key 换了
provider”一次比较就能检出。_setEpoch 比较 old/new
epoch:相同就 no-op(neutral),从 INACTIVE→非 INACTIVE 触发
reload(activating),反之触发 unload(deactivating)。这精确对应论文
Definition 26 的三分类。Plugin + Fiber(registry.ts +
fiber.ts)论文 Definition 43 ℭΓ = 𝔇Γ × 𝔓Γ × 𝔈Γ∗。实现里:
d(coeffect spec /
inject):Plugin.Base.inject(registry.ts:106),Inject.resolve
归一化成 {name: interceptConfig} map。p(provision):Service 子类构造时
super(ctx, name) 自动
ctx.reflect.provide(name, this)(service.ts:56);Plugin.Base.provide
声明提供哪些 key。e(effect function):插件的
apply(ctx, config) 或函数体或 constructor,在
_reload 里经 _execute
执行(fiber.ts:646-656)。provide() 检查
if (this.store[key]) throw(reflect.ts:289-291)——直接报错
service "x" has been registered at <fiber>。uid = parent.registry.counter(fiber.ts:235),counter
单调递增(registry.ts:207
++this._counter),uid = null 标记已
disposed——永不复用,正是论文要求的”uid drawn fresh and never
reused”(论文 5.1.3)。这是 DSH 最激进的地方,也是论文 Section 1.2.2 “self-evolving agent harness” 动机的直接落地。看架构表(architecture.md:43-52):
| 论文概念 | DSH 里的角色 | 代码位置 |
|---|---|---|
| coeffect provider | model adapter (ctx.llm) |
packages/llm/llm |
| coeffect provider | tool registry (ctx.tools) |
packages/core/tools,ToolRuntime extends Service,static inject = ['systemPrompt'] |
| coeffect provider | agent loop (ctx.agentLoop) |
packages/core/agent-loop,同样
extends Service |
| coeffect provider | session log (ctx.sessions) |
packages/core/session |
| coeffect consumer | agent-loop inject systemPrompt + tools | agent-loop/src/index.ts 顶部 import + inject |
| capability seam | fs/shell/sandbox/subprocess/terminal | architecture.md:99-102 |
关键观察:agent loop
本身就是一个插件。ToolRuntime 在构造函数里
ctx.systemPrompt.tools(...) 注册 prompt
section(tools/src/index.ts:832)——这是通过 ctx.effect
注册的可逆 effect,所以卸载 tools
插件会自动撤掉它注册的所有 prompt section。agent-loop 的
FactoryOwnership(index.ts:40-90)用
AbortController 把”caller 取消 / owner fiber unload /
factory teardown”三个取消源 fuse 成一个 abort signal,并且
teardown 注册在资源创建之前(index.ts:447-449
注释:“registered BEFORE any resource exists, so an unload arriving
while the scope is still minting finds a working disposer instead of a
leak”)——这就是论文 Theorem 63 coeffect ordering
的工程化:先保证依赖在位才读,读完到卸载之间 binding 不变。
论文 5.2.2 的三阶段 HMR 在 DSH 里完整实现:
vendor/hmr 里(对应
Algorithm 8 classify)。watchUserPatches(index.ts:232)通过
hmr.registerConfig 监听
cordis.patch.yml,变化时 entry.update() 触发
include 重算。boot() 的 catch
分支(index.ts:786-801)在启动失败时
await ctx.fiber.dispose()——整树回滚,对应论文 Algorithm 10
的”任何模块 import 失败就 restore caches + 重建 fiber from
backup”。论文是理想化的,实现里有几处能看出工程现实迫使其偏离纯理论:
Γ→Γ,而是
DisposableList。论文把逆建模为函数复合;实现里逆是有副作用的闭包列表,按
LIFO 顺序执行。这失去了”recover
是纯函数应用”的数学美感,但换来了能处理不可逆外部副作用(发邮件、扣款)——disposer
可以做 best-effort 补偿。论文 6.1 “system boundary” 承认了这点。':' + uid 拼接,靠 uid 唯一性保证”换 provider
必检出”。这在依赖少时高效,但论文里的图论保证退化成了”uid
永不复用”这一工程不变式。g(f(γ))=γ;实现里 _unload 对每个 disposer
try/catch 并
ctx.logger.error(fiber.ts:677-685),一个 disposer
失败不阻断其余——这是论文没展开的 failure semantics,对应 4.3.4
但更防御性。this.inertia: Promise
+ _setEpoch 的 if (this.inertia) return
是更朴素的”一次只跑一个 transition”实现,chain 靠
_reload/_unload 末尾的 epoch 重检查。DSH 是这篇论文的”活体证明”,而且比 Koishi(论文的正式 case
study)更贴近论文 1.2.2 描述的”self-evolving agent
harness”动机。论文提供的三大东西——∂Γ
的可逆效应代数、reactive coeffect 的响应式分类、component calculus
的元理论——在 DSH 里分别落地为 Fiber._disposables + LIFO
unload、ReflectService.notify + _refresh epoch
比对、Plugin/Fiber/Registry
三件套。论文省略的工程细节(错误隔离、abort fusion、terminal 还原、env
分层加载)在 DSH 的 app-boot 和 agent-loop 里补上了,这些恰恰是论文 6.x
discussion 里指出的”system boundary / failure /
co-design”开放问题。你说的”太理论”是对的——理论给了骨架和不变式,DSH
是长了肌肉的骨架,肌肉的形状多处溢出骨架但没断骨头。
我在这里面觉得比较烧脑的一点就是,我其实不是特别信服这个东西需要用数学符号来表达。所以我现在想要的东西就是你去看一下 internal writing skill,我们有没有一种更 intuitive 的方式让我来理解这一切。现在我就看到这么多符号就觉得如果真的花时间去钻是能钻的,但是我的 cognitive bandwidth 特别宝贵,咱们能不能用另外一种 low cognitive burden 的方式来重新讲一遍。
这是内部分析、讲给鸭哥自己听,属于 internal-facing。让我加载内部写作工作流。
好,我已经吃透内部写作工作流。下面直接用纯日常语言重新讲一遍 Cordis 论文的核心,不走任何数学符号。
这篇论文解决的是同一个问题:怎么让软件里的零件能随时插、随时拔、拔完跟没插过一样干净。 今天的软件做不到,因为”拔干净”这件事没人系统性地管。论文给了一套机制让运行时自动管,DSH 是第一个真实跑起来的大产品。
你装了个 VSCode 扩展。它注册了三个命令、改了你的快捷键、连了一个数据库。现在你不想用了,点了”卸载”。
实际发生的事:VSCode 让你重启整个扩展宿主。所有其他扩展也跟着重新加载,你正在编辑的会话状态全丢。它没有能力只卸载这一个扩展——因为没人记得这个扩展到底改了什么、改的东西该按什么顺序还原。
再换个场景:一个 AI agent harness 在运行中,它自己生成了一个新工具插件并装载进来。这个插件注册了几个 tool schema、监听了几个事件、改了系统提示词。半小时后它发现这个工具有 bug,要替换掉。理想情况:只卸载这一个插件,它注册的 tool schema 自动消失、事件监听自动摘掉、系统提示词自动还原,其他插件和正在进行的对话不受影响。现实情况:你得重启整个 harness 进程,对话中断,缓存清空。
这两个场景的共通痛点不是”能不能卸载”,而是卸载之后环境能不能干净地回到这个插件没来过的状态。这就是论文要解决的第一个维度。
论文的解决思路极其朴素,朴素到你想想会觉得”本来就该这样”。
你每次对环境做一个改动——注册一个命令、监听一个事件、往全局配置里写一个值——框架不光替你执行这个改动,还顺手让你当场写下一张”撤销条”。这张撤销条是个函数,调用它就能撤销你刚才做的那个改动。框架把所有撤销条按顺序收好。
等你这个插件要卸载的时候,框架不需要你写任何卸载逻辑。它把你的撤销条按反过来的顺序一张一张执行:最后注册的最先撤销。这就保证了每张撤销条执行时,它面对的环境跟它当初做改动时面对的环境是一样的——因为后来的改动还没开始撤销,前面的改动也没被破坏。
一个具体例子。插件 A 注册了一个命令
foo,撤销条是”删掉命令 foo”。接着插件 A 监听了事件
bar,撤销条是”摘掉这个监听器”。卸载时先摘监听器再删命令——顺序完全反过来。如果反过来先删命令,那张撤销条面对的环境就跟它当初注册时不一样了(虽然这个例子里不影响,但换成有依赖关系的操作就会出问题)。
这就完了。论文把这个叫”revertible effects”,但本质就是:每个改动附带一张撤销条,框架按反序自动执行。 插件作者不需要写卸载逻辑,框架替你管。
你可能会问:那如果插件做了不可逆的事怎么办?比如发了一封邮件、扣了一笔款。论文也老实承认——这种外部副作用没法自动撤销,只能靠你自己写补偿事务(比如再发一封”请忽略上一封”的邮件)。框架管的只是”软件内部环境的改动”:注册的东西、监听的东西、写进配置的东西。这条边界论文里专门讨论了,不是偷偷藏起来的。
现在插件之间不是孤立的。一个”数据库工具”插件需要”数据库驱动”插件先就位才能工作。一个”终端工具”插件需要”shell 后端”插件先就位。这就是依赖关系。
今天大多数插件系统怎么处理依赖?基本不处理。VSCode 的扩展之间几乎不声明依赖(top 100 里只有 7 个声明了),因为它的 API 设计就不鼓励扩展之间互相依赖——扩展只能往固定的扩展点贡献功能,不能依赖另一个扩展。结果就是,如果数据库驱动换了,所有用它的工具插件不会自动知道,要么继续用旧的(可能已经不兼容了),要么自己写一堆 ad-hoc 的检测代码。
论文的解决思路同样朴素。每个插件声明”我需要这些服务”——比如”我需要
ctx.database 和
ctx.shell“。框架每次发现有插件提供了新服务,或者撤掉了某个服务,就通知所有声明了依赖这个服务的插件,让它重新检查自己的依赖是否还满足。
具体来说,每个插件有三种状态变化:
关键细节:框架判断”依赖是否变了”不是看服务的值变没变,而是看提供这个服务的插件实例变没变。为什么?因为如果只比值,一个插件把自己提供的服务值原地改了一下,框架不会触发依赖者的重新加载——这是有意的,避免无意义的频繁重载。但如果提供这个服务的插件整个被替换了,即使新插件提供的服务值跟旧的一模一样,框架也会触发重载——因为新插件是个新身份,它的生命周期、它的撤销条都跟旧的无关,依赖者必须重新适配。
这就实现了”插件之间能声明依赖,依赖变化时自动响应,插件作者不用自己写检测代码”。论文叫它”reactive coeffects”,但本质就是:声明你要什么,框架替你盯着,变了就通知你。
一个插件同时涉及两个维度:它依赖一些服务(空间维度),又对环境做了一些可撤销的改动(时间维度)。论文把这两件事统一在同一个”上下文”对象里——在
DSH 的实现中这就是 ctx。
每个插件拿到的 ctx
既是它的依赖入口(从上面读别人提供的服务),也是它的改动出口(往上面注册自己提供的服务、注册命令、监听事件)。所有通过
ctx 做的改动都被自动附带撤销条;所有通过 ctx
声明的依赖都被自动盯着。
还有一个很精巧的设计:隔离。有时候你不想让两个插件互相干扰。比如两个插件都想提供
ctx.database,正常情况下框架只允许一个插件提供一个服务名(避免冲突)。但你可以创建一个”隔离作用域”——在这个子作用域里,database
这个名字指向一个独立的绑定,跟外面的 database
互不污染。丢弃这个子作用域时,它里面的所有绑定自动消失,不需要显式清理。
DSH 最激进的地方在于:它的每一个组成部分都是一个插件。 不是”核心是硬编码的、只有扩展是插件”,而是连 agent 循环本身、模型适配器、工具注册表、会话日志、系统提示词组装——全是插件。
举一个真实的代码例子。工具注册表(ToolRuntime)是一个
Service
子类,它在构造函数里做了一件事:向系统提示词服务注册了一个 prompt
section,让模型知道有哪些工具可用。这个注册是通过
ctx.effect
做的——也就是说,如果工具注册表插件被卸载,它注册的那个 prompt section
会自动消失,模型不会再看到那些工具。不需要任何人写”卸载时清理
prompt”的逻辑。
agent 循环插件声明依赖 systemPrompt 和
tools。如果工具注册表插件还没加载,agent
循环就处于”等待”状态,不会启动。等工具注册表就位了,框架通知 agent
循环,它才启动。如果工具注册表被替换了(比如热更新),框架通知 agent
循环,它自动重新加载来适配新的工具集。
整个产品的启动过程是这样的:一个配置文件(YAML)列出要加载哪些插件、各自什么配置。框架按这个列表加载,插件们各自声明依赖、各自注册服务,框架根据依赖关系自动决定启动顺序。某个插件依赖的服务还没就位?等。就位了?启动。所有插件都启动了?产品就 ready 了。
热更新(HMR)是这套机制的自然延伸。你改了一个插件的代码,框架识别出哪些插件依赖了被改的模块,只重新加载这些插件,其他的不动。重新加载 = 卸载旧的(执行它的所有撤销条)+ 加载新的。如果新代码有语法错误加载失败,框架回滚到旧版本——因为旧版本的撤销条还留着,重新执行一遍就恢复了。
论文专门讨论了这个问题。答案是:以前大家用”粗粒度 workaround”凑合。操作系统给你进程级的时间可组合性——杀掉进程就回收了它所有资源。容器编排器给你服务级的空间可组合性——Kubernetes 管服务之间的依赖和替换。所以大多数软件不需要细粒度的插件级可组合性,重启进程就够了。
但现代软件的组合粒度越来越细。一个 agent harness 可能在同一个进程里有几十个插件,随时增删改,不能每次都重启进程——重启会丢掉对话上下文、缓存、正在执行的任务。进程级的”杀掉重来”不再够用,需要在更细的粒度上做到”拔完干净”。这就是论文和 DSH 出现的现实驱动力。
论文里那些 ∂Γ、𝔈Γ∗、twisted
composition、monoid
homomorphism,本质上是在给上面这套朴素机制提供数学保证。具体保证三件事:
第一,撤销一定能回到干净状态。只要每张撤销条在它做改动时的那个状态下是有效的,那么按反序执行所有撤销条后,环境一定回到初始状态。这不是工程经验,是可证明的——论文 Theorem 7 和 Theorem 16。
第二,依赖传播不会无限循环。插件 A 依赖 B,B 依赖 C,C 变了通知 B,B 重载又可能导致 A 需要重载——这个链条必须有终点。论文证明了依赖图的拓扑结构保证传播一定收敛。
第三,多个并发的状态切换最终结果一致。两个插件同时卸载,无论框架先处理哪个,最终的系统状态是一样的。这叫 confluence,论文 4.4.5 证了。
这些保证对工程很重要——它们意味着框架的行为是可预测的,不是”大部分时候能用,偶尔出幺蛾子”。但理解这套机制不需要理解这些证明。你只需要理解”每个改动附带撤销条 + 按反序执行 + 声明依赖自动盯着”这个核心闭环,剩下的数学是证明这个闭环在并发和复杂依赖下仍然成立。
如果你觉得上述已经够清楚,我可以停在这里。如果你想再深入某个具体点(比如隔离作用域具体怎么实现、HMR 的事务性回滚细节、agent 循环怎么跟这套生命周期配合),告诉我哪个点烧脑,我单独展开。
然后我也希望对照 Codex 的实现来看这二者之间有什么区别。就 Codex 它本身也是有 plugin system 的,但是我的理解是,这个东西啊,DeepSeek 的 harness 只是单纯为了解决两个问题吧。
第一个,它把 agent loop 本身都变成了一个 plugin,这个是彻底的工程化,但是我不知道——这东西很优雅,但是我不知道工程上到底有什么好处。也许配置我自己的 harness 会更好,我用它去用比如说 recursive language model 这样的,或者 memGPT 这样的自动 manage cache 和 context window 的会比较友好。
然后第二个是它似乎允许理论上完备地撤销一个 plugin 的所有副作用。这个我比较感兴趣,但我觉得它写得实在是太差了,我们再看一看。
先去看 Codex 的实际实现,再回来对照。
有意思——Codex 有
plugin/、core-plugins/、ext/
等目录。让我重点看这几个。
3147 行的 manager。让我看核心的 load/reload 逻辑,以及它怎么把插件装载进运行时。
现在看 Codex 的 agent loop(core)怎么和插件衔接的。
现在看 Codex 的 agent loop 本身——看它是不是硬编码的。
rollout 是会话记录/持久化。真正的 agent loop 在别处。让我找。
这是 stream 层。真正的 turn loop 在哪:
现在看 Codex 的插件卸载/副作用清理逻辑。
现在看 Codex 的插件怎么在运行时生效——MCP server 注册。
关键发现:ExtensionRegistry 是
immutable——build() 之后只有 getter,没有
register/unregister/reload。让我确认 Codex 怎么处理插件变化。
现在看 MCP reload 怎么干。
现在看 McpConnectionSet::new 怎么处理 diff:
够了。Codex 的模式清楚了。让我再快速确认一下它有没有细粒度的 effect dispose。
现在我有足够材料做完整对照了。让我清理临时仓库然后输出。
好,材料齐了。下面直接回应你的两个具体问题。
第一个判断(“agent loop 变成 plugin 只是工程上优雅,但不知道有什么好处”)——部分对,但低估了它的实际回报。 好处不在”优雅”,在于它让你能在不碰核心代码的情况下替换整个认知架构。下面用 Codex 做对照组说明。
第二个判断(“它允许理论上完备地撤销 plugin 的所有副作用”)——对,但 Codex 也不是完全没做这件事,只是做法完全不同,粒度粗得多。 两者之间的差距比你想的要小,但方向确实不同。
我把 Codex 的源码翻了一遍。它的插件系统跟 DSH 的设计哲学完全不同,不是同一个物种。
Codex 的插件是一个磁盘上的包——一个文件夹,里面有
manifest(声明它带了哪些 skills、哪些 MCP server 配置、哪些 hooks、哪些
apps)。manifest 的结构在
plugin/src/manifest.rs:PluginManifestPaths 有
skills、mcp_servers、apps、hooks
四个字段。插件本身不包含可执行代码注入到 host
进程里。它贡献的是声明性的东西:skill 文件(Markdown prompt
片段)、MCP server 的启动配置(host 去拉起一个独立进程)、hook
脚本(shell 命令,在特定事件时由 host spawn 执行)。
Codex 的 agent loop 在 core/src/session/turn.rs:153
run_turn()——一个硬编码的 Rust async 函数,里面有一个
loop {} 反复做”组装 prompt → 发模型请求 → 处理 tool call →
循环”。这个 loop 不是插件,不可替换,你要改它的行为只能通过它暴露的
contributor
trait(TurnLifecycleContributor、ToolContributor
等)在固定扩展点上插钩子。ExtensionRegistry 在
ext/extension-api/src/registry.rs:143 是一个
immutable 结构——build() 之后只有
getter,没有 register/unregister/reload。contributor 在 session
启动时一次性注册,运行期间不变。
Codex
怎么应对插件变化?reload_user_config(session/handlers.rs:239)做的事是:重新读配置文件
→ 清掉 plugin manager 的缓存(clear_cache)→ 重新算 MCP
配置 → mcp_runtime.replace()。replace() 在
codex-mcp/src/runtime.rs:184 做的是:拿新的配置,跟旧的
connection set 对比,能复用的 MCP
连接留着(reusable_client,connection_manager.rs:87),不能复用的关掉、新的拉起来。这是一个声明式
desired-state reconcile——你告诉它”我要这些 server”,它去 diff
然后收敛。
这里有一个关键区别:Codex
的”卸载插件”=uninstall_plugin(manager.rs:1686)=
从磁盘删掉插件文件夹 + 清配置 +
清缓存。它不执行任何撤销逻辑,因为插件从来没有在 host
进程里注册过任何带副作用的东西。插件贡献的 MCP server
是独立进程,关掉进程就是撤销。插件贡献的 skill 是 prompt 片段,下次组装
prompt 时不再包含它就是撤销。插件贡献的 hook 是 shell 脚本,从 hook
registry
里移除就是撤销。撤销的粒度是”整个插件贡献的整类东西”,不是”插件做过的每个独立操作”。
| 问题 | DSH (Cordis) | Codex |
|---|---|---|
| 插件能往 host 进程里注册什么 | 任意服务、任意事件监听、任意 prompt section、任意工具——全部是 in-process 的 TypeScript 对象 | 声明性资源:skill 文件、MCP server 配置、hook 脚本、app connector——host 解释这些声明,插件代码不在 host 里跑 |
| agent loop 能不能替换 | 能,它就是一个插件(agent-loop 包),卸载它换一个别的
loop 插件就行 |
不能,run_turn() 是硬编码
Rust,只能通过固定扩展点插钩子 |
| 卸载一个插件时怎么撤销副作用 | 框架按反序执行这个插件注册时留下的每张”撤销条”——每个注册的命令、每个事件监听、每个 prompt section 各自独立撤销 | 关掉它贡献的 MCP server 进程 + 下次组装时不再包含它的 skill/hook。没有逐操作撤销 |
| 插件之间能不能互相依赖 | 能,声明
inject,框架自动盯着,依赖出现/消失/换提供者时自动通知重新加载 |
不能直接声明插件间依赖。插件各自独立贡献给 host,host 负责调度 |
| 热更新一个插件时 | 只重载受影响的插件,其他不动,事务性回滚(新代码加载失败就恢复旧的) | 重新 reconcile MCP 配置,复用能复用的连接,重建不能复用的。不回滚,失败就报错 |
| 谁管副作用的生命周期 | 框架自动管(ctx.effect 返回的 disposer
自动收集、反序执行) |
host 手动管(每个子系统自己实现 reconcile 逻辑:MCP 有
replace,hooks 有
reconfigured,各自为政) |
你提到想用 MemGPT 或 recursive language model 这类自动管理 context window 的方案。这恰好是 DSH 设计回报最大的场景。
在 Codex 里,context window 管理在 run_turn() 的
run_pre_sampling_compact 和
run_auto_compact(turn.rs:997 /
turn.rs:1163)里——硬编码的 compaction 逻辑。你要换成 MemGPT
风格的分层记忆,你得 fork Codex 的 core crate,改
run_turn(),重新编译。或者你通过
TurnLifecycleContributor
插钩子,但你只能在固定的时间点观察和修改,不能改变 compaction
的根本策略——因为 compaction 调用链是硬编码的。
在 DSH 里,agent loop 是一个插件。它的 inject 声明了依赖
systemPrompt 和 tools。你要换 context
管理策略,写一个新的 agent loop 插件,用不同的
inject(比如依赖一个你新写的 memory
服务),在配置文件里把原来的 agent-loop
那行换掉、加上你的新插件。原来的 agent loop
插件被卸载——它注册的事件监听、prompt section
全部自动撤销——你的新插件加载,依赖就位后启动。不改一行 DSH
核心代码。
这不是”优雅”,这是降低替换成本。Codex 的扩展点设计是”我在核心 loop 里预设了你能钩的位置”,DSH 的设计是”核心 loop 本身也是可替换的”。前者扩展成本低但替换成本高,后者两者都低但需要一套更重的运行时基础设施(Cordis 那套 effect tracking + coeffect resolution)来支撑。
对你想做的事——自定义 context window 管理——DSH 的模型确实更友好。你不需要 fork 任何东西,写一个插件就行。但代价是你得理解 Cordis 的生命周期模型(插件加载/卸载/依赖变化时怎么响应),这比理解 Codex 的 contributor trait 门槛高。
你的直觉是对的——DSH 的撤销确实更”完备”,但这个完备性是有边界的,而且 Codex 也不是完全没有撤销,只是做法不同。
DSH 的撤销完备性体现在:每个独立的 in-process
操作都有自己的撤销条,按反序执行。 比如
ctx.provide('database', driver)
注册了一个数据库服务,撤销条是”删掉这个 service
binding”。ctx.on('event', handler)
注册了一个事件监听,撤销条是”摘掉这个监听器”。ctx.effect(() => { setup; return teardown })
注册了任意副作用,撤销条是你自己写的 teardown。这些撤销条在
fiber 卸载时由框架自动按 LIFO
执行。完备性在于:只要每个撤销条在它做改动时的状态下是有效的,反序执行后环境一定回到初始态——这是论文
Theorem 7 证明的,不是经验性的。
Codex 的撤销粒度粗得多。MCP server 的撤销 = 关进程。skill 的撤销 = 下次不包含。hook 的撤销 = 从 registry 里移除。它没有”插件 A 注册了 3 个命令和 2 个事件监听,卸载时按注册反序逐个撤销”这种机制,因为插件压根不能在 host 进程里注册这种东西。Codex 的设计选择是用进程隔离来获得撤销的简洁性——每个 MCP server 是独立进程,关掉就干净了。代价是插件不能修改 host 的 in-process 行为(比如 agent loop 策略),只能在进程外贡献声明性资源。
这里有个 trade-off 你需要判断:你要的”完备撤销”是指 in-process 的每个操作都能精确撤销(DSH 的路),还是指插件贡献的东西能干净移除(Codex 的路已经够了,只是粒度粗)?如果你要做自定义 context 管理且需要往 host 进程里注册一堆带状态的服务和事件钩子,那 DSH 的细粒度撤销确实有用——你不用担心你的 memory 插件卸载时留下垃圾。如果你的自定义逻辑可以做成一个独立进程或纯声明性贡献,Codex 的粗粒度撤销就够了。
DSH 的 Cordis 模型有一个隐含成本:所有插件代码在同一个 Node.js
进程里跑,共享同一个 ctx。一个插件写崩了(比如
apply 函数 throw),框架会 catch 并标记 fiber 为
FAILED,但不会隔离它的内存占用或 CPU 消耗。Codex 的 MCP
server 是独立进程,一个 server crash 不影响 host,OS 自动回收资源。DSH
靠的是框架层面的 disposer 来清理,如果 disposer 本身有
bug(比如忘了清理某个 setInterval),就泄漏了。
论文 6.3 专门讨论了 sandboxing 的问题,承认这是 Cordis
模型的开放问题。DSH 的架构文档里也有 ctx.sandbox 和
ctx.subprocess 这些 capability
seam,说明它知道有些东西必须进程级隔离——它把进程级隔离也做成了插件提供的服务,而不是像
Codex 那样把进程隔离作为唯一的插件边界。
所以真实的图景是:DSH 把”进程内细粒度可组合性”做到了极致,Codex 把”进程间粗粒度可组合性”做到了够用。你的需求落在哪个区间,决定了哪个对你更值。
这个东西我没太看懂啊,就是卸载一个插件时是怎么取消副作用的?它是……这个副作用指的就不是说我们使用这个 plugin 所做的事情,是吧?我的感觉是它好像只是说把插件的声明这个东西,让它不是静态的每次启动时候搞的,而是动态的。但是我还是不太懂这玩意儿有什么用?它唯一的作用就是说我安装插件、卸载插件不用费老大麻烦,是这么着?
你这个问题问到点子上了。我先纠正一个理解偏差,然后给你一个真正有用的场景。
你说的”副作用是不是指使用 plugin 所做的事情”——不是。它指的是插件装载时对环境做的改动,不是插件运行中执行的任务。
举个例子:你装了一个”数据库查询”插件。它装载时做了两件事:往
ctx 上注册了 ctx.database
这个服务,以及往系统提示词里加了一段”你可以使用 SQL
查询工具”。这两件事就是副作用。框架替你记住了怎么撤销它们:删掉
ctx.database 这个绑定、从系统提示词里去掉那段话。
插件运行中你用它查了一次数据库——那个查询结果是你用插件做的事,不是框架管的。框架不管你的业务逻辑产生的数据,它只管”插件自己在环境里搭了哪些脚手架,卸载时把脚手架拆掉”。
你觉得”这不就是让安装/卸载插件不用重启吗”。对,但这恰恰是关键,关键在于没有这套机制,你根本做不到不重启。
让我给你一个你现在就会碰到的真实场景,而不是抽象地讲。
你现在用 OpenCode 这类 harness。你给它配了一个 MCP server——比如一个搜索引擎。这个 MCP server 在 harness 启动时拉起来,连上。现在你想换一个搜索引擎,从 Tavily 换成 Brave Search。
你今天怎么做?改配置文件,重启 harness。重启意味着你正在进行的对话上下文丢了、你之前缓存的文件索引丢了、你正在跑的后台任务丢了。就为了换一个搜索工具。
这件事在 Codex 里也是一样。Codex 的
refresh_mcp_if_dirty(session/mcp.rs:156)能重新拉起
MCP server,但它处理的是”MCP server
是独立进程”这个场景——它关掉旧进程、拉起新进程。如果你想让一个
in-process 的插件(比如一个自定义的 context compaction
策略)被替换,Codex 做不到,因为那个策略是编译进 core 的 Rust
代码,不是进程外的东西。
现在假设你用 DSH,而且你想在对话进行中把你的 context
管理策略从”简单截断”换成”MemGPT 式分层记忆”。你写了一个新插件,它提供
ctx.memory 服务。你在配置里把它加上。
发生了什么?框架发现有新插件要加载。新插件声明”我提供
ctx.memory“。框架把它装上——它的 apply
函数跑起来,注册了 ctx.memory 这个服务,撤销条是”删掉
ctx.memory“。同时,依赖 ctx.memory
的那些插件(比如你的 agent loop)收到通知:你依赖的东西来了。agent loop
重新加载,开始用新的 memory 服务。
旧的 context 管理插件呢?如果你同时把旧的卸载了,框架执行它的撤销条——它注册的所有东西消失。正在进行的对话不中断,因为你正在进行的对话状态在 session log 里,那是独立的持久化层,不挂在任何一个插件上。
这就是差别。没有这套机制,你换一个 in-process 的策略就得重启,重启就丢对话。有了这套机制,对话是持久化的、插件是可热替换的,两者解耦了。
第一,开发期的反馈循环短了。 你在写一个 DSH 插件,改了一行代码,保存。HMR 引擎检测到文件变了,卸载旧版本(执行撤销条)、加载新版本。你的对话还在,你的 agent 还在跑,但插件逻辑已经换成新的了。这跟前端开发里 Vite HMR 一个体感——你不用等”重新编译+重启+重新连+重新加载对话”这一整条链。
第二,运行期的自进化 agent 真的能自进化了。 你让一个 agent 跑一个长任务。它跑着跑着发现自己需要一个新工具。它生成一个工具插件,框架热加载进来。任务继续,不用停。如果这个工具有 bug,它发现后热替换掉。这在论文里是动机场景(Section 1.2.2),不是凑数的——没有热加载,每次工具迭代都要重启进程,agent 就没法长时间自主运行。
第三,多租户和 A/B 实验变简单了。 你想在同一个 DSH
实例上让两个 agent 用不同的 context 管理策略。Cordis 的
isolate 机制让你给两个 agent
各自一个隔离作用域,同一个服务名指向不同的实现。一个 agent
用截断策略,另一个用分层记忆,互不干扰。这在 Codex 里做不到——compaction
策略是全局的、编译进去的。
它把”插件”从”启动时静态配置的东西”变成了”运行时可增删改的东西,且增删改不破坏运行中的状态”。如果你只是装一次配好就不动了,这套机制对你没用,Codex 的模型更简单。如果你需要运行中动态调整 harness 的能力组合——不管是自进化、热开发、还是多租户——这套机制是前提条件。代价是它多了一整层 Cordis 运行时来管 effect 跟踪和 coeffect 依赖。
你现在想用 MemGPT 这类自定义 context 管理,正好落在”运行中需要替换 in-process 策略”这个区间。但你得判断一件事:你的自定义策略是写成 in-process 插件(需要 DSH 这套机制),还是可以做成一个独立的 MCP server(Codex 也能用,只是不能热替换策略本身,只能热替换 server 实例)。前者灵活但你要学 Cordis,后者受限但更简单。
但我还是觉得设计怪怪的,有点为了这种世界观硬凹的感觉。万物皆插件,但我要是不用插件,就用 CLI 和 MD 文件,它想解决的插件的很多问题突然都打在空的地方了。我本来改 MD 文件、改 CLI 就不需要重启。是不是我对它的理解还是比较狭隘?只是 cover 了一些方面。比方你能不能举个例子啊?你看我们本地不是有 context infrastructure 嘛,就是你现在的 workspace 里面有很多 skills,那我现在改 skills 本来也就不用重启 OpenCode 呀。我要重启 OpenCode 的时候是比如说改了一个配置,我要加一个什么东西,加一个 model,加一个 sub-agent,但是把它变成 hot reload 也非常 tricky 哦。我还是不太理解它这个到底有什么用,是不是立了个稻草人呢在那儿打?
你的直觉是对的,我不想硬替它辩护。让我诚实地拆一下。
你的 skills 是 markdown 文件。OpenCode 每次加载 skill 时重新读文件。你改了文件,下次用到就生效。不需要重启,不需要任何框架。
这是因为 skill 是声明性数据——它就是一段文本,没有内部状态,不持有任何活的东西。改文件 = 改数据 = 下次读取时自动生效。
Cordis 那套机制(effect 跟踪、coeffect 依赖、fiber 生命周期)根本不是为这类东西设计的。对于声明性数据,“文件 + 下次读”已经是最优解,没有改进空间。
它管的是另一类东西:在 host 进程里活着的、有内部状态的对象。
比如一个数据库连接池。它不是一个文件,它是一个活的对象——持有到数据库的
TCP
连接、维护着连接队列、有后台心跳任务。其他代码通过函数调用直接用它(ctx.database.query()),不是通过
IPC。
这种东西你没法用”改文件”来替换。你改了文件,那个活的对象还在内存里,其他代码还持有对它的引用。你必须有一套机制来:关掉旧对象(释放连接、停心跳)、创建新对象、把所有持有旧对象引用的代码指向新对象。
这就是 Cordis 干的事。ctx.effect
记住怎么关,notify 通知持有者重新拿引用。
你提出的问题在这里:agent harness 里到底有多少东西必须是”活对象”而不能是”文件 + 独立进程”?
Codex 的回答是:几乎没有。它的插件贡献的是 skill 文件(数据)、MCP server(独立进程)、hook 脚本(shell 命令)。全都是声明性的或进程外的。agent loop 是硬编码的 Rust,不是可替换的活对象。它不需要 Cordis,因为它的架构里没有”需要被热替换的 in-process 活对象”。
DSH 的回答是:很多东西应该是活对象。agent loop 是活对象、tool registry 是活对象、model adapter 是活对象、session store 是活对象。它们在同一个进程里互相持有引用、互相函数调用。所以它需要 Cordis 来管这些活对象的生命周期。
这两种选择都成立,但代价不同。
Codex 的选择:简单,不需要 Cordis,但 agent loop 不可替换、插件不能修改 in-process 行为。
DSH 的选择:agent loop 可替换、插件可以修改任何 in-process 行为,但需要一整层 Cordis 运行时来管这件事。
我觉得你的直觉大方向上是对的,但有一个边界没踩到。
你说”改 skill 不用重启,改 model 加 sub-agent 要重启但 hot reload 也很 tricky”。这没错。但你把这两件事放在同一个层面了,它们其实不一样:
改 skill 不用重启,是因为 skill 是纯数据,谁都不持有它的活引用。
改 model 要重启,不是因为 model 配置本身有多复杂,而是因为 model adapter 在 OpenCode 里是一个活对象——它持有到 API 的连接、维护着路由状态。你换 model 配置,你得让那个活对象重新初始化。OpenCode 没有做这件事的机制,所以你重启。
Cordis 的价值就在这里:如果你决定把 model adapter 做成可热替换的活对象,你需要一套机制来管它的生命周期。 Cordis 就是那套机制。
但问题是——你需要 model adapter 可热替换吗?你多久换一次 model?一天一次?重启 3 秒钟能接受吗?如果能,Cordis 对你就是多余的。
唯一一个我觉得 Cordis 确实比”重启”有明显优势的场景:一个 agent 正在执行一个长任务(几小时),中途需要换一个 in-process 的策略组件,且不能中断任务。
比如:agent 在跑一个大规模代码重构,已经跑了 2 小时,积累了一堆上下文(探索过的文件、已做的修改、中间推理)。这时你发现它的 context compaction 策略有 bug,在长上下文下会丢失关键信息。你想换成 MemGPT 式的分层记忆。
重启方案:停掉 agent,换策略,重启,从 session log 恢复。session log 是持久化的所以不丢。但 agent 的 in-flight 推理状态(“我正准备改第 3 个文件因为前两个改完了”)可能丢了,取决于你持久化了多少。
Cordis 方案:热替换 compaction 插件,agent loop 收到通知,下一个 turn 开始用新策略。in-flight 状态不丢。
差别:重启可能丢 in-flight 推理上下文,热替换不丢。但前提是——你的 session log 够完整、你的 in-flight 状态够重要、你的任务够长,这个差别才值得。对一个 5 分钟的对话任务,不值得。
对于 Koishi(4000 个插件、多作者生态、插件之间密集依赖),Cordis 的价值是实的——依赖管理、隔离、热替换在生产环境里每天发生上千次。
对于一个个人 agent harness,你控制所有组件,插件数量是个位数,重启代价是几秒钟——Cordis 的价值大幅缩水。你用”文件 + MCP 进程 + 重启”能覆盖 90% 的需求。剩下 10% 是”运行中替换 in-process 策略且不能中断”,这个场景真实但罕见。
它不是稻草人,但它的适用面比论文暗示的窄得多。论文把它写成一个通用范式,实际上它专门服务于”密集 in-process 服务网格 + 运行时热替换”这个特定架构选择。如果你不选这个架构,它解决的问题大部分就不存在。
OK,我看了你的这个 MCP 的例子,就是 OpenCode 加上一个 search MCP,这个例子我觉得是蛮好的。所以它根本上是这样的,就是说这件事情 Codex 想要支持不是说做不到,关键是它只要实现这个接口就可以了。
事实上我就在想理解这个东西跟 Codex 之间设计上的差异到底是什么,比如说我为啥要去重启这个呢?OK 是因为我有状态要更新,我有一个配置要更新,那我为什么不能把这个 service 做成 hot reload 呢?就我仍然是用 Codex 的接口,那你重启就重启呗,重启咋了?然后像 DeepSeek DSH 它就是说把重启给拆了一下,怎么说呢,我觉得两者一个是做加法、一个是做减法。如果我用 Codex 这个框架想要实现这个功能,那我需要做加法,我需要去加上 serialization 和 deserialization 这块,它默认是不支持的,但是你可以支持,只是你要自己去实现。DeepSeek 这边呢,它默认是支持的,但还是要你自己去实现,因为没人知道你怎么样去保证、怎么样回滚、怎么样刷新,就是要刷新哪些东西这是你自己来定的。
所以我在想,它俩好像怎么说呢,就上限没有差别,差别是下限。DeepSeek 就要求你一定要实现 hot reload,Codex 就给了更多的自由。我觉得这个就是 trade-off 吧。我最终想讨论的是这样一个东西:它从设计思想上、本质上和现在 Codex 之类的机制到底有什么区别,以及它为什么这么设计,以及它有没有什么实在的场景,它的好处是什么?就到目前为止其实我没看到什么好处,就是在你举的这个例子里面,用同样的 dev effort,我觉得 Codex 可以实现同样的功能。DeepSeek 这个呢,只是用强行的协议来把它的下限提高,它就是逼着你用更大的 dev effort,那它当然有更大的好处。但这个东西是有 trade-off 的,那我就想理解 trade-off 的 benefit 到底是什么。
然后你说的这个运行期自进化 agent 真的能自进化,我觉得这是一个非常核心的场景,但是我也不理解。比如还是 Codex 这个例子,我要改这个 mcp_server,那我就 hot reload 呗,无所谓。而且我觉得这可能不是个特别好的例子,因为每一个 search session 它应该都是彼此独立的,我们要找那种对 context 更加依赖的例子才能更符合现实,也更符合它的 benefit。但是这个我觉得还蛮难找的,你也不妨 brainstorm 一下,就是我还是在 struggle in finding a solid and concrete example showing DSH is better。
然后另外一个呢,我觉得是 better 的就是它把 agent loop 搞成了一个插件,我觉得这个非常值得 deep dive,因为这个确实是让它能够自进化的一个非常核心的东西,我觉得 Codex 是做不到的因为它是写死的。这部分咱们也可以沿这个方向做一下 deep dive,看看到底应该怎么用。然后下面呢是我的一些跟别人讨论的想法,我们也可以看一看,也许对我们的讨论有启发。
这里面牵扯到太多讨论的背景了。我简单概括一下我的观点:
Harness 整个领域的发展有一个倾向,就是抽象化。MCP、skills、plugins 都是针对给 AI 新的能力这件事情不同的抽象方式。DeepSeek 把这三者又加了一步更高层的抽象。抽象化的好处是人理解起来方便,看起来很爽。但也有 trade-off。具体是什么这里就不展开了。
如果我们抛弃这些抽象化去看最底层它想解决什么样的问题,你会发现它没有带来语意上的丰富性。至少我到目前还没有找到任何一个例子,MD 和 CLI 解决不了,但是 MCP 或者 skills 或者 plugins 能解决得了的。我不知道 DSH 会不会带来改变,这个是下面要尝试的地方。
你的”上限一样、下限不同”框架是准确的。我沿着这个往下走,不替 DSH 硬辩。
你说的完全对:Codex 要支持 in-process 热替换,做加法就行——给它加序列化/反序列化、加生命周期接口、加依赖图管理。DSH 要支持一个插件,你也得自己写 effect 的 setup/teardown——框架只管调度,不管你的业务逻辑具体怎么回滚。两边的上限是”你自己实现的逻辑质量”,跟框架无关。
所以真正的问题不是”能不能做到”,是”框架替你免费提供了什么基础设施,以及这个基础设施值不值你被迫付出的额外 dev effort”。
Cordis 免费提供三件事,你在 Codex 里要自己造:
第一,撤销条的生命周期管理。 每个
ctx.effect() 返回的 disposer 自动进 fiber 的
_disposables 列表,fiber 卸载时按 LIFO
自动执行。你不需要自己维护”谁注册了什么、按什么顺序清理”。在 Codex
里你要自己写这套——一个 Vec<Disposer>,卸载时 reverse
遍历,处理异常,处理并发卸载。
第二,依赖变化的自动通知。 插件 A 声明依赖
ctx.database,有人提供了 ctx.database
或撤掉了它,框架自动通知 A 重新检查。在 Codex
里你要自己写——维护一个依赖图,注册回调,处理循环依赖。
第三,事务性热更新。 HMR 时新代码加载失败自动回滚到旧版本。在 Codex 里你要自己写——备份旧状态、try 加载新状态、catch 回滚。
这三件事都不是 rocket science,但每件都有 edge case:并发卸载的竞态、依赖链的传播终止、回滚时 disposer 失败的容错。Cordis 的 750 行 fiber.ts 就是把这些 edge case 踩过的实现。你在 Codex 里自己造,大概也是几百行,但第一次大概率踩坑。
这就是 trade-off 的核心:DSH 的 benefit 不是”能做 Codex 做不到的事”,是”不用自己造这几百行基础设施且踩坑”。 你被强制走这条路,换来的是路已经铺好。
你要的那种”context 强依赖、Codex 真做不到、DSH 真有优势”的例子,我想了很久,最诚实的答案是:只有一个场景 DSH 有结构性优势,而且这个场景窄到让你判断值不值。
场景:agent 正在执行一个长任务(几小时),中途需要替换一个 in-process 的、持有状态的、被其他组件直接函数调用的 策略组件,且不能中断任务。
具体化:agent 在做一个大型代码重构。跑了 2 小时,探索了 50 个文件,改了 15 个。它的 context compaction 策略是简单截断——截掉最早的消息。现在 context 长到 80k token,截断开始吃掉它早期探索时发现的关键约束(“这个函数不能改签名因为被 3 个外部调用方依赖”)。agent 还没改完,但快忘掉为什么不能改这个签名了。
你想把 compaction 从”截断”换成”分层记忆”——保留所有关键约束的摘要,只压缩中间推理过程。
Codex 路径:
run_pre_sampling_compact(turn.rs:997)是硬编码的。你
fork core crate,改 compaction 逻辑,重编译,重启。重启后从 session log
恢复对话历史——这 50 个文件的探索记录在 log 里,不丢。但 agent 当前的
in-flight 推理(“我正准备改第 16 个文件,因为前 15 个改完了,下一个要改
auth.rs 因为它依赖被我改过的 token
校验逻辑”)丢了,除非你把这种推理状态也持久化了。Codex 的 session log
记录的是已完成的 event,不记录”我下一步打算干什么”。
DSH 路径: 写一个新的 context management 插件,提供
ctx.memory,实现分层记忆。热加载它,agent loop
收到通知(依赖的 ctx.memory 变了),重新加载。下一个 turn
开始用新策略。agent 的 in-flight 推理状态在 agent loop 插件的 fiber
里——如果 agent loop 插件本身没换,只是 context manager 换了,in-flight
状态不丢。
差别就在这:重启可能丢 in-flight 推理状态,热替换不丢。 但前提是 in-flight 状态足够重要、任务足够长、且你要换的是 in-process 组件。
你那个”search session 彼此独立”的直觉完全正确——search 不持有跨 session 状态,换 search provider 重启无成本。真正有成本的例子必须是持有跨 turn 状态的 in-process 组件,而且这个状态没有被完整持久化到 session log。这种组件在 agent harness 里不多,context manager 是最典型的一个,可能也是唯一一个。
这是唯一一个我觉得 Codex 不是”做加法就能追上”的地方。不是技术上追不上,是架构选择上追不上。
Codex 的 run_turn()
是一个函数。它内部的调用链是:pre-sampling compaction → build context →
send model request → process tool calls → loop。这条链是硬编码在
Rust 编译期的。你能通过 TurnLifecycleContributor
在固定时间点插钩子,但你不能改变链的结构——不能把”build
context”从”一次性组装”改成”流式按需加载”,不能把”单 agent 循环”改成”多
agent 协调循环”,不能把”model 请求 → 等 response → 处理 tool
call”改成”model 请求 → 流式处理 → 并行执行 tool”。
这些不是”加一个钩子”能做的,是控制流的根本结构变了。在
Codex 里要做到,你得改 run_turn()
本身。那就不叫”加钩子”了,叫”换引擎”。
DSH 里 agent loop 是
packages/core/agent-loop,一个普通插件。它提供
ctx.agentLoop 服务,声明依赖 ctx.systemPrompt
和 ctx.tools。你可以写另一个 agent loop 插件——比如一个多
agent 协调循环——提供同样的 ctx.agentLoop
接口但内部完全不同的控制流。配置里把旧的换成新的,框架自动卸载旧的(它的所有事件监听、注册的服务自动撤销)、加载新的(依赖就位后启动)。
这里 Codex 做加法也追不上的不是”能不能替换”——你当然能 fork 改
run_turn()——而是”替换的边际成本”。 fork core
crate、改
Rust、重编译的代价远大于写一个新插件。而且如果你想在运行中换(比如 agent
发现当前 loop 策略不适合当前任务),fork-recompile-restart
根本不现实。
所以 agent loop 作为插件的真正价值是:把”改变 agent 根本行为”的边际成本从”fork+recompile+restart”降到”写一个插件+热替换”。 这是量变到质变——边际成本足够低时,事情就从”理论可行”变成”实际会做”。
你问”agent 能不能自进化”。诚实地说,今天不能,不管用 DSH 还是 Codex。
原因不在框架,在模型。让一个 LLM 生成一个比当前更好的 agent loop 插件——写正确的 TypeScript、正确的 Cordis 接口、正确的控制流——这超出了当前模型的能力。模型能生成一个简单的 tool 插件(几十行代码,单一功能),但生成一个几百行的 agent loop(处理 turn 状态、context 组装、tool 调度、错误恢复)还不现实。
所以”self-evolving agent harness”这个场景目前是理论上的。DSH 提供了基础设施让它成为可能——如果有一天模型能力到了,DSH 不需要改架构就能支持,Codex 需要先把 agent loop 提取成可替换的。但”如果有一天”不是 today。
“抛弃抽象化看底层,没有 MD+CLI 解决不了的问题”——这个判断在能力层面是对的。DSH 不让 AI 做任何它做不到的事。MCP 不让 AI 做任何 CLI 做不到的事。skill 不让 AI 知道任何 MD 文件里没有的东西。
这些抽象解决的不是能力问题,是工程问题:怎么管理组件的生命周期、怎么处理组件间的依赖、怎么在不中断运行的情况下替换组件。这些在 AI 能力上是零增益,在系统工程上是实打实的。
所以判断 DSH 值不值,取决于你把 agent harness 看成什么:
如果你把它看成一个跑 AI 的程序——AI 的能力来自模型,harness 只是个壳——那 DSH 的那套机制确实是 over-engineering。壳不需要这么复杂,重启壳无成本。
如果你把它看成一个需要自己演化、自己管理、长时间运行的系统——系统的行为本身需要动态调整——那 DSH 的机制是基础设施数量级的贡献。不是因为它让 AI 更聪明,是因为它让系统更 adaptable。
你现在的用例(个人 harness、自定义 context 管理、想用 MemGPT 式策略)落在中间偏前者。你需要的是”换一个 context 管理策略”,这件事 fork Codex 改 compaction 也能做,重启代价是几秒钟。DSH 的热替换优势在这个场景下不构成质变。
它构成质变的场景是:agent 跑着、不能停、要换一个 in-process 的东西。 这种场景今天罕见,但如果 agent 真的开始跑小时级或天级的长任务,它会从罕见变成必要。DSH 赌的就是这个未来。
我觉得第一个 DSH 免费给的东西到底是什么,这个非常好。但我的 counter argument 是为什么这不能是一个 skill?你看一下我们的 blog,有一篇讲 generative kernel 的 blog,讲的是超越 DIY、AI native 时代的软件重构,你可以看一看。但这是一个 minor point 了。然后关于自进化 agent 的真实约束我不赞同你的观点,我觉得让 LLM 生成一个比当前更好的 agent loop 插件这应该是 well within its boundary 的。我相信 AI 可以做到,哪怕是你都可以做到。提供基础设施我觉得 make sense。然后我那个观察不是我朋友的,是我的,那段话是我写的。
这是一个偏主观的文章。我想的是,就讨论 DSH,有点像深度剖析吧。就比方先介绍一下 DSH 的基本思想,然后呢你会发现它有很多新特性。
那我们这篇文章的主要目的就是抓大放小。它有些新特性其实跟 Codex 是完全 parity 的。然后我目前感觉,conclusion 应该是如果你是普通的 AI builder 可能这个东西对你没什么用。它更像是一个为了让 AI 能够、或者为了让 Harness 能够自我进化的一个前置 infrastructure,对大多数人来说没什么用。它不会比 Codex 更好用,或者 Claude Code、Cursor、OpenCode 这样的东西,它不会更好用,但是它会是探索 Harness 上限的那帮人手里的一把利器。
然后介绍它的一些——这个不一定要照这个顺序写啊——我想的是介绍它的一些特性,就把一些特性先讲一下,讲这些都不关键。关键是两个特性。第一个是不同的抽象层,Codex 的抽象层是……就主要讲它那个回滚啊、事务性啊之类的东西。然后再讲它的局限,这个东西对绝大部分东西都没有用。就 Codex 它是声明式的,然后 DSH 它是命令式的,它需要有内部状态。但如果真的想找的话其实很难想到例子它什么地方有用。但是它有个特例特别有用,就是 agent loop 本身是一个 plugin。
然后再照我们刚才讲的,这个对大多数人也用不到,但如果你要 AI 自我进化 Harness 的话,这个是必备,现在只有 DSH 它能做到,别的都做不到。等于是为了……我的观点是,为了这盘醋,哦对,为了这盘醋包了这碟饺子。它的核心是实现这个 plugin,但是为了比如说合理的抽象、软件工程的抽象,它去硬搞了一套抽象出来。我们想想看这个 external facing 文章 outline 可以怎么写。
让我先看看你那篇 generative kernel 的 blog。
搜不到。让我换个方式——搜中文标题关键词。
我看到了几个可能相关的文件——knowledge_engine.md、context-infrastructure.md、agentic-memory.md、skill-first.md、mcp-revisited.md。让我快速看几个候选。
context-infrastructure.md
看起来最可能是你说的那篇。让我读一下。