Profile / Bundle / Patch:用戶能把產品改到什麼程度
不改源碼也能換掉深層能力:配置由四層 patch 對着空陣列疊出來,晚應用的贏,命中同一條就整條替換。
--patch(一次性實驗)這幾層,層與層怎麼排優先級;patch 命中同一條目時為什麼是整條替換,這個選擇會造成哪個反直覺結果;以及 dsh --dump-config 怎麼把最終生效的配置攤開給你看。
下面的沙盤把四層配置做成了可開關的卡片,從下往上是應用順序。點播放,看每一層怎麼把自己的改動刷到右邊的最終條目上。重點看第三層:它只寫了一個字段,結果把下面兩層辛苦設好的字段刷沒了。玩完可以切到 deep-merge 對照模式,看同一份改動在合併語義下的另一種結局。
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 的分工也講究:Profile 層跟着這套組裝走,Home 層是機器本地偏好,對每個 profile 都生效,所以排在 Profile 層之後、壓過它。兩層改同一個 id 時,贏家是 Home 層那條。--patch 排最後,用來做不想寫進文件的一次性實驗,可以重複傳多份,按命令行順序應用。長會話裏兩個用戶層的文件還被 watch 着,改一下保存,運行中的樹就按同樣的層序重組一次。
層序在源碼裏就是一個陣列字面量:launcher 把組合好的 profile 攤平成一個 patch 陣列,陣列順序就是應用順序。這段函式短到能當金句看,它證明四層的先後被寫死在一個陣列裏,沒有任何條件分支:
/** 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,
]
}
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 能寫哪些鍵,查這份目錄就行。
出處:替換語義(頂層鍵逐個賦值)在 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 mergexai-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 目前沒有等價機制,這一條基於已公開材料。
手推一次兩層衝突
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 的思路説明怎麼在不啓動的情況下驗證你的答案。