deepseek-harness 学习手册
架构基石M2 · 一切皆插件
柱一90 min · 全站主干

一切皆插件

Cordis 的五个概念、capability seam 三件套、三层组合机制,以及这套纪律要付的账单。

最后核对于 v0.1.1-rc.2 · commit b150a551b8 · 2026-08-22

「一切皆插件」不是一句口号,它是一条可证伪的断言:在 dsh 里找不到一个不能被卸载的特权核心——模型适配器、工具注册表、会话日志,连 agent loop 本身,都只是插件树上的一行。官方 docs/architecture.md 把这句话写死了:「没有一个可以打补丁的特权核心:你通过在其他插件旁边挂载一个插件来扩展 dsh。」

这一页先把承载它的框架讲清楚,再用 web 能力当解剖样本看一个 seam 到底长什么样,然后拆开三层组合机制,最后算一笔账:这套纪律要求每个能力写三个包,什么规模的团队值得付这个成本。

2.1 Cordis 十分钟入门

Cordis 是 dsh 底下那层插件框架,以 vendor/ 源码副本的方式钉在仓库里。它不是这个项目发明的,但 dsh 对它的用法是这套架构的全部前提。官方 primer 把它压成五句话,这五句话你得先接受,后面的一切才成立。

四种分发模式,以及 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 只按接口取用、永远不知道背后是谁。

图 2.2

三个角色,缺一不成 seam

Definition 声明接口 拥有 ctx.web 这个 key,定义方法签名、词汇类型与不变量。
Provider 注册实现 不拥有 key,只把自己注册注册表;可以同时挂多个,平级共存。
Consumer 按接口取用 tool-web,把能力包装成模型可见的工具。
注意箭头方向:provider 和 consumer 都指向 definition,彼此之间没有依赖。一个包可以同时扮演多个角色(dsh-llm 就自己拥有 Definition 和 Consumer),但那是「一件事」的判断,不是偷懒的借口。

关键在于:seam 是这三个角色的总和,单独一个角色不算 seam。仓库的 AGENTS.md 把这条写成了硬约束——只有当三个角色会各自独立演化时才允许拆成三个包,否则就是在制造无谓的抽象。

这也是判断一份「插件化架构」是真是假最省事的检验:看它有没有一个不属于任何 seam 的、谁都动不了的中心。大多数号称插件化的 agent 产品,工具层是插件化的,而模型调用、会话存储、主循环是硬编码的核心——插件只能在核心留好的几个 hook 上挂东西,换不掉核心本身。

案例解剖:web 能力的六个包

packages/web/ 打开,六个包正好排成三个角色。这不是我归纳的,是这个 group 自己的 README 表格:

角色ctx key
webDefinition拥有 ctx.web,用 searchProvider / fetchProvider 选路
web-search-deepseekProvider注册 id deepseek-official,base bundle 的默认
web-search-exaProvider注册 id exa;Lab 2 就换这个
web-search-perplexityProvider第三个搜索实现,与前两个平级
web-fetch-httpProvider抓取侧的实现,base bundle 里默认挂载
tool-webConsumer把能力变成模型能调的工具,只认接口

这六个包里没有一个知道另一个的存在。tool-web 不知道搜索是谁做的,三个 provider 也不知道自己被谁调用——它们唯一的共识是 web 声明的那份接口。

一个容易被跳过的细节:默认不挂 fetch

web-fetch-http 存在,但 base bundle 里没有挂载它tool-web 的配置里明写着 fetch: false。补丁文件里给了理由:那个 provider 暂缓了 SSRF 防护,而抓取目标是模型选的

值得学的不是这个具体决定,是这个决定被记在哪儿——它就写在 packages/bundle/base/cordis.patch.yml 那一行的上面,跟被它关掉的那行贴在一起。

所以「换后端」这件事,在 dsh 里退化成了一次配置改写:

~/.dsh/profiles/web/cordis.patch.ymlyaml
# 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 里写死:

  1. profile manifest 里 dsh.profile.bundles 列出的每个 bundle 的 patch,按列表顺序
  2. 然后是 profile 自己的 cordis.patch.yml
  3. 然后是 home 级的 $DSH_HOME/cordis.patch.yml(机器本地偏好,所以它排在 profile 后面、优先级更高)
  4. 最后是 argv 顺序的每一个 --patch <path> overlay

后写的赢。每一层做的事只有两种:按 id 定位一行、替换它整个 config,或者 insert 新行。

最容易踩的坑:patch 是替换,不是合并

id-targeted patch 不做深合并。如果原来那行还有别的字段,你没写的都会消失。官方 README 在三个不同的地方重复了这句话,还把它列进了 dsh-base 的「已知限制」——这个频率本身就说明了它有多容易踩。

正确姿势:先 --dump-config 把那一行抄全,再改你要改的字段。

想知道你这台机器上到底叠出了什么,不需要读代码:

terminal
$ 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 让你自己写一个树外插件。

目录

本页

M2 · 一切皆插件