DeepSeek Harness · 持久化與基建

持久化治理:版本、fork 邊界與拒絕解讀

日誌格式怎麼演進,分叉邊界怎麼定,讀不懂的資料寧可拒絕。

課程目標讀完你能說清三件事:會話日誌的格式版本為什麼只用一個單調整數;新老版本互讀日誌時命運為什麼不一樣;以及為什麼在 DSH 眼裡,靜默跳過一條不認識的事件屬於安全事故,寧可整個會話拒絕開啟。
互動演示 · 日誌考古現場

先玩再講。上方是磁碟上的一份會話日誌:一行 header 加一串事件。下面兩個載入器同時讀它:左邊是 DSH 的「拒絕解讀」式,讀不懂就報錯;右邊是很多系統的慣用做法,「best-effort 跳過」式,讀不懂就跳過接著讀。三個場景各有一份問題日誌,點播放,看同一份資料在兩種載入器手裡各是什麼下場。

磁碟上的日誌(~/.dsh/sessions/session-42/log.jsonl.zst)
DSH 載入器(拒絕解讀式)
(尚未恢復任何訊息)
best-effort 載入器(跳過式)
(尚未恢復任何訊息)
點「播放」,看兩種載入器同時讀同一份日誌。
演示為教學化模擬,但左側的兩條拒絕文案逐字復刻自 coordinator.ts 第 79 與 1064 行的原始碼模板。真實 DSH 沒有右邊這個 best-effort 載入器,它是用來對照的反面教材。
邏輯拆解 · 一個整數管版本

背景一句話:DSH 的會話日誌是唯一真源,恢復、分叉、回放全從它派生(見 上一課的不變數)。真源要活得比任何一個版本的程式都久,所以格式演進不是小事:今天寫的日誌,明年的 harness 要能讀;反過來,新版本寫的日誌落到老版本手裡,老版本得知道自己讀不了。

DSH 的版本方案樸素到只有一個數字:SESSION_FORMAT_VERSION,當前是 0,定義在 packages/core/session/src/types.ts 第 56 行。沒有 1.2.3 這種大小版本。設計筆記的理由是:某一步升級能不能自動轉換,由那一步的升級器寫不寫得出來決定,兩級編號等於提前承諾了一件設計時根本不知道的事。

升不升版本的標準很明確:當且僅當老版本執行時無法在語義上完全正確地處理新日誌時,才必須升。「解析不報錯」不算數,能讀完但重建出錯誤的會話,這就是讀錯了。拿不準就升,因為一個近似恆等的升級器幾乎零成本,漏升一次卻會讓老版本靜默讀壞資料。

三種命運 · 按方向區分的讀取規則

開啟一份儲存的日誌時,先比版本號,三種結果對應三種完全不同的處理:

開啟儲存的日誌 讀 header.version 相等 正常讀 再逐事件過未知型別守衛 日誌更舊 升級器鏈 n → n+1 逐級轉換 查看只在記憶體轉換,不動檔案 繼續會話才落盤:原子替換 + 留備份 日誌更新 拒絕,並說明方向 「請升級 harness」+ 原始檔案路徑 未知事件型別守衛 在 KNOWN_SESSION_EVENT_TYPES 裡?放行 不在,但帶 ignorable: true?跳過 不在,也沒標記?整個會話拒絕恢復 兩道閘門都在讀取側:寫入側不做詞彙檢查,因為寫入時拒絕會讓活躍會話的持久化中途停擺
教學化結構圖:依據 session-log-version-mechanism Agent Note 與 coordinator.ts 原始碼整理。

最值得咂摸的是「拒絕,並說明方向」這一格。早先的 assertVersion 對任何版本不匹配都拋同一條含糊的錯誤,改動之後報錯分方向:日誌比你新,明說「由更新的 harness 寫入,請升級」,並附上原始日誌檔案的路徑;日誌比你舊但升級鏈斷了,就說「本構建沒有它的升級路徑」。使用者看到的永遠是「該升級了」,絕不是「檔案損壞」。資料明明沒壞,報損壞是冤枉它。

往舊讀的方向還有個細節。舊日誌被新版本開啟,升級器鏈只在記憶體裡逐級轉換,看一眼不落盤;只有使用者真的繼續這個會話,轉換結果才原子替換寫回磁碟,原檔案留備份。設計筆記否決過「查看時自動遷移落盤」:開啟即改寫等於把讀操作變成破壞性寫操作,轉換器有 bug 會在瀏覽時損壞日誌。

為什麼「跳過未知事件」是安全事故

版本號管結構變更,管不了詞彙增長:事件的種類由掛了哪些外掛決定,一個整數描述不了它。DSH 的方案是逐事件標記。讀取器遇到不認識的事件型別,預設整個會話拒絕恢復,除非那條事件的信封上帶著寫入方宣告的 ignorable: true。已知詞彙清單 KNOWN_SESSION_EVENT_TYPES 不是手寫的,由腳本從全倉庫所有事件宣告合併生成,共 44 個型別,連同 946 行的持久化事件目錄 docs/persistence-catalog.zh.md 一起,有專門的校驗腳本保證不過期。

為什麼預設必需、忘寫標記寧可拒絕過頭?設計筆記把這筆帳算得很清楚。忘寫 ignorable 的後果是一個本可恢復的會話被拒絕開啟,使用者不爽,是體驗問題;反過來預設可忽略,同樣的疏忽會靜默恢復出一個內容殘缺的會話,模型接著在錯誤的歷史上繼續工作,是安全事故。演示的場景 A 就是後者的現場:跳過一條裝著使用者訊息的未知事件,恢復出來的對話裡助手在回答一個不存在的問題。兩種失敗不對稱,防線自然偏向吵鬧的那邊。

版本是一個單調整數

不分大小版本。能不能自動升級由那一步的升級器存在與否表達,編號方案不提前承諾。當前 SESSION_FORMAT_VERSION = 0。

未知事件預設必需

讀取器拒絕解讀含未知型別的日誌,除非事件帶 ignorable: true。方向性拒絕優於 best-effort parse,靜默跳過就是讀錯。

fork 邊界寫兩份

header 的 seedLength 是持久的血統邊界;日誌裡的 session/end-seed 事件給只拿到儲存位元組的讀者用。seed.length 兩個都替代不了。

fork 的雙邊界 · seedLength 與 session/end-seed

分叉一個會話,就是把源會話到某個穩定位置為止的事件深複製一份,當作子會話的種子。麻煩在於邊界:子會話日誌的前半段是繼承來的種子,後半段才是自己寫的,兩段在位元組層面長得一模一樣。哪裡是分界線?

直覺答案是數一數構造時種子有幾條,也就是 seed.length。這個答案錯得很隱蔽:恢復的會話拿完整儲存日誌當構造種子,seed.length 算出的邊界會隨著每次重新開啟往後跑;header 裡的 seedLength 才一直保留著最初 fork 時的值。所以 DSH 把邊界寫了兩份:第一份在 header,fork() 建立子會話時把 parentSession 和 seedLength 寫進建立中繼資料;第二份在日誌裡,帶種子的會話把 session/end-seed 事件作為自己的第一次即時寫入追加在種子之後,專門服務只拿得到儲存位元組的消費方。

這條邊界事件解決的問題很具體。種子歷史裡可能有一個沒配對的 compaction/start,它到底是「上個生命週期崩在壓縮中途」還是「此刻正在壓縮」?光看位元組分不出來。有了 session/end-seed,在它之前的未配對開啟標記一律屬於已結束的生命週期。型別定義的 JSDoc 裡還有一句狠話:Session 的建構函式是唯一合法寫入方,外掛擅自追加一條,等於把它之前的所有即時工作靜默歸類成種子歷史。

順帶一句大日誌的恢復成本。恢復一個 130 萬事件、62 MiB 壓縮資料的會話,DSH 全程不物化整份明文,這輪優化把恢復准入從約 600ms 壓到 263ms(Agent Note 2026-08-05)。校驗和凍結一項沒省:持久儲存屬於執行時邊界,防線本身不動。

關鍵證據 · 拒絕的原文

「按方向區分」在原始碼裡是一個五行的小函式 sessionFormatVersionRefusal:版本號比自己大,文案是「由更新的 harness 寫入,請升級 harness 開啟」;比自己小又沒有升級路徑,文案是「本構建沒有它的升級路徑」。這個函式被協調器的載入檢查和各儲存後端共用,後端在解碼任何結構之前就先用它拒絕外來版本,保證使用者看到的永遠是「請升級」,絕不是「損壞」。演示左側那條紅色報錯就是它的原文。

出處:packages/session/session-persistence/src/coordinator.ts 第 77 至 81 行,核對日期 2026-08-13。

「未知事件預設拒絕」的守衛更短,整個就一個迴圈:型別在清單裡,或者寫入方標了 ignorable,放行;否則丟擲,報錯裡帶上事件型別、seq 位置和「很可能來自更新的 harness」的方向提示:

packages/session/session-persistence/src/coordinator.ts第 1061 至 1066 行
  private assertEventsSupported(meta: SessionHeader, events: readonly SessionEvent[]): void {
    for (const event of events) {
      if (KNOWN_SESSION_EVENT_TYPES.has(event.type) || event.ignorable === true) continue
      throw this.unsupported(meta, `session "${meta.id}" contains event type "${event.type}" (seq ${event.seq}) unknown to this harness and not marked ignorable; refusing to interpret the log — it was likely written by a newer harness`)
    }
  }
原始碼快照說明:依據本地倉庫 deepseek-harness-master,核對檔案 packages/session/session-persistence/src/coordinator.ts,核對日期 2026-08-13。程式碼塊保留原始碼原文。
橫向對比 · 別家怎麼對待讀不懂的資料

Grok Build

會話摘要讀取走的是標準 serde 反序列化路徑。persistence.rs 第 700 到 725 行的 resume 預讀迴圈裡,讀不出或解析不了的 summary.json 直接 continue 跳過,不報錯不留痕。

會話相關的 serde 結構體沒有一處標 deny_unknown_fields,預設靜默丟棄未知欄位:新版本加的欄位被舊版本讀一遍再寫回,就沒了。這是快速迭代產品的常見取捨,只是它把格式演進的正確性交給了「新老版本別混用」這個假設。

Claude Code

會話以 .jsonl 存在 ~/.claude 下,支援 resume 和查看。書稿材料(claude-code-sourcemap 的 study 章節)覆蓋了啟動、上下文管理和可觀測性,但沒有出現會話日誌格式版本協商或未知記錄拒絕機制的還原始碼。

基於已公開證據,讀到不認識的資料時的行為未知。閉源產品可以靠「客戶端總是最新版」兜底;DSH 是開源基建,各版本會長期共存,兜底假設不成立,所以把拒絕規則寫進了讀取器。

課堂練習
01

給你的外掛事件選一個預設值

你寫了個 DSH 外掛,往會話日誌裡追加自訂事件 myplugin/audit,記錄每次工具呼叫的審計資訊。推演兩種情況:不標 ignorable,使用者把日誌拷到一臺沒裝你外掛的同版本 harness 上開啟,會發生什麼?(提示:KNOWN_SESSION_EVENT_TYPES 由倉庫內宣告生成,倉庫外外掛的事件按構造就在清單之外。)標了 ignorable: true 又會怎樣,你的審計資訊在重建中去了哪裡?兩種選擇各適合什麼樣的事件,用「丟了它會不會改變日誌其餘部分的解讀」這把尺子量一量。

Takeaway:版本是一個單調整數,讀取規則按方向區分:相等正常讀,更舊走升級器鏈在記憶體轉換,更新則明確拒絕並指路「請升級」。未知事件預設拒絕解讀,因為拒絕過頭是體驗問題,靜默跳過是安全事故。fork 邊界寫兩份,header 的 seedLength 加日誌裡的 session/end-seed,seed.length 誰也替代不了。