DeepSeek Harness · 系统地图

Profile / Bundle / Patch:用户能把产品改到什么程度

不改源码也能换掉深层能力:配置由四层 patch 对着空数组叠出来,晚应用的赢,命中同一条就整条替换。

课程目标读完你能说清三件事:DSH 的配置为什么拆成 Bundle(发行版默认)、用户 patch(Profile 层 + Home 层)、--patch(一次性实验)这几层,层与层怎么排优先级;patch 命中同一条目时为什么是整条替换,这个选择会造成哪个反直觉结果;以及 dsh --dump-config 怎么把最终生效的配置摊开给你看。
交互演示 · Patch 叠层沙盘

下面的沙盘把四层配置做成了可开关的卡片,从下往上是应用顺序。点播放,看每一层怎么把自己的改动刷到右边的最终条目上。重点看第三层:它只写了一个字段,结果把下面两层辛苦设好的字段刷没了。玩完可以切到 deep-merge 对照模式,看同一份改动在合并语义下的另一种结局。

语义
合成后的最终条目 · id: conversation-model
空条目列表。DSH 的根配置就是一个空数组,插件树从零开始由 patch 一层层刷出来。
点播放开始叠层,或点每层右侧的开关先改变参战名单。滚动到这里会自动播放一遍。
演示为教学化模拟:条目 id、字段与文件名做了简化,叠层顺序与替换语义对应 apps/cli/src/profile-boot.ts 第 121 至 129 行与 vendor/include/src/index.ts 第 58 至 128 行。deep-merge 模式是教学假设,DSH 未实现该语义。
先搞清楚三个名词

先给结论:DSH 没有一个大而全的配置文件。它的产品形态是由一摞一摞的 patch 列表叠出来的,每一摞都有明确的归属方。

Bundle 是发行版默认。它是一个 npm 包,实体就是包里带的那份 patch 列表。内置的三个是 base(共享核心)、web-app(浏览器表层)、headless(一次性任务模式)。

Profile 是用户的一套组装。它是 $DSH_HOME/profiles/<name> 下的一个目录,manifest 里排好要用哪些 bundle、按什么顺序,旁边放一份用户自己的 patch 文件。首次使用 web 或 headless 这两个名字会自动初始化模板。

Patch 是改动的最小单位。一条 patch 按 id 找到目标条目,改配置、禁用、或者 insert 新条目。树外插件也从这里进来:用 dsh plugin add 装进 profile,之后它就是普通的一层 patch。

出处:三个内置 bundle 与 package.json 里的 bundle 声明方式见 packages/bundle/README.zh.md;profile 模板自动初始化在 apps/cli/src/profile.ts 第 114 至 117 行。

设计思路一 · 每一层配置都有归属方

它解决什么问题

想象反面做法:一个大配置文件,发行版默认、这套组装的定制、个人偏好、临时实验全写在里面。三个月后升级发行版,新默认和你的旧改动搅在同一个文件里,你说不清哪一行是谁写的、哪一行能动,升级变成一场手工比对。

还有更日常的翻车:想临时试一个实验模型,顺手改了配置忘了改回来,第二天整套环境跑的都是实验配置。根源是改动没有归属,谁写的、跟着什么走、什么时候该消失,一个文件说不清这三件事。

思路是什么

DSH 把配置拆成四层,每层一个归属方:Bundle 跟着发行版走,Profile 跟着这套组装走,Home 跟着这台机器走,--patch 跟着这一次命令走。启动时对着一个空数组,按固定顺序把四层 patch 依次刷上去,晚应用的赢。

起点真的是空数组:根配置文件的内容就是 [],模板注释直接写着别改这个文件,去改 patch 文件。所有实际内容都由 patch insert 进来,所以最终插件树里的每一行配置都能回答自己从哪层来。

四层 patch 按 1 到 4 依次应用,晚应用的赢 1 · Bundle 层 发行版默认,随 dsh 安装走 2 · Profile 用户层 跟着这套组装走 3 · Home 用户层 全机偏好,对每个 profile 生效 4 · --patch 覆盖层 一次性实验,随命令走 对空数组按序应用 全库唯一的 patch 算法 最终插件树 boot() 挂载运行 dsh --dump-config 同一算法离线合成
同一摞 patch、同一个算法,挂载和 dump 的结果不可能漂移。

两层用户 patch 的分工也讲究:Profile 层跟着这套组装走,Home 层是机器本地偏好,对每个 profile 都生效,所以排在 Profile 层之后、压过它。两层改同一个 id 时,赢家是 Home 层那条。--patch 排最后,用来做不想写进文件的一次性实验,可以重复传多份,按命令行顺序应用。长会话里两个用户层的文件还被 watch 着,改一下保存,运行中的树就按同样的层序重组一次。

层序在源码里就是一个数组字面量:launcher 把组合好的 profile 摊平成一个 patch 数组,数组顺序就是应用顺序。这段函数短到能当金句看,它证明四层的先后被写死在一个数组里,没有任何条件分支:

apps/cli/src/profile-boot.ts第 121 至 129 行
/** The full patch stack of one composed profile, in application order. */
function allPatches(composed: ComposedProfile): PatchOptions[] {
  return [
    ...composed.bundlePatches,
    ...composed.profile.patches,
    ...composed.homePatches,
    ...composed.overlays,
  ]
}
源码快照说明:依据本地仓库 deepseek-harness-master,核对文件 apps/cli/src/profile-boot.ts,核对日期 2026-08-13。代码块保留源码原文。

出处:根配置为空数组与模板注释在 apps/cli/src/profile-boot.ts 第 60 至 64 行;Home 层压过 Profile 层的理由在 packages/boot/app-boot/README.zh.md 第 43 行;--patch 可重复传在 apps/cli/src/args.ts 第 132 行;热重载见同包 watchUserPatches

为什么长期成立

分层覆盖是配置系统的通则:CSS 的层叠、systemd 的 drop-in 目录、编辑器里用户设置压过默认设置,全是同一个结构。只要一份产品同时被发行方、团队、个人、单次命令四种角色修改,层就必须分开,否则升级和回滚都无从下手。换个语言重写整个 harness,这四层还是这四层。

设计思路二 · 命中就整条替换

它解决什么问题

叠层还剩一个关键决定:两层 patch 改到同一个条目时怎么办?直觉答案是 deep-merge,字段级合并,上层只写想改的字段,其余字段自动保留,写起来省事。

省事的代价有两个。第一,合并表达不了删除:下层设了一个字段,上层想把它去掉,merge 语义下没有这个动作,只能覆盖成别的值。第二,最终结果和任何一份文件的字面内容都对不上,排查配置时你得在脑子里把所有层的合并算法跑一遍,才知道现在生效的到底是什么。

思路是什么

DSH 选了整条替换。patch 按 id 命中目标条目后,把 patch 里除 id 之外的每个顶层键直接赋值过去。config 是一个顶层键,所以旧 config 对象被整个换掉,里面的字段一个都不保留。想只改一个字段,也得把要保留的字段重新写一遍,这是官方文档明说的已知限制,原话是「profile 覆盖必须重述需要保留的组合包字段」。

由此产生最容易踩的反直觉结果,就是演示第三层演的那一幕:Home 层只写了 model 一个字段,下面两层设好的 provider 和 temperature 被刷没了,而且没有任何警告,只有结果不对。顺带记三条行为边界:patch 指向不存在的 id 只警告不报错,一行 stderr 提示后跳过;patch 文件内容为空或只有注释会直接抛异常,因为解析结果不是列表;想让某一层什么都不做,写一个 []

替换语义的回报在可预测性。这个算法全库只有一份:挂载用它,dsh --dump-config 的离线合成也用它,dump 出来的结果和真正启动的内容不可能漂移。算法的输入永远不被改动、结果永远是深拷贝,这样配置热重载时撤掉一条 patch 才能真的还原,早先的值不会被烤进缓存。配套还有一份所见即可改的地图:docs/config-catalog.zh.md,3152 行的生成文档,把每个可加载包的 config 类型原样列出来,想知道某条 patch 能写哪些键,查这份目录就行。

dump 里看到什么,改配置就能得到什么。

出处:替换语义(顶层键逐个赋值)在 vendor/include/src/index.ts 第 110 至 124 行,唯一算法与不可变输入的承诺在同文件第 43 至 52 行的 JSDoc;整条替换的官方说明在 packages/boot/app-boot/README.zh.md 第 60 行。

为什么长期成立

这是声明式配置里的一道老选择题:merge 的省事,还是 replace 的可读。替换语义让每个条目有唯一的最后作者,出了问题只要找到最后那条 patch,字面内容就是生效内容;代价是写的时候要重述。DSH 把账算在了可预测性这边。只要一个配置系统允许多方叠加、又要求用户能自助排查,这道题就存在,和实现语言没有关系。

横向对比 · 三家怎么做配置分层

DeepSeek Harness

插件条目级 · 整条替换

层的单位是插件条目:一条 patch 换掉一整条 config。四层从 Bundle 到 --patch 顺序固定,根配置是空数组,一切内容可溯源到某一层。

换掉深层能力等于换掉一行条目:把 compaction 插件的 config 整条替换,甚至 insert 一个第三方实现。

Claude Code

设置字段级 · 五层来源

SETTING_SOURCES 排了五层:userSettings、projectSettings、localSettings、flagSettings、policySettings,越靠后越大(settings/constants.ts 第 7 至 22 行)。管理员的 policy 层永远压顶,还有一批字段只在 managed 来源生效。

分层的对象是设置字段,改的是行为参数;插件树本身没有摆上配置台面。

Grok Build

TOML 字段级 · deep merge

xai-grok-config 用 deep_merge_toml 递归合并(loader.rs 第 415 至 426 行):表合并、数组替换、上层字段赢。层序是 system_managed、managed、user,requirements 与 MDM 管控层最后压顶(loader.rs 第 234 至 248 行)。

它选了 DSH 演示里那个 deep-merge 对照:改一个字段不用重述,但上层没法删掉下层的字段。

对比焦点在两处。第一是合并语义:Grok 与 Claude Code 都是字段级合并,写起来省事;DSH 是条目级替换,写起来啰嗦,换来 dump 与文件字面一致的可预测性。第二是分层对象:那两家分的是设置字段,DSH 分的是插件树本身,所以用户能改到的深度不一样,patch 一条 insert 就能把第三方 compaction 实现接进主循环,这在前两家的配置系统里没有对应物。Claude Code 有 DSH 没有的东西也要说:管理员策略层和只在 managed 来源生效的锁定字段,DSH 目前没有等价机制,这一条基于已公开材料。

课堂练习
01

手推一次两层冲突

Profile 层写 id: conversation-model, config: { provider: deepseek, model: v3.2, temperature: 0.2 },Home 层写 id: conversation-model, config: { temperature: 0 }

问题一:按整条替换语义写出最终条目的 config,指出哪些字段消失了、会不会有任何警告提示你。

问题二:把这两条 patch 对调所在层,结果变成什么?

问题三:用 dsh --dump-config 的思路说明怎么在不启动的情况下验证你的答案。

Takeaway:DSH 的配置是四层 patch 对着空数组刷出来的,顺序是 Bundle、Profile、Home、--patch,晚应用的赢。patch 命中同一 id 时整条替换 config,不做字段合并,想保留的字段必须重述。同一个算法既管挂载也管 dump,所以 --dump-config 看到什么,改配置就能得到什么。