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