我按下“发送”之后:DeepSeek Harness 如何把一次请求变成 1,177 个增量片段

很多人把"流式回答"概括成一句话:前端发起 SSE,请求保持连接,模型一边生成一边返回。

我原来也会这样解释。但当我沿着 DeepSeek Harness 的真实代码走完一次请求,又亲手启动 Web profile、发送消息、导出 Session 日志后,我发现这个说法只描述了中间一段,而且会掩盖系统最重要的设计:DeepSeek Harness 的流式回答不是一条贯穿浏览器和模型的长连接,而是三段不同职责的通道。

  1. 浏览器用一次短生命周期的 HTTP POST,把用户意图交给 Harness。
  2. Harness 用 SSE 从 DeepSeek API 接收模型增量。
  3. 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(),携带:

  • sessionId
  • modequeuesteer
  • 文本或图片内容
  • 浏览器解析出的时区

底层 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_reasonusage 不会一出现就立刻封口。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() 同步完成四件事:

  1. 为事件分配连续的 seq
  2. 写入毫秒级 time
  3. 把事件加入内存中的规范日志。
  4. 同步通知 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"更具体:

  • 546reasoning-delta
  • 631text-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 显示首 token 0.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 保留相邻事件的时间差;seq0time0 锚定首个事件。读取时,Harness 会展开出原始的 assistant/chunk,恢复完全相同的 seqtime、片段边界和顺序。

这是一种很务实的取舍:逻辑层坚持"一增量一事件",磁盘层不必为每个两三个字的 token 重复写一大段 JSON envelope。源码注释给出的真实 DeepSeek 会话测量中,未打包 envelope 的开销约为 payload 的 56 倍。

十、[DONE] 之后为什么还要有 assistant/message

模型流结束并不意味着 Session 只保留几百个碎片。

Agent Loop 一边记录 chunk,一边用 BlockAssembler 组装完整内容。收到 finish 后,它创建最终 AssistantMessage,再追加一个带有 sourceEventSeqsassistant/message

text 复制代码
1,177 个增量 chunk
        ↓
BlockAssembler 形成完整 reasoning / text / tool-call blocks
        ↓
assistant/message 引用生成它的 chunk seq

这不是重复保存同一事实,而是区分两种用途:

  • assistant/chunk 描述生成过程,支持直播、轨迹和精确恢复。
  • assistant/message 是完成后的规范消息,支持历史展示和下一轮模型输入。

如果回答包含工具调用,Agent 会执行工具并开启下一 step;如果没有工具调用,本轮在 step/endturn/end(completed) 处闭合。

十一、失败和重连为什么不会被"流式"掩盖

流式系统最危险的错误,是把半截响应当成功。DeepSeek Harness 在几个位置明确拒绝这种模糊状态:

  • SSE 在 [DONE] 前断开:STREAM_CLOSED
  • data 不是合法 JSON:MALFORMED_RESPONSE
  • HTTP 非 2xx:映射 provider 错误、requestIdRetry-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
相关推荐
Gem_S_6081 小时前
从 Chonkie 到 RAGFlow:主流开源知识库 Chunking 工具横向测评
开源
昨日之日20061 小时前
LTX-2.5:更清晰、更可控、同步音画、多镜头连贯的AI视频神器
人工智能·音视频
九硕智慧建筑一体化厂家1 小时前
数字化管控落地,直流照明构筑安全照明体系
运维·人工智能·笔记·安全·智慧城市
江厌011 小时前
本地部署DeepSeek显卡怎么选?我把显存计算公式和实测记录写下来了
人工智能·百度·联想工作站·代理商推荐·渠道对比·工作站
小席是个热心肠1 小时前
AI相关的自我学习
java·人工智能·学习
FII工业富联科技服务2 小时前
工业AI自治的演进趋势:从内容生成到Agent驱动的工厂运营范式转变
人工智能
云和数据.ChenGuang2 小时前
fastapi的参数剖析
人工智能·深度学习·机器学习·语言模型·状态模式·fastapi
日常筹谋记2 小时前
技术研究:数据中心HVDC系统直流保护配合与ABB电气产品参数解析
人工智能·创业创新·业界资讯
元岳数字人小元2 小时前
AI数字人系统赋能场景升级,数字人一体机实现服务提效降本
人工智能