DeepSeek Harness 的插件是怎么实现的:从装载到卸载的全流程
给一个系统加插件机制,第一版通常很好写:维护一张注册表,启动时挨个调 register() 把实现放进去,用的时候按名字取出来。八成场景这样就够,很多项目就停在这儿,也没什么毛病。
难的是它跑起来之后。你想换掉其中一个插件,又不想重启整个进程——这时候那张表就不够了,至少还有四件事得有人负责:
- 插件之间有依赖,加载顺序谁来排
- 依赖还没就绪的时候,这个插件是先启动起来然后拿不到,还是压根不启动
- 卸载的时候,它装过的监听器、定时器、子进程谁来收
- 收到一半又有人往里装东西,算谁的
前三件是流程本身,第四件是流程打架的时候,它最容易被忽略,也最难在事后补。
这四件事在传统做法里基本靠开发者自觉。自觉的代价是每次卸载都可能残留点东西,最后只能靠重启进程兜底。
对普通服务来说重启没什么。对一个跑着长任务、缓存着上下文的 agent 来说,重启意味着这些状态全部丢失。
DeepSeek Harness(命令行名 dsh,2026 年 8 月开源)把这四件事交给了底层框架 Cordis,自己又补了几处加固,交得足够彻底——彻底到 agent 主循环本身也只是一个可以被换掉的插件。
我把主流 coding agent 的扩展机制挨个翻了一遍,Claude Code 的 hooks 和 plugin、Codex 的 config.toml、Pi 的 Extension、OpenCode 的 plugin、Hermes 的两套 hook 面,没有第二家做到这件事。所有扩展点都在循环内部的固定位置拦截、改写、阻断,循环骨架被当作不可变的核心。
| harness | 扩展形态 | 主循环能不能换 |
|---|---|---|
| Claude Code | hooks、MCP server、plugin/marketplace、skills | 否,文档未提供该机制 |
| Codex CLI | AGENTS.md 指令文本 + config.toml + MCP + hooks | 否 |
| Pi | Extension API,注册工具、注册命令、监听事件 | 否 |
| OpenCode | plugin,20 多个 hook point | 否,SessionPrompt.loop() 被当作不可变核心 |
| Hermes | 进程内 plugin hooks + 文件系统驱动的 gateway hooks | 否 |
| dsh | 一切都是 Cordis 插件 | 是,agent-loop 只是 packages/core 下的一个包 |
这张表是侧面反推出来的。没有哪篇文章正面讨论过「agent loop 本身作为可替换组件」这个设计问题,无论支持还是反对,各家资料都只是从扩展点长什么样透出循环动不了。准确的说法是 dsh 在这一点上目前找不到直接先例可比。
主循环能被换掉靠的是两件事:一是扩展插件一律依赖 agent 这个接口包,哪怕需要发起 Agent 时也一样,绝不直接依赖 agent-loop 的实现;二是插件的装和卸都被管死了,摘下来能摘干净,装回去不依赖装的先后。
这篇讲的是后一件,也就是那条流程本身:一个插件从被装进来,到依赖就绪被激活,到被换掉或退出,中间每一步由谁负责,上面那四件事分别落在哪儿。
它的解法可以压成一句:把每次注册都要求交出逆操作,把每个依赖都要求声明成激活条件。 于是加载等于执行一串可撤销的操作,卸载等于逆着把它们执行回去。同一套机制管两头,这是理解后面所有细节的前提。
先说清 Cordis 是什么
后面的事全挂在它上面。
Cordis 是 dsh 底层的一个元框架,也就是用来写框架的框架,负责插件的加载、卸载、依赖解析和生命周期。dsh 自己不实现这套东西。
它不是为 agent 生的。它原本是聊天机器人框架 Koishi 的底层,那个框架 2019 年开源,服务端的每一项功能都是一个 Cordis 插件。dsh 没有把它当外部依赖引用,而是整包复制进自己仓库、自己维护,然后才用于 agent 运行时——后面会看到,这个「自己维护」不只是拿来用,它还往里补了东西。
Cordis 管一个插件实例叫 Fiber。每装一个插件,就对应一个新建的 Fiber,它持有这个插件当前处在哪个状态,以及它一路上登记了哪些待撤销的东西。生命周期这件事,说的就是这个 Fiber 的一生。
也正是这个名字,让这篇有个答案给不了:dsh 跑得快不快。全网没有任何一份量化性能数据,启动耗时、插件数与内存占用的关系、热重载延迟,一个都查不到,而搜 Fiber benchmark 命中的全是 Go 语言那个同名 Web 框架。
一个插件的一生
Fiber 有六个状态:等待中(PENDING)、加载中(LOADING)、活跃(ACTIVE)、失败(FAILED)、卸载中(UNLOADING)、已销毁(DISPOSED)。
flowchart TD
P["等待中 PENDING<br/>依赖还没齐,先挂着"]
L["加载中 LOADING<br/>依赖齐了,正在跑初始化"]
A["活跃 ACTIVE<br/>正常工作"]
F["失败 FAILED"]
U["卸载中 UNLOADING"]
D["已销毁 DISPOSED"]
P -->|"依赖全部就绪"| L
L -->|"初始化跑完"| A
A -->|"任一依赖消失<br/>或被显式卸载"| U
L -.->|"初始化出错"| F
U -.-> D
F -.-> D
style P fill:#ECEFF1,stroke:#5B7B9E,color:#37474F
style L fill:#E3F0F7,stroke:#5B7B9E,color:#37474F
style A fill:#E0E7EE,stroke:#5B7B9E,color:#37474F
style F fill:#F0ECEC,stroke:#9E7B5B,color:#37474F
style U fill:#F5F2E8,stroke:#9E8B5B,color:#37474F
style D fill:#ECEFF1,stroke:#5B7B9E,color:#37474F这张图里各条边的证据强度不一样,得交代清楚。
依赖驱动的那几条有明确依据:依赖没齐就挂着、依赖齐了才激活、任一依赖消失会触发卸载,这些官方文档和源码里都写了。六个状态本身也是枚举里明写的,所以「加载中」和「活跃」之间必然有一道分界,只是官方没细说这道界具体怎么判。
虚线那三条是我按状态名推的。FAILED 和 DISPOSED 具体怎么进、进去之后还能不能出来,我没有查到权威说法。还有一条我同样没查到,所以图上干脆没画:卸载执行到一半,某个撤销方法自己抛错了会怎样——是跳过继续往下清,还是整个卸载中断。图上缺这条边不是因为它不存在,是因为我不知道。
这张图值得记住的地方在于,两种东西在管不同的事:状态之间往哪走,由依赖状况说了算;而每次走进卸载中要收拾些什么,由注册时交出的撤销方式说了算。
加载:等到条件齐了才启动
顺序谁来排,依赖没就绪时插件处在什么状态,这两件事都在这一步定下来。
多数人把依赖注入读成「容器帮我把实例递进来」,关心点全在取不取得到。Cordis 里的 inject 要回答的正是后一个问题:取不到的时候,这个插件处于什么状态。
答案是它压根不启动。
插件声明自己需要哪些服务,依赖没就位它就一直挂在等待中,等到位了才进入加载。「先取到再判空」是另一回事:那时候插件已经跑起来了,判空是它自己的运行时逻辑。这里插件根本还没被启动。
由此带来一个很实际的好处,加载顺序不需要人工排。它是依赖关系的推论,不是配置文件里的一个数组。声明清楚谁依赖谁,顺序自己就出来了。
依赖是按名字声明的。服务用稳定的名字占位,ctx.tools、ctx.llm、ctx.sessions,插件按名字查找,不导入具体实现——这也是后面换实现不用改使用方的机制来源。而这些名字不是随便写的字符串,它们从上下文的类型里推出来,写错一个字,编译期就会被拦下。
反过来那个方向更容易被忽略:服务提供方变了,依赖它的插件会先卸载,再按新依赖重新绑定。这是自动发生的,没人点过卸载。很多时候你根本没碰那个插件,是它脚下的东西换了,它自己就走了一遍卸载再装回来。
这里有个异步带来的麻烦。初始化经常是异步的,跑到一半的时候,依赖可能已经变了。等它跑完,这次加载的结果其实已经作废。照样把结果装上去,装的就是一个基于旧依赖算出来的东西。
Cordis 的处理办法是给依赖满足状况盖一个戳:依赖齐了是一个正常值,任一依赖缺失就置成一个特殊值。异步初始化开始时记下当前的戳,跑完之后再比一次,不相等就说明期间依赖变过,这次的结果直接丢掉。
这个办法本身有个已知的漏洞:戳只能看出首尾一不一样,看不出中间变没变过。就像出门前看一眼门锁着,回来再看一眼还锁着,你没法据此断定中间没人进来过。
具体到这里:一次异步加载正在跑,中途有人调用了更新配置的接口,戳从正常值变成特殊值又变回正常值。等这次加载跑完再比对,两个值相等,于是误判「期间没变过」——该作废的结果被留下,新配置反而被静默丢弃。Cordis 仓库里挂着的这个 issue 至今还开着。
卸载:注册的时候就把撤销方式交出来
装的时候麻烦在排队,卸的时候麻烦在收尾——它装过的那些东西,得有人收。
多数框架的做法是给一个清理钩子,onDestroy 或者 deactivate,你在里面写清理逻辑。开头说的那种残留就是从这儿来的——这个设计有个隐藏前提,写插件的人得记得自己装了什么。想起来就登记一下,忘了就漏。
Cordis 换了个位置来解决。它不提供清理钩子,注册这个动作本身就要求你把撤销方式一起交出来。交出来的东西形态不对,运行时当场抛错。
装一个监听器,得连摘掉它的方法一起交上去。定时器、子进程同理,三类资源走的是同一套注册接口。一次注册里交出的撤销方式按登记顺序攒成一份清单,插件卸载时倒着走一遍。
sequenceDiagram
participant P as "插件"
participant F as "运行时"
participant R as "外部环境"
P->>F: "一次注册里装两样,监听器和定时器"
P->>F: "同时交出摘除和取消这两个方法"
F->>R: "监听器装上"
F->>F: "摘除方法进清单"
F->>R: "定时器跑起来"
F->>F: "取消方法进清单,排在后面"
Note over P,R: "插件被卸载"
F->>R: "先取消定时器"
F->>R: "再摘监听器"
Note over P,R: "这一次注册的清单倒着走完,环境回到装之前"为什么必须倒着走,用装修打个比方就够了:先铺地板再摆家具,拆的时候当然得先搬家具再撬地板。后装的东西可能压在先装的东西上面,撤销顺序反了就会撞车。
这个顺序有出处。Cordis 有一篇配套论文,把副作用建模成「做」和「撤」的一对,两个副作用接起来时,做的按顺序、撤的按倒序。倒序是这个模型自带的性质,实现的时候不用再另外约定。
这条规矩在 dsh 里的覆盖范围包括提示词片段、工具 schema、模型适配器和事件监听器,它们全都经由这条路径安装,reload 和 teardown 时按同一套规则撤销。dsh 把它写进了仓库约定,原话是「Registrations are effects」,注册即副作用。
责任的位置因此变了。卸载不再需要插件作者写清理逻辑,只需要他在注册的当口把撤销方式交出来。从「写的人得记着」变成了「接口签名当场就收走」。
这是我在这套设计里最认的一处。它没有让清理变简单——该摘的监听器一个都不会少——它是让「忘了清理」这件事变得写不出来:你交不出撤销方式,这段代码根本注册不进去。
翻那几家的时候我特意看了各自怎么处理撤销。Claude Code 的单个 hook 没法单独停用,只能删掉配置条目,全局那个开关是二元的;Hermes 提供的是「禁用而不卸载」;OpenCode 的文档提到可以显式禁用,但没给出语法。这些说的都是「还触不触发」,不是「装进去的东西还在不在」——后者才是卸载真正的难处。
装和卸共用一套机制,换来了什么
加载靠等条件,卸载靠还欠账,这两件事合起来给出一个性质:路径无关性——应用的最终状态只取决于哪些插件被启用,跟它们的加载卸载顺序无关。
这是热插拔敢做的前提。没有它,「先装 A 再装 B」和「先装 B 再装 A」可能得到两个不同的系统,那就没法动态启停了。
这正是开头那句话的后半截。主循环能被换掉,一半靠扩展插件只依赖接口包、不碰具体实现,另一半就靠这里:摘下来能摘干净,装回去不依赖装的先后。前者是架构上的解耦,跟这条流程是两码事,这里不展开。
最难的地方:流程自己会打架
前面那张六态图看起来很规整,一个状态接一个状态。真实情况没那么听话:状态迁移的过程中,可能又有新的迁移被触发——收拾到一半又有人往里装东西,这笔账算谁的。
dsh 把 Cordis 整包 vendor 进来之后,对生命周期这部分做了本地加固,改动记录里写得很直白:修复三处可重入的销毁间隙。这三处正好是生命周期最容易出问题的地方,值得一条条看。
第一处,插件初始化到一半,自己把自己卸载了。初始化代码是可以触发卸载的,比如它发现环境不满足、决定退出。这时候麻烦在于:初始化还没跑完,它已经登记的那些撤销方式,卸载流程看不看得见?处理办法是把收集清理动作的那层挂在初始化代码运行之前,这样从初始化内部发起的卸载会等初始化跑完,连它中途登记的清理一起收走。
第二处,同步初始化失败了。失败发生在半路,前面已经登记了几样撤销方式。如果什么都不做,这几样就成了孤儿:插件没起来,但它已经在环境里留下了东西。处理办法是失败时把已经收集的清理回滚掉,回到没开始的状态。改动记录里这一条明确限定在同步失败,异步初始化失败走不走同一套回滚,我没有查到说法。
第三处,正在卸载的时候又来注册。这一处最隐蔽。卸载流程本质上是「拿着一份清单挨个执行」,如果执行到一半有新的注册进来,这个新注册就不在那份清单里,它会活过这次卸载。处理办法是拒绝:插件处在卸载中时,新的注册直接不允许。而等待中和加载中这两个状态仍然允许注册,因为那两个状态下清单还在正常收集。
这一条是 dsh 自己加的,所以上面那张状态图里我没把它画进「卸载中」那个节点——它未必是上游 Cordis 的通用行为。
三处里我觉得第三处最反直觉。前两处好歹还在「初始化」这个语境里,写代码的时候你会想到它可能中途出事;而「正在卸载的时候还有人来注册」这件事,插件作者几乎不会主动想到——在他的视角里卸载是别人触发的,那会儿他已经不在场了。
这三处放在一起看,说的是同一件事:生命周期难的不是走完流程,是流程走到一半被打断时还能自洽。 而这恰恰是自己手写清理逻辑最容易漏掉的部分。
所以这套设计真正替你扛下的是这几种边界情况,清理代码本身反倒是小事。不用 Cordis,它们依然存在,只是从框架代码搬进了你自己的代码,变成偶发 bug。
它撤不回来的东西
这套机制有个边界,很容易被「一切皆插件」这个说法盖过去。
能撤回的只有登记过的进程内资源。一个已经发出去的 HTTP 请求、一条已经执行的 shell 命令、一次已经落盘的共享文件写入,撤不回来。可逆的是插件之间的组合关系,不是已经发生的外部操作。
另有一条边界跟它同样容易被误读:这套机制管的是插件之间怎么组合、退出时怎么收拾,不管操作系统权限。 插件仍然跑在同一个进程里,声明依赖用的 inject 约束的是依赖关系,少声明一个 fs 依赖并不会阻止它直接读文件。想要真正的隔离得回到进程、容器、虚拟机那一层,那跟插件机制不在一个层面上。
倒序保证的范围也比字面上窄。上面那张时序图画的是一次注册,它内部交出的多个撤销动作确实严格倒着执行;但一个插件可以注册很多次,这些注册之间的清理在插件整体卸载时可能并发启动,谁先跑完不保证。
flowchart TD
U["插件被卸载"]
R1["第 1 次注册的清单<br/>内部严格倒序"]
R2["第 2 次注册的清单<br/>内部严格倒序"]
R3["第 3 次注册的清单<br/>内部严格倒序"]
N["三份清单之间可能并发启动<br/>谁先跑完不保证"]
U --> R1
U --> R2
U --> R3
R1 --> N
R2 --> N
R3 --> N
style U fill:#E0E7EE,stroke:#5B7B9E,color:#37474F
style R1 fill:#ECEFF1,stroke:#5B7B9E,color:#37474F
style R2 fill:#ECEFF1,stroke:#5B7B9E,color:#37474F
style R3 fill:#ECEFF1,stroke:#5B7B9E,color:#37474F
style N fill:#F5F2E8,stroke:#9E8B5B,color:#37474F有先后依赖的资源因此要塞进同一次注册里。跨注册去指望顺序会踩空,而且踩空的时候现象是偶发的。
什么时候值得引入这套设计
这套机制不是免费的。一个插件当前处在哪个状态、它的依赖此刻由谁提供,都会影响它的实际行为;一次服务实现的替换可能带来一串使用方的卸载与重绑,排障时除了调用栈,还得看这次实际装了什么、谁依赖了谁。插件作者要理解的东西也变多了:注册副作用、声明依赖、交出清理方式、搞清生命周期状态,这些在写第一个插件的时候就得懂。
值不值这个代价,取决于你的插件要不要在运行期动。这一整套东西——等条件、交撤销方式、防重入——服务的全是「装上之后还要卸下来、还要换」这个前提。如果插件只在启动时装一次、跟着进程一起结束,它给你的收益接近于零,学习成本却是实打实的。
真正换不掉的只有一件:主循环。硬编码的控制流只能在预设的拦截点插 hook,没法在运行时把单 agent 循环换成多 agent 协作循环——这就是开头那张表里唯一一个「是」的来源。
所以判据是这样的。以下几种压力同时出现,它才划算:
- Web、Headless(无界面形态)或其他宿主要共用同一组 agent 能力
- 不同会话要用不同的工具、不同的服务实现、不同的文件系统或沙箱
- 插件需要动态启停,退出时还要回收监听器、进程、句柄
- 模型、工具与会话记录要分别演进,又不能让一段执行历史失去解释
- 团队需要查清这次实际加载了什么,而不只看仓库里声明了什么
只有一个入口、一个模型、固定工具、一条稳定循环的系统,我不会上这套——普通注册表加清晰接口更好懂,排障也更直接。
参考资料
dsh 目前是 developer preview,README 用大写警告后续会有破坏性变更。这篇写的是 2026 年 8 月中旬那个快照下的设计,实际使用请以你手上那个版本为准。
- DeepSeek Harness 官方仓库,https://github.com/deepseek-ai/deepseek-harness
- Cordis 元框架源码,https://github.com/cordiverse/cordis
- 论文 A Programming Paradigm for Spatiotemporal Composability,https://github.com/cordiverse/paper
- Cordis 入门文档,https://deepseek-harness.github.io/deepseek-harness/reference/cordis-primer
- cordis issue #34(依赖状态戳的竞态),https://github.com/cordiverse/cordis/issues/34
- yage.ai 对 dsh 的深度分析,https://yage.ai/share/dsh-deep-analysis-en-20260813.html