LLM 適配層:單次嘗試、顯式重試、雙流持久
推理流與正文流分開存,重試是顯式事件。核心原始碼:packages/llm/llm/src/assembler.ts 與 packages/core/agent-loop/src/agent.ts。
先玩再講。左邊是網線上的 SSE 分片(provider 逐幀吐出來的原始資料),右邊是會話日誌(每個分片立刻落一條 assistant/chunk,流結束再派生一條 assistant/message),上方是使用者看到的 UI。三個情景:A 是一次順利的雙流落盤;B 中途斷線,看重試怎麼以顯式事件出現在時間線上;C 是反面教材,SDK 靜默重試模式,同一個故障,日誌裡無跡可查。
先立規矩。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)在分片協議裡就是不同型別,各自獨立組裝、各自落盤。
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)
}
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() 的呼叫方就是一次裸嘗試。
推演一次斷在 block 中間的流
模型正在輸出第 2 個 text 塊,吐了 3 個 text-delta 之後連線被重置,轉接器以 finish {kind:'error', TRANSPORT} 收尾。請回答:日誌裡此刻有幾類事件、各多少條?assistant/message 會不會出現?UI 上那半句話怎麼被撤回,依據是哪條事件?重試成功後,模型重建上下文時能看到那 3 個 delta 嗎?再想一層:如果把這段歷史發給另一家 provider 的轉接器,上一條成功訊息裡的 replayState 去哪了?
llm/retry 事件落進持久日誌,每一次等待都可審計。一次響應存兩份:chunk 流保回放保真,message 流保歷史乾淨,失敗的半截進前者不進後者。把重試藏進 SDK 省的是程式碼,丟的是證據。