乐于分享
好东西不私藏

DSH 拆解手记 02:Cordis 插件树,没有核心系统的核心系统

DSH 拆解手记 02:Cordis 插件树,没有核心系统的核心系统

目录

0. 背景

1. 一句话定位

2. 树怎么长出来:启动装配

3. 树的节点:四个内核概念

4. Loader:配置到树的翻译器

5. 「没有核心系统」是怎么做到的

6. 极致形态:cordis 自进化模式

7. 代价与边界

8. 小结

0. 背景:开源 48 小时里最大的质疑

8 月 13 日 DeepSeek Harness(简称 DSH)开源那天,媒体的解读文章铺天盖地:「让 Agent 在运行中改写自己」「自进化软件的蓝图」「汽车高速公路上换发动机」。标题一个比一个抓人,但都没说到点子上。

我第一次打开仓库,第一反应是:这不是又一个 Agent 框架吗?第二反应才是关键:这个仓库里为什么躺着 9 个从上游拷贝进来的包? cordis、loader、include、group、timer、hmr……这些名字几乎没在 Agent 圈子里见过。一个开源项目把别人的代码整个 vendored 进来,要么是上游太不成熟,要么是这家公司想完全掌控自己的框架层。DSH 是后者,vendor/README 里写得很直白:让 harness 完全拥有自己的框架层,auditable、patchable、pinned(可审计、可打补丁、可锁定)。

先交代一句常识层面的痛点,你就能理解 Cordis 想解决什么。VSCode 的扩展系统是目前最成功的插件模型之一,但它有个长期被吐槽的硬伤:扩展共享一个进程,没法卸载单个扩展。装了一个有问题的扩展,要么手动禁用再重启,要么整个编辑器跟着遭殃。扩展之间生命周期纠缠在一起,谁也不敢做"运行中把 A 拔下来"这种操作。

动态组合这件事,工业界绕了十年。DSH 说自己绕开了,靠的是一棵「插件树」。

这篇文章想回答三个问题:这棵树到底长什么样?"没有核心系统"是怎么做到的?代价是什么?

文中涉及的源码分析,均基于 master 分支 commit 47f9438(2026-08-13 提交),与 01 篇同一基准。这个项目还在高速演进,实现细节可能很快变化。

1. 一句话定位:DSH 就是一棵插件树

01 篇里给过官方公式:Agent = Model + Harness。模型负责推理,剩下所有工程化能力都归 Harness。那 Harness 的实体到底是什么?答案很直接:一棵由配置文件长出来的插件树

DSH 整个运行时,就是一堆插件实例挂在树上。llm 是一个插件,session 是一个插件,agent 主循环是一个插件,文件系统、bash、凭证、沙箱、审批栈,全是插件,没有谁是"内置特权"的。树根上连配置文件都只是个空列表。

这个空列表值得单独说。apps/cli/src/profile-boot.ts 里,DSH 每次启动都会往 profile 目录重写一份根配置,内容是这样的:

# dsh profile root: an empty entry list. The tree is composed as patches: # each bundle in package.json's dsh.profile.bundles, then cordis.patch.yml, # then any --patch overlays. Edit cordis.patch.yml, not this file. [] 

注释写得明明白白:根是一棵空树,整棵树靠 patch 层叠出来。bundle 的插件行、用户的 cordis.patch.yml、命令行的 --patch 覆盖,一层层叠上去,最后长成你要的形态。

树有三层结构,一句话:配置行(声明)→ Entry(节点)→ Fiber(生命周期)。配置行是你在 yml 里写的一行 - id: xxx,Loader 把它翻译成一个 Entry 节点,Entry 启动时对应一个 Fiber,Fiber 管这个插件从生到死的状态。下面先看启动时这棵树怎么长出来。

2. 树怎么长出来:启动装配

packages/boot/app-boot/src/index.ts 里的 boot() 函数是整棵树的出生地,核心流程长这样:

const ctx = new Context()                        // 1. 建根上下文 ctx.provide('dshHomePath', dshHomePath) await ctx.plugin(Loader)                         // 2. 挂 Loader 插件 await mountRootInclude(ctx, configPath, patches) // 3. 根配置 + 所有 patch 挂进树 await assertEntriesActivated(ctx, binName)       // 4. 等所有 Entry 激活,失败就报错 return ctx 

new Context() 建出根上下文,ctx.plugin(Loader) 把「读配置、翻译配置」的能力也做成一个插件装进去,然后 mountRootInclude 把根配置(就是那个空列表)和所有 patch 层叠起来,形成完整插件清单,逐个激活。最后 assertEntriesActivated 做终检:有插件没走到可用状态,直接抛错拒绝启动,不带病运行。

boot() 对失败的分工也很讲究。启动分两段:prepare 阶段(宿主初始化)出错,报错标签是 host preparation failed;进入插件树之后出错,标签变成 plugin tree failed to load。两个标签把「宿主没搭好」和「插件树坏了」分开,排障时一眼定位是哪个环节。另外启动不是死等:如果 UI 端在树还没长完时就要求退出,mountRootInclude 会在每个 await 之后重新检查 Loader 还在不在,进程可以按用户要求干净退出,而不是把启动流程跑完再死。

整棵树分两个平面,分层逻辑 01 篇讲过,这里只说结论:

— host 平面:base bundle 的 78 行插件,管 preset 不该拥有的东西。注册表、沙箱、审批栈、持久化、模型路由、凭证都在这一层。timer、hmr、llm、session、agent、tools……每行一个 - id,全部列在 packages/bundle/base/cordis.patch.yml 里。

base bundle 的插件名单本身就是一张 DSH 架构图:timer 管定时任务,hmr 监听文件变更做热重载,llm 管模型适配器,session 管会话,typert 三件套(typert / typert-loader / typert-gateway,把源码分析、运行时存储、查询网关分开),session-title 自动生成会话标题,user-questions 处理需要向用户追问的场景,agent 是主循环本体,再往后是 fs-observation-policy、subprocess、sandbox、credentials、session-telemetry-otel……一个能力对应一行配置,架构一目了然,想查什么直接按 id 找。 - agent 平面:preset 层,standard 29 行、code 30 行、minimal 8 行、cordis 30 行,管每个会话自己的工具和提示词。

两个平面之间有一条硬约束,写在 preset 配置的开头注释里:

A service row here MUST sit inside a group carrying an isolate realm. Without one it publishes into the root realm, where it is process-global.

翻译成大白话:preset 里凡是注册服务的行,必须包在一个带 isolate 的分组里。不然服务会发布进全局 root realm,两个 preset 注册同名服务就撞车,一个会话读到的是另一个 preset 的实例。隔离不是可选项,是 mount 时的硬校验,挂载器看到违规直接拒绝。

树的骨架到这就清楚了。接下来看组成这棵树的单个节点,也就是四个内核概念。

3. 树的节点:四个内核概念

Cordis 的内核代码在 vendor/cordis/src/ 下,文件不多,概念四个就够:Context、Fiber、effect、inject。

Context = 服务仓库。 插件之间不互相 import。想用某个能力,直接在 ctx 上按 key 取:ctx.llmctx.toolsctx.sessions。context.ts 里这个对象是用 Proxy 实现的,插件往 ctx 上 provide 什么,别处就能读到什么,没有"编译期绑定死"这回事。extend() 派生子上下文(原型继承,改子不改父),isolate() 隔离服务作用域,第 2 节那条 isolate 硬约束就是靠它实现的。

Fiber = 插件的生命周期。 每个插件一个 fiber,有自己的状态机。fiber.ts 里定义得很干脆:

PENDING → LOADING → ACTIVE → FAILED → UNLOADING → DISPOSED 

PENDING 是等待依赖的服务,LOADING 是插件回调正在跑,ACTIVE 是加载完开始提供能力,FAILED 是回调或配置抛了异常,UNLOADING 是析构函数正在执行,DISPOSED 是彻底结束。一个插件从挂载到卸载,状态全程可查。

effect = 可逆副作用。 这是整棵树的灵魂。插件的所有动作都通过 ctx.effect(fn) 注册:注册的同时立即执行,fn 返回一个 disposer(逆函数),卸载时逆序执行。举最朴素的例子:插件 A 注册一个事件监听器,disposer 就是把监听器摘掉;A 被卸载时,它的所有 disposer 逆序跑一遍,它在系统里留下的所有痕迹自动清干净。卸载 = 逆序执行 disposer,这一句话就是"可逆"的全部含义。

inject = 依赖声明。 插件启动前先声明自己需要什么服务:inject: ['llm', 'tools']。服务齐了才进入 LOADING 激活;缺哪个就停在 PENDING 等着。更妙的是,某个 provider 被替换时,只有依赖它的消费者会重载,不相关的插件动都不动。加载顺序根本不是人工编排的,是依赖关系自己推出来的。

这四个概念在真实代码里长什么样?看 DSH 自己的 llm 插件,packages/llm/llm/src/index.ts。服务类先继承 Cordis 的 Service 基类,构造函数里注册服务名:

export class LlmRuntime extends Service {   constructor(ctx: Context) {     super(ctx, 'llm')   // 注册 ctx.llm 服务   } } 

注册一个能力(注册适配器路由)用的是 generator effect,注册即执行、yield 出 disposer:

const dispose = this.ctx.effect(function* (this: LlmRuntime) {   // 注册即执行:先校验再提交路由   if (providers.length === 0) throw new LlmError('an adapter must register at least one provider', 'INVALID_ADAPTER')   this.commitRoutes(owned, this.prepareRoutes(providers, adapter, owned))   // yield 的 disposer 在卸载时执行   yield () => {     released = true     for (const provider of owned) this.adapters.delete(provider)     owned.clear()     this.emitAdaptersUpdated()   } }.bind(this), 'llm.registerAdapter()') 

ctx.effect 接受 generator:函数体立即跑完,yield 出来的函数留到卸载时执行。提交路由、清理路由成对出现,可逆性不是约定,是 API 形态。

节点之间怎么通信?Cordis 有四种事件派发:emit(观察,谁都能听)、waterfall(环绕中间件,可以短路)、parallel(并行)、serial(有序且带返回值)。llm 插件里的 this.ctx.events.dispatch('emit', ['llm/adapters-updated']) 就是 emit 的用法,通知适配器拓扑变化。代码注释里有个真实世界的细节:Cordis 的 emit 内部用 Array.map 分发,一个同步异常就会让后面的监听者饿死,所以 DSH 自己把每个回调单独包了层 try,不让单个坏监听者影响提交。理想机制落地到生产,处处是这种补丁。

这四个概念加事件机制合起来,就是树的节点协议:一个节点 = 声明依赖(inject)+ 提供能力(provide)+ 副作用可逆(effect)。下面看 Loader 怎么把配置文件翻译成这些节点。

4. Loader:配置到树的翻译器

Loader 在 vendor/loader/src/config/ 下,数据结构三层:EntryTree、Entry、EntryGroup。

— EntryTree 是整棵树的抽象基类,root 是一个 EntryGroup,内部有个 store 按 id 索引所有节点,提供 create / remove / update / resolve。

— Entry 对应一行配置:id + name(模块说明符)+ config + disabled + inject。init() 负责 import 模块并启动。

— EntryGroup 就是 cordis:group,一个"文件夹"节点,config 是子行列表,用来组织嵌套结构。

有意思的是 Entry 的 disabled 支持动态求值。standard preset 里有两行:

- id: tool-bash   name: '@deepseek-ai/dsh-tool-bash'   disabled: !!js process.platform === 'win32'  - id: tool-pwsh   name: '@deepseek-ai/dsh-tool-pwsh'   disabled: !!js process.platform !== 'win32' 

!!js 表达式在加载时对着真实环境求值:Windows 上禁用 bash 工具,非 Windows 上禁用 PowerShell 工具。同一份配置,在不同平台长出不同的树,不用维护两份文件。

还有个反直觉的点,base bundle 的注释原话:

Row order carries no load semantics (activation is service-availability driven); the grouping is for readers.

行顺序不决定加载顺序。谁先激活,由 inject 依赖关系说了算:声明依赖别人的,等 provider 就绪了再上。分组和排序只是为了给人读,机器不看这个。这跟传统框架"配置文件顺序 = 执行顺序"的心智模型完全不同,第一次接触的人容易在这里栽跟头。

Entry 的更新也是 diff 驱动的:配置变了,先算哪些行要换,能原地更新的不动,必须替换的先启动新的、确认可用再换掉旧的,失败还能回滚。运行时改配置(cordis 模式的 define/run 走的就是这条路径)不会把整棵树推倒重建,而是精准替换受影响的节点。

到这一步,树怎么长出来、节点长什么样、配置怎么变成节点,都讲完了。下面回答最核心的问题:为什么说它"没有核心系统"。

5. 「没有核心系统」是怎么做到的

先看传统方案差在哪。React 的 useEffect 有 cleanup 机制,可以清理副作用,但它不可组合:cleanup 只在组件卸载时整体跑,不能嵌套、不能异步、不能只撤销一半。VSCode 的扩展系统更直接,干脆放弃了运行时卸载,改禁用加重启。共同点是:副作用一旦注册,就变成全局不可逆的状态,谁都不敢在运行中动它。

论文开头把这个问题点得很透:动态组合(运行时加载、卸载、重配置)一直没有形式化基础,所以工业界只能绕开它。VSCode 不是不想支持运行时卸载扩展,是没人能证明"卸载一个扩展不会把共享进程搞坏"。Cordis 的路线是先给机制一个理论保证,再谈工程实现。

Cordis 把问题拆成两个维度(论文《A Programming Paradigm for Spatiotemporal Composability》,作者 Yifan Shi,也就是 Cordis 作者 Shigma,北大 + DeepSeek,2026-08-13 与 DSH 同天发布):

时间可组合性(Temporal)。 副作用必须可逆。论文把 effect 形式化为一个可逆变换:执行时 Γ→Γ×(Γ→Γ),留下一个逆函数;卸载时按注册的逆序把逆函数逐个应用。这就是第 3 节说的"卸载 = 逆序执行 disposer"的理论版本。有了它,运行时挂载/卸载插件才敢做,因为任何时刻都能安全回到之前的状态。

空间可组合性(Spatial)。 组件对环境的依赖要能表达、能响应。插件声明 inject,环境变化时系统把插件分成 activating / deactivating / neutral 三类:依赖的服务出现了就激活,服务没了就停用,跟你不相干的变更直接无视。依赖缺失时静默不激活,而不是整个系统崩掉,这是自治组件能共存的根本原因。

两个维度合起来,系统的形态就变了:系统 = 插件集合 + 依赖关系。没有哪个模块是"主程序",主程序只是树根上一个空列表。核心逻辑散落在每个插件的 effect 里,靠可逆性保证随时可以重组。这就是"没有核心系统的核心系统"。

这套理论不是纸上谈兵。Cordis 的作者 Shigma 同时也是 Koishi 的作者,Koishi 基于 Cordis 跑了四年,社区插件数千个,热插拔、HMR 都是生产环境天天在用的功能。论文里的形式化证明,背后是有真实项目验证过的。

论文里还有个细节值得说:激活顺序容易(provider 先上),停用顺序难(provider 必须等所有消费者先走)。Cordis 用 UNLOADING 状态加守卫条件解决,论文用 progress 定理证明了这套机制无死锁。证明过程不展开,感兴趣的读者直接看论文,文章里只引用结论:动态组合不是玄学,是有形式化保证的。

6. 极致形态:cordis 自进化模式

前面讲的都是"人能改树",cordis 模式是"树能改自己"。

这个 preset 在 standard 基础上加了一个 tool-cordis 插件,注册七个工具,分两组:

— inspect 组:cordis_inspect_list / cordis_inspect_query / cordis_inspect_self,读当前运行时的服务注册表和插件树。

— 修改组:cordis_define / cordis_run / cordis_stop / cordis_undefine,定义、激活、停止、删除插件。

inspect 组有个很妙的实现细节:每个服务"能干什么",来自构建期生成的 api-catalog.ts(静态能力清单),live 状态来自运行时反射,两者 join 之后才呈现给模型。模型看到的服务目录,既准确又新鲜。

于是 Agent 可以干这件事:inspect_self 看一眼自己跑在什么插件树上,define 写一个新插件(比如"数一数仓库里有多少 TODO 注释"的工具),run 激活它,用完 stop、undefine 卸掉。运行中的 Agent 动态修改自己运行的运行时,这就是媒体说的"高速公路上换发动机"。

两个边界必须说清楚。第一,临时插件只存在内存里,重启即失,想常驻要把 preset 存到 ~/.dsh/.agent-presets/<id>/ 下面,官方 persona 还专门警告过别去改内置 preset,升级会被覆盖。第二,安全边界。preset 开头的 TRUST 注释说得非常直白:模型写的 JavaScript 会在真实运行时里执行,Treat a session on this preset as shell access(把这个会话当 shell 权限对待)。

所以 cordis preset 默认不开。论文的 future work 部分甚至自己承认:完全自主的自进化 harness 是目标,当前 creation mode 缺沙箱,语言级访问控制挡不住恶意插件。自进化这件事,DSH 只给了机制,安全网还没织完。

7. 代价与边界

理念讲完,泼几盆冷水。这套设计不是没有代价的,至少四个维度。

心智负担。 一行 - id: llm 背后是一个 fiber 状态机加一张依赖图。配置好写,Debug 难:插件没激活,你要查的是"它 inject 的服务谁还没就绪",而不是看报错堆栈。base bundle 的注释都说"grouping is for readers",言下之意是机器才不管你排得多整齐。对只想改个配置的普通用户,这套心智模型的门槛是真实存在的。

安全。 插件拥有完整的进程权限。vendored 框架层解决的是"可审计、可打补丁、可锁定",不是沙箱。cordis 模式的 TRUST 注释自己都说了是 shell access,论文 future work 也承认 creation mode 缺沙箱。在 Agent 生态里,一个"插件随便跑 JS"的运行时,安全网迟早要补。

生态维护。 DSH 把 Cordis 全家 9 个包 vendored 进仓库,重命名成 @deepseek-ai scope,还维护了 18 条本地修改日志,每条都登记理由和测试覆盖。好处是框架层完全可控,坏处是升级要重新贴补丁。上游 cordis 在 fork 那天停在 4.0.0-rc.7(写稿时已到 rc.8),DSH 本地已经维护到 4.0.1,这种 fork 的维护账,只有 DeepSeek 这种体量的团队算得过来。

成熟度。 Cordis v4 还是 rc 版本,上游 @cordisjs/plugin-loader 仍要求 1.0.0-rc.5(本地 vendored 已到 1.0.2),DSH 自己 0.1.0-rc.5。整个技术栈都处在"候选版本"阶段,API 说变就变。你在 47f9438 这个 commit 上读到的细节,可能过两周就改了。对个人开发者来说,现在押注这套生态,要有跟进上游变更的预期。

8. 小结

把 02 篇收束成三句话:

DSH 的"核心系统"是一个空列表。它的能力全部来自一棵插件树,树由配置文件经 Loader 翻译生长出来,节点协议是「声明依赖 + 提供能力 + 副作用可逆」。可逆的 effect 让热插拔安全,依赖驱动的激活让系统可以动态重组,这就是"没有核心系统的核心系统"的全部秘密。

01 篇讲的是分层(Profile / Bundle / Patch),这篇讲的是底层机制(Context / Fiber / effect / inject)。下一篇换个视角,从使用者的角度动手:从 preset 到插件,写一个自己的 DSH 模式。到时候你会看到,理解了这棵树之后,往上面挂一个新能力有多简单。


参考链接

— DeepSeek Harness GitHub 仓库

— Cordis 上游仓库(cordiverse/cordis)

— 论文《A Programming Paradigm for Spatiotemporal Composability》

— DSH 拆解手记 01:DeepSeek Harness,把 Agent 从"能跑"变成"能拆"