一切皆插件
Cordis 的五个概念、capability seam 三件套、三层组合机制,以及这套纪律要付的账单。
「一切皆插件」不是一句口号,它是一条可证伪的断言:在 dsh 里找不到一个不能被卸载的特权核心——模型适配器、工具注册表、会话日志,连 agent loop 本身,都只是插件树上的一行。官方 docs/architecture.md 把这句话写死了:「没有一个可以打补丁的特权核心:你通过在其他插件旁边挂载一个插件来扩展 dsh。」
这一页先把承载它的框架讲清楚,再用 web 能力当解剖样本看一个 seam 到底长什么样,然后拆开三层组合机制,最后算一笔账:这套纪律要求每个能力写三个包,什么规模的团队值得付这个成本。
2.1 Cordis 十分钟入门
Cordis 是 dsh 底下那层插件框架,以 vendor/ 源码副本的方式钉在仓库里。它不是这个项目发明的,但 dsh 对它的用法是这套架构的全部前提。官方 primer 把它压成五句话,这五句话你得先接受,后面的一切才成立。
- 插件就是一个实现了 Service 的对象。可以是一个带可选
inject和apply(ctx)字段的函数,也可以是一个Service子类。 - context 是服务的仓库。一个服务占住一个稳定的
ctx.<key>——ctx.tools、ctx.llm、ctx.sessions——别的插件按 key 找服务,而不是 import 某个具体实现。 - 依赖靠
inject声明。一个插件列出它需要哪些服务,Cordis 就等到那些服务存在了再启动它。加载顺序是推导出来的,不是手写的启动序列。 - 通信靠类型化事件。服务用 TypeScript 声明合并(declaration merging)声明事件名,再按
emit/waterfall/parallel/serial之一分发。 - 注册是可撤销的 effect。prompt section、工具 schema、适配器、provider、监听器,全部通过
ctx.effect()或ctx.on()安装,于是 reload 和 teardown 能可预测地把它们撤回去。
四种分发模式,以及 next() 的含义
事件的分发模式是它公开契约的一部分,不是实现细节。新加的 harness 事件必须用 @mode 标注,生成式 catalog 会拿声明去核对真实的分发点——声明和调用对不上,门禁就红。
| 模式 | await? | 顺序 | 有返回值? | 典型用途 |
|---|---|---|---|---|
emit | 否 | 注册顺序 | 否 | 纯观察 |
waterfall | 否 | 注册顺序 | 是 | 拦截、改写、策略 |
parallel | 是 | 并行 | 否 | 扇出通知 |
serial | 是 | 注册顺序 | 是 | 有序求值 |
waterfall 是 around-middleware:监听器收到 (...args, next)。调用 next() 才把(可能已被包装的)结果交给下一个服务;不调 next() 直接返回就是短路。这两种行为都是设计内的:一个只做标注或观察的监听器必须委托下去,而一个拥有决定权的策略监听器应该短路。
这条规则被 AGENTS.md 提到了硬约束的高度——因为忘记调 next() 造成的 bug 表现为「某个插件莫名其妙不生效」,而不是崩溃,极难查。
2.2 Capability Seam 三件套
一个 capability seam(能力接缝)由三个角色构成:Service Definition 声明接口并拥有 ctx 上那个 key,Service Provider 把一个具体实现注册进去,Consumer 只按接口取用、永远不知道背后是谁。
三个角色,缺一不成 seam
ctx.web 这个 key,定义方法签名、词汇类型与不变量。
tool-web,把能力包装成模型可见的工具。
dsh-llm 就自己拥有 Definition 和 Consumer),但那是「一件事」的判断,不是偷懒的借口。关键在于:seam 是这三个角色的总和,单独一个角色不算 seam。仓库的 AGENTS.md 把这条写成了硬约束——只有当三个角色会各自独立演化时才允许拆成三个包,否则就是在制造无谓的抽象。
这也是判断一份「插件化架构」是真是假最省事的检验:看它有没有一个不属于任何 seam 的、谁都动不了的中心。大多数号称插件化的 agent 产品,工具层是插件化的,而模型调用、会话存储、主循环是硬编码的核心——插件只能在核心留好的几个 hook 上挂东西,换不掉核心本身。
案例解剖:web 能力的六个包
把 packages/web/ 打开,六个包正好排成三个角色。这不是我归纳的,是这个 group 自己的 README 表格:
| 包 | 角色 | ctx key |
|---|---|---|
web | Definition | 拥有 ctx.web,用 searchProvider / fetchProvider 选路 |
web-search-deepseek | Provider | 注册 id deepseek-official,base bundle 的默认 |
web-search-exa | Provider | 注册 id exa;Lab 2 就换这个 |
web-search-perplexity | Provider | 第三个搜索实现,与前两个平级 |
web-fetch-http | Provider | 抓取侧的实现,base bundle 里默认不挂载 |
tool-web | Consumer | 把能力变成模型能调的工具,只认接口 |
这六个包里没有一个知道另一个的存在。tool-web 不知道搜索是谁做的,三个 provider 也不知道自己被谁调用——它们唯一的共识是 web 声明的那份接口。
web-fetch-http 存在,但 base bundle 里没有挂载它,tool-web 的配置里明写着 fetch: false。补丁文件里给了理由:那个 provider 暂缓了 SSRF 防护,而抓取目标是模型选的。
值得学的不是这个具体决定,是这个决定被记在哪儿——它就写在 packages/bundle/base/cordis.patch.yml 那一行的上面,跟被它关掉的那行贴在一起。
所以「换后端」这件事,在 dsh 里退化成了一次配置改写:
# patch 按 id 定位某一行,替换它整个 config,而不是合并进去 - insert: - id: web name: '@deepseek-ai/dsh-web' config: searchProvider: exa - id: web-search-exa name: '@deepseek-ai/dsh-web-search-exa' config: searchType: neural apiKey: !!js process.env.EXA_API_KEY
代码 2.2 核心零改动。dsh --profile web --dump-config 可以看到这两段落在树的哪一层。完整走一遍见 Lab 2。
2.3 组合机制:三层叠加
一个跑起来的 dsh 是启动时按顺序叠出来的一棵插件树。层序在 apps/cli/reference/README.md 里写死:
- profile manifest 里
dsh.profile.bundles列出的每个 bundle 的 patch,按列表顺序 - 然后是 profile 自己的
cordis.patch.yml - 然后是 home 级的
$DSH_HOME/cordis.patch.yml(机器本地偏好,所以它排在 profile 后面、优先级更高) - 最后是 argv 顺序的每一个
--patch <path>overlay
后写的赢。每一层做的事只有两种:按 id 定位一行、替换它整个 config,或者 insert 新行。
id-targeted patch 不做深合并。如果原来那行还有别的字段,你没写的都会消失。官方 README 在三个不同的地方重复了这句话,还把它列进了 dsh-base 的「已知限制」——这个频率本身就说明了它有多容易踩。
正确姿势:先 --dump-config 把那一行抄全,再改你要改的字段。
想知道你这台机器上到底叠出了什么,不需要读代码:
$ dsh --profile web --dump-default-config # 只有 bundle 层 $ dsh --profile web --dump-config # 加上 profile / home / --patch 三层
两条命令都会打注释,标明每一行来自哪个文件、被哪些 overlay 改过;!!js 表达式原样保留不求值;patch 指向的 id 在树里找不到,会往 stderr 报一行警告。app-boot 里有个更讲究的设计:dump 走的是 include 自己的解析器和 patch 算法(entryListSchema / applyEntryPatches),所以 dump 出来的东西和 boot() 实际挂载的不可能不一致。它不是一个「大概是这样」的诊断工具。
2.4 纪律的代价
刚才那份优雅是有账单的。一个能力拆三个包,意味着三份 package.json、三份 README(每份还得写「模型看见什么 / 花多少 token / 对 KV cache 有什么影响」三节,见 M4)、三份要跑到逐文件 100% 行覆盖的测试。
227 个包不是灵活性的证据,是这套纪律的成本。把账摊开:
| 你要付的 | 具体是什么 | 什么时候值 |
|---|---|---|
| 包的数量 | 一个能力 3 个包起步。全仓 50 个分组里,有一半的分组只有 3 个包或更少——正好是「定义 + 一个实现 + 一个消费者」的最小编制。 | 这个能力真的会有第二个实现,或者真的会被第二方替换。 |
| 文档的数量 | 每个包一份 README,含 Model Experience 三节 + 已知限制节,还有中英双语。 | 你的代码会被 agent 读、被外部贡献者读,而不只是被写它的人读。 |
| 测试的强度 | 逐文件 100% 行覆盖是合并门禁(test:coverage,不是 test)。 | 你接受「未覆盖的行通常是该删的死代码」这个前提。 |
| 认知的负担 | 加一个能力要同时想清楚三个角色,以及单元 / e2e / snapshot 三层覆盖怎么写。 | 团队里有人专门守架构,或者你用 agent 来守(dsh 选的是后者,见 M8)。 |
三个人以下的团队、还在找 PMF 的产品,几乎肯定付不起。seam 的价值在「换实现」,而在找 PMF 的阶段你换的是产品形态,不是实现——为一个只会有一个实现的能力拆三个包,纯亏。
更诚实的判断标准不是团队规模,是这个能力有没有真实的第二个实现。dsh 敢这么切 web,是因为它真的同时挂着 deepseek / exa / perplexity 三个 provider;它敢这么切 subagent,是因为它真的要同时驱动 in-process、ACP、Codex、Claude Code 四类子代理。没有第二个实现的 seam,是想象出来的灵活性。
反过来说,dsh 自己是有资格付这个账的:它的目标读者是「要改 seam 背后实现的人」,而不是「今天就要一个能用的 agent 的人」。M1 的决策树把这个分岔画出来了。
Lab 2 · 零 fork 换掉一个 provider(20 分钟)会把这一节的说法当场验一遍;Lab 3 让你自己写一个树外插件。