DeepSeek Harness · 模型與外部接入

LLM 適配層:單次嘗試、顯式重試、雙流持久

推理流與正文流分開存,重試是顯式事件。核心源碼:packages/llm/llm/src/assembler.ts 與 packages/core/agent-loop/src/agent.ts。

課程目標讀完你能説清三件事:DSH 為什麼規定一次適配器調用就是一次提供方嘗試、連 SDK 自帶的重試都要禁用;一次流式響應怎麼以兩種形態落盤,原始分片流管回放保真、派生消息流管對話歷史,失敗的半截輸出為什麼進得了前者進不了後者;以及重試為什麼被做成持久日誌裏的顯式事件,UI 靠它撤回半截輸出,事後靠它解釋每一秒都花在了哪。
互動演示 · 請求生命週期播放器

先玩再講。左邊是網綫上的 SSE 分片(provider 逐幀吐出來的原始數據),右邊是會話日誌(每個分片立刻落一條 assistant/chunk,流結束再派生一條 assistant/message),上方是用戶看到的 UI。三個情景:A 是一次順利的雙流落盤;B 中途斷綫,看重試怎麼以顯式事件出現在時間綫上;C 是反面教材,SDK 靜默重試模式,同一個故障,日誌裏無跡可查。

用戶看到的 UI
(等待模型回覆…)
網綫 · provider 吐出的 StreamChunk
(尚無分片)
會話日誌 · 持久事件(append-only)
(空日誌)
選擇情景後點「播放」,或滾動到此處自動播放情景 A。
邏輯拆解 · 一次調用就是一次嘗試

先立規矩。DSH 的適配器約定裏有一條寫得很硬:一次適配器調用就是一次提供方嘗試,適配器必須禁用庫自帶的重試(docs/subsystems/llm-streaming.zh.md 適配器約定一節)。HTTP 庫們都愛替你悄悄重試,看起來是貼心,實際是把信息藏起來了:請求為什麼慢、重試了幾次、每次因為什麼失敗,全部消失在庫的內部循環裏。DSH 把這層全部剝掉,適配器只幹一件事:發一次請求,把響應轉成統一的 StreamChunk 分片吐出來,失敗就規範化成一個可序列化的 LlmFailure(帶穩定的錯誤 code),別的不管。

防掛起也在這層解決:兩個交付的遠程適配器都帶 streamIdleTimeoutMs 看門狗,預設五分鐘,provider 停頓超時就映射成 TIMEOUT 錯誤。還有一條容易漏的:空回覆算錯誤。模型返回一個不帶任何內容塊的 stop,適配器把它映射成 EMPTY_RESPONSE 錯誤而非靜默的成功,這樣重試層才有機會救它。

邏輯拆解 · 雙流持久:分片流和消息流分開存

然後看落盤。agent loop 消費分片流的時候幹兩件事:每個分片原樣追加一條 assistant/chunk 事件進會話日誌,同時把分片餵給 BlockAssembler(組裝器)。流成功結束,組裝結果作為一條 assistant/message 事件再落一次盤。這就是雙流:chunk 流是原始錄像,回放測試靠它逐幀重建當年的響應;message 流是派生歷史,下一次請求的對話上下文從它派生。推理塊(reasoning,模型的思考過程)和正文塊(text)在分片協議裏就是不同類型,各自獨立組裝、各自落盤。

packages/core/agent-loop/src/agent.ts第 343 至 351 行
      const assembler = new BlockAssembler()
      const chunkSeqs: number[] = []
      const stream = preparedCall?.stream(request) ?? this.loopCtx.llm.stream(request)
      signal.throwIfAborted()
      for await (const chunk of stream) {
        signal.throwIfAborted()
        chunkSeqs.push(this.session.append('assistant/chunk', { turn, step, chunk }).seq)
        assembler.push(chunk)
      }
源碼快照説明:依據本地倉庫 deepseek-harness-master,核對文件 packages/core/agent-loop/src/agent.ts,核對日期 2026-08-13。程式碼塊保留源碼原文。

這九行是雙流的心臟:先落盤,再組裝,一個分片都不例外。流結束後走分岔:finish 是錯誤或中止,就把失敗交給 agent/request-error 事件去決定重不重試(第 354 到 371 行);成功才在第 381 行追加 assistant/message,並把這批 chunk 的序號寫進 sourceEventSeqs,標明這條消息是從哪些分片派生的。所以失敗的半截輸出有明確的歸宿:留在 chunk 流裏作證,絕不進派生歷史。下一次請求重建上下文時,那半截就像沒發生過。

組裝器本身也值得停一下。BlockAssembler 是全倉庫唯一的分片摺疊實現,適配器只要按 index 吐格式正確的分片,塊重組不用每家自己寫。它對畸形流有明確態度:處理 block-end 分片時,先看這個塊是不是已經關閉過,關過就直接返回,後面再來的重複關閉一律忽略。註釋裏管這叫「First close wins」,理由是只有第一次關閉説了算,流式輸出和最終組裝出的塊才能保持一致。這一行防禦恰好是性質測試抓過真 bug 的地方(複盤見 測試一個非確定性系統)。

出處:packages/llm/llm/src/assembler.ts 第 75 至 82 行的 block-end 分支,核對日期 2026-08-13。

邏輯拆解 · 重試是持久日誌裏的顯式事件

重試住在更高一層。dsh-llm-retry 插件監聽 agent/request-error,按每條 provider 路由註冊時捕獲的策略決定救不救。預設的 normal 策略:只對 EMPTY_RESPONSE、RATE_LIMIT、SERVER、TIMEOUT、TRANSPORT 五種 code 重試,最多兩次,退避從 500 毫秒到 10 秒,帶 10% 抖動;provider 用 Retry-After 指定的合法延遲會替換本地退避(packages/llm/llm-retry/README.zh.md)。

關鍵在於它怎麼記帳。等待退避之前,插件先往會話日誌追加一條 llm/retry 事件,裏面裝着重試 id、提供方、策略 mode、完整的失敗信息和計劃延遲;退避結束真要動手了,再追加一條 llm/retry-started。這兩條事件不進模型可見的表層,模型對重試一無所知,但 UI 和事後分析全靠它們:UI 據此撤回失敗的半截輸出、顯示「第 1/2 次重試,3 秒後」;排查問題時,日誌裏每一次嘗試、每一段等待都有時間戳。重試本身則開一個新的編號輪次,從持久歷史重建同樣的請求重發,舊輪次的記錄一個字不改。

還有一個跨適配器的細節:replayState。成功的 finish 分片可以攜帶適配器私有的回放狀態(比如 DeepSeek 推理內容的原生表示),跟着 assistant/message 一起存。下次請求把歷史發給適配器前,LlmRuntime.forAdapter() 檢查每條歷史消息:歷史 provider 和目標 provider 歸同一個適配器實例,狀態才透傳;換了適配器,狀態被剝離,對方只拿到提供方無關的內容(packages/llm/llm/src/index.ts 第 822 至 836 行)。一家的私貨絕不餵給另一家。

重試開新輪次

失敗輪次正常關閉,重試輪次帶新編號從頭開始。沒有任何記錄被改寫,兩次嘗試在日誌裏各自完整,時間綫永遠向前。

半截輸出進 chunk 不進 message

失敗前收到的分片都留在 assistant/chunk 流裏,回放能精確復現事故現場;但派生歷史裏沒有這半截,模型下一步看到的上下文是乾淨的。

空回覆是可重試錯誤

不帶內容塊的 stop 被映射成 EMPTY_RESPONSE 錯誤,預設策略會重試它。靜默接受一個空成功,等於把退化響應寫進對話歷史。

橫向對比 · 重試藏在哪一層

Grok Build:重試內建在採樣器裏

Grok Build 把流式與重試一起放進 xai-grok-sampler:retry.rs 是純邏輯的分類與退避模組,actor 層包重試循環。預算給得很足:預設最多重試 15 次(DEFAULT_MAX_RETRIES = 15,30 秒退避上限下約 6 分鐘),429 限流單獨降到 2 次就上報(RATE_LIMIT_RETRY_THRESHOLD = 2),413 圖片超限走特殊通道,剝掉圖片再試一次且不佔預算,服務端還能用 x-should-retry 頭一票否決(crates/codegen/xai-grok-sampler/src/retry.rs 第 1 至 34 行的行為總結註釋)。分類做得細,但重試循環發生在採樣器內部,對上層就是一次格外慢的調用。

Claude Code:API 客戶端層的重試包裝器

還原源碼裏,重試住在 restored-src/src/services/api/withRetry.ts:一個包在 Anthropic SDK 外面的應用層包裝器,預設最多 10 次(第 52 行 DEFAULT_MAX_RETRIES = 10),帶 Retry-After 感知和 fast mode 的冷卻處理。重試發生在 API 客戶端內部的 for 循環裏,會打調試日誌,但基於已公開證據,沒有看到把每次重試作為持久會話事件落盤的機制。

三家對齊來看,差別就一句話:Grok 和 Claude Code 的重試是函式內部的循環,DSH 的重試是日誌裏的一等公民。前兩家省事,後者可審計:策略、延遲、失敗原因、第幾次嘗試,全部持久化,UI 和回放測試都能拿它當事實依據。代價是 DSH 的重試邊界只有 agent 輪次這一層,繞過 loop 直接調 ctx.llm.stream() 的調用方就是一次裸嘗試。

課堂練習
01

推演一次斷在 block 中間的流

模型正在輸出第 2 個 text 塊,吐了 3 個 text-delta 之後連接被重置,適配器以 finish {kind:'error', TRANSPORT} 收尾。請回答:日誌呢陣有幾類事件、各有幾多條?assistant/message 會不會出現?UI 上面嗰半句點樣被撤回,依據係邊條事件?重試成功後,模型重建上下文嗰陣睇唔睇到嗰 3 個 delta?再想一層:如果把這段歷史發給另一家 provider 的適配器,上一條成功消息裏的 replayState 去哪了?

Takeaway:適配器只負責一次誠實的嘗試,重試住在 agent 層並作為 llm/retry 事件落進持久日誌,每一次等待都可審計。一次響應存兩份:chunk 流保回放保真,message 流保歷史乾淨,失敗的半截進前者不進後者。把重試藏進 SDK 省的是程式碼,丟的是證據。