憑據、設定、儲存與遙測
不起眼但全是坑:憑據每次現取、配置不落盤。
先玩再講。上方是磁碟上的憑據檔案,下面兩個行程同時在跑請求:左邊是 DSH 的做法,每次請求都回檔案現取一遍 key;右邊是很多程式的慣用做法,啟動時讀一次,存進記憶體用到死。腳本會在執行中途輪換一次 key、再把 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 時不存在可排序的託管儲存,環境就是全部的憑據平面:
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,壞檔案系統可以退到幾種回滾日誌模式,但 memory 和 off 被從型別上排除了(同檔案第 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 在乎輪換後下一次讀多對。終端產品重啟成本低、憑據輪換少,預取加快取划算;基建行程長時間駐留,重啟要中斷所有會話,每次現取划算。兩邊都對,因為伺候的場景不一樣。
輪換了 key,為什麼沒生效
你的部署在啟動腳本裡 export DEEPSEEK_API_KEY=舊key,後來又在 web 的 Models 頁寫過一份新值到 .credentials.yaml。現在舊 key 洩露要緊急吊銷,你在 Models 頁填了新 key,儲存成功,但下一次請求用的還是舊的。推演原因:四層來源裡行程環境優先順序最高,檔案層寫得再新也排在它後面。再想想介面本可以怎麼救你:describe 會把這個引用報成 writable: false,介面提前把輸入框渲染成只讀,你就不會白填了。真正的出路是改啟動環境,或者別在環境裡放這個變數。
emit()、脫敏 fail-closed,一個懶建立的匿名 id 伺候三個消費方,沒用過就不落盤。