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 的思路說明怎麼在不啟動的情況下驗證你的答案。