deepseek-harness 学习手册
案例研究M7 · 什么时候写插件,什么时候 fork
40 min

什么时候写插件,什么时候 fork

判断题、一个新 provider 到底要写哪些文件、守规矩的 delta 长什么样,以及 fork 维护的残酷算术。

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

前面六个模块讲的都是「这套设计是什么」。这一页讲「你真要动手改的时候会撞上什么」——这是最容易被架构文章跳过、也最贵的一段。

这一页的材料来源

本页的判断框架、文件清单、门禁清单、速度数据全部来自本仓库,可点到源码坐标与命令。本页不复述任何一个具体第三方 fork 的 commit 细节——那类材料本站无法逐条核对,也就不写。

如果你手上正好有一个 fork,把它对着 7.3 的清单过一遍,比读任何案例都有用。

7.1 判断题:什么时候树外插件就够

先把结论摆出来,再解释:

你要做的事够不够为什么
加一个 provider——新的搜索后端、新的模型厂商、新的沙箱、新的存储 树外插件够 seam 的 Definition 已经存在,你只是往注册表里再塞一个实现。核心零改动。
加一个工具 / 一段 prompt section 树外插件够 ctx.toolsctx.systemPrompt 都是公开注册面。见 Lab 3
换掉某个能力的默认实现 一段配置够 cordis.patch.yml 按 id 替换那一行的 config。见 Lab 2
拦截 / 改写请求、工具调用、turn 树外插件够 agent/*tools/* 是文档化的 waterfall 扩展点。
改一个 seam 的接口本身——比如让 credentials 支持一种它没有的获取方式 必须 fork Definition 拥有那个 ctx key 和方法签名。你没法从外面给一个抽象类加方法。
改 agent loop 的行为本身 先别 fork 官方规则是「插件,而不是改 loop」。agent-loop 必须同时更新 docs/architecture.md——这条规则的存在本身就是在告诉你,先去扩展点上找答案。
改一个持久格式 / 线上协议 必须 fork,而且要慎重 SessionEventMap 的成员默认 required-on-read;不认识的事件会让别的构建拒绝这份日志

7.2 一个新 provider 到底要写哪些文件

packages/web/web-search-exa 完整摊开——这是一个真实的、在树内跑着的搜索 provider 的全部:

packages/web/web-search-exa/tree
package.json          # 名字、exports、peerDeps(cordis + 它要用的 seam)
tsconfig.json
README.md             # 含 Model Experience 三节 + 已知限制节
README.zh.md          # 中文对照,与英文等权威
README.i18n.yaml      # 双语一致性记录:两侧的 git blob hash
src/index.ts          # 插件本体:name / inject / Config / apply —— 约 70 行
src/provider.ts       # 真正干活的:请求映射、错误映射
src/types.ts
src/invariant.ts      # 不变量伴生插件(这个包显式声明为空
tests/exa.spec.ts     # 单测,逐文件 100% 行覆盖
tests/exa.e2e.ts      # 真 API e2e,无 EXA_API_KEY 时自跳过

插件本体本身薄得出乎意料。它的核心就是一个 apply

src/index.ts(节选)ts
/** Cordis 插件名,loader 诊断用。 */
export const name = 'web-search-exa'

/** 它注册进去的那个 seam。 */
export const inject = ['web']

/** 把 Exa 搜索 provider 注册到 `ctx.web`。 */
export function apply(ctx: Context, config: Config): void {
  ctx.web.registerSearchProvider(new ExaSearchProvider({
    apiKey: config.apiKey ?? launchEnvironmentOf(ctx).get('EXA_API_KEY')?.value ?? '',
    baseURL: config.baseURL ?? EXA_DEFAULT_BASE_URL,
    // …其余默认值
  }))
}

文件头那段 JSDoc 才是这个包最值钱的部分,它把角色说得清清楚楚:

「一个函数 / 命名空间插件(不是 default-export 的 service):一个搜索 provider 不拥有 ctx.web 这个 key——它注册这个 seam 的 provider 注册表,就像 dsh-llm-deepseek 把一个适配器注册进 ctx.llm 一样。key 由 @deepseek-ai/dsh-web 拥有。」

这句话是从一次真实事故里长出来的。docs/postmortem/0001 记的就是:一个插件用了 default export,导致 inject 被丢掉。所以现在 「Namespace plugin:具名导出 name / inject / apply,无 default export」 这句话,出现在几乎每个包的 README 的「Export shape」一节里。

那个「空的」不变量伴生插件

src/invariant.ts 值得单独说,因为它体现了一条很少见的规矩。AGENTS.md 写着:「运行时不变量断言的是自己拥有的关系……在没有一个说得通的关系时,一个被解释过的空伴生插件才是正确的。」

src/invariant.ts(节选)ts
/**
 * 没有运行时不变量:这个包除了它所属 seam 已经强制的契约之外,
 * 不暴露任何独立的事件序列或可变数据关系。
 */
const install: InvariantInstaller = () => {}

「我检查过了,这里确实没有可断言的东西,原因是这个」——这比「我没写」多出来的信息,正是 review 需要的。这跟 M4.1.2 那个「审计过的零」是同一套思路:把「有意的缺席」和「忘了」在结构上区分开。

7.3 守规矩的 delta 长什么样

假设你要往这个仓库里加一个包(或者往你的 fork 里加)。除了代码,你还欠这些:

欠什么谁在管
双语 READMEverify-translation-pairingREADME.i18n.yaml 记着两侧的 git blob hash,改了一侧不带上另一侧,门禁红
Model Experience 三节verify-package-readme-model-experience(见 M4
已知限制节verify-package-readme-limitations
每个导出的 JSDocverify-export-jsdoc:函数型导出还必须有 @param / @returns
逐文件 100% 行覆盖test:coverage——注意是它,不是 test,才是 CI 的覆盖率门禁
一篇 Agent Noteverify-agent-note-format + verify-agent-note-classification:非平凡改动必须在同一个 PR 里加或更新一篇,而且也是双语
一个 keyless 快照场景模型可见 / 协议可见 / 人可见的改动,要在同一个 PR 里通过某个可运行 example 的快照套件加一条
不重复的代码duplication(jscpd 跨文件克隆检测)——这就是为什么那个空 invariant 文件要用 /* jscpd:ignore-start */ 包起来
干净的依赖hygiene:knip(找没用的导出/依赖)+ publint + workspace 约束 + NodeNext consumer 检查

7.4 fork 维护的残酷算术

说服力最强的不是道理,是数字。本站在 commit b150a55 上实测:

窗口落到 master 的合并数提交数
最近 4 天63367
最近 7 天92604
最近 14 天288
最近 30 天713
从第一个 commit(2026-06-10)到 v0.1.1-rc.21,07313,147

表 7.4 「合并数」用 git rev-list --first-parent 数,即真正落在主线上的改动批次;「提交数」是全部 commit。73 天,13,147 个 commit。你可以自己复现:git log --first-parent --since='7 days ago' --oneline | wc -l

把这个数字换算成你的成本

你出门喝杯咖啡回来,上游可能已经多了几十个合并。请一周假,就是九十多个。

这意味着:一个碰了核心的 fork,不是「以后要花时间同步」,而是「从第一天起就在持续偿还」。而且冲突不会均匀分布——它们会集中在你改过的那几个文件上,因为那些文件恰恰是仓库最活跃的部分。

反过来,一个只在 cordis.patch.yml 里加了两段的「fork」,上游怎么跑都不关它的事。这就是 M2 那套 seam 抽象,最终兑现价值的地方。

如果你确实必须 fork:三条生存策略

  1. 把 delta 分层。能进 cordis.patch.yml 的进配置层,能进新包的进新包,实在必须改的核心文件单独列一张清单——每次同步只需要人工看那张清单上的文件。
  2. 让 delta 长在别人不动的地方。加一个新包,冲突概率接近零;改一个既有文件的中间,冲突概率接近一。dsh 的包粒度细到 227 个,恰恰让「新增而不修改」这条路特别好走。
  3. 盯着仓库自己的「预发布姿态」。根 AGENTS.md 里有一段叫「Pre-release stance: foundation over blast radius」,明写着「在第一个正式发布时删掉本节」——在那之前,官方明确保留了自由重命名、自由重构包结构的权利,后端会直接拒绝旧的磁盘格式。这段话还在,就意味着你的 fork 随时可能撞上一次大规模重命名。
动手

Lab 4 · 复刻一个 web search provider(60 分钟)会照着 7.2 的清单,从零裁出一个新后端。

目录

本页

M7 · 什么时候写插件,什么时候 fork