很多人把"流式回答"概括成一句话:前端发起 SSE,请求保持连接,模型一边生成一边返回。
我原来也会这样解释。但当我沿着 DeepSeek Harness 的真实代码走完一次请求,又亲手启动 Web profile、发送消息、导出 Session 日志后,我发现这个说法只描述了中间一段,而且会掩盖系统最重要的设计:DeepSeek Harness 的流式回答不是一条贯穿浏览器和模型的长连接,而是三段不同职责的通道。
- 浏览器用一次短生命周期的 HTTP POST,把用户意图交给 Harness。
- Harness 用 SSE 从 DeepSeek API 接收模型增量。
- Harness 先把每个增量写成 Session 事件,再通过 WebSocket 推给浏览器。
这三段之间不是简单转发。中间的 Session 事件日志既是实时广播源,也是恢复、轨迹、统计和最终消息的共同事实来源。理解这一点,才算真正理解 DeepSeek Harness 的"流式"。
一、先做一次真实运行,而不是只看代码猜
我从源码启动 Web profile,让系统自己选择空闲端口:
sh
node --import tsx/esm apps/cli/src/bin.ts web --host 127.0.0.1 --port 0
进程给出的唯一启动日志很克制:
text
dsh web: http://127.0.0.1:57960
随后我在真实页面中创建会话,输入下面这条消息:
请用三段话解释 DeepSeek Harness 中一次流式回答从请求发出到增量展示的过程。不要调用工具,每段以「阶段一」「阶段二」「阶段三」开头,最后输出「流式演示完成」。

请求完成后,页面给出的实测指标是:
| 指标 | 实测值 |
|---|---|
| 模型 | DeepSeek-V4-Flash,High |
| 回合 / 步数 | 1 轮 / 1 步 |
| LLM 用时 | 15.5 s |
| 首 token | 0.8 s |
| 生成速率 | 80 tok/s |
| 输入 token | 9.2K |
| 输出 token | 1.2K |
| 缓存命中 | 0% |

这里有一个必须先说明的细节:截图中的模型回答把浏览器链路概括成了 SSE。这段回答是实验输出,不是架构证据。 源码和浏览器网络记录都表明,浏览器向 Harness 发送消息使用 HTTP POST,回答增量从 Harness 到浏览器使用 WebSocket;只有 Harness 到 DeepSeek API 的模型响应使用 SSE。让模型解释自己的宿主并不等于完成源码验证。
二、真正的结构:一次上行,两条下行
把完整链路画出来后,三个通道的边界非常清楚:

| 通道 | 方向 | 载体 | 负责什么 |
|---|---|---|---|
| 用户命令上行 | Browser → Harness | POST /api/session.prompt |
提交消息并获得"已接收"结果 |
| 模型增量下行 | DeepSeek API → Harness | SSE,text/event-stream |
返回 reasoning、正文、工具参数、usage 和 finish |
| Session 事件下行 | Harness → Browser | ws://.../api/events.mux |
推送已经进入 Session 的事件 |
浏览器不会把一个 HTTP 请求挂 15 秒等答案。它先完成一次普通 RPC;Harness 接管后续执行,再把事件通过已存在的 WebSocket 下行连接推回来。
我从浏览器记录中看到的实际请求是:
text
POST /api/session.create -> 200 OK
POST /api/session.history -> 200 OK
POST /api/session.prompt -> 200 OK
刷新页面并监听新建连接时,浏览器打开了:
text
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(),携带:
sessionIdmode:queue或steer- 文本或图片内容
- 浏览器解析出的时区
底层 callUnary() 为请求生成 rpcId,把它封装成 client-request,再发送:
http
POST /api/session.prompt
Content-Type: application/json
响应必须回显同一个 rpcId,否则客户端直接把它视为协议错误。
Host 收到请求后,会解析时区、找到或恢复目标 Agent、把图片转换为可持久化附件、创建 UserMessage,最后根据模式调用 agent.followup() 或 agent.steer()。成功响应只是:
json
{ "accepted": true }
这个 200 OK 表示"消息已由 Agent 接管",不表示"模型已经回答完"。这一步把浏览器交互与可能持续几十秒、可能包含工具调用和多 step 的 Agent 执行解耦了。
四、第二段:Agent Loop 组装请求,DeepSeek Adapter 打开 SSE
Agent 开始 step 后,先从当前 Session 推导历史消息,再组装系统提示词、工具定义、模型选择和采样参数。最终得到统一的 GenerateOptions,交给 LLM Runtime 按 provider 选择适配器。
DeepSeek Adapter 把统一请求序列化为 Chat Completions 请求。两个字段决定了响应不是一次性 JSON:
json
{
"stream": true,
"stream_options": {
"include_usage": true
}
}
然后 Host 直接请求:
http
POST {baseURL}/chat/completions
Authorization: Bearer ...
Content-Type: application/json
Accept: text/event-stream
API 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 返回的每个 SSE data 是 provider 协议。Harness 还要把它翻译成 provider 无关的 StreamChunk:
| DeepSeek 增量 | Harness StreamChunk |
|---|---|
| 首次出现 reasoning | block-start(reasoning) |
reasoning_content |
reasoning-delta |
| 首次出现正文 | block-start(text) |
content |
text-delta |
| 工具调用参数片段 | tool-call-delta |
| 块完成 | block-end |
| token 统计 | usage |
| 完成原因 | finish |
这种"块 + 增量"的模型比一串纯文本更重要。它允许 reasoning、正文和多个工具调用同时存在,并让前端知道每个片段应该追加到哪个 block,而不是靠猜测文本格式。
finish_reason 和 usage 不会一出现就立刻封口。Adapter 会等到 [DONE],依次补出所有 block-end、最新 usage 和唯一的 finish,保证 finish 后不再出现新 chunk。
六、最关键的一行:每个 chunk 先进入 Session
Agent Loop 消费模型流时,核心顺序可以概括成:
ts
for await (const chunk of stream) {
chunkSeqs.push(session.append('assistant/chunk', { turn, step, chunk }).seq)
assembler.push(chunk)
}
我认为这是整条链路最值得记住的设计。
它不是先更新 UI、结束后再补日志;也不是先把完整答案攒在内存中。每个模型增量先成为 assistant/chunk Session 事件。 Session.append() 同步完成四件事:
- 为事件分配连续的
seq。 - 写入毫秒级
time。 - 把事件加入内存中的规范日志。
- 同步通知
session/event观察者。
持久化插件监听同一个 session/event,把冻结后的事件放进异步写队列;热路径不会等待磁盘 I/O。API Proxy 也是观察者:它把事件封装成:
ts
{
type: 'session/event',
sessionId,
event
}
然后压入 events.mux 下行队列。
这带来一个非常强的性质:实时 UI、持久日志、轨迹视图和最终消息都观察同一批事件,没有一套"给前端看的流"和另一套"事后拼出来的日志"。
七、第三段:WebSocket 收事件,浏览器按动画帧发布
Web 客户端为 events.mux 建立下行 WebSocket。每个文本 frame 到达后,它先解析 RPC envelope,再校验 MuxFrame。畸形 frame 会被丢弃并输出诊断,不会污染客户端状态。
对话投影收到 assistant/chunk 后,按 chunk 类型更新 block:
text-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 为时间零点,尾部时间线如下:
text
seq=12 +0 ms request/header
seq=13 +7 ms request/context
seq=15 +783 ms assistant/chunk block-start(reasoning)
seq=16...562 546 个 reasoning-delta(seq=23 穿插 session/title)
seq=563 +6,373 ms assistant/chunk block-start(text)
seq=564...1194 631 个 text-delta
seq=1195 +15,465 ms assistant/chunk block-end(reasoning)
seq=1196 +15,465 ms assistant/chunk block-end(text)
seq=1197 +15,466 ms assistant/chunk usage
seq=1198 +15,466 ms assistant/chunk finish(stop)
seq=1199 +15,477 ms assistant/message
seq=1200 +15,479 ms step/end
seq=1201 +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 事件也与页面统计吻合:
json
{
"inputTokens": 9242,
"outputTokens": 1178,
"cacheReadTokens": 0,
"reasoningTokens": 546
}
九、为什么 101 行 JSONL 能装下 1,202 个事件
导出的 session.jsonl 只有 101 个物理记录:1 个 Session header,加 100 个事件或存储记录。如果只用 wc -l 判断事件数量,会得到完全错误的结论。
原因是 JSONL 后端会把连续、同 block 的 delta 无损打包成:
json
{
"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/message:
text
1,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_CLOSED data不是合法 JSON:MALFORMED_RESPONSE- HTTP 非 2xx:映射 provider 错误、
requestId和Retry-After - caller 取消:中止 fetch 和流消费
- WebSocket frame 不符合 RPC schema:客户端丢弃并记录诊断
浏览器下行断开后的恢复策略也不是"猜上次看到哪一个 token"。当前版本重新打开流,并重新获取 Session history。因为规范事件已经进入 Session,页面可以从历史重建完成状态。
我的实验里还出现了一个意外:导出 Session ZIP 后,前端控制台记录了:
text
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 一次"更符合浏览器的工作节奏。
源码索引
| 主题 | 代码位置 |
|---|---|
| 浏览器提交 prompt | packages/client/runtime/src/client/sessions/session.ts |
| HTTP RPC 封装 | packages/host/apiproxy/src/fetch/client.ts |
Host 接收 session.prompt |
packages/host/apiproxy/src/api-proxy.ts |
| DeepSeek 请求序列化 | packages/llm/llm-deepseek/src/serialize.ts |
| DeepSeek HTTP 请求 | packages/llm/llm-deepseek/src/adapter.ts |
| SSE 解析 | packages/llm/llm-deepseek/src/sse.ts |
| Provider 增量翻译 | packages/llm/llm-deepseek/src/translate.ts |
| Agent 记录 chunk、生成最终消息 | packages/core/agent-loop/src/agent.ts |
Session 分配 seq/time 并发布 |
packages/core/session/src/index.ts |
| Session 事件转为 mux frame | packages/host/apiproxy/src/api-proxy.ts |
| 浏览器 WebSocket carrier | packages/client/connection/src/client/web-api-client.ts |
| 对话增量投影与动画帧发布 | packages/client/ui-conversation/src/client/conversation-nodes/assistant.ts |
| JSONL 增量无损打包 | packages/core/session/src/chunk-rows.ts |