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 看到什麼,改配置就能得到什麼。