在 DeepSeek Harness(下称 dsh)的部署实践里,最常被问到的问题不是“插件怎么写”,而是“我明明配了,为什么生效的不是我配的那个值”。这个问题背后其实横跨三条链路:插件如何通过 Config 接口 + 同名 Schemastery schema 把可注入字段暴露出来;组合包 bundle 与 profile 两份 manifest 如何各司其职;以及多个层的 patch 叠加时,最终生效配置按什么顺序决定。本文是《插件配置与 Bundle / Profile 分层:dsh 的生效配置到底从哪来》的第 1/2 段,先把“配置从哪来”的前半程讲透:从 cordis.yml 到插件 apply 的字段映射、默认值为什么写在 schema 字段上、配置与代码分离的边界在哪、bundle 与 profile 的 dsh 键各自回答什么问题,以及 profile 为什么被放在安装目录之外。后半段会接着讲安装进 profile、加载顺序分层、pnpm 转发与自动维护 bundles 的完整链路。理解这一段,你才能在“我改了配置没生效”时,第一反应是去定位层,而不是反复改代码。

从 cordis.yml 到插件 apply:Config 接口如何定义可注入字段

dsh 的插件模型建立在 cordis 的依赖注入容器之上。一个插件在被加载时,容器会调用它导出的 apply 函数,并传入两个参数:第一个是 Context(当前插件可见的上下文,用来访问服务、注册命令、挂载子插件等),第二个就是本文的主角——校验后的配置对象。也就是说,你在 cordis.yml 里写的那些键,最终会被解析、校验、补默认值,然后作为 apply 的第二个参数交到你手里。这条链路的端点非常明确:cordis.yml 传入的键 → 插件导出的 Config 接口 → apply 的第二个参数

要让这条链路成立,插件必须导出两样东西,而且名字必须相关:一个 Config 接口,一个 同名的 Config schema。前者是给 TypeScript 编译器看的类型契约,后者是给运行时看的校验与默认值来源。很多人第一次写插件时会疑惑:为什么接口和 schema 要同名?因为 dsh 的加载器在运行时需要按名字找到 schema,而 TypeScript 侧需要按名字找到类型;同名让“类型”和“运行时 schema”成为同一个符号的两种形态,加载器拿到 schema 做校验,编辑器拿到接口做补全,互不打架。

来看一个具体的 Config 接口。假设我们的插件叫 my-plugin,它接受三个字段:greeting、maxRetries、verbose。接口可以写成这样:

import type { Context } from '@deepseek-ai/cordis'
import Schema from '@deepseek-ai/schemastery'

export const name = 'my-plugin'

// Config 接口:定义插件接受哪些配置项
export interface Config {
  greeting: string // 问候语
  maxRetries: number // 最大重试次数
  verbose?: boolean // 是否输出详细日志(可选)
}

注意三个字段的可选性差异,这是本节最容易被忽略、也最容易踩坑的地方。greeting 和 maxRetries 是必填字段(没有问号),意味着在类型层面,任何一个 Config 类型的值都必须提供它们;verbose 是可选字段(带问号),意味着类型允许它缺失。但这个“可选”在运行时并不等于“可以不管”——它只是说 TS 编译器不会因为你没写而报错。真正决定“用户不写时会发生什么”的,是下一节的 schema 默认值。类型层面的可选性和运行时的默认值填充,是两件事,必须分开理解。

另外要注意一个映射细节:cordis.yml 传入的键名,直接对应 Config 接口的字段名。你在 yml 里写 greeting,schema 里就必须有 greeting 这个键;写 max_retries(下划线风格)就不会映射到 maxRetries,结果要么是校验失败,要么是字段拿不到值而落到默认值。dsh 不会做驼峰与下划线的自动转换,这一点和某些框架不同,配置键与字段名必须逐字对齐。

那 apply 里怎么用这些配置?非常直接:

export function apply(ctx: Context, config: Config) {
  // 打印的是用户传入的值或 schema 默认值
  console.log(config.greeting)
}

这里的 config 已经是“合并后的结果”:用户显式传了 greeting,就是用户的值;用户没传,就是 schema 里的 default;用户传了但类型不对(比如把 maxRetries 写成字符串 "3"),则会在进入 apply 之前被 schema 拦截。换句话说,apply 拿到的永远是一份已经填充、已经校验过、结构完整的配置。插件作者不需要在 apply 里写“如果没传就……”这类兜底逻辑,兜底属于 schema 的职责。

为什么这个设计在工程上重要?因为它把“配置的合法性”和“业务逻辑”彻底分开。想象一下没有 schema 的时代:插件作者会在 apply 里写一堆 config.maxRetries ?? 3typeof config.verbose === 'boolean' ? config.verbose : false 这样的代码,每个插件各写各的,错误处理风格五花八门,用户传错类型时行为不可预测。现在这些都收敛到 schema 一处,插件作者只需要关心“拿到合法配置后该干什么”。

示意图
从 cordis.yml 的配置键,经 Config 接口与 Schemastery schema 校验补默认值,最终注入到插件 apply 第二个参数的完整链路。

理解这条链路后,还有一个高级读者常关心的点:apply 的第二个参数是配置,那“应用参数”去哪了?这一点在后面讲加载顺序时会明确——应用参数不是另一层 patch,它通过普通应用自有服务解析。也就是说,命令行参数和配置分层是两套机制,不要把它们混为一谈。当前你只需要记住:apply(ctx, config) 里的 config,来源是配置分层体系的产物,而不是命令行直接塞进来的。

Schemastery 同名 schema:默认值为什么写在字段上而不是 apply 里

上一节反复提到“默认值”,这一节把它的归属讲清楚。dsh 插件配置的默认值,写在 同名导出的 Config schema 的字段上,而不是 apply 函数里。这个约定的写法是:export const Config: Schema<Config> = Schema.object({ ... })。注意这里的巧妙之处:接口名叫 Config,常量也叫 Config,前者是类型,后者是值,TypeScript 允许这种同名的类型/值双声明。加载器按名字取到的是那个常量(schema),类型系统用到的是那个接口。

一个完整的 schema 大概长这样:

// 同名的 Config schema:默认值写在这里
export const Config: Schema<Config> = Schema.object({
  greeting: Schema.string().default('Hello'),
  maxRetries: Schema.number().default(3),
  verbose: Schema.boolean().default(false),
})

逐行拆解这段 schema 的信息量:

  • Schema.object({...}) 声明这是一个对象型 schema,它的键集合就是插件接受的配置字段集合。任何不在这个对象里的键,都不会进入最终的 config。
  • Schema.string().default('Hello') 声明 greeting 是字符串,缺省值是 'Hello'。用户不写 greeting 时,config.greeting 就是 'Hello';用户写了非字符串(比如数字),校验阶段就会失败。
  • Schema.number().default(3) 声明 maxRetries 是数字,缺省值是 3。这是素材里给出的具体数值,也是重试类字段一个很自然的默认档位。
  • Schema.boolean().default(false) 声明 verbose 是布尔值,缺省值是 false。即使接口里 verbose 是可选的,schema 依然给了它一个明确默认值,于是运行时 config.verbose 永远是 true 或 false,不会是 undefined。

为什么默认值必须写在字段上而不是 apply 里?有几个层层递进的理由,越往后越是工程层面的:

  1. 单一事实来源。如果默认值写在 apply 里,那么“这个插件支持哪些配置、各自默认是什么”这件事就散落在代码逻辑中间,用户读文档、读 schema、读代码要拼三处信息。写在 schema 上,schema 就是唯一权威,文档可以自动生成,类型可以推导。
  2. 校验与填充必须在进入 apply 之前完成。apply 是业务逻辑,一旦它开始执行,就意味着配置已经被认定为合法。如果默认值填充放到 apply 里,那么 apply 内部在填充之前的任何代码都必须在“配置不完整”的假设下工作,这会让插件作者写出大量防御性代码。
  3. 可选字段的语义要靠 schema 兜底。回到 verbose:接口里它是可选的,但业务代码里你希望它一定是布尔值。schema 的 .default(false) 正好完成了这个转换——类型层面允许缺失,运行时层面保证存在。这是“可选”这个设计意图的正确落地方式:接口标注可选,schema 补默认值,两边配合而不是互相依赖。
  4. 分层覆盖时默认值的位置决定行为。默认值属于 schema 所在的层,用户层只写自己关心的键,未被覆盖的键自然落到该层默认值。如果把默认值硬编码在 apply 里,分层覆盖讨论的就变成了“代码里的 if 分支”,完全失控。

再强调一遍素材给出的判断标准:默认值直接写在 schema 字段上。这不是风格偏好,而是 dsh 配置机制的一部分。初学者常见的错误是把 Schema.string() 写成不带 default 的形式,然后疑惑“用户不写时 config.greeting 是什么”。答案是:那取决于校验器对该字段的处理,可能是 undefined,也可能直接报错——但无论如何,你都不应该依赖这种不确定性。凡是接口里声明为可选、或你希望有确定初值的字段,schema 上都应该显式给出 .default()

这里还可以延伸一个对比,帮助高级读者建立直觉。插件配置的 schema 校验,与常见的“环境变量 + 手动 parse”方案相比,差异如下:

维度Schemastery 同名 schema环境变量 + 手动 parse
类型声明来源Config 接口与 schema 同名,类型与运行时一致靠文档约定,运行时靠手写 typeof 判断
默认值位置schema 字段上的 .default()散落在各处的 || 与 ??
校验时机进入 apply 之前,统一校验用到哪个才校验哪个,早晚不定
错误反馈校验失败即拦截,定位到具体字段往往是运行到某行才崩,栈不指向配置
分层覆盖由 patch 层按行替换,schema 提供缺省无层级概念,后写覆盖先写全靠顺序
文档同步schema 即文档,可读可推导文档与代码容易漂移

这张表的核心结论是:schema 把“配置长什么样”变成一份可执行的契约,而手动 parse 只是把同样的信息用代码摊开,且无法在分层体系里被统一处理。在 dsh 这种强调多层 patch 叠加的系统里,如果没有 schema 这个统一入口,分层覆盖根本无从谈起。

最后提醒一个实操细节:schema 与接口的字段集合应当保持一致。你在接口里加了字段但忘了在 schema 里加,用户传这个键时不会进 config;你在 schema 里加了字段但接口没加,TS 侧看不到它,apply 里想用还得强转。两者必须同步演进,这也是把接口和 schema 放在同一个文件、紧挨着写的工程理由。

配置与代码分离的边界:哪些值该进 schema,哪些该留在插件内

素材开篇给了一个非常典型的反例:greet 工具把问候语写死在代码里,不同部署想换就得改代码。这一个句子其实概括了“配置与代码分离”的全部动机——凡是不同部署之间可能不同、且换起来不该改代码的东西,就该外置为配置;凡是插件自身的固有行为、换不换都不该影响用户的东西,就留在代码里。

判断一个值该进 schema 还是该留在插件内,可以用下面这几条标准逐个过:

  • 是否因部署而异?问候语就是教科书式的例子:测试环境想说 'Hello',生产环境想说 '您好',不同客户部署可能各要各的文案。它会变,所以它进 schema(greeting)。反过来,插件内部某个常量用来做字符串前缀拼接,所有部署都一样,就没必要外置。
  • 是否因环境/负载而异?maxRetries 属于这一类:网络抖动的环境想把重试次数调高,稳定环境想调低省时间。这种“同一份代码、不同运行条件要不同值”的参数,天然适合放进 schema。
  • 是否是排障/观测开关?verbose 就是典型的调试开关:平时关着省日志,排障时打开看细节。这类布尔开关几乎都应该外置,且默认值通常取“安静”那一档——素材给的默认就是 false。
  • 是否涉及密钥、路径、端点?凡是凭证、文件路径、服务地址,几乎必然因部署而异,必须外置,绝不能硬编码进仓库。
  • 是否属于算法不变量?某个内部状态机的状态数量、协议里固定的 magic number、哈希算法选型,这些改了就是改行为、需要改代码和测试,不适合做成“可配置”。把它们外置反而会让配置面变大、组合爆炸、难以维护。

用这套标准回看素材里的三个字段,边界就很清楚:greeting 属于“因部署而异”的文案,外置;maxRetries 属于“因环境而异”的调优参数,外置且给 3 作为默认;verbose 属于“排障开关”,外置且默认 false。三个字段都外置,且在 schema 上各自有默认值。示例代码里 apply 只做了一件事:console.log(config.greeting)——它消费配置,而不是决定配置。这正是“配置与代码分离”想要达到的状态。

关于 verbose 这类可选布尔项,还有一个容易被忽略的处理方式问题。既然接口里已经写了 verbose?: boolean,有些作者就觉得 schema 里可以省掉它,或者不写 default。这两种做法都不推荐:

  • 不要在 schema 里省略可选字段。省略意味着用户传了 verbose 也不会被识别、不会进入 config,等于这个开关形同虚设。schema 的字段集合是白名单,没列进去的键不会通过。
  • 不要依赖 undefined 表达“关”。业务代码里写 if (config.verbose) 看似能工作,但如果日志逻辑需要显式区分“未设置”和“显式 false”,undefined 就会带来歧义。schema 给出 .default(false) 之后,语义是确定的:要么 true 要么 false。
  • 默认值应选“安全”的那一侧。对调试开关而言,安全侧是关闭(false);对重试而言,安全侧是有限次数(3),而不是无限。默认值的选择本身就是一种工程决策,schema 是它的落点。

这里再给出一个对比表,把“该外置”和“该内联”的判断落到具体类型上,方便你在实际项目里对照:

值的类型是否外置进 schema典型字段与默认值理由
面向用户的文案greeting,默认 'Hello'不同部署/客户可能不同,换文案不该改代码
调优参数maxRetries,默认 3随环境与负载变化,需要现场可调
排障开关verbose,默认 false平时安静、排障打开,默认取安全侧
密钥/端点/路径随部署注入,通常无安全默认部署间必然不同,且不能进仓库
协议常量/magic number留在插件内改了就是改行为,需改代码与测试
算法/状态机内在逻辑留在插件内属于实现不变量,外置会组合爆炸

总结成一句话:配置面的大小是一种设计权衡,外置的标准是“部署时可变且不该改代码”,而不是“能配就配”。把不该外置的东西做成配置,短期内看起来灵活,长期会让配置组合失控、默认值语义模糊、排障成本上升。dsh 的 schema 机制给了你外置的能力,但用不用、用多少,仍然是插件作者的判断。素材里那三个字段恰好覆盖了“文案、调优、开关”三种典型外置场景,是一个非常合理的最小配置面。

bundle 与 profile:dsh 键下两种 manifest 的分工

讲完单个插件的配置,视角要抬到“这些插件怎么被打包、安装、启动”。dsh 的安装机制建立在两个概念之上:bundle(组合包)profile。它们都由一份 package.json 描述,但在 dsh 键下携带的 manifest 种类不同,回答的问题也不同。这是理解整个分层体系的关键前提。

先把两个概念的定义摆出来:

  • 组合包(bundle)是附带一个配置层的 npm 包。它的 manifest 声明 dsh.bundle,回答的问题是“这个包贡献什么”——具体来说,贡献一个插入或覆盖插件行的 patch 文件。
  • profile 是位于 $DSH_HOME/profiles/<name> 下、描述一份可启动组合的目录。它的 manifest 声明 dsh.profile,回答的问题是“这套配置由哪些组合包按什么顺序组成”。

两者在角色上的分工可以这样记:bundle 是你编写并分发的东西;profile 是用户用 dsh --profile <name> 启动的东西。这句话值得反复读。插件作者写 bundle,把 patch 文件连同代码一起发到 npm(或本地 checkout);用户不需要手工拼装 bundle,而是启动一个 profile,profile 里记录了“要加载哪些 bundle、按什么顺序”。素材里有一句斩钉截铁的结论:没有东西同时是两者。一个包要么是 bundle,要么是 profile,身份是互斥的。即便两者的描述都藏在 package.json 的 dsh 键下,dsh.bundle 和 dsh.profile 也是两个不同的 manifest 种类。

用一个对比表把两者的差异钉死:

概念manifest 键回答的问题谁编写 / 谁使用
bundle(组合包)dsh.bundle这个包贡献什么(一个 patch 文件)插件作者编写,随包分发
profiledsh.profile这套配置由哪些 bundle 按什么顺序组成由 dsh plugin 自动创建维护,用户启动

这张表里有三个信息点值得展开。第一,manifest 键不同:bundle 用 dsh.bundle,profile 用 dsh.profile,加载器据此判断这个包在体系里扮演什么角色。第二,回答的问题不同:一个是“贡献什么”(内容视角),一个是“由谁组成”(编排视角)。第三,编写者与使用者不同:bundle 由插件作者编写、随包分发,是发布产物;profile 由 dsh plugin 命令自动创建维护,是用户侧的运行配置。

还有一个容易被混淆的细节:素材用一句话点出“表层组合包可以通过普通应用自有服务解析它们”。这句话的意思是,bundle 提供的 patch 插入或覆盖的插件行,本身可以正常参与应用服务的解析,不存在“bundle 里的插件是特殊物种”这回事。对高级读者来说,理解这一点有助于建立正确的心理模型:bundle 不是一种新的运行时,它只是给现有插件行加上了一个配置层

再看安装机制的整体图景。安装机制建立在两个概念之上,二者都由一份 package.json 描述。这意味着当你 dsh plugin add 某个包时,dsh 会去读这个包的 package.json,看它是声明了 dsh.bundle 还是 dsh.profile,然后决定把它放到体系里的什么位置。如果它声明 dsh.bundle,dsh 会把这个包追加进目标 profile 的 dsh.profile.bundles 列表;profile 本身则由 dsh plugin 在首次使用时初始化。这个“读 manifest 决定角色”的过程,正是两个 manifest 分工的落地体现。

示意图
生效配置在空根之上按 profile bundles、profile 自身 patch、home 级 patch、命令行 overlay 四层依次叠加,后应用层按行胜出的顺序关系。

最后纠正一个常见误解:有些读者以为 bundle 和 profile 是“包含关系”,即 profile 里包含 bundle,所以 bundle 也是 profile 的一种。不是的。profile 的 bundles 列表里引用 bundle,是引用关系,不是身份继承。一个 bundle 被多个 profile 引用,它自己仍然只是一个 bundle;一个 profile 引用了若干 bundle,它自己仍然只是一个 profile。素材的“没有东西同时是两者”就是在切断这种身份混同。把这一点记牢,后面讨论 bundles 数组顺序和覆盖关系时,才不会把“谁包含谁”和“谁覆盖谁”搅在一起。

dsh.bundle 声明了什么:一个插入或覆盖插件行的 patch 文件

上一节说 bundle 回答“这个包贡献什么”,这一节把这个“什么”具体化:bundle 的核心产物是一个 patch 文件,它的作用是插入或覆盖插件行。这是 dsh 组合包机制里信息密度最高的一句话,值得逐词拆开。

先看“patch 文件”。在 dsh 的配置分层体系里,patch 是参与“逐层组合”的基本单位。生效配置不是一份写死的 yml,而是从空根开始,按固定顺序把若干 patch 层叠加上去的结果。bundle 携带的这个 patch 文件,就是它作为一层参与组合的凭据。用户在 cordis.yml 或 profile 里看到的很多插件行,实际来源就是某个 bundle 的 patch 被应用后的产物。

再看“插入或覆盖插件行”。这揭示了 patch 的两种基本动作:

  • 插入:原本配置里没有这一行插件,bundle 的 patch 把它加进来。这对应“给系统增加一个新能力”的场景,比如装一个 hello-plugin,它的 patch 把 hello 相关的插件行插入配置。
  • 覆盖:原本配置里已经有这一行插件,bundle 的 patch 替换它的配置。这对应“定制已有行为”的场景,比如某个 bundle 想调整基础包里某个插件的参数。

这里必须引出一个关键机制,素材在讲加载顺序时给出了明确结论:patch 会替换目标行的整个 config 值,而不是深度合并各键。这一点对理解 bundle 的行为极其重要。假设基础配置里某插件行是 { greeting: 'Hello', maxRetries: 3 },你的 bundle patch 只写了 { greeting: '您好' },那么覆盖后这一行的 config 就是 { greeting: '您好' },maxRetries 不会从基础配置“继承”过来——除非它在更高优先级的层里被重新补上,或者由该插件自己的 schema 默认值兜底。这是一个非常高频的踩坑点:很多人以为 patch 是“打补丁式地只改一个键”,实际是整行替换。解决方案通常有两个:要么在 patch 里写全这一行的所有键,要么确认被省略的键能由 schema 默认值补回正确语义。

为什么 bundle 的产物是 patch 文件,而不是别的形式?因为 dsh 的分层模型需要一种“可叠加、可排序、按行胜出”的配置单元。patch 文件恰好满足:它可叠加(多个 bundle 的 patch 依次应用)、可排序(bundles 数组顺序就是应用顺序)、按行胜出(后应用的层覆盖先应用的层)。如果 bundle 直接产出“最终配置”,就失去了分层叠加的能力,多个 bundle 之间也无法协调。

从分发视角看,素材明确说 bundle 由插件作者编写,随包分发。这是一条清晰的职责线:

  1. 插件作者写插件代码(导出 Config 接口与同名 schema)。
  2. 插件作者编写 patch 文件,声明这个包要插入或覆盖哪些插件行。
  3. 插件作者在 package.json 的 dsh 键下声明 dsh.bundle,指向这个 patch。
  4. 包被发布或被本地 checkout。
  5. 用户通过 dsh plugin 把它安装进某个 profile。

这五步里,前四步是 bundle 侧的事,第五步进入 profile 侧。bundle 作者不需要关心用户会用哪个 profile 启动、也不应该假设自己会被怎么组合;它只负责回答“我贡献什么”。这个边界感是生态可组合的基础:正因为 bundle 不越界,同一个 bundle 才能被任意多个 profile 复用。

还有一个高级读者会追问的点:bundle 的 patch 与用户自己的 patch(比如 profile 级的 cordis.patch.yml、home 级的 patch、命令行 --patch overlay)在机制上是不是一回事?答案是它们都是“层”,都遵循“后应用层按行胜出”的规则,区别只在于位置和优先级。bundle 的 patch 通过 dsh.profile.bundles 列表进入体系,位于分层顺序的第一档;用户的 patch 排在它之后,因此用户总能覆盖 bundle 的设定。这种“bundle 提供默认、用户拥有最终话语权”的设计,是 dsh 分层体系一个很务实的选择。

$DSH_HOME/profiles/<name>:profile 为什么放在安装目录之外

现在把视角切到 profile。素材给出了一个非常精确的定位:profile 是位于 $DSH_HOME/profiles/<name> 下、描述一份可启动组合的目录,以及一句同样精确的路径说明:profile 位于安装目录之外,路径模板是 $DSH_HOME/profiles/<name>。这个“安装目录之外”不是随意选择,而是 profile 定位的直接后果。

先理解 profile 是什么。它描述一份可启动组合——也就是说,profile 本身不是可执行代码,不是安装产物,而是一份“启动配方”:需要哪些 bundle、按什么顺序、叠加哪些用户 patch。它的 manifest 声明 dsh.profile,核心内容就是 dsh.profile.bundles 数组。用户用 dsh --profile <name> 启动时,dsh 去这个路径读 profile 描述,据此组装生效配置,然后启动。

为什么这种“配方”要放在安装目录之外?可以从几个角度理解:

  • 安装目录是可替换、可升级的。dsh 本体和它的依赖可能被重装、升级、换版本;如果 profile 写在安装目录里,一次升级就可能把用户的启动配方抹掉或覆盖。把 profile 放到 $DSH_HOME 下,安装目录怎么变,用户的配置资产都不受影响。
  • profile 是用户侧、机器本地的资产,不是发行物。bundle 是插件作者分发的,profile 是用户/机器自己的。用户可能在不同机器上建不同的 profile,这些不应该被打包进发行物,也不应该跟着包走。
  • 一份 profile 可以引用来自多个来源的 bundle。profile 的 bundles 列表里既有 @deepseek-ai/dsh-base 这样的官方基础包,也有本地 checkout 的第三方包。这要求 profile 处于一个“中立的、不被某个包独占”的位置,而 $DSH_HOME/profiles 正好提供这样一个中立路径。
  • 多 profile 并存的需要。路径模板里的 <name> 意味着可以有多个 profile(demo、prod、debug……)并存于同一台机器,各自描述不同的启动组合,共享同一套安装。这只有在 profile 独立于安装目录时才好做。

把 profile 放进 $DSH_HOME 还有一个隐含好处:它是机器本地偏好与共享配置的天然落点。素材在讲加载顺序时把 $DSH_HOME/cordis.patch.yml 列为 home 级层,说明 $DSH_HOME 不只是 profile 的容器,也是“各 profile 共享的机器本地偏好”的存放处。profile 位于 $DSH_HOME/profiles/<name>,正是这个体系的自然延伸。

这里可以对比一下“把 profile 放在安装目录内”和“放在安装目录外”的差异,帮助建立判断:

维度放在安装目录内放在 $DSH_HOME/profiles/<name>
升级 dsh 时可能被覆盖或丢失不受影响,资产保留
多 profile 并存需要额外隔离,容易互相干扰按 <name> 天然隔离
与分发包的关系易被误打包进发行物明确属于用户侧,不随包分发
与 home 级 patch 的关系难以建立共享层同处 $DSH_HOME,共享偏好顺理成章
启动引用 bundle路径耦合安装位置引用中立,来源可多样

一个常见的混淆点需要澄清:profile 的目录名 <name> 就是启动时 dsh --profile <name> 里那个 <name>。所以 demo 这个 profile 的路径是 $DSH_HOME/profiles/demo,启动就是 dsh --profile demo。名字是唯一标识,dsh 靠它在 $DSH_HOME/profiles 下定位目录。这也是为什么素材说 profile “由 dsh plugin 自动创建维护”——首次用某个 profile 时,dsh plugin 会在 $DSH_HOME/profiles/<name> 下初始化它,用户通常不需要手工建目录、写 package.json。

还有一点与后续安装步骤直接相关:素材提到“首次使用会初始化 profile”,并给出一个非常具体的细节——@deepseek-ai/dsh-base 会成为它的第一个组合包。这句信息量很大:意味着一个全新 profile 的 dsh.profile.bundles 列表里,第一项总是 @deepseek-ai/dsh-base。它是分层顺序的第一档,所有用户安装的 bundle 都排在它之后。这也解释了为什么后装的 bundle 能覆盖基础包的行为——顺序决定胜负。profile 位于安装目录之外,但它引用的第一个 bundle 却来自官方基础包,这种“位置解耦、引用耦合”正是分层体系的设计意图。

dsh.profile.bundles 列表:这套配置由哪些组合包按什么顺序组成

profile 的核心内容就是 dsh.profile.bundles 列表,它回答的问题在素材里写得很清楚:这套配置由哪些组合包按什么顺序组成。注意这里有两个要素——哪些(成员)和什么顺序(排序)。这两者共同决定最终生效配置,缺一不可。

先看成员维度。bundles 数组里的每一项都是一个组合包名。素材在安装步骤里给出一个真实清单:先是 dsh-base,再是每个已安装组合包按其加入顺序。也就是说,一个初始化后的 profile,bundles 大致呈现为 ['@deepseek-ai/dsh-base', '...用户安装的包...'] 的形态。官方基础包永远站在第一位,用户安装的包依次跟在后面。

再看顺序维度。顺序为什么重要?因为分层体系遵循后应用的层按行胜出。bundles 数组的顺序就是 patch 应用顺序:数组里靠前的 bundle 先应用,靠后的后应用;当两个 bundle 都触及同一行插件配置时,后应用的那个胜出。所以 bundles 数组不只是一个“清单”,它是一个优先级序列。把某个 bundle 放在列表更靠后的位置,就等于给它更高的覆盖优先级。这是 profile 层面最实用的一个调节手段。

结合前面讲的“patch 替换整行 config 而非深度合并”,可以推演一个具体场景:基础包 dsh-base 的 patch 插入了某插件的行,配置为 { greeting: 'Hello', maxRetries: 3, verbose: false };用户随后安装的 hello-plugin 的 patch 也想配置这一行,只写了 { greeting: '您好' }。由于 hello-plugin 在 bundles 里排在 dsh-base 之后,它的 patch 后应用,这一行最终变成 { greeting: '您好' }——注意 maxRetries 和 verbose 不会保留基础包的值,它们要么由插件 schema 默认值补回(3 和 false),要么就缺失。这个例子把 bundles 顺序、按行胜出、整行替换三个机制串在了一起,是理解 profile 的关键推演。

素材里给出了一个 profile 的 package.json 实例,非常值得逐字分析:

{
  "name": "dsh-profile-demo",
  "private": true,
  "dependencies": {
    "dsh-hello-plugin": "link:/path/to/hello-plugin"
  },
  "dsh": {
    "profile": {
      "bundles": [
        "@deepseek-ai/dsh-base",
        "dsh-hello-plugin"
      ]
    }
  }
}

这份 profile 的 package.json 有几个细节要注意:

  • name 是 dsh-profile-demo,与 profile 名 demo 对应,命名风格是 dsh-profile-<name>。
  • private: true,表明这是一个私有、不发布的包——它本来就是用户侧的配置目录,不该被发布。
  • dependencies 里是 link:/path/to/hello-plugin,说明这个 hello-plugin 来自本地 checkout,用 link 方式接入。这也解释了为什么素材建议在包含 hello-plugin 的目录里执行安装命令——本地路径链接不需要发布到 npm。
  • dsh.profile.bundles 数组第一项是 @deepseek-ai/dsh-base,第二项是 dsh-hello-plugin,与我们前面的推演完全一致。

再强调一遍“由 dsh plugin 自动创建维护”这条职责。用户不是手工编辑这个 bundles 数组的——至少常规路径不是。用户执行 dsh plugin --profile <name> add <包>,dsh 在 profile 目录内转发给 pnpm 完成依赖安装,同时因为这个包声明了 dsh.bundle,就把它追加进 dsh.profile.bundles。也就是说,bundles 数组是安装行为的副产物,顺序由“加入顺序”决定。这带来一个实用推论:如果你想让某个 bundle 拥有更高的覆盖优先级,重新安装它不一定会把它移到列表末尾(取决于 dsh 的实现细节),更稳妥的做法是理解顺序语义后,在需要时按机制调整。但无论如何,常规使用中你应该信任 dsh plugin 的自动维护。

把 bundles 列表放到整个分层顺序里看,它的位置更清晰。素材给出的完整加载顺序是:

  1. profile 的 dsh.profile.bundles 列表——各组合包 patch 按列表顺序,先是 dsh-base,再是每个已安装组合包按其加入顺序;
  2. profile 自己的 cordis.patch.yml——用户 profile 级的 patch 层;
  3. home 级的 $DSH_HOME/cordis.patch.yml——各 profile 共享的机器本地偏好;
  4. 每个 --patch <path> overlay——按 argv 顺序。

可以看到,bundles 列表位于分层顺序的第 1 档,是所有用户 patch 之前的基础层。这意味着:bundle 提供基础组合,profile 级 patch 可以覆盖它,home 级 patch 可以再覆盖,命令行 overlay 拥有最终话语权。用一个表格把四层的“谁写、共享范围、优先级”对比清楚,是理解后续内容的最好铺垫:

顺序谁写 / 共享范围相对优先级
1dsh.profile.bundles 列表插件作者提供 patch,dsh plugin 维护列表;单 profile 内最低(最先应用)
2profile 自己的 cordis.patch.yml用户为该 profile 编写;单 profile 内高于 bundles
3$DSH_HOME/cordis.patch.yml用户为整台机器编写;各 profile 共享高于 profile 级
4--patch <path> overlay命令行按 argv 传入;单次启动最高(最后应用)

这张表也澄清了两个易混点。第一,home 级 patch 高于 profile 级 patch——尽管 profile 看起来更“专属”,但加载顺序上 home 级在其后,因此按行胜出时 home 级赢。设计意图是:机器本地偏好可以统一压过单个 profile 的设定。第二,应用参数不是另一层 patch。命令行里的应用参数通过普通应用自有服务解析,不参与这四层叠加;真正参与叠加的命令行输入是 --patch overlay,且它按 argv 顺序、排在最末、优先级最高。把这两点分清,你就能在“到底哪一层赢了”的问题上给出准确答案。

mkdir -p hello-plugin:创建组合包的最小起步动作

理论讲完,落到动手。素材按官方教程给出的第一步非常简单:

mkdir -p hello-plugin

就是创建包目录。这个动作本身没什么技术含量,但它在整个叙述里的作用值得说明,因为它是后续所有安装与分层讨论的物理起点。

为什么从建目录开始?因为一个组合包首先是一个 npm 包,npm 包的最小形态就是一个目录加一份 package.json。素材里后续会在这个目录里放插件代码、patch 文件和 package.json(其中声明 dsh.bundle),把它变成一个真正可安装的 bundle。hello-plugin 在教程里的角色是一个贯穿示例:它会被安装进 profile,会成为 dsh.profile.bundles 列表里的第二个成员,会参与“谁覆盖谁”的分层推演。所以现在创建的目录,是这条完整链路的第一环。

把这个动作放到正确的顺序里看:

  1. 创建包目录(本节的 mkdir -p hello-plugin)。
  2. 在目录内写插件代码,导出 Config 接口与同名 schema(对应本文前几节)。
  3. 编写 patch 文件,声明这个包插入或覆盖哪些插件行。
  4. 在 package.json 的 dsh 键下声明 dsh.bundle。
  5. 在包含 hello-plugin 的目录中执行 dsh plugin --profile demo add ./hello-plugin,把它装进 profile。
  6. dsh 初始化 profile(首次使用时 @deepseek-ai/dsh-base 成为第一个组合包),pnpm 链接该 checkout,dsh 因为这个包声明了 dsh.bundle 而把它追加进 dsh.profile.bundles。

注意第 5 步的命令形式:dsh plugin --profile <name> <args...>,素材明确说它在 profile 目录内转发给 pnpm,因此所有 pnpm 子命令都可用。这意味着 add、remove、install、update 之类的 pnpm 操作都能以 dsh plugin 为入口,在指定 profile 的上下文里执行。这是 dsh 把“profile 管理”和“依赖管理”合二为一的实用设计。

还有一个细节值得高级读者留意:素材说“在包含 hello-plugin 的目录中,安装该包的 checkout”,用的命令是 add ./hello-plugin。这个相对路径会被 pnpm 记录为 link 依赖(在 profile 的 package.json 里体现为 link:/path/to/hello-plugin)。也就是说,bundle 的开发可以完全本地化,不需要先发布到 npm 就能被 profile 引用。这对调试组合包、迭代 patch 非常友好:改完代码和 patch,profile 通过 link 直接生效。理解了这一点,你就能在自己的部署环境里用同样的方式验证 bundle 行为,而不必绕道发布流程。

mkdir -p 里的 -p 也值得一提:它保证父目录不存在时不报错、已存在时也不报错。这看起来是个小事,但符合脚本化的最佳实践——安装脚本或 Makefile 里可以放心重复执行。对于一个面向部署与运维的教程来说,这种“可重复执行”的细节往往比语法本身更重要。

本段到这里,已经把“配置从哪来”的前半程讲完:从 cordis.yml 到 apply 的字段映射、Schemastery 同名 schema 的默认值归属、配置与代码分离的判断标准、bundle 与 profile 两种 manifest 的分工、bundle 的 patch 产物、profile 放在 $DSH_HOME 的理由、bundles 列表的顺序语义,以及 hello-plugin 的起步动作。后半段将接着讲安装命令的完整执行细节、加载顺序四层如何逐层组合、patch 整行替换带来的具体坑位与规避方案,以及 dsh 自动维护 bundles 的完整行为,把“生效配置到底从哪来”这条线索彻底收口。

上一段我们把插件侧的 Config 接口与同名 Schemastery schema 讲透了,也把 bundle 与 profile 这两份 manifest 的分工摆到了台面上。现在往下走一步:把 hello-plugin 真正装进一个 profile,然后搞清楚 dsh 的生效配置到底是从哪一层冒出来的。

dsh plugin --profile <name> <args...>:在 profile 目录内转发给 pnpm 的机制

很多读者第一次看到 dsh plugin --profile demo add ./hello-plugin 这条命令时会误以为 dsh 自己实现了一套包管理器。事实完全相反:dsh 在这里只做一个搬运工——它把 --profile 之后的剩余参数原封不动地转发给 pnpm,并且是在 profile 目录内执行这次转发。理解这一点,等于一次性拿到了整条插件安装链路的解释权。

先把命令拆开看。命令形如:

dsh plugin --profile <name> <args...>

其中 --profile <name> 是 dsh 自己的参数,用来定位要操作哪个 profile;而 <args...> 是整段透传给 pnpm 的参数,也就是 pnpm 的命令行。这意味着你平时熟悉的 pnpm 子命令在这里几乎都能用:add 装依赖、remove 卸依赖、install 按 lockfile 还原、update 升版本、list 看已装内容、why 追依赖来源、link 挂本地目录、run 跑脚本。因为转发是原样的,所以你在 pnpm 上学到的参数写法、--save-dev 之类的开关、甚至 --filter 这类的用法,都不需要 dsh 额外支持。dsh 不做语法翻译,它只负责换一个工作目录,然后把控制权交给 pnpm。

为什么必须在 profile 目录内执行?因为 profile 本身是一个位于 $DSH_HOME/profiles/<name> 的目录,它有自己的 package.json 和依赖树。你安装的每一个组合包,最终都会落到这个目录的 node_modules 里,并被写进这个目录的 package.json。如果 dsh 在你当前的工作目录里调用 pnpm,装出来的东西就会挂错地方——依赖落在项目根,而 profile 读不到;或者更糟,两个 profile 之间共享了不该共享的依赖。所以「在 profile 目录内转发」不是实现细节,而是安装语义的一部分:profile 的依赖闭包归属于 profile 自己的目录

再说首次使用。当你第一次对一个尚不存在的 profile 名执行 dsh plugin --profile demo ... 时,dsh 不会报「profile 不存在」然后退出,而是初始化这个 profile:创建 $DSH_HOME/profiles/demo 目录,生成一份最简的 profile 骨架(含 package.json),并且把 @deepseek-ai/dsh-base 作为它的第一个组合包登记进去。也就是说,初始化这个动作天然带着一个「基线」。这个设计很关键:profile 从诞生的第一秒起就不是空配置,它有一个 dsh-base 图层兜底,保证即使你一个第三方插件都没装,profile 仍然是一份可启动的组合。

工程上这里有两个容易踩的点。第一,别手动去改 profile 里的 node_modules 或手写依赖,profile 的依赖应当通过 dsh plugin 这条路径维护,这样 dsh 才能在 pnpm 安装完成之后继续补做它自己的登记工作(稍后讲)。第二,profile 名字会成为路径的一部分,用 demoprodstaging 这类小写英文更省心,避免大小写与平台差异带来的路径问题。第三,如果你在 CI 里做一次性安装,先确认 $DSH_HOME 可写且落在预期的持久化卷上——profile 是运行时状态,把它放在临时目录里,下一次构建就会丢失 bundles 列表。

还有一个概念要在这里钉住:--profile 是选择「装配哪一套配置」,它和插件本身无关,也和 patch 无关。同一个插件包,可以同时被 demo 和 prod 两个 profile 安装;两个 profile 各自维护自己的 dsh.profile.bundles 列表,互不干扰。这为后面的「环境差异化配置」埋好了伏笔。

dsh plugin --profile demo add ./hello-plugin:一次安装触发的三重变化

现在把上一段的 hello-plugin 真正装进去。命令是:

dsh plugin --profile demo add ./hello-plugin

这一条命令看起来平淡,实际上在 demo 这个 profile 里同时发生了三件事,理解这三件事的顺序和职责边界,你就能在任何安装异常时快速定位问题层。

第一重变化:pnpm 链接了这个 checkout。 因为参数 ./hello-plugin 是一个本地目录而不是 npm 上的包名,pnpm 会把它当作本地路径依赖处理,在 profile 的 node_modules 下建立指向该目录的链接,并把这条依赖写进 profile 的 package.jsondependencies 中,形式类似 "dsh-hello-plugin": "link:/path/to/hello-plugin"。注意 link 语义的含义:你在源目录里改代码,安装点的内容会立即跟着变,不需要重新 add。这对插件开发期极其友好,但上线前要留意——link 依赖让「已安装的版本」与「源目录状态」绑定在一起,如果源目录被清理或换分支,profile 就会跟着变。要固化版本,应当发布到 registry 后用版本号安装。

第二重变化:dsh 依据 dsh.bundle 声明把它追加进 dsh.profile.bundles。 这是整个安装流程里 dsh 独有的那一步。pnpm 装完依赖之后,dsh 会去读这个包的 package.json,检查它的 dsh 键下是否声明了 dsh.bundle。如果声明了,dsh 就把这个包追加到 profile 的 dsh.profile.bundles 数组里。这一步是「安装」区别于「只有依赖」的关键:一个包可以被 pnpm 装进 profile,但如果没有声明 dsh.bundle,它就不会成为组合包,也就不会贡献配置层。bundle 的本质是一个附带配置层的 npm 包,而这个配置层就是它提供的 patch 文件;dsh.bundle 这个 manifest 键回答的问题正是「这个包贡献什么」。所以一装箱动作可以这样记:pnpm 负责让包「存在」,dsh 负责让包「生效」。

第三重变化:@deepseek-ai/dsh-base 成为第一个组合包。 因为 demo 是首次使用而初始化的 profile,boss 位永远留给 dsh-base——它是 bundles 列表的首位,也是基础层。之后每追加一个已安装的组合包,都会按照「加入顺序」排在它后面。这个顺序并非无意义:它直接决定了后面加载时的叠放次序,dsh-base 先铺底,第三方包在它之上叠加。这也是为什么你在排查覆盖问题时,第一件要看的事就是 bundles 数组的排列。

把三重变化按执行顺序串起来:pnpm 链接 checkout → 依赖进入 package.json → dsh 读 manifest 并把包追加进 bundles → dsh-base 稳居首位。任何一步出问题都会表现为不同的症状:如果是 pnpm 那步失败(比如目录里没有合法的 package.json),你会看到包管理器报错;如果是 dsh.bundle 那步没生效(比如包名与目录名不一致、manifest 写错位置),你会看到依赖装上了、但配置毫无变化——这是最隐蔽的一类故障,因为安装「看起来成功了」。遇到这种情况,先去看 profile 的 dsh.profile.bundles 里到底有没有那个包名。

下面给出一段可直接粘贴运行的示例,覆盖从建包到安装再到验证的完整闭环。示例中的包名与目录名请按你的实际工程替换。

# 1) 在任意工作目录准备一个最小组合包源码
mkdir -p hello-plugin/src
cat > hello-plugin/package.json <<'EOF'
{
  "name": "dsh-hello-plugin",
  "version": "0.0.1",
  "private": true,
  "main": "src/index.js",
  "dsh": {
    "bundle": {
      "patch": "cordis.patch.yml"
    }
  }
}
EOF

# 2) bundle 必须提供的那个 patch 文件
cat > hello-plugin/cordis.patch.yml <<'EOF'
# 这个 patch 声明本组合包向配置里插入 / 覆盖哪些插件行
# 具体行内容请按你自己的插件 id 与 config 填写
EOF

# 3) 安装进 demo profile(首次会自动初始化 profile)
dsh plugin --profile demo add ./hello-plugin

# 4) 验证:看 bundles 列表里是否出现该包
cat "$DSH_HOME/profiles/demo/package.json"

# 5) 顺带看看 pnpm 侧装了什么
dsh plugin --profile demo list --depth 0

这段示例里有两个需要读者替换的地方:dsh.bundle 下 patch 文件的实际路径,以及 patch 文件内真正要插入或覆盖的插件行。篇幅所限不展开 patch 行格式,但要牢记它的角色定位:bundle 的 manifest 回答「这个包贡献什么」,答案就是一个 patch 文件——它插入或覆盖插件行。

生成后的 profile package.json 长什么样:dependencies 与 dsh.profile.bundles 对照

安装完成后,生成的 profile 的 package.json 大致如下(形状来自素材,路径按你的实际 checkout 替换):

{
  "name": "dsh-profile-demo",
  "private": true,
  "dependencies": {
    "dsh-hello-plugin": "link:/path/to/hello-plugin"
  },
  "dsh": {
    "profile": {
      "bundles": [
        "@deepseek-ai/dsh-base",
        "dsh-hello-plugin"
      ]
    }
  }
}

逐字段读一遍,这份文件其实是一张「谁装了什么、谁贡献了什么」的双列表。

  • name: dsh-profile-demo。这是 profile 自身的包名,注意它与你安装的插件包名不是一回事。profile 是一个独立目录,它需要自己的标识,方便在 pnpm 工作区与日志里被认出来。它通常由 dsh 在初始化时按 profile 名生成,不需要你手工维护。
  • private: true。profile 永远不该被发布到 registry。它描述的是「这台机器上这套可启动的组合」,是运行时装配信息,不是可分发的制品。private 这个标记同时也在提醒你:profile 位于安装目录之外,路径模板是 $DSH_HOME/profiles/<name>,它属于环境,不属于代码仓库。
  • dependencies: { "dsh-hello-plugin": "link:/path/to/hello-plugin" }。这是 pnpm 的地盘,记录「这个 profile 的依赖树里有哪些包、从哪来」。link 前缀说明它指向一个本地 checkout。这一节回答的是「包在不在」。
  • dsh.profile.bundles。这是 dsh 的地盘,记录「这套配置由哪些组合包按什么顺序组成」。数组首位的 @deepseek-ai/dsh-base 是初始化时自动加上的基础层,后面按加入顺序排列。这一节回答的是「包生不生效、按什么次序生效」。

把两个字段对照起来看,就得到了本段最重要的一个心智模型:dependencies 决定「装没装」,bundles 决定「用不用、怎么叠」。两者并不自动同步——pnpm 可以装进来一个不声明 dsh.bundle 的包(只进 dependencies,不进 bundles);并且在工程实践中,bundles 的顺序才是覆盖关系的决定因素。所以当你怀疑某个插件「没生效」时,排查链条应当是:先在 dependencies 里确认包装上了,再到 bundles 里确认包被登记了,最后才去怀疑 patch 内容和加载顺序。

这里还有两个抽象的判别问题值得牢记,它们来自 bundle 与 profile 的定位差异:bundle 是你编写并分发的东西,profile 是用户用 dsh --profile <name> 启动的东西;两者都由一份 package.json 描述,但携带的 manifest 种类不同。没有东西同时是两者——一个包要么声明 dsh.bundle(它贡献一个 patch 文件),要么作为 profile 存在(它描述由哪些 bundle 按什么顺序组成),不存在既是组合包又是 profile 的包。记牢这条排除法,很多概念混淆会自然消解。另外,profile 的 manifest 通常由 dsh plugin 自动创建与维护,你不太需要手写它;而 bundle 的 manifest 是插件作者自己写的,属于你要负责的那部分。下表把这两类 manifest 的关键差异并列出来,便于速查。

维度bundle(组合包)profile
manifest 键dsh.bundledsh.profile
回答的问题这个包贡献什么(一个 patch 文件)这套配置由哪些 bundle 按什么顺序组成
谁编写插件作者编写,随包分发由 dsh plugin 自动创建维护
谁使用作为依赖被安装进 profile用户用 dsh --profile <name> 启动
所在位置随 npm 包分发,落在 profile 的依赖树中安装目录之外,路径模板为 $DSH_HOME/profiles/<name>
典型内容一个插入或覆盖插件行的 patch 文件一个 bundles 列表,决定叠加次序
可否手动改可以,属于你的代码库建议交给 dsh plugin 维护
能否同时是另一个不能。没有东西同时是两者。

生效配置的四层加载:从空根逐层组合到 argv overlay

插件装好了、bundles 也登记了,但「最终生效的配置」还没成形。dsh 的做法是:在一棵空根之上,按固定顺序逐层组合,后应用的层按行胜出。素材给出的完整顺序如下:

  1. profile 的 dsh.profile.bundles 列表。各组合包的 patch 按列表顺序依次应用——先是 dsh-base,再是每个已安装组合包按其加入顺序。这是配置的主体来源。
  2. profile 自己的 cordis.patch.yml。位于 profile 目录内的用户 profile 级 patch 层,用来做这一套环境的个性化调整。
  3. home 级的 $DSH_HOME/cordis.patch.yml。位于 DSH_HOME 层,是各 profile 共享的机器本地偏好,影响这台机器上的全部 profile。
  4. 每个 --patch <path> overlay。按 argv 顺序逐个应用,用于启动时的临时叠加。

这个顺序图值得画出来贴在工位上,因为它同时决定了两件事:谁能覆盖谁,以及改动应该放在哪里。层的粒度从「包」到「profile」到「机器」到「本次进程」,这是一个从宽到窄、从共享到临时的阶梯。想改某套环境的配置,放第 1、2 层;想改这台机器上所有 profile 的偏好,放第 3 层;只在这一次启动里做实验,用第 4 层。

这里有一条极其关键、却最容易被误读的结论,素材里用一句话点明了:应用参数不是另一层 patch。很多人下意识地把命令行上的各种参数当成「第五层」,但它不在 patch 分层体系里,不参与「按行胜出」的那套覆盖比较。如果你把某个关键设定期待成「命令行参数会覆盖配置文件」,而在 v1 里它其实是走服务解析的普通应用参数,你就会得到一个与预期不符的生效值,且完全找不到「第五层」在哪。排查这类问题时,先把应用参数从 patch 分层的心智模型里摘出去。

另外一个容易忽视的细节是「空根」:分层不是从一份默认配置起算,而是从空根开始,一层层往上铺。这意味着最终配置里存在哪些插件行、哪些 config,完全由这四层共同决定,没有任何隐藏的「隐含默认」。这对排查反而有利——每一行都能追溯到某一层,只要你会读层。

顺手给一个常被问到的定位问题:profile 的 bundles 列表是「按其加入顺序」排列的,而这个加入顺序由 dsh plugin add 的调用历史决定。如果你反复 add / remove 同一批包,顺序可能与代码审查时看到的目录顺序不一致。顺序即语义,所以当你在做覆盖关系排查时,不要假设 bundles 的排列等于 README 里的推荐顺序,去实际读 profile 的 package.json。

后应用的层按行胜出:patch 替换整行 config 而非深度合并

现在切入本段最容易翻车的一个点,也是全文最需要背下来的一条语义:后应用的层按行胜出,且 patch 会替换目标行的整个 config 值,而不是深度合并各键

「按行胜出」是粗粒度的:比较的单位是插件行,不是 config 里的单个键。低层里有一行插件生效了,高层里出现同一个插件行,那么高层那一行会胜出,低层那一行整体不再参与。不会出现「低层提供 greeting、高层只补 maxRetries」这种按键盘级拼接的默契。

「替换整个 config 值」是这条语义最锋利的刃。假设低层某插件行携带着完整配置:

  • greeting: "Hello"
  • maxRetries: 3
  • verbose: false

而高层只想改一个字段,比如把 maxRetries 改成 5,于是写了一条只含该键的 patch 行。结果不是「其余键保留、maxRetries 变成 5」,而是整个 config 被这条新值替换:如果新值里只有 maxRetries,那 greeting 与 verbose 就失去了来源,最终配置里这两个键的取值就与你的直觉不符(取决于该插件的 schema 是否提供默认值、以及缺省时的行为)。这是真实发生在生产环境的坑,症状往往是「我只改了一个参数,怎么另一个参数也跟着变了」,而且很难想到是替换语义导致的。

对策非常明确:想让高层胜出又要保留其余键,就必须在高层那条 patch 里把整份 config 写全。把「整行替换」当成合同:谁提供了胜出的那一行,谁就要为这一行的全部键负责。为了降低维护成本,一个常见做法是让低层的 config 键尽可能少、把易变项集中到高层;或者干脆把每个环境的完整 config 都写成完整块,用可读性换确定性。

还有一个与「按行胜出」直接相关的细节来自上一段:插件侧的 Config schema 里,默认值是写在 schema 字段上的(如 Schema.string().default('Hello')Schema.number().default(3)Schema.boolean().default(false)),并且 apply 的第二个参数就是校验后的配置。所以当高层替换掉了某个键、导致该键缺失时,最终 config 里该键是否还有值,取决于 schema 是否给了默认值以及缺省校验的行为。这就把「schema 设计」和「分层覆盖」两件事绑在了一起:可选键(如 TypeScript 里的 verbose?: boolean)与带默认值的键在替换语义下的表现并不相同。写 patch 时如果不想被迫写全,依赖 schema 默认值是一条路,但要先确认默认值符合该环境的预期,而不是默默把值「带回」到某个你不想要的方向。

再说第三层的语义后果:home 级 $DSH_HOME/cordis.patch.yml 位于 profile 自己的 patch 层之后,所以它会覆盖所有 profile 的 profile 级设置。这非常符合「机器本地偏好」的定位(比如本机的路径、代理、日志开关),但也是个容易误伤的层:如果你在一台共享机器上写了 home 级 patch,它会盖掉每个 profile 的意图。排查「某个 profile 的配置怎么在本机不生效」时,先看 $DSH_HOME/cordis.patch.yml 有没有同名插件行。同理,argv overlay 在最后,它盖过前面所有层,非常适合临时诊断,但别把关键差异长期塞在启动命令里——它不落盘、不可审查、且在「应用参数不是另一层 patch」的规则下,你以为的覆盖路径可能根本走不到。

下面这段示例演示一个典型的整行替换场景:高层只想动一个键,却因为替换语义把低层其余键一起带走。示例用两段伪 patch 表达层级关系,帮助你在自己的工程里复现与验证。

# 低层:profile 的 dsh.profile.bundles 中某组合包贡献的 patch(示意)
- id: my-plugin
  config:
    greeting: "Hello"
    maxRetries: 3
    verbose: false

# 高层:profile 自己的 cordis.patch.yml,只想把 maxRetries 调到 5
- id: my-plugin
  config:
    maxRetries: 5

# 结果(按行胜出 + 整行替换,而不是逐键 merge):
#   greeting   —— 不再来自低层那一行,取值改由该层 config 与 schema 默认值决定
#   maxRetries —— 5
#   verbose    —— 同理,不再来自低层那一行
# 正确做法:高层要把整份 config 写全
- id: my-plugin
  config:
    greeting: "Hello"
    maxRetries: 5
    verbose: false

验证这类覆盖是否按预期发生,最有用的动作不是猜,而是逐层加 patch 观察最终配置的变化:先在没有任何 patch 的情况下记录基线;再加 profile 级 patch,观察被改动的键;再加 home 级 patch,观察哪些键又被压回去;最后在启动命令上加一个 --patch overlay,观察它是否如你所愿盖过前面所有层。每次只加一层,是排查覆盖语义唯一可靠的方法。

表层组合包解析自有服务的路径:普通应用如何拿到依赖

分层讲完,还有一类问题必须回答:插件被层层 patch 之后,它怎么访问别的服务?素材给了一句非常精炼但信息量极大的话——表层组合包可以通过普通应用自有服务解析它们

把它翻译成工程语言:组合包虽然是被「配置层」装配起来的,但它并不是一个只能被配置改写的静态声明;作为一个带代码的 npm 包,它仍然是一个普通的 cordis 应用部件,在运行时可以通过常规的服务解析机制去拿自己依赖的服务。也就是说,「被 patch 管理」和「能解析服务」是两件事:前者决定它这一行的 config 从哪来,后者决定它运行时从哪拿能力。

这一区分解决了一个常见的概念焦虑:有人担心「既然我的插件是被 patch 插入的,是不是就拿不到上下文、不能依赖别的插件了」。答案是否定的。组合包活在 cordis 的应用体系里,服务解析走的是应用自有那条路径,与你是否用 bundle、被哪一层 patch 改动无关。config 是可以被覆盖的输入,服务是运行时的依赖解析

实践上这带来两个有用的推论。第一,config 里该放什么、服务里该拿什么要分清:凡是允许运维按环境调整的(开关、阈值、路径、问候语这类),放进 Config schema 让 patch 层去覆盖;凡是代码级的能力依赖(日志、存储、其他插件提供的服务),走服务解析,不要试图用 config 去「开关」一个本应在依赖层面解决的问题。第二,排查「插件拿不到依赖」时,不要往 patch 分层里找。分层只解释配置值的来源,服务解析失败通常是依赖安装顺序、服务注册时机或上下文获取方式的问题,两者是不同的问题域。把这两个问题域分开,能省下大量在错误方向上的翻找时间。

2026 年 9 月实践:dsh 分层配置的排查清单

把前面的机制落到排查台面上。面向高级读者,下面这份清单可以直接作为 dsh 分层配置问题的起手式,按「先分层、后语义」的顺序推进,能覆盖绝大多数「配置没生效 / 生效了但不是我要的」类故障。

  1. 先确认 profile 选对了。 检查启动命令里的 --profile <name> 是否指向你以为的那个 profile。profile 位于 $DSH_HOME/profiles/<name>,不同 profile 有各自的 package.json 与 bundles,选错就是选错了整棵配置树。
  2. 读 bundles 顺序,而不是猜。 打开 profile 的 package.json,看 dsh.profile.bundles:dsh-base 是否在首位、第三方包的排列是否与你的预期一致、有没有一个你以为装上了但实际不在列表里的包(那说明它没声明 dsh.bundle,或者安装步骤没走完)。
  3. 定位两类 patch 层的位置。 看 profile 目录内的 cordis.patch.yml(profile 级)和 $DSH_HOME/cordis.patch.yml(home 级)。尤其注意 home 级是各 profile 共享的,它在 profile 级之后应用,会盖过 profile 级;「本机某 profile 配置不生效」的答案经常就藏在这一层。
  4. 核对 overlay 的 argv 顺序。 每个 --patch <path> overlay 按 argv 顺序应用,最后一个盖住前面所有。如果启动脚本里拼了多个 overlay,或由环境变量动态生成,顺序就是覆盖结果的直接决定因素。顺序不稳,行为就不稳。
  5. 套用整行替换语义,而不是逐键 merge。 遇到「改了一个键、别的键也跟着变」时,先假设命中替换语义:高层那条 patch 的 config 是否写全了?是否需要把低层其余键一并带上?不要指望高层缺省的键会从低层继承。
  6. 把应用参数从分层模型里摘出去。 记住应用参数不是另一层 patch。如果一个设定你期望它参与「按行胜出」,但它是作为应用参数传入的,那它就不走那条比较路径。检查你期待发生的覆盖,是不是根本不在这套分层里。
  7. 分离 config 问题与服务问题。 确认「插件拿不到东西」是 config 取值不对,还是服务解析失败。前者在 patch 分层里找,后者往依赖与服务注册方向找,不要混为一谈。
  8. 用单层增量法复现。 记录无 patch 的基线 → 加 profile 级 → 加 home 级 → 加 overlay,每次只加一层并 diff 最终配置。这比一次性怀疑三层要快得多,也是唯一能真正确认覆盖顺序的方法。

如果要给这份清单配一句排查哲学,那就是:先问「这一行从哪来」,再问「为什么是这个值」。分层模型回答的是第一问,替换语义回答的是第二问,而应用参数与服务解析则提醒你,并非所有东西都装在这套分层里。

总结与最佳实践

把全文压成一张可执行清单。希望你在下次面对「生效配置到底从哪来」时,能按照它逐条走,而不是从零开始猜。

  • 插件侧先立契约:导出 Config 接口与同名 Schemastery schema,默认值写在 schema 字段上(如 Schema.string().default('Hello')Schema.number().default(3)Schema.boolean().default(false)),apply 的第二个参数就是校验后的配置。这是「配置与代码分离」的起点。
  • 分清两个 manifestdsh.bundle 回答「这个包贡献什么」——一个插入或覆盖插件行的 patch 文件;dsh.profile 回答「这套配置由哪些 bundle 按什么顺序组成」。bundle 是插件作者编写并分发的东西,profile 是用户用 dsh --profile <name> 启动的东西,没有东西同时是两者
  • 理解 dsh plugin 的本质dsh plugin --profile <name> <args...> 在 profile 目录内把参数转发给 pnpm,因此所有 pnpm 子命令都可用;首次使用会初始化 profile,并让 @deepseek-ai/dsh-base 成为它的第一个组合包。
  • 一次安装三重变化:pnpm 链接 checkout → dsh 依 dsh.bundle 声明把包追加进 dsh.profile.bundles → dsh-base 稳居首位。记住 dependency 决定「装没装」,bundles 决定「用不用、怎么叠」。
  • 四层加载顺序背下来:① profile 的 dsh.profile.bundles(含 dsh-base 与各已安装组合包,按加入顺序)→ ② profile 自己的 cordis.patch.yml → ③ $DSH_HOME/cordis.patch.yml(各 profile 共享的机器本地偏好)→ ④ 每个 --patch <path> overlay(按 argv 顺序)。应用参数不是另一层 patch
  • 覆盖语义只有一条:后应用的层按行胜出,且 patch 替换目标行的整个 config 值,而不是深度合并各键。想改一个键又想保留其余,就在高层把整份 config 写全;别指望从低层继承键。
  • 服务解析与配置分层正交:表层组合包可以通过普通应用自有服务解析它们。config 用来承载可被 patch 覆盖的输入,服务用来解析运行时的能力依赖,别把两件事混成一个问题。
  • 工程纪律:不要让手写依赖与手写 bundles 绕过 dsh plugin;开发期用 link 依赖方便,上线前用固定版本;profile 属于环境、放在持久化且可写的 $DSH_HOME 下;把启动命令里的 overlay 视为临时诊断手段,而不是长期配置差异的藏身处。
  • 排查口诀:先确认 profile 选对 → 读 bundles 顺序 → 定位两类 patch 层 → 核对 overlay 的 argv 顺序 → 套用整行替换语义 → 把应用参数摘出分层 → 分离 config 与服务问题 → 用单层增量法复现。先问「这一行从哪来」,再问「为什么是这个值」。

把这套分层与 manifest 机制吃透之后,你手里就有了三样东西:一份可被 patch 覆盖的配置契约(Config 与 schema)、一套可安装可复用的分发单元(bundle),以及一棵可预测的加载树(四层顺序加按行胜出)。它们组合起来,正是 dsh 的生效配置从哪来这个问题的完整答案。