
很多人把“流式回答”概括成一句話前端發起 SSE請求保持連接模型一邊生成一邊返回。我原來也會這樣解釋。但當我沿著 DeepSeek Harness 的真實代碼走完一次請求又親手啟動 Web profile、發送消息、導出 Session 日志后我發現這個說法只描述了中間一段而且會掩蓋系統最重要的設計DeepSeek Harness 的流式回答不是一條貫穿瀏覽器和模型的長連接而是三段不同職責的通道。瀏覽器用一次短生命周期的 HTTP POST把用戶意圖交給 Harness。Harness 用 SSE 從 DeepSeek API 接收模型增量。Harness 先把每個增量寫成 Session 事件再通過 WebSocket 推給瀏覽器。這三段之間不是簡單轉發。中間的 Session 事件日志既是實時廣播源也是恢復、軌跡、統計和最終消息的共同事實來源。理解這一點才算真正理解 DeepSeek Harness 的“流式”。一、先做一次真實運行而不是只看代碼猜我從源碼啟動 Web profile讓系統自己選擇空閑端口node--importtsx/esm apps/cli/src/bin.ts web--host127.0.0.1--port0進程給出的唯一啟動日志很克制dsh web: http://127.0.0.1:57960隨后我在真實頁面中創建會話輸入下面這條消息請用三段話解釋 DeepSeek Harness 中一次流式回答從請求發出到增量展示的過程。不要調用工具每段以「階段一」「階段二」「階段三」開頭最后輸出「流式演示完成」。請求完成后頁面給出的實測指標是指標實測值模型DeepSeek-V4-FlashHigh回合 / 步數1 輪 / 1 步LLM 用時15.5 s首 token0.8 s生成速率80 tok/s輸入 token9.2K輸出 token1.2K緩存命中0%這里有一個必須先說明的細節截圖中的模型回答把瀏覽器鏈路概括成了 SSE。這段回答是實驗輸出不是架構證據。源碼和瀏覽器網絡記錄都表明瀏覽器向 Harness 發送消息使用 HTTP POST回答增量從 Harness 到瀏覽器使用 WebSocket只有 Harness 到 DeepSeek API 的模型響應使用 SSE。讓模型解釋自己的宿主并不等于完成源碼驗證。二、真正的結構一次上行兩條下行把完整鏈路畫出來后三個通道的邊界非常清楚通道方向載體負責什么用戶命令上行Browser → HarnessPOST /api/session.prompt提交消息并獲得“已接收”結果模型增量下行DeepSeek API → HarnessSSEtext/event-stream返回 reasoning、正文、工具參數、usage 和 finishSession 事件下行Harness → Browserws://.../api/events.mux推送已經進入 Session 的事件瀏覽器不會把一個 HTTP 請求掛 15 秒等答案。它先完成一次普通 RPCHarness 接管后續執行再把事件通過已存在的 WebSocket 下行連接推回來。我從瀏覽器記錄中看到的實際請求是POST /api/session.create - 200 OK POST /api/session.history - 200 OK POST /api/session.prompt - 200 OK刷新頁面并監聽新建連接時瀏覽器打開了ws://127.0.0.1:57960/api/events.mux ws://127.0.0.1:57960/api/events.host其中events.mux承載各 Session 的事件events.host承載 Session 創建、銷毀、運行狀態等 Host 級信息?;卮?token 走的是前者。三、第一段我點擊發送瀏覽器只負責“交棒”發送按鈕背后并不是“開始讀取模型流”而是調用客戶端 Session 的prompt()。客戶端先同步把promptAttempted和首輪 pending 狀態寫進本地狀態再進行第一次await。這讓界面可以立即進入“正在處理”狀態不必等網絡往返。隨后它調用api.sessions.prompt()攜帶sessionIdmodequeue或steer文本或圖片內容瀏覽器解析出的時區底層callUnary()為請求生成rpcId把它封裝成client-request再發送POST /api/session.prompt Content-Type: application/json響應必須回顯同一個rpcId否則客戶端直接把它視為協議錯誤。Host 收到請求后會解析時區、找到或恢復目標 Agent、把圖片轉換為可持久化附件、創建UserMessage最后根據模式調用agent.followup()或agent.steer()。成功響應只是{accepted:true}這個 200 OK 表示“消息已由 Agent 接管”不表示“模型已經回答完”。這一步把瀏覽器交互與可能持續幾十秒、可能包含工具調用和多 step 的 Agent 執行解耦了。四、第二段Agent Loop 組裝請求DeepSeek Adapter 打開 SSEAgent 開始 step 后先從當前 Session 推導歷史消息再組裝系統提示詞、工具定義、模型選擇和采樣參數。最終得到統一的GenerateOptions交給 LLM Runtime 按 provider 選擇適配器。DeepSeek Adapter 把統一請求序列化為 Chat Completions 請求。兩個字段決定了響應不是一次性 JSON{stream:true,stream_options:{include_usage:true}}然后 Host 直接請求POST {baseURL}/chat/completions Authorization: Bearer ... Content-Type: application/json Accept: text/event-streamAPI Key 只在 Host 側解析和使用不需要下發給瀏覽器。這里的響應才是標準意義上的 SSE。DeepSeek Adapter 沒有自己手寫字符串切分而是讓eventsource-parser負責任意網絡分塊下的事件重組UTF-8、CRLF 和 BOM 處理多個data:行拼接comment 與非 data 字段過濾空行終止一個 SSE event解析器逐個產出data內容并把字面量[DONE]作為終止哨兵。如果連接在[DONE]前結束Harness 不會把半截回答冒充成功而是拋出STREAM_CLOSED。五、SSE JSON 不是直接扔給前端而是先翻譯成統一事件DeepSeek 返回的每個 SSEdata是 provider 協議。Harness 還要把它翻譯成 provider 無關的StreamChunkDeepSeek 增量HarnessStreamChunk首次出現 reasoningblock-start(reasoning)reasoning_contentreasoning-delta首次出現正文block-start(text)contenttext-delta工具調用參數片段tool-call-delta塊完成block-endtoken 統計usage完成原因finish這種“塊 增量”的模型比一串純文本更重要。它允許 reasoning、正文和多個工具調用同時存在并讓前端知道每個片段應該追加到哪個 block而不是靠猜測文本格式。finish_reason和usage不會一出現就立刻封口。Adapter 會等到[DONE]依次補出所有block-end、最新usage和唯一的finish保證finish后不再出現新 chunk。六、最關鍵的一行每個 chunk 先進入 SessionAgent Loop 消費模型流時核心順序可以概括成forawait(constchunkofstream){chunkSeqs.push(session.append(assistant/chunk,{turn,step,chunk}).seq)assembler.push(chunk)}我認為這是整條鏈路最值得記住的設計。它不是先更新 UI、結束后再補日志也不是先把完整答案攢在內存中。每個模型增量先成為assistant/chunkSession 事件。Session.append()同步完成四件事為事件分配連續的seq。寫入毫秒級time。把事件加入內存中的規范日志。同步通知session/event觀察者。持久化插件監聽同一個session/event把凍結后的事件放進異步寫隊列熱路徑不會等待磁盤 I/O。API Proxy 也是觀察者它把事件封裝成{type:session/event,sessionId,event}然后壓入events.mux下行隊列。這帶來一個非常強的性質實時 UI、持久日志、軌跡視圖和最終消息都觀察同一批事件沒有一套“給前端看的流”和另一套“事后拼出來的日志”。七、第三段WebSocket 收事件瀏覽器按動畫幀發布Web 客戶端為events.mux建立下行 WebSocket。每個文本 frame 到達后它先解析 RPC envelope再校驗MuxFrame。畸形 frame 會被丟棄并輸出診斷不會污染客戶端狀態。對話投影收到assistant/chunk后按 chunk 類型更新 blocktext-delta追加到正文 block。reasoning-delta追加到 reasoning block。tool-call-delta追加工具參數并保留 call id 和工具名。block-end用完整 block 封口。usage更新 token 統計。值得注意的是Harness 沒有強迫 React 為每個 token 單獨渲染。普通 chunk 的發布策略是animation-frame事件仍然逐個進入狀態但同一幀內的多個更新可以合并后再繪制。這樣既保留精確事件順序又避免高 token 速率把主線程拖進無意義的重復渲染。軌跡視圖把 System、User、Context 和 Assistant 分開顯示上方時間條展示本輪不同階段它不是另一份遙測數據而是 Session 事件的另一種投影。八、真實日志里到底發生了多少次“增量”我導出了這次會話的 Session ZIP并只統計事件類型、序號、時間差和 token 數不讀取或公開完整系統上下文。結論比頁面上的“1.2K 輸出 token”更具體546個reasoning-delta631個text-delta合計1,177個模型增量完整邏輯事件序號為0..1201共1,202個事件以request/header為時間零點尾部時間線如下seq12 0 ms request/header seq13 7 ms request/context seq15 783 ms assistant/chunk block-start(reasoning) seq16…562 546 個 reasoning-deltaseq23 穿插 session/title seq563 6,373 ms assistant/chunk block-start(text) seq564…1194 631 個 text-delta seq1195 15,465 ms assistant/chunk block-end(reasoning) seq1196 15,465 ms assistant/chunk block-end(text) seq1197 15,466 ms assistant/chunk usage seq1198 15,466 ms assistant/chunk finish(stop) seq1199 15,477 ms assistant/message seq1200 15,479 ms step/end seq1201 15,479 ms turn/end(completed)這組數據解釋了頁面上的兩個數字首個reasoning-delta與 reasoning block 的開始事件同在783 ms到達所以 UI 顯示首 token0.8 s。正文 block 在6.373 s才出現因為前面是 546 個 reasoning 增量。因此首 token 延遲不等于首個可見正文字符延遲。對 thinking 模型做體驗分析時至少應該區分“首模型增量”“首可見內容”和“完整回答結束”三個時間點。usage事件也與頁面統計吻合{inputTokens:9242,outputTokens:1178,cacheReadTokens:0,reasoningTokens:546}九、為什么 101 行 JSONL 能裝下 1,202 個事件導出的session.jsonl只有 101 個物理記錄1 個 Session header加 100 個事件或存儲記錄。如果只用wc -l判斷事件數量會得到完全錯誤的結論。原因是 JSONL 后端會把連續、同 block 的 delta 無損打包成{type:text-chunks,seq0:564,time0:1786777166089,data:{turn:1,step:1,index:1,texts:[...,...,...],dt:[12,0,8]}}本次日志中有27 個reasoning-chunks存儲記錄42 個text-chunks存儲記錄共 69 個打包記錄texts保留每個原始片段不會把它們連接成一個大字符串dt保留相鄰事件的時間差seq0和time0錨定首個事件。讀取時Harness 會展開出原始的assistant/chunk恢復完全相同的seq、time、片段邊界和順序。這是一種很務實的取舍邏輯層堅持“一增量一事件”磁盤層不必為每個兩三個字的 token 重復寫一大段 JSON envelope。源碼注釋給出的真實 DeepSeek 會話測量中未打包 envelope 的開銷約為 payload 的 56 倍。十、[DONE]之后為什么還要有assistant/message模型流結束并不意味著 Session 只保留幾百個碎片。Agent Loop 一邊記錄 chunk一邊用BlockAssembler組裝完整內容。收到finish后它創建最終AssistantMessage再追加一個帶有sourceEventSeqs的assistant/message1,177 個增量 chunk ↓ BlockAssembler 形成完整 reasoning / text / tool-call blocks ↓ assistant/message 引用生成它的 chunk seq這不是重復保存同一事實而是區分兩種用途assistant/chunk描述生成過程支持直播、軌跡和精確恢復。assistant/message是完成后的規范消息支持歷史展示和下一輪模型輸入。如果回答包含工具調用Agent 會執行工具并開啟下一 step如果沒有工具調用本輪在step/end和turn/end(completed)處閉合。十一、失敗和重連為什么不會被“流式”掩蓋流式系統最危險的錯誤是把半截響應當成功。DeepSeek Harness 在幾個位置明確拒絕這種模糊狀態SSE 在[DONE]前斷開STREAM_CLOSEDdata不是合法 JSONMALFORMED_RESPONSEHTTP 非 2xx映射 provider 錯誤、requestId和Retry-Aftercaller 取消中止 fetch 和流消費WebSocket frame 不符合 RPC schema客戶端丟棄并記錄診斷瀏覽器下行斷開后的恢復策略也不是“猜上次看到哪一個 token”。當前版本重新打開流并重新獲取 Session history。因為規范事件已經進入 Session頁面可以從歷史重建完成狀態。我的實驗里還出現了一個意外導出 Session ZIP 后前端控制臺記錄了Error: web boot: appShell service missing頁面一度空白但 Harness 進程仍然存活。刷新后同一個會話、完整回答和15.5 s / 0.8 s / 80 tok/s指標全部恢復。這個現象不能證明異常根因也不能替代專門的崩潰恢復測試它至少證明本次已提交的 Session 不是只存在于某個 React 組件的臨時狀態中。十二、把整條時序壓縮成一張圖我現在會用下面這句話概括 DeepSeek Harness 的流式原理瀏覽器發送一次命令模型通過 SSE 推送多次增量每個增量先進入 Session再通過 WebSocket 廣播瀏覽器按動畫幀合并渲染最后由assistant/message封口。這里真正有價值的不是“用了 SSE”或“用了 WebSocket”而是事件日志位于兩條下行流之間。它把易逝的網絡字節轉成了有序、可驗證、可持久化、可重放的產品事實。十三、我認為最值得借鑒的四個設計1. 不讓瀏覽器直接擁有模型流模型憑據、重試、工具調用、多 step 和持久化都留在 Host瀏覽器只提交意圖、消費產品事件。這樣 Agent 的執行狀態不會綁定在頁面組件中前端也不需要理解 provider 協議。2. 先記錄再廣播如果先推 UI、后補日志實時顯示與恢復歷史遲早會分叉。Harness 讓assistant/chunk同時驅動持久化和廣播從結構上減少了這種漂移。3. 邏輯粒度和存儲粒度分離邏輯層保留 1,177 個增量磁盤層只寫 69 個打包行壓縮沒有侵入 Session API也沒有犧牲時間和邊界信息。4. 逐事件接收不等于逐事件重繪前端保留精確增量卻按 animation frame 發布視圖。這比“每來一個 token 就 setState 一次”更符合瀏覽器的工作節奏。源碼索引主題代碼位置瀏覽器提交 promptpackages/client/runtime/src/client/sessions/session.tsHTTP RPC 封裝packages/host/apiproxy/src/fetch/client.tsHost 接收session.promptpackages/host/apiproxy/src/api-proxy.tsDeepSeek 請求序列化packages/llm/llm-deepseek/src/serialize.tsDeepSeek HTTP 請求packages/llm/llm-deepseek/src/adapter.tsSSE 解析packages/llm/llm-deepseek/src/sse.tsProvider 增量翻譯packages/llm/llm-deepseek/src/translate.tsAgent 記錄 chunk、生成最終消息packages/core/agent-loop/src/agent.tsSession 分配seq/time并發布packages/core/session/src/index.tsSession 事件轉為 mux framepackages/host/apiproxy/src/api-proxy.ts瀏覽器 WebSocket carrierpackages/client/connection/src/client/web-api-client.ts對話增量投影與動畫幀發布packages/client/ui-conversation/src/client/conversation-nodes/assistant.tsJSONL 增量無損打包packages/core/session/src/chunk-rows.ts