← Harness Engineering
Harness Engineering

模型可見 ⟺ 已記錄:DeepSeek Harness 的 Runtime Invariant

2026-08-23 · — views

Debug agent 的時候,最想回答的問題往往是最答不出來的那個:第 23 輪出錯的那次 LLM 呼叫,模型當下到底看到了什麼?多數系統給不出答案,因為 context 是一個一路被摸來摸去的可變 messages array,中間誰塞了一段提示、誰壓縮了歷史、誰改了 system prompt,事後全部無從對帳。

DeepSeek Harness(dsh)對這個問題的答案激進得多:模型看到的每一個字都必須能從 session log 重建,而且不是靠紀律,是靠一條每次請求都會執行的 runtime 斷言。這是本系列第二篇,第一篇拆了它的組合式架構,這篇拆它的狀態核心。

讀完精華版(2 分鐘),你會理解:

  • 「模型可見 ⟺ 已記錄」這條 invariant 的確切內容,以及它怎麼被強制執行
  • Context 壓縮、大輸出外溢、對話 fork 為什麼可以共用同一套機制
  • 這個設計跟「可變 messages array」路線的根本差異
  • 企業建 domain agent 最在意的可回溯、可控、穩定,這個模式各換到什麼,以及最小可偷的起步版本

這篇不是 event sourcing 教學,是看一個具體的 invariant 怎麼把一堆各自為政的 context 操作收斂成同一件事。


精華版

同一批 context 操作,兩條路線的處理方式對照:

操作可變 messages arraydsh 的投影式 log
壓縮歷史直接改陣列,原文消失寫入帶 replace 語義的新事件,改寫本身留痕
大輸出處理截斷或原樣塞進 context移出 transcript,留下模型可見的定位符
Fork 對話複製當下陣列,來歷不明複製 log 前綴,可追溯到每一個事件
使用者插話直接 append,時機難重現Durable 事件,重播時插話位置一致
Crash 恢復陣列在記憶體裡,直接沒了補一個合成的結束事件,log 永不截斷
  • dsh:agent 狀態是一條 append-only 的事件 log,模型看到的 messages 完全由 deriveMessages() 從 log 投影出來,每次 LLM 請求前有 runtime 斷言檢查兩者逐字相等。
  • 可變 array 路線:context 是一個共享的 messages 清單,任何 middleware、hook、壓縮邏輯都可以直接改它,最終送出的內容沒有單一權威來源。

幾個關鍵設計問題的簡答:

Invariant 具體檢查什麼? 每次 llm/stream 請求前,斷言「即將送出的 messages」與「從 log 重新投影的結果」JSON 序列化後逐字相等,連 model、system prompt、temperature、tools 清單都要跟記錄過的 request header 一致。不等就直接炸。

所有事件都會變成模型輸入嗎? 不會。44 種 durable 事件裡只有三種是 surface 事件(user message、assistant message、tool result)會投影成模型訊息,其餘都是 log-only,供重播、UI 與稽核使用。

壓縮歷史不就改寫了 log? 改寫的是投影,不是 log。壓縮摘要以一個帶 replace 語義的新事件寫入,宣告「投影時用我取代第 X 到 Y 段」,原始事件原封不動留在 log 裡。

這樣做的代價? 每個想影響模型輸入的功能都必須先變成一種事件,不能抄捷徑直接改陣列。設計成本前置,換來的是 context 永遠可對帳。

這跟 Langfuse 這類 observability 平台差在哪? 方向相反。Langfuse 是旁路觀測,記的是程式碼願意回報的副本,掛了 agent 照跑;event log 是主路狀態,模型看到的就是從它投影的,掛了 agent 就停。兩者是上下游互補,取代不了彼此。


以下是完整版,按需取用。

為什麼「模型當時看到什麼」在多數系統裡答不出來?

答不出來的原因是 context 沒有單一權威來源:messages array 是共享的可變狀態,從組裝到送出之間,任何一段程式碼都可以改它。RAG 檢索塞一段、壓縮邏輯刪一段、某個 middleware 重寫 system prompt,每一步都合理,疊起來就是沒有人知道最終送出的完整內容是什麼。log 有記嗎?通常記的是「我做了什麼」,不是「模型收到了什麼」,兩者中間隔著所有你沒記到的修改。

這在 demo 階段無所謂,出問題的是兩種時刻。一是 debug:agent 在某一輪開始鬼打牆,你想重現那一輪的請求,卻發現 context 是幾十輪累積加壓縮的結果,重建不出來。二是稽核:有人問「模型做這個決定時看到了哪些資料」,你只能回答「大概是這些」。

dsh 把這個問題定義成一條不變量:任何到達 model request 的內容,必須能從 session log 重建。反過來說,新的模型可見輸入等於新的 session event,想讓模型看到什麼,唯一的路是先寫進 log。

Context 是投影,不是狀態:deriveMessages() 怎麼運作?

dsh 的 agent 狀態核心是一條 append-only 的 SessionEvent log:每個事件帶型別、序號、時間戳,寫入後深凍結、強制可 JSON 序列化。模型看到的 messages 不是存起來的狀態,是每次由 deriveMessages() 從 log 投影出來的結果。

投影規則刻意簡單。44 種 durable 事件裡,只有三種是 surface 事件,會出現在模型視野:

  • user/message → user 訊息原樣
  • assistant/message → assistant 訊息(模型的原始 streaming chunks 另外記錄,投影時跳過)
  • tool/result → 帶工具結果的 user 訊息

其餘全部 log-only:turn 的開始結束、每一個 streaming chunk、request header、inbox 變動,都完整記錄但不進模型視野。這個切分讓一條 log 同時服務三個消費者:模型(surface 投影)、UI(chunk 級重播)、稽核(全量事件),三者永遠對同一份事實。

dsh 的 Web UI 有一個「軌跡」分頁,直接把這條 log 渲染成可檢視的 trajectory,就是這個設計的實際樣子:

DeepSeek Harness 的 Trajectory 檢視:每個 turn 的 USER、CONTEXT、ASSISTANT、TOOL 事件依序排列,runtime context 快照與 tool call 失敗(紅字錯誤碼)都完整留痕

注意兩個細節。CONTEXT 列就是前面說的 runtime context 快照事件,它以 durable 事件的身分出現在 log 裡,跟 user message 平起平坐;TOOL 列的紅字錯誤(WEB_PROVIDER_CREDENTIAL_MISSING)顯示失敗的 tool call 一樣被記錄,模型下一輪對「哪些工具不可用」的回答就是從這些事件推出來的。整個畫面沒有任何資訊來自 log 以外的地方。

Surface 事件支援兩種投影操作:append(常態)與 replace(宣告「投影時用我取代第 X 到 Y 段」)。replace 是後面壓縮機制的基礎,先記住它:改寫模型視野的能力本身也是一種事件

工程含義:投影式設計把「context 長什麼樣」從過程性知識(要重跑所有修改才知道)變成宣告性知識(讀 log 就知道)。debug 第 23 輪的請求,就是取 log 前綴投影一次的事。

一條 runtime 斷言,怎麼讓投影從慣例變成強制?

dsh 把 invariant 做成掛在 llm/stream 上的 runtime 斷言,每次真實請求前都執行,比對即將送出的內容與 log 投影的結果。需要做到這麼硬的原因是:光有投影函式不夠,只要有人繞過它直接組 messages,log 和模型視野就會靜默脫鉤,等你發現時已經對不了帳。斷言的內容是:

JSON.stringify(options.messages) === JSON.stringify(session.deriveMessages())

即將送出的 messages,必須跟從 log 重新投影出來的結果逐字相等。不只 messages:model、system prompt、temperature、maxTokens、tools 清單也要跟已記錄的 request header 一致。任何一項對不上,請求不會送出,直接拋錯。

兩個實作細節看得出這條斷言的地位。第一,它以 prepend 方式全域註冊,排在所有攔截器之前,任何 plugin 都無法讓它閉嘴。第二,它是在每次真實請求的路徑上執行,不是測試環境限定。這是「fail loud」哲學用在狀態一致性上:log 重建脫鉤是最嚴重的一類 bug,值得用每次請求的序列化成本去換立即爆炸。

對照第一篇講的組合式架構,這條斷言還有一層意義:當任何 plugin 都能攔截和改寫請求,「誰改壞了 context」的風險本來會放大,invariant 等於給所有攔截器立了一條底線,改可以,但改的結果必須同樣可從 log 重建。自由和對帳能力用同一條線綁在一起。

壓縮與外溢:改寫歷史怎麼在 invariant 下運作?

dsh 的 compaction 走事件路線:壓縮不直接改任何東西,而是寫入一個帶 replace 語義的新事件改變投影,原始事件全數留在 log。背景是 context 一定會爆、壓縮不可免,而多數系統的壓縮直接改陣列,「壓縮前模型看到什麼」從此成為懸案。事件路線的完整流程是:開始事件、LLM 生成的摘要事件(含被遮蔽的範圍與 token 數)、一個帶 replace 語義的 user message、結束事件。投影時摘要取代被壓縮的段落,模型視野變小了,但 log 裡原始事件一個都沒少,連「這次壓縮遮了哪些序號」都可查。

觸發設計有一個值得抄的細節。壓縮有兩個觸發點:常態的 pressure 觸發在請求組裝前,以及 context 超限報錯後的補救觸發。補救那條有個防呆:只有投影的 replace 世代真的前進了,才回報「可以重試」。沒有這個檢查,一次沒實際縮小 context 的壓縮會讓系統陷入「超限、壓縮、還是超限」的無效迴圈。

Spill 處理另一個方向的問題:單次工具輸出太大。過大的輸出移出 transcript,模型看到的是一個不透明的定位符加上取回提示和位元組數,原始內容另外存放。模型知道「這裡有個大東西、多大、怎麼拿」,context 不用付全額。fork 出去的 session 繼承定位符而不複製內容。

工程含義:壓縮和外溢在多數系統裡是兩套獨立的 hack,在 dsh 裡是同一個原語的兩種用法。判斷你的系統有沒有這個性質,問一個問題就夠:壓縮之後,還答得出「壓縮前模型看到什麼」嗎?

同一條 log,還順便解決了哪些問題?

狀態收斂到一條 log 之後,幾個原本各自需要專門機制的功能變成投影的副產品。

Fork:複製 log 前綴就是複製對話。邊界規則嚴格:fork 點不能落在未關閉的 turn 內,寧可拒絕也不裁切事件。子 session 記住 seed 長度,之後只處理自己新增的事件。

使用者插話dsh 的 inbox 有三個通道,插話進來先變成 durable 事件再被 claim 進對話,所以重播時插話出現的位置跟當時一致(steer 與 interrupt 的行為差異我在 Agentic Loop 設計關卡用 hermes-agent 講過,這裡的重點是 dsh 把它們也收進了 log)。三個通道裡最特別的是 inject:把內容放進模型視野但不喚醒 agent,等下一個會喚醒的訊息一起被帶入。這種「安靜的 context 注入」在可變 array 路線裡幾乎無法做到可重播。

Crash 恢復:行程掛掉時 log 裡會留下沒有結束事件的 turn。恢復策略是補一個合成的「interrupted」結束事件,絕不截斷。修復用增量表達,歷史永遠只增不減。

KV-cache 友善:動態的 runtime context(目前的工作目錄狀態這類)不是每次重算塞進 prompt,而是物化成 durable 的快照事件,且只在渲染結果真的改變時才追加新快照。context 前綴穩定,provider 的 prompt cache 命中率跟著穩定。這個主題我在 LLM Session 設計框架談持久化策略時碰過邊,dsh 把它跟事件模型綁在了一起。

這些功能共用機制這件事本身就是訊號:當你發現 fork、重播、稽核、crash 恢復各自需要一套特製邏輯,通常表示狀態沒有單一權威來源。

企業要建 Domain Agent,這個模式換到什麼?

把場景放到多數企業實際在做的事:針對一個特定領域建 agent 來解決問題,客服單據處理、報表產出、內部流程審核、SRE 值班,而非做一個通用的 coding agent。這類 agent 要上線,門檻從來不是 demo 跑不跑得動,是三件事:可回溯、可控、穩定。投影式 log 對這三件事各給了一個硬保證。

可回溯:domain agent 的決定會被質疑。客戶申訴「為什麼我的案件被這樣處理」、審核部門問「模型判斷時看到哪些資料」、事故複盤要重現出錯那一輪的請求。可變 array 路線下這些問題只能考古,投影式 log 下它們是查詢:取 log 前綴、跑一次投影,模型當時的完整視野原樣重現。invariant 保證這個重現不是「大概」,是逐字。

可控:domain agent 的 context 注入點特別多,RAG 檢索、客戶資料、業務規則、人工插話,每個都是團隊裡不同的人在不同時間加的。可變 array 路線下每個注入點都是一條暗道,出問題時要一條一條翻。事件路線把所有注入收斂到同一個入口:想讓模型看到什麼,先寫事件。新人加功能繞不過去,因為繞過去的那次請求會直接炸在 invariant 上。控制力來自結構,不用靠 code review 盯。

穩定:長任務的 agent 一定會遇到行程重啟,log 永不截斷加上合成修復事件,代表恢復後的 agent 接得上完整歷史。壓縮的防無效迴圈檢查避免 context 超限時陷入死循環。context 前綴穩定則直接反映在帳單上:prompt cache 命中率穩定,token 成本可預測。這三個都是上線之後才會痛的點,也是這個設計預先付掉的。

這個保證當然有價格。每個想影響模型輸入的功能都必須先定義成事件,不能在送出前順手改一下陣列;投影函式在關鍵路徑上,每次請求多付一次重建與序列化。所以判斷點跟第一篇的結論同構,看任務生命週期和稽核需求:內部工具、跑分鐘級、出錯重來就好的 agent,可變 array 夠用;任務跑小時級以上、決定需要對外負責、或「模型看到什麼」有合規意義的場景,這個模式從第一次事故複盤開始回本。

讀到這裡你可能會想:這不就是 Langfuse 這類 observability 平台在做的事?把它整進 harness 了?重疊確實存在,兩者都記錄請求、回應、tool call,但有一個維度是相反的。Langfuse 是旁路觀測:程式碼回報發生了什麼,它記下副本,埋點忘了或埋錯了,它會安靜地記下一份錯的;掛了 agent 照跑,telemetry 本來就該 fail-open。event log 是主路狀態:context 從 log 投影出來,沒寫進 log 的東西模型根本看不到,不存在「忘了埋點」這回事;掛了 agent 就停,狀態核心必須 fail-closed。範圍也不同,Langfuse 的主場在跨 session 的成本儀表板、eval 評分、prompt 版本管理(實戰整理在這),event log 是單 session 的 ground truth,沒有分析層。企業導入時兩者是上下游:log 提供不依賴埋點紀律的可信原料,observability 平台消費它做全域分析(可觀測性該量什麼,之前這篇有完整的設計思路)。

而且不必整套搬。最小可偷的版本是把 invariant 當測試斷言:你的系統大概已經有某種 transcript 或 log,寫一個測試,在真實跑完一段對話後,斷言「送給 provider 的最後一次 messages」可以從你的記錄重建。這個測試第一次跑通常會失敗,失敗的地方就是系統裡那些繞過記錄直接改 context 的暗道。dsh 只是把這個測試放到了每次請求的路徑上。

順帶一提,Claude Code 路線在這個光譜上的位置很有意思:它的 compaction 用確定性算法而非 LLM 摘要(claw-code 分析有拆過),換來的是壓縮結果可重現,但 transcript 對「模型視野」的還原能力仍然依賴實作紀律,沒有一條 runtime 斷言把它鎖死。兩邊都做了取捨,dsh 選擇把對帳能力做成硬保證。


結語:可變的 context 是過程,投影的 context 是事實。「模型可見 ⟺ 已記錄」的價值不在 event sourcing 這個詞,在於它讓「模型當時看到什麼」從一個考古題變成一個查詢。

本文是 DeepSeek Harness 系列第二篇。下一篇離開架構,看這個大量由 AI agent 開發的 repo 怎麼守住工程品質:Agent Notes、coverage 哲學、驗證世界而非自我報告。

延伸閱讀