Model-visible ⟺ logged:一條會崩給你看的不變數
模型看到的一切都要能從日誌重建,發請求前還要現場驗一遍,驗不過直接崩。
先玩再講。左邊是一條只能往後追加的事件日誌,右邊是從日誌重建出來的消息陣列,和即將發給模型的請求。點播放看事件流入。播完你可以當一次壞人:刪一條日誌,或者繞過日誌直接改請求,再點「發起下一次請求」,看比對怎麼逐條打鈎、在分歧處打紅叉、然後當場崩給你看。
實驗台的比對順序與源碼一致,紅條裏的報錯保留源碼原文:比對邏輯在 packages/core/agent-loop/src/invariant.ts 第 31 至 42 行,報錯前綴拼接在 packages/runtime-diagnostics/invariants/src/index.ts 第 62 行。核對日期 2026-08-13。
它解決什麼問題。大多數聊天程式都有兩份對話:記憶體裏一份陣列,磁盤上一份存檔,各寫各的。某天進程崩了,你從存檔恢復會話,恢復出來的歷史比模型當時實際看到的少了一條工具結果。模型接下來的回答全對不上號,你還查不出原因,因為兩份狀態誰也證明不了誰。只要真相有兩份,它們遲早漂移。
思路是什麼。DSH 把真相壓縮到一份。規矩寫在倉庫根部的 AGENTS.md 第 107 行:
AGENTS.md 第 107 行,核對日期 2026-08-13
拆開説。會話日誌是一份只追加的事件流,任何想讓模型看到的內容,必須先變成一條事件寫進日誌。消息歷史從日誌派生,官方文檔的説法是「從不單獨存儲」。所以在 DSH 裏,日誌就是對話本身,系統裏沒有第二份對話狀態。
出處:docs/subsystems/session.zh.md 第 5 行,核對日期 2026-08-13。
然後是標題裏那個雙向箭頭 ⟺。日誌推得出請求,請求也必須能被日誌解釋。光寫日誌做不到這一點,還得有人在發請求的路口站崗。每次請求出站前,DSH 從日誌現場重新派生一份應有的消息陣列,和請求裏實際帶的那份做全量字串比對;系統提示詞、模型名、採樣參數、工具清單也要和日誌裏的請求頭快照逐字段對上。全對上才放行。
站崗的核心就四行,短到可以當金句貼牆上。它在證明一件事:每次派發前,DSH 真的會從日誌重新派生一份消息,和實際請求做全量字串比對,對不上就地失敗:
const expected = session.deriveMessages()
if (JSON.stringify(options.messages) !== JSON.stringify(expected)) {
fail(`llm request for session "${String(session.id)}" diverges from the dispatch-time durable derivation (log-reconstruction desync)`)
}
deepseek-harness-master,核對文件 packages/core/agent-loop/src/invariant.ts,核對日期 2026-08-13。程式碼塊保留源碼原文。還有一個細節。檢查用的派生函式,和恢復、回放用的是同一套公開函式,檢查者和被檢查者共享同一套重建規則,誰也沒有私貨。請求頭的重建也只是一個七行的純函式,掃一遍事件、取最後一個快照。
出處:完整檢查順序在 packages/core/agent-loop/src/invariant.ts 第 22 至 52 行;請求頭重建在 packages/core/session/src/request-header.ts 第 65 至 71 行。核對日期 2026-08-13。
為什麼長期成立。single source of truth(單一事實來源)是數據庫領域用了五十年的老道理:帳本只能有一本,其餘全是帳本的視圖,視圖可以隨便丟、隨便重建。DSH 只是把這條紀律搬進了 agent 的對話管理。哪天源碼重寫成 Rust、事件類型翻三倍,帳本只有一本這件事照樣成立。
它解決什麼問題。設想站崗的發現對不上,只打一行告警會怎樣。告警意味着違規請求已經發給了模型:某個插件繞過日誌偷偷改了消息,模型看到的和日誌記的從這一刻起是兩回事。日誌接着記,記的全是錯賬。三天後有人拿這份日誌回放排查,怎麼都復現不出綫上的怪行為。靜默偏差比崩潰可怕,它把排查成本悄悄轉嫁給了未來。
思路是什麼。所以 DSH 選了最硬的那條路:比對不過,拋異常,這次請求當場作廢,根本發不出去。設計筆記裏明確否決過温和方案,對「比較連續請求,發散時告警」這條備選的判詞是「因違規必須在接口層面不可表達而否決」。
出處:.agents/notes/implemented/architecture/2026-07-05-reconstructable-requests.zh.md「曾考慮的替代方案」一節,核對日期 2026-08-13。
配套還有兩個小機關。檢查器插在事件監聽隊列的隊頭,防止別的監聽器提前短路、把檢查靜默跳過,站崗的人要第一個看到請求。然後請求對象和消息陣列必須深度凍結,堵住先過檢、再改內容的後門。
出處:隊頭註冊見 invariant.ts 第 20 至 21 行與第 54 行,凍結與會話檢查見第 22 至 29 行。核對日期 2026-08-13。
為什麼長期成立。這就是 fail-fast(快速失敗)。崩潰把損失鎖在零:日誌裏沒有被污染的輪次,修好 bug 重跑就行。帶病運行的成本是複利,跑得越久壞數據越多,最後連哪天開始壞的都查不出來。在邊界上崩一次,比在三天後的詭異現象裏考古便宜得多。這條判斷和語言、框架都沒有關係。
前兩個思路各花一份力氣,回報在這裏一起結算。日誌既然是唯一真相,圍繞它能白拿一整套能力:
- 恢復:進程崩了,從日誌重新派生一遍,接着聊。
- 分叉:從任意一條事件岔出去,開一條平行會話。
- 回放:把日誌再派生一遍就是當年的請求,回放測試連 API Key 都不用。
- 審計:介面上看到的軌跡就是模型看到的內容,背後有運行時斷言背書。
連 Compaction(壓縮,把過長的歷史換成摘要)都被這條不變數罩着:壓縮產生的摘要同樣以事件形式寫進日誌,壓縮之後的請求照樣要過出站比對,壓縮實現寫出 bug 會當場崩,逃不掉。
為什麼長期成立。這套玩法有個學名,event sourcing(事件溯源),銀行和會計系統用了很多年:不存餘額,只存流水,餘額永遠從流水算出來。DSH 只是把交易流水換成了對話事件。只要事件流還是唯一真相,這些能力就一直是免費的副產品。
會話持久化幾乎是編碼 agent 的標配,差別在方向。Grok Build(xAI 開源的 Rust 編碼 agent)的持久化是一條單行道:記憶體裏的對話狀態是主,落盤是從。每來一條消息,把記憶體條目拷一份丟進落盤通道,發送結果直接丟棄,落盤失敗也不打斷對話;壓縮時還允許把整份落盤歷史一次性替換掉。這是合理的產品取捨,只是持久層不承擔正確性職責:沒有任何路徑把日誌反向派生回來、和出站請求比對。
出處:grok-build-main 倉庫 crates/codegen/xai-grok-shell/src/session/chat_persistence.rs 第 30 至 38 行(persist_message 與 replace_history),核對日期 2026-08-13。
Claude Code 閉源,公開可見的是 ~/.claude 下的 .jsonl 會話日誌和恢復功能。基於已公開證據,它屬於事後記錄型持久化;沒有公開材料表明存在派發時刻從日誌重建請求並比對的運行時斷言,內部有沒有等價機制屬於未知。
所以這裏只比機制方向:Grok Build 和 Claude Code 把持久化當恢復手段,DSH 把日誌升格為需要運行時證明的第一性原理。Codex 在同一道題上把閘門開在了更早的地方:每一種注入先得是一個登記過的類型,裝配時按字段分揀,站內 Codex 上下文碎片 一課有源碼拆解。
推演一次觸發路徑
某插件監聽請求出站事件,想在發出前往消息陣列裏塞一條系統提示。按本課的檢查順序推演兩種情況:佢直接改嗰個被凍結嘅陣列會發生咩事?它先克隆整個請求、改克隆體再轉發,又會喺邊一步被攔低?提示:凍結檢查和逐字節比對各自守住什麼。