第二季第 5 篇。拆解对象:DeepFlux 前端共享包
client-sdk里的流式三件套(stream/reconnect/resumeStream)和它们背后的服务端推送器。这篇是 SSE 实时推流 —— Token 怎么一个个蹦出来 / 流式消费:从 StreamReader 到 SSE 推送 (服务端怎么推流)的消费端视角:同样的事件,到了浏览器这一侧,要解决"怎么拆帧、断了怎么续、怎么停下来"三件事。不要求懂前端,概念当场讲。
先交代真实状态。我在本机跑了两样东西:一是这套 SDK 自己的单元测试(4 个文件 11 个用例,189 毫秒全绿);二是一个断线重连的实景 demo——把真 SDK 打包后连上一个模拟服务器,让服务器推字推到一半时强拆网络连接,再用重连接口把剩下的字接回来。demo 的关键输出先放在这里,后面会反复引用:
[client] 帧 event_id=1 type=content delta="第一"
[client] 流中断:TypeError: terminated ← 服务器强拆了 TCP 连接
[client] 已收 1 帧,最后 cursor=1
[client] 重连帧 event_id=2 type=content delta="第二"
[client] 重连帧 event_id=3 ... 4 ... 5 ...
[client] 重连帧 event_id=6 type=done
[result] 共收 6 帧,event_id 序列 = [1, 2, 3, 4, 5, 6]
[result] 拼接文本 = "第一第二第三第四第五" ← 5 个字全到齐
[result] ✅ 断线重连回放:无丢帧、无重复、顺序正确
注意一个细节:服务器日志显示它向这条连接推了 2 帧才强拆,但客户端只收到了 1 帧——第 2 帧还在操作系统的发送缓冲区里,连接一断就被丢弃了。这个"发了不等于收到"的细节,是整篇所有设计的起点。
一、先弄懂三个词
SSE(Server-Sent Events,服务器推送事件):HTTP 协议上的一种"服务器持续回话"的约定。普通 HTTP 是一问一答——浏览器问一句,服务器答完就断。SSE 是浏览器问一句,服务器把 Content-Type 标成 text/event-stream,然后这条连接不断开,想说什么就往下写一行 data: ……,写完一个意思空一行。AI 对话的"打字机效果"(一个字一个字往外蹦)就是这么做的。
帧(frame):SSE 里一个空行分隔的完整消息。DeepFlux 的每帧是一个 JSON,长这样(服务端 Go 结构体的真实字段):
data: {"type":"content","event_id":"3","payload":"第三"}
type 是帧的种类(content=正文增量、turn.tool_call/turn.tool_result=工具调用、interrupt=暂停等人审批、done/error=终点),event_id 是这帧的流水号,payload 是内容本身。
游标重连(Last-Event-ID):断线后浏览器带着"我最后收到第几号"重新问服务器要,服务器把之后的帧补发,再接着直播。跟视频网站"上次看到第 8 集,从第 9 集继续"一个道理。HTTP 头里的名字就叫 Last-Event-ID。
二、服务端怎么发:两段真实事故换来的写法
先说发送侧,因为接收侧的每个怪癖都对应发送侧踩过的坑。服务端的推送器(hertz_streamer.go)干的事是:每个会话一条投递管道,Agent 引擎每产出一个事件,就写进管道,HTTP handler 从管道里读出来、写给浏览器。
看似简单,但仓库注释里记着两次实打实的事故:
事故一:流不是流(2026-07-12)。 上线后前端发现"打字机效果"不存在——完整回复的所有帧在 37 毫秒内一次性到达(注释原话:"时间戳采样实锤")。原因:Web 框架 Hertz 默认把响应攒在缓冲区里,handler 跑完才一次性发出。修法是换用 chunked 写入器并每帧强制 flush。从那以后每帧都是真的"写一笔、递一笔"。
事故二:流不停(同一时期)。 Agent 暂停等人审批时(HITL 场景),有一版代码忘了把 interrupt 帧当作"终止帧"处理——服务端的 handler 永远挂在等待循环上不返回,浏览器的请求就一直悬着(前端日志里表现为 http=000,即连接从未正常完成)。现在的规则很明确:done、error、interrupt 三种是终止帧,写出去之后服务端立刻收摊。
还有一条同样来自事故的规则:Agent 循环内部如果出错了,错误也必须变成一帧 {"type":"error",...} 写出去。注释原话是"不然 goroutine 的错误就凭空消失,浏览器看到的是一条被无声截断的流"——用户看到的是回复说一半戛然而止,连个错误提示都没有。
三、浏览器怎么收:SDK 手写的拆帧循环
浏览器其实有个原生的 SSE 接收器(叫 EventSource),但 DeepFlux 没用它,原因在代码里可以直接看到:原生 EventSource 只能发 GET 请求、不能带自定义请求头,而"发消息给 Agent"是 POST 请求、还要带登录令牌。所以 client-sdk 里手写了一个约 80 行的拆帧循环(streamSSERequest),三个公开方法共用它:
// web/packages/client-sdk/src/index.ts(方法签名,2026-09-06 核对)
sessions.stream(id, content, attachmentIds?, onParseError?, signal?) // 发消息+收流(POST)
sessions.resumeStream(id, interruptId, decision, modifiedArgs?, signal?) // 审批后续跑+收流(POST)
sessions.reconnect(id, lastEventId?, signal?) // 断线重挂(GET)
拆帧循环做的事:把字节流按 \n 切行、攒 data: 行、遇到空行就把攒的内容当一帧 JSON.parse。有两个值得停留的细节:
细节一:401 自动续命。 循环开头发现响应是 401(登录凭证过期),会先调一次刷新接口换取新凭证,然后重发同一个请求——注释说这是"对话中途过期的最常见路径"。刷新逻辑还做了"单飞"(并发多个请求同时 401 时,只发一次刷新,大家共享结果)。
细节二:畸形帧不再静默吞掉(2026-07-06,commit 79f33657)。 之前的写法是 try { JSON.parse(x) } catch { /* 什么都不做 */ }——一帧 JSON 坏了(网络截断、后端 bug),解析失败就被无声跳过,raw 字段退化成空对象 {}。调用方拿到 {},分不清这是"正常的空内容"还是"坏了一帧"。修复后:坏帧显式带上 parseError: true 标记,流不中断(后面的好帧照常送达),另外提供一个可选回调把坏帧的原始文本递出去(可以计数、上报)。对应的测试今天还在跑:喂进去一帧 data: {not valid json,断言消费方收到 parseError=true 的帧、且回调收到原文、紧随其后的合法帧完好——三个用例全绿。
四、断了怎么办:两端配合的一套协议
现在回答 demo 里那个问题:服务器推了 2 帧、客户端只收到 1 帧,第 2 帧去哪了、怎么找回来。
服务端的准备:推送器给每个会话维护一份"重放缓存"——所有帧按流水号(event_id 从 1 开始单调递增)存着,最多 1024 帧,会话到了终态(done/error/interrupt)之后再保留 5 分钟才清掉。缓存的存在意味着:帧一旦产生,就不依赖某条具体的连接活着。
客户端的动作(chat 前端的 recoverStream 函数):流的 for-await 循环如果没收到终止帧就断了(网络抖动、服务器重启),先别报错,走重连——
- 带上最后的流水号(demo 里是
Last-Event-ID: 1)调sessions.reconnect; - 服务器把 2 号之后的帧(含被吞的 2 号)全部回放,然后继续直播新的;
- 如果重连又失败,指数退避再试:间隔 0、500、1000、2000……最多 30 秒,共 7 次。注释里留了一句自曝:旧版是固定
[0, 250, 750]三次、总共不到 1 秒——"网络闪断恢复普遍需要几秒",旧版等于没重连。 - 7 次全失败还不直接认输,而是问一次会话状态:会话已经是终态?那就改走"拉历史消息"把结果补全;会话还活着?回去继续重试;会话空闲?说明这轮真的断了,才把错误亮给用户。
这套协议在 2026-08-02 一次落地(commit f153cfd9,改动 24 个文件、+1030 行:服务端重放缓存、GET 重连端点、SDK 的 reconnect 方法、一个 Playwright 端到端用例)。同一个 commit 日志里还有一条时间线值得玩味:凌晨 03:54 先提交了 e8d5d7c1(给审批续跑的流加 30 秒超时和取消),两小时后的 05:58 重连方案落地。一晚上两连击,都是流式对话在生产上摔出来的。
如实说明:e2e 用例本次没有跑(需要起完整后端栈);本篇的断线重连验证靠的是上面 demo(真 SDK + 模拟服务器,2026-09-06 实测 6 帧全到齐)和单元测试。
五、怎么停下来:一条 AbortSignal 贯穿三层
用户点"停止"按钮,这件事比看起来难,因为要三层同时停:
- 前端:
AbortController产出一个AbortSignal传给 SDK,SDK 把它透传给底层fetch——浏览器直接掐断 TCP 连接(单测验证了 signal 确实抵达 fetch 参数); - 前端自己的等待逻辑:有的等待不响应取消信号(比如审批续跑),所以外层再包一个 30 秒的兜底超时(
withAbortTimeout),超时强制恢复可重试状态——这就是 08-02 凌晨那个 commit 干的事; - 服务端:连接断开被推送器感知,管道关闭会释放阻塞中的发送方;Agent 循环还有墙钟护栏(
MaxWallSeconds)兜底,保证没有连接挂着时引擎不会永远跑下去。
错误分类(classifyStreamError)也值得一笔:用户主动停止(AbortError)不显示任何错误;TypeError: Failed to fetch 是网络离线,提示"请检查网络"并建议 3 秒后重试;stream failed: 429 是限流,建议等 15 秒;503 维护 60 秒;其他 5xx 等 10 秒。SDK 抛的 stream failed: ${状态码} 这个字符串格式,正是被前端按前缀解析消费的约定。
六、一帧都不许丢的另一面:背压
最后补一个 2026-07-15 的决策(注释里日期写得明明白白)。推送管道容量 1024 帧,如果客户端长时间不读(标签页切后台、网络假死),管道满了怎么办?
旧方案是"满了就丢 content 帧"(drop-on-full)——结果注释里写着:"前端渲染缺字且不可恢复"。AI 回复缺一段字,用户根本没法知道丢了什么。新方案反过来:满了就阻塞发送方,让 Agent 引擎等消费端腾出空间。代价是慢客户端会拖慢引擎,所以用墙钟超时兜住上界。这是一个教科书式的取舍:丢数据不可恢复时,背压(backpressure,让生产者等消费者)是唯一正确答案。
小结
- "服务器发了"和"客户端收到"是两件事。 demo 里那帧消失在第 2 号位的字,靠流水号 + 服务端重放缓存找回来。任何"直播型"系统(SSE、WebSocket、音视频)的可靠性设计,起点都是这个不等式。
- 每个怪癖背后是一次事故。 每帧 flush(37ms 突发)、终止帧三兄弟(http=000)、错误也要成帧(静默截断)、畸形帧带标记(无法区分)、指数退避(1 秒内放弃等于没重连)——五条规则五次教训,全部写在代码注释里带日期可查。
- 停止按钮是三层协议。 前端掐连接、等待逻辑有超时兜底、服务端有墙钟护栏。单点取消在分布式系统里不存在,只能三层对齐。
- 机器守的部分今天全绿:SDK 单测 11 例 189ms,demo 六帧闭环;e2e 存在但本次未跑(需完整栈),如实分层。