详细解释
服务器发送事件 SSE(Server-Sent Events) 是一种构建在普通 HTTP 连接之上的单向推送技术:由客户端发起一个普通的 HTTP 请求,服务端在响应建立后保持这条连接不关闭,持续把文本数据按固定格式推给客户端,直到主动结束或连接中断。它和几种常见做法有本质区别。短轮询是客户端每隔几秒反复发请求去问有没有新数据,绝大多数请求都是空跑;长轮询是客户端发一个请求后服务端挂住不返回,直到真的有数据才响应,再立刻发起下一轮,虽然省掉了空跑,但每来一次数据就要重建一次连接;WebSocket 则在一次 HTTP 握手之后升级成独立的双向长连接,用二进制帧通信,收发都能实时。与此相对,SSE 只支持服务端到客户端的单向推送,数据是纯文本而非二进制帧,并且能直接复用标准 HTTP 端口、代理、鉴权与负载均衡,不需要额外的协议升级,因此在穿透企业防火墙与网关时明显更省心。
在 API 接口 层面,大模型的流式输出几乎都建立在 SSE 之上。客户端发出的是一个再普通不过的 POST 请求,只是多带了一个要求流式返回的参数;服务端的响应头会把内容类型声明为事件流,之后的响应体不再是一次性吐出的完整结果,而是被拆成许多个增量分片持续推送。每一片都写成一行以 data 字段承载的文本,事件与事件之间用一个空行分隔,等于告诉客户端「上一段到这里结束、下一段从这里开始」;当生成全部结束时,服务端再推一个约定好的固定结束标记行,客户端读到它就关闭连接。如果请求里没有要求流式,服务端就会在生成完毕后一次性返回完整的响应体。这套约定的好处是它对中间设施几乎是透明的:一个普通的 HTTP 响应流,任何代理或网关都能转发,客户端只要按行解析、边收边渲染,就能把等待首字的时间从「等完整段话」压缩到「等第一个 Token」,这正是 流式输出 体验的基础,也直接改善了 首 Token 延迟 的体感。
工程上,SSE 的难点几乎都不在协议本身,而在链路中间的各种缓冲与超时。由于它是长连接,任何一个中间层如果把响应攒起来再转发,客户端就会在整段生成结束前什么都看不到,这也是绝大多数「流式失效」的真实原因;同时,连接空闲太久又可能被代理按空闲超时切断,于是需要在生成过程中周期性发送心跳。客户端侧还要自己处理断线与重连,并且把已经渲染出来的片段留存下来,才能在重连之后接着拼接,而不是从头再来。此外,客户端想中途取消生成时,SSE 协议本身不提供取消语义,只能由客户端关闭连接或额外发一个取消请求,这一点在设计交互时必须提前考虑。协议的权威说明可以查阅 MDN 的文档 https://developer.mozilla.org/en-US/docs/Web/API/Server-sent_events 。对做聚合服务的团队而言,把这些细节收在网关侧统一处理是更划算的选择:唯元智创 在流式网关侧统一管理响应缓冲、心跳保活、超时与断线续传,并把 OpenAI 兼容接口 的分片格式规整为一致的事件流,让上层应用不必为不同上游的差异写各自的适配;同时网关会区分首包与后续分片的时延指标,便于把 延迟 问题定位到究竟是首包慢还是中途卡顿。
实时推送技术对比表
| 技术 | 通信方向 | 底层协议 | 是否需独立端口 | 代理穿透难度 | 断线重连能力 | 典型适用场景 |
|---|---|---|---|---|---|---|
| 短轮询 | 客户端到服务端单向拉取 | 普通 HTTP | 否 | 低 | 由客户端自行控制下一次请求 | 数据更新不频繁的简单场景 |
| 长轮询 | 客户端到服务端单向拉取 | 普通 HTTP | 否 | 中,需要关注连接占用 | 每轮响应后立即重新发起 | 即时性要求不高的消息通知 |
| SSE | 服务端到客户端单向推送 | 普通 HTTP | 否 | 低,可复用标准端口与代理 | 由客户端监听断开并重连,可配合续传 | 大模型流式输出、进度与通知推送 |
| WebSocket | 双向 | 独立协议,握手后升级 | 通常需要额外配置 | 高,部分代理不支持升级 | 需自行实现心跳与重连 | 双向实时交互、游戏、协同编辑 |
| HTTP 双向流 | 双向 | HTTP 版本 2 或版本 3 的原生流 | 否 | 中,依赖较新的中间设施 | 依赖具体实现,通常需重试 | 现代浏览器与服务端的高效双向通信 |
| 唯元智创 流式网关 | 服务端到客户端单向推送 | 基于 SSE,向客户端统一转发 | 否 | 低,网关侧统一处理缓冲与代理差异 | 内置心跳与断线续传策略 | 多上游聚合、需要统一流式体验的场景 |
SSE 工程配置要点表
| 配置项 | 推荐做法 | 常见错误 | 失败表现 | 排查手段 |
|---|---|---|---|---|
| 响应缓冲关闭 | 在应用层与网关层都显式关闭响应缓冲,强制逐块下发 | 只改了应用没改中间层,或依赖默认的开启状态 | 客户端要等整段生成结束才一次性收到全部内容 | 在中间层抓包观察分片是否被攒批,检查缓冲相关配置 |
| 心跳保活 | 在无新分片时周期性发送空事件或注释行作为心跳 | 长时间思考期间一个字节都不发 | 连接被代理按空闲超时切断,客户端报错中断 | 对照代理的空闲超时与生成耗时,观察是否在固定时间点断开 |
| 超时设置 | 读取超时按最长生成时间放宽,并区分连接超时与读取超时 | 沿用普通的几十秒读取超时 | 长回答生成到一半被客户端或网关掐断 | 比对断开时刻与超时配置,检查客户端读取超时是否过短 |
| 代理缓冲 | 在 Nginx 等代理上关闭缓冲并启用分块传输 | 沿用默认的代理缓冲设置 | 表现为假流式,首字节时间被严重拉长 | 临时绕过代理直连后端对比,若差异明显即为代理缓冲 |
| 断线重连续传 | 客户端记录已接收片段与最后进度,重连后从头续接 | 断线即整段重来,没有保存任何进度 | 网络抖动后内容重复或丢失,用户体验断裂 | 模拟断网重连,检查客户端拼接逻辑与去重是否完整 |
| 并发连接数限制 | 按浏览器与网关上限规划并发,及时复用与释放连接 | 页面或组件未清理旧连接导致连接堆积 | 达到上限后新请求排队或直接被拒 | 统计活跃连接数,检查是否有未及时关闭的 SSE 连接 |
实战经验与避坑
-
绝大多数「流式失效」都是中间层开了响应缓冲造成的,必须显式关闭,否则客户端要等全部生成完才收到:这个问题极具迷惑性,因为本地开发时请求直连后端,分片一出就被发出,一切正常;一上生产,请求要经过反向代理、网关或 CDN,只要其中任意一层把响应攒够一定大小再转发,客户端就要等到整段生成结束才一次性收到全部内容,表现为首批内容的等待时间被拉长到接近总生成时间。排查的正确顺序是由近到远逐层绕过:先直连后端确认分片正常,再依次加回代理与网关,看在哪一层开始攒批。定位后要在该层显式关闭缓冲并启用分块下发,而且要同时检查应用层与基础设施层,只改一处往往仍会被另一处拦住。
-
长思考期间要设心跳保活,否则生成时间超过代理空闲超时就会被掐断:SSE 是长连接,而长连接最怕「长时间没有任何字节流动」。当模型在做长推理、检索或调用慢工具时,可能几十秒都不产生新的分片,中间层看到连接空闲就可能按自己的空闲超时主动关闭它,此时客户端会突然报错中断,用户以为是模型出错,其实是链路被掐了。做法是在空闲期间周期性发送一个不含实际内容的心跳事件,让连接始终有字节流动;心跳间隔要明显小于链路上最短的那个空闲超时,并且要在网关层统一配置,而不是指望每个上游应用都记得发。经验值是把心跳间隔设为链路最短空闲超时的三分之一左右,可以有效避免这类静默断开。
-
SSE 是单向的,客户端要中途取消生成必须关闭连接或另发一个取消请求,不要指望协议层取消:很多团队第一次做流式交互时会想当然地认为「停止」按钮能通过协议通知服务端,但 SSE 只定义了服务端到客户端的推送,没有从客户端回传取消指令的通道。可行做法有两种:一是客户端直接关闭这条事件流连接,服务端检测到连接断开后终止上游生成;二是在连接之外单独提供一个取消接口,客户端带上任务标识调用,服务端据此中止对应生成并释放资源。更稳的组合是两者并用,客户端断开加显式取消接口双保险,专门应对连接状态未能及时同步的边缘情况,无论用哪种,服务端都要确保上游请求被及时取消,避免计算资源空转和持续计费。
-
断线要设计重连续传策略,把已生成的片段记录下来以便拼接:移动网络切换、WiFi 抖动、客户端休眠都会让长连接断掉,如果每次断线都从头重新生成,既浪费成本又破坏体验。工程上要让客户端把已经收到并渲染的片段按序留存,重连后请求续接而不是重来;服务端或网关则要支持从指定进度继续生成,或者至少保证重连后不会重复发送已经发过的内容。要特别注意的是拼接去重:重连时最后一段可能有部分重叠,客户端需要按事件标识或进度标记做去重,否则用户会看到内容重复。这套机制在弱网与移动端场景里几乎是必需的,否则重连体验会很差。
-
不要在单条事件里塞过大的数据,会触发缓冲与分片问题:SSE 的事件是按行组织的,一条事件过大时,中间层可能因为缓冲策略而延迟下发,也可能在传输层被拆分,客户端如果按固定长度切分解析就会出错。正确做法是保持单条事件尽可能小,把大产物拆分或改由稳定链接承载,需要传大块内容时通过引用而不是直接塞进事件流。此外,内容里如果出现与协议分隔约定冲突的字符,要在发送前做转义处理,避免客户端把一条事件误读成两条。经验是单条事件控制在几 KB 以内比较稳妥,超过之后流式的实时性和稳定性都会明显下降。
-
监控要区分首字节时间与后续分片间隔,才能定位是首包慢还是中途卡顿:流式的用户体验由两段指标共同决定,一是从发请求到收到第一个内容片段的等待,二是后续片段之间的间隔是否均匀。如果只看端到端总时延,出现问题时根本分不清是首包慢还是中途卡顿,排查方向会完全走偏。正确做法是分别埋点:首字节时间反映排队、排队与首包处理的情况,分片间隔反映生成与推送的连续性。典型的判断是,首字节时间正常而分片间隔偶发拉长,问题多半在推流链路或上游抖动;首字节时间整体偏高,则更可能是排队或首包处理慢。把这两段指标分开看,才能把问题快速归因到正确的环节。