很多人把“流式回答”概括成一句话前端发起 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