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