流式输出与 SSE
公开教程 · 无需登录

流式输出与 SSE

第 4 课的问答接口要等整段回答生成完毕才一次性返回。本课改为服务端逐段推送:学生提交后显著降低首字延迟(time to first token),无需等待整段生成完毕。推送采用 SSE(Server-Sent Events,服务器推送事件)。

1为什么选 SSE 而不是 WebSocket

维度SSEWebSocket
方向服务端到浏览器的单向推送全双工,双方随时互发
承载普通 HTTP 响应,长连接不关闭先握手升级协议,之后不再是 HTTP
鉴权沿用会话 Cookie 与既有中间件握手阶段可带 Cookie,之后需自行维护会话
断线恢复浏览器自动重连需自行实现重连与续传
实现成本浏览器只收不发,提交提问需另发一条普通 POST多一条长连接与一套心跳机制
本课是「提交一次请求、接收一段响应流」的单向场景,且已有会话 Cookie 与反向代理。改用 WebSocket 需额外实现协议升级、心跳与重连,边际收益为零。
浏览器 fetch 提交提问 Nginx 关闭代理缓冲 Go 检索模块 先检索再生成 对话网关 只在有切片时调用 逐片产生文本 同一条连接推回 meta → delta × n → done
提问是一次普通 POST;回答沿这条连接逐段推回,连接直到 done 才关闭。

2四类事件与帧格式

每一帧由 event: 与 data: 两行组成,以一个空行结束。data 是 JSON。服务端按用途分四类事件。

事件何时推送载荷
meta检索完成之后、生成之前,最先到达状态、本次命中的技能、引用列表
delta生成过程中多次推送本次新增的文本片段,不是累计全文
done助手消息落库之后该条消息的编号
error流已开始但后续失败通用错误文案
event: meta data: {"status":"ok","skill":{"name":"数学解题引导"},"citations":[…]} event: delta data: {"text":"先看这一步"} ← 两条帧之间用一个空行分隔
把各次 delta 的文本按顺序拼接,必须等于最终回答;任一条 delta 都不得是此前片段的累计全文。

3三个工程实现细节

反向代理默认启用缓冲

Nginx 默认开启代理缓冲(proxy buffering),会攒够一段再转发。流式传输必须显式禁用:proxy_buffering off、proxy_cache off,并将读超时放宽到 120 秒。服务端同时返回 X-Accel-Buffering: no 头,构成两层防护。否则浏览器仍要等整段生成结束才收到首字节。

每帧都要显式 flush

Go 的 ResponseWriter 同样有缓冲。每写入一帧必须调用 http.Flusher 的 Flush 把缓冲区刷到连接,否则片段滞留在服务端。响应头另需 Content-Type: text/event-stream、Cache-Control: no-cache 与 Connection: keep-alive。

流一旦开始,状态码不可再变更

服务端在第一个事件产生时才写响应头并返回 200——因为检索是否命中要等检索阶段完成才能确定。此后再出错已无法返回 4xx 或 5xx,只能以 error 事件收尾。检索前即可判定的失败(会话不存在、检索服务不可用)才返回真正的 404 与 503。

浏览器端的 EventSource 只能发起 GET,而提交提问要带 JSON 正文。因此本课用 fetch 发起 POST,再读 response.body 的流,自己按 event: 与 data: 行解帧。