DeepSeek Harness · 持久化與基建

憑據、設定、存儲與遙測

不起眼但全是坑:憑據每次現取、配置不落盤。

課程目標讀完你能説清三件事:為什麼在 DSH 裏輪換 API key 不需要重啓任何進程;兩個進程同時寫設定文件,為什麼不會互相抹掉對方的改動;以及一個匿名 UUID 怎麼同時伺候遙測、回饋和 DeepSeek 請求頭,還能做到你連 key 都沒配時壓根不被創建。
互動演示 · 憑據輪換演練

先玩再講。上方是磁盤上的憑據文件,下面兩個進程同時在跑請求:左邊是 DSH 的做法,每次請求都回文件現取一遍 key;右邊是很多程式的慣用做法,啓動時讀一次,存進記憶體用到死。腳本會在運行中途輪換一次 key、再把 key 清空,點播放,看兩邊各是什麼下場。

磁盤上的憑據文件($DSH_HOME/.credentials.yaml,web 的 Models 頁寫的就是它) credentials/updated (DEEPSEEK_API_KEY)
DEEPSEEK_API_KEY:sk-live-01
DSH:每次操作現取
進程記憶體裏不緩存 key,每個請求開始時回存儲解析一次
(還沒有請求)
對照組:啓動時讀一次
記憶體緩存:(進程未啓動)
(還沒有請求)
點「播放」,兩個進程開始向 DeepSeek 發請求。
演示為教學化模擬:key 的值和請求內容是課程虛構的 fixture,但左側缺 key 時的那條報錯逐字復刻自 packages/llm/llm-deepseek/src/index.ts 第 241 至 245 行的源碼模板。真實 DSH 沒有右邊這個「啓動時讀一次」的進程,它是用來對照的反面教材。
邏輯拆解 · 配置裏只有引用,值每次現取

先説清一件事:DSH 的設定文件和 cordis.yml 裏沒有任何一處寫着 API key 的值。它們攜帶的是引用,一個 POSIX 風格的環境變數名,比如 DEEPSEEK_API_KEY。值歸憑據提供方所有,本地提供方按四層來源找:進程環境優先級最高,然後是 $DSH_HOME/.credentials.yaml 文檔,最後是項目和用戶的 .env。這就是副標題説的「配置不落盤」:落盤的只有名字,機密被擋在配置之外(docs/subsystems/credentials.zh.md 第 5 行)。

然後是本課最重要的一條規則:消費方在每個操作中重新解析引用,絕不跨操作緩存。文檔原話説得很直白,這種按操作進行的讀取正是熱更新機制(同文檔第 20 行)。落到 DeepSeek 適配器上,就是 packages/llm/llm-deepseek/src/adapter.ts 第 214 至 222 行:每次 stream() 開頭,把連接配置和 key 一起凍成一份快照,這個請求從頭到尾用這一份,下一次請求自動重新解析。

大綱裏問的邊界條件在這裏有了答案。請求進行到一半你輪換了 key,本次請求拿舊 key 跑完,新 key 從下一次請求開始生效,中間不會出現半新半舊。而且 key 是從連接快照裏解析出來的,端點和發給它的密鑰永遠來自同一代配置,配置回滾時不會出現新端點配舊 key 的雜交(該處註釋寫明瞭這個意圖)。

還有兩條容易忽視的 seam 級規則。第一,空的存儲值在任何地方都視為不存在,把 key 設成空字串等於沒配,下一次請求直接報 MISSING_CREDENTIAL,演示最後一步就是它。第二,配置介面走 describe(ref),只回「配沒配、來自哪層、能不能寫」,絕不回值;由進程環境供值的引用被報成 writable: false,因為往那裏寫會表面成功、而解析繼續返回環境裏的舊值,seam 乾脆提前拒絕(同文檔第 34 行)。

最能看出這套架構乾淨的是 credentials/updated 事件(同文檔第 50 行)。憑據變更時確實會發事件,但文檔專門寫了一句:消費方不需要它,它只服務於配置介面刷新「已配置」徽標。熱更新靠的是讀取時機,壓根不靠通知廣播,沒有失效消息要追、沒有訂閲要管理。

每次操作現取

輪換 key 免重啓,下一次請求自動用新值。進行中的請求用同一代快照跑完,端點和密鑰永不雜交。

空值 = 未配置

seam 級規則,處處一致。缺 key 報 MISSING_CREDENTIAL 並點名配置入口;describe 回答一切但絕不回顯值。

一個匿名 id 三個消費方

OTel 的 user.id/feedback 回執、DeepSeek 請求頭共用一個 UUID,懶創建:沒成功用過就不落盤。

關鍵證據 · 解析發生在每次請求裏

這段在 resolveApiKey 函式體內(第 225 行起),每次模型請求都會走一遍:掛了憑據 seam 就向它現解析,沒掛 seam 就退回啓動環境變數。注意 else 分支裏的註釋,沒有 seam 時不存在可排序的託管存儲,環境就是全部的憑據平面:

packages/llm/llm-deepseek/src/index.ts第 230 至 240 行
    if (credentials !== undefined) {
      const hit = await credentials.resolve(ref)
      if (hit !== undefined) return assertUsableApiKey(hit.value, 'llm-deepseek', ref)
    } else {
      // Without the seam there is no managed store to rank against, so the
      // environment is the whole credential plane.
      const ambient = launchEnvironmentOf(ctx).get(ref)
      if (ambient !== undefined && ambient.value.length > 0) {
        return assertUsableApiKey(ambient.value, 'llm-deepseek', ref)
      }
    }

兩條路都落空,緊跟着拋出的就是 MISSING_CREDENTIAL(第 241 至 245 行),報錯把兩個配置入口都寫在話裏,演示左側最後那條紅字就是它的原文。

源碼快照説明:依據本地倉庫 deepseek-harness-master,核對文件 packages/llm/llm-deepseek/src/index.ts,核對日期 2026-08-13。程式碼塊保留源碼原文,為 resolveApiKey 函式體節選。
設定文件 · 誰都能寫,誰也別抹掉誰

設定是另一處坑。用戶拿編輯器改 settings.yaml,web 介面也在改,兩個 harness 進程可能同時開着。樸素實現是把記憶體裏的設定快照直接序列化寫回,後寫的贏,把先寫的整段抹掉:你在編輯器裏剛加的配置,被另一個進程一次保存衝得乾乾淨淨。

DSH 的寫路徑把這條堵死了(Agent Note 2026-07-30-settings-write-path-integrity.md)。每次寫盤之前先重讀磁盤、合併外部改動,然後在一把跨進程文件鎖裏完成「讀、渲染、原子提交」一整輪。鎖的實現是 withFileLock:用 wx 標誌獨佔創建 <文件名>.lock,創建成功即持鎖;別人佔着就指數退避重試,從初始延遲一路翻倍到上限,超過時限報錯。讀者不參與搶鎖,提交靠臨時文件 rename 原子替換,讀到的永遠是完整的一版。

有個細節值得停一下:等鎖超時後,它寧可報錯也不刪掉別人的鎖文件。函式上方的註釋給了理由,鎖文件的年齡證明不了它的主人已經死了,搶佔一把還活着的鎖比等待超時危險得多,清理孤兒鎖是運維動作。又是熟悉的配方:拿不準,寧可吵鬧地失敗,別靜默地闖禍。

出處:packages/util/atomic-write/src/index.ts 第 86 至 111 行的 withFileLock,核對日期 2026-08-13。

存儲與遙測 · 拒絕遷移,一個身份

KV 存儲的 SQLite 後端把版本立場延續了下來。STORAGE_SQLITE_SCHEMA_VERSION 當前是 1,寫在 PRAGMA user_version 裏;打開數據庫時,全新的空庫蓋上當前版本戳,其他任何版本一律拒絕打開,沒有就地遷移。和上一課的會話日誌版本是同一套哲學:未發佈軟件沒有需要保全的歷史數據,與其揹着一堆遷移程式碼,不如明確拒絕。

還有一處小而硬的取捨:journal 模式預設 WAL,壞文件系統可以退到幾種回滾日誌模式,但 memoryoff 被從類型上排除了(同文件第 23 至 29 行註釋)。理由一句話,扔掉日誌持久性會靜默違反 KV 後端合同裏的持久性條款。想快可以,想快到説謊不行。

遙測這塊最怕的是喧賓奪主,DSH 把它做成一項可選能力 seam:不在 agent loop 主幹上,沒有任何遙測內容會進入模型請求,harness 的職責到 emit() 為止(docs/subsystems/session-telemetry.zh.md)。每條記錄導出前要過一道脫敏流水綫,部署方掛規則監聽器;監聽器拋異常按 fail-closed 處理,直接扣下這條記錄不發。脫敏只改導出副本,權威會話日誌一個字都不動。

最後是匿名身份的設計。一個隨機 UUID v4 落在 $DSH_HOME/.anonymous-user-id,三個消費方共用:OTel 上報的 user.id/feedback 命令的確認回執、以及每次發往 DeepSeek 的 x-deepseek-harness-user-id 請求頭(packages/identity/anonymous-user-id/README.zh.md)。共用一個 id,接收側才能把三路記錄關聯起來,不用各自生成三個身份。

妙在創建時機。llm-deepseek 裏這個 id 是懶創建的,userId ??= getOrCreateAnonymousUserId(),第一次真正要用才生成文件(index.ts 第 248 至 249 行);而 stream() 裏憑據解析排在身份解析之前(adapter.ts 第 221 至 222 行)。連起來看:一台從沒配過 key 的機器,發起的請求在憑據那步就失敗了,磁盤上不會平白多出一個跟蹤身份。工具還沒為你幹過一件事,就先給你編了個號,這種事 DSH 不幹。

橫向對比 · 別家怎麼伺候憑據

Grok Build

憑據走 AuthCredentialProvider 接口(crates/codegen/xai-grok-auth/src/auth_provider.rs)。接口文檔要求實現方在每次取快照前做一次廉價的磁盤重讀,讓 grok-desktop、grok login 這些兄弟進程寫入的新憑據能被當前進程看到,方向和 DSH 的按操作重解析一致。

它還多一層事後兜底:refresh_after_unauthorized(),請求吃到 401 就嘗試刷新 token 並重試一次,主要伺候會過期的 OAuth 場景。事前現取加事後重試,比單靠緩存的方案穩得多。

Claude Code

它的功課做在啓動那一刻:utils/secureStorage/keychainPrefetch.ts 在進程啓動時並行發出 macOS Keychain 讀取,跟約 135ms 的模組 import 同時跑,業務程式碼真正要用時才等結果,把原本約 200ms 的串行讀省到接近零(書稿第 1 章啓動分析)。

優化方向和 DSH 相反:它在乎啓動那一次讀多快,DSH 在乎輪換後下一次讀多對。終端產品重啓成本低、憑據輪換少,預取加緩存划算;基建進程長時間駐留,重啓要中斷所有會話,每次現取划算。兩邊都對,因為伺候的場景不一樣。

課堂練習
01

輪換了 key,為什麼沒生效

你的部署在啓動腳本裏 export DEEPSEEK_API_KEY=舊key,後來又在 web 的 Models 頁寫過一份新值到 .credentials.yaml。現在舊 key 洩露要緊急吊銷,你在 Models 頁填了新 key,保存成功,但下一次請求用的還是舊的。推演原因:四層來源裏進程環境優先級最高,文件層寫得再新也排在它後面。再想想介面本可以怎麼救你:describe 會把這個引用報成 writable: false,介面提前把輸入框渲染成只讀,你就不會白填了。真正的出路是改啓動環境,或者別在環境裏放這個變數。

Takeaway:配置裏只存引用,值每個操作現取一次,輪換免重啓,熱更新靠讀取時機而非通知廣播。設定寫盤先合併外部改動,再在跨進程文件鎖裏做原子提交,孤兒鎖寧可超時報錯也不搶佔。存儲 schema 非當前版本拒絕打開,不做就地遷移。遙測止於 emit()、脫敏 fail-closed,一個懶創建的匿名 id 伺候三個消費方,沒用過就不落盤。