☰
SSE 协议详解:从 AI 流式输出到前端接入与生产环境避坑
2026/10/14 15:19:28 网站建设 项目流程

你肯定用过 ChatGPT 或类似的 AI 产品,点击发送之后,回答不是“叮”一下整篇弹出来,而是像打字机一样一个字一个字往外蹦。这个“蹦”的过程背后就是一个关键技术——SSE(Server-Sent Events,服务器发送事件)。作为前端开发者,如果你只听说过 SSE 这个名字,却搞不清它为什么能让 AI“边想边说”,也不知道前端到底怎么接入,那这篇就是为你准备的。我会从协议原理、服务端报文格式、前端两种接入方式、流式渲染配套逻辑,一直讲到我在实际项目中踩过的那几个坑,争取让你看完之后,能独立把“AI 流式问答”完整落地。

1. 先搞清楚:AI 为什么非要“流式”输出

1.1 思考本身就是逐字发生的

大模型生成内容的过程,本质是“根据前文预测下一个字”。你问它“今天是几号”,它内部并不是先憋出一整段答案再来发给你,而是一个 token 一个 token 地推理:先算出“今”,再算“天”,再算“是”,每一步都依赖前面已经生成的内容。服务端如果等全部 token 生成完再一次性返回,那用户在十几秒甚至几十秒里面对的就是一个空白页面,完全不知道 AI 是卡住了还是在思考。

流式输出解决的就是这个体验问题。服务端每生成一个 token,就立刻通过 SSE 推送给你,前端收到一个渲染一个。用户看到第一个字出现的时间,也就是俗称的“首 token 延迟”,通常只有几百毫秒到两三秒。这个体验差异是极其明显的:一边是干等之后突然一大段糊脸,一边是看着 AI 像真人一样边想边写。

我做过一个小对比测试,用同一个模型回答同一道题,不流式返回大概 11 秒后一次性吐出 400 字;流式返回第一条数据 0.8 秒就到了,虽然全部字收完也要 11 秒,但用户的体感是“它秒回了”。这就是流式输出的核心价值:不是加速,而是让等待过程变得有反馈、可感知。

1.2 为什么偏偏是 SSE,而不是轮询或 WebSocket

可能有人会问:想做到“实时接收”,用 WebSocket 不是更常见吗?这里有个关键差异需要想清楚。

AI 对话这个场景,数据流向是单向的:用户在浏览器里发起请求,服务端持续推送回答内容。整个过程几乎没有“客户端频繁往服务端发消息”的需求,用户只是在开始说了一句话。而 WebSocket 的设计目标是全双工通信,连接建立、心跳维护、协议帧解析都更重,还要单独处理升级握手、跨域、代理兼容等问题。

SSE 是纯 HTTP 协议,基于一个普通的 GET 请求“挂住连接不关闭”,服务端不断往响应体里写入文本。它的优势在于:实现极简、天然支持浏览器自动重连、基于普通 HTTP 跑在 80/443 端口上没有任何额外的协议层。在“服务端推送”这个单一方向上,SSE 比 WebSocket 更省事、更可靠。

顺带说一句,有人看 AI 接口响应头里同时有Transfer-Encoding: chunked和Content-Type: text/event-stream,会误以为 SSE 是某种“更高级的分块传输”。其实 SSE 就是把 HTTP 分块传输用到极致,然后把消息格式规范成了data:开头的文本结构,让浏览器能按“事件”逐个解析,而不是拿一整块乱码等你拼。

2. SSE 核心机制:它就是一条不断写入数据的 HTTP 连接

2.1 报文格式就是几行“key: value”

SSE 的协议格式并不复杂,你完全可以用 curl 直接看到它的“原始长相”。一条标准 SSE 响应长这样:

HTTP/1.1 200 OK Content-Type: text/event-stream Cache-Control: no-cache Connection: keep-alive data: {"token": "今"} data: {"token": "天"} data: {"token": "是"}

注意几个细节:

  • 响应头必须带Content-Type: text/event-stream,这是触发浏览器走 SSE 解析逻辑的开关。
  • 每条消息以data:开头,每条消息以一个空行(也就是\n\n)结束。上面就是发一条{"token": "今"},空一行,再发下一行。
  • 如果一条数据中间换行,可以写多个data:行,浏览器会用换行把它们拼起来,但实际开发中大多数 AI 推送都是一行一个 JSON。
  • 除了data,协议还支持event:(自定义事件名,默认是 message)、id:(事件 ID,用于断线续传)、retry:(告诉浏览器重连间隔毫秒数)。

如果服务端在连接中途一秒钟没数据可发,它应该发一个注释行“:keep-alive”加上空行来保活。这个细节在部署到 Nginx 后面时特别重要,后面我会专门说。

2.2 浏览器的 EventSource:为握手和重连兜底

你直接用fetch读流当然也可以,但浏览器原生还提供了一个专门的 API——EventSource。它会自动做三件事:帮你发 GET 请求建立 SSE 连接、按 SSE 格式自动解析事件、连接意外断开后按retry或默认 3 秒自动重连。

const es = new EventSource('/api/chat/stream?q=你好'); es.onmessage = (event) => { console.log(event.data); // 每条 data 都触发一次 }; es.onerror = () => { // 这里的触发很频繁:连接中断、重连失败、HTTP 非 200 都会进入这里 };

这个 API 的好处是省心,但限制也很明显:只能用 GET、不能自定义请求头、无法携带请求体。如果你想给后端发 POST JSON、或者带Authorization头做鉴权,EventSource就派不上用场了。绝大多数 AI 产品最终都走的是 fetch 流模式,而不是 EventSource。

2.3 自动重连有个隐藏机制:Last-Event-ID

SSE 的断线重连不是简单粗暴地“重新连上从头开始”,它支持续传。如果服务端在每条事件里带id:,浏览器断开后会自动带上Last-Event-ID头去重连,服务端可以从该 ID 之后继续推,避免用户重复看到一大段已经渲染过的话。

在 AI 场景里,这个机制用得不算多,因为对话上下文都在后端,前端重连后通常会让后端重新从上次的 chunk 续推。但如果你们做的是“重要通知流”之类的需求,id和Last-Event-ID就是救命的。

3. 服务端到底在做什么:Chunk 与事件流

3.1 一个极简的 FastAPI SSE 例子

前端要调试 SSE 接口,最好自己手里就能跑一个。我用 Python FastAPI 起一个最简单的流式服务,你可以把这个文件存下来直接跑:

from fastapi import FastAPI from fastapi.responses import StreamingResponse import time, json, asyncio app = FastAPI() async def event_stream(): words = ["今", "天", "是", "个", "好", "日", "子"] for w in words: yield f"data: {json.dumps({'token': w}, ensure_ascii=False)}\n\n" await asyncio.sleep(0.3) # 模拟模型逐字生成 @app.get("/api/chat/stream") async def chat_stream(q: str = ""): return StreamingResponse( event_stream(), media_type="text/event-stream", headers={ "Cache-Control": "no-cache", "Connection": "keep-alive", "X-Accel-Buffering": "no", # 这行很关键 }, )

StreamingResponse本身是一个生成器,服务端“生成一个 chunk,发送一个 chunk”。这里强调一点:media_type一定要是text/event-stream,否则一些网络库会拿它当普通 JSON 处理,前端等半天也触发不了事件。

如果你用的不是 FastAPI,思路也一样:Node 里就是res.write(JSON.stringify(...));Spring 里就是SseEmitter。底层都是同一个 HTTP 长连接,没有魔法。

3.2 生产环境最大的坑:代理层把流“缓冲”没了

本地调试好好的,一上测试环境就发现前端什么也收不到,等接口整个推完才一次性返回全部——这种问题的根源几乎都是 Nginx 默认开启了代理缓冲。

Nginx 收到后端的数据后,会先攒在自己缓冲区里,攒满 4k 或 8k 才往浏览器吐。对 SSE 这种“一条一条小消息”的流来说,可能等一两分钟才凑够一大块发出一次。解决办法是给 SSE 对应的 location 关掉缓冲:

location /api/chat/stream { proxy_pass http://backend; proxy_buffering off; proxy_cache off; proxy_set_header Connection ''; proxy_http_version 1.1; chunked_transfer_encoding off; add_header X-Accel-Buffering no; }

你可能会注意到,上面对 FastAPI 代码里也加了X-Accel-Buffering: no,这是从后端层面告诉 Nginx“别缓冲我”。如果只是本地调试不透这层,等上了 Nginx 就一脸懵。我做 AI 流式产品的经验是,后端代码里固定加这个响应头和 Nginx 关缓冲两步一起做,两个地方都要动。

3.3 用 curl 验证一个 SSE 接口是否正常

在写任何前端代码之前,先用 curl 验证服务端,能把问题快速拦在两端之外:

curl -N --no-buffer http://localhost:8000/api/chat/stream?q=hello

如果服务端是正常的 SSE,你会看到data: ...行一条一条蹦出来,而不是等全部结束一次性打印。-N参数就是告诉 curl 别做缓冲,收到就显示。这一步我建议所有人排查问题前先做:先确认服务端在往外吐,再去看前端。

4. 前端接入:两条路线怎么选

4.1 路线一:EventSource 一行接入

如果你们后端接口简单,无需自定义协议字段,也无关鉴权,那 EventSource 一行代码就够。它的优点是可以免费拿到自动重连:

const es = new EventSource('/api/chat/stream'); es.onmessage = (e) => { appendContent(JSON.parse(e.data).token); }; es.onerror = () => { /* 自动重连 */ };

但需要心里有数:EventSource 的“自动重连”不总是你想要的。用户在主动停止生成后,如果连接还在重连,反而会造成资源浪费,你得手动es.close()。而且它无法控制重连次数和退避策略——它只是个简单的定时重连工具。

4.2 路线二:fetch 流式读取,适合真实 AI 场景

真要上生产,我推荐用fetch + ReadableStream。它能自定义请求头、能 POST JSON、能随时AbortController.abort()中断请求,这些是 AI 对话里几乎躲不掉的需求。

async function chatStream(messages, { token, onToken, signal }) { const resp = await fetch('/api/chat/stream', { method: 'POST', headers: { 'Content-Type': 'application/json', 'Authorization': `Bearer ${token}`, }, body: JSON.stringify({ messages }), signal, // 中止信号 }); if (!resp.ok || !resp.body) { throw new Error(`HTTP ${resp.status}`); } const reader = resp.body.getReader(); const decoder = new TextDecoder('utf-8'); // 存上一次的残余数据 let buffer = ''; while (true) { const { done, value } = await reader.read(); if (done) break; buffer += decoder.decode(value, { stream: true }); let boundary; while ((boundary = buffer.indexOf('\n\n')) !== -1) { const rawEvent = buffer.slice(0, boundary); buffer = buffer.slice(boundary + 2); const lines = rawEvent.split('\n'); for (const line of lines) { if (line.startsWith('data:')) { const payload = line.replace(/^data:\s*/, ''); try { const json = JSON.parse(payload); onToken(json.token); } catch (e) { // 忽略非 JSON 的注释行 } } } } } }

这个代码就是绝大多数 AI 前端 SDK 的雏形。核心思路是把二进制流转成文本,按\n\n切分事件,再逐条解析data:行。

关于decoder.decode(value, { stream: true }),这行容易被忽略,但它是防止中文乱码的关键。因为网络分块可能把一个中文字符的 UTF-8 编码截成两半,如果流式解码模式不开启,那一整个字就会变成乱码。开启之后 decoder 会把不完整的字节保留在内部,等下一个 chunk 补全再输出。

4.3 对话分析与选择建议

需求维度EventSourcefetch + ReadableStream
自定义请求头不支持支持
POST JSON body不支持支持
请求中断支持 close 连接支持 AbortController
自动重连内置,但粗糙需要自己实现
接入成本极低中,需要处理流解析
标准接口对接适合初始演示生产推荐

我做过的 AI 对话项目,前端一开始图省事用了 EventSource,后来加流式鉴权和后端密签就卡住了,老老实实改成 fetch 流解析。实际落地时,如果你预计产品会一直迭代,直接从 fetch 流开始做,免得到时候重构一遍。

5. 流到了之后:渲染层的三个连锁问题

5.1 打字机效果不是“来了就渲染”

SSE 推送消息的频率通常远高于人眼可感知的渲染频率。你如果收到一条就立刻 setState 一次,浏览器可能在几毫秒内连续触发十几次渲染,中间还会出现文字闪烁和跳动。要做出平滑的“打字机”效果,需要一个令牌队列节流。

我的做法是:维护一个待展示 token 数组,用一个固定间隔(比如 40ms)的计时器从队列里取一小段刷新到 DOM,队列为空就暂停计时器。这样无论服务端是每秒推 20 条还是 2 条,UI 节奏都稳定在用户舒适的阅读速度附近。

这里有个细节原则:接收流和渲染流必须解耦。接收线程只做入队,渲染线程只做消费,否则后端一条大 chunk 过来,前端就会被逼着一次性喷出十几个字,打字机效果直接变成抽搐式输出。

5.2 Markdown 实时渲染的代码块难题

AI 回答几乎都是 Markdown 格式。流式渲染时最痛苦的场景是:你收到三行内容,里面有个代码块以```python开头,但还没有出现闭合的```,这时候如果直接扔给 marked 或 markdown-it 去渲染,整段内容可能被吞掉。

我的实操方案是:先纯文本展示,等看到闭合的```再功劳格式化补上一段渲染。更精细一点的做法是:在流式过程中,先按行渲染普通文本;检测到代码围栏开启后,之后的内容当作“未完成代码块”高亮展示;闭合符到了之后一次性渲染这个代码块。

还有个更低成本的方案:用“延迟 300ms 防抖渲染”策略,即用户收到最后一个 chunk 后 300ms 内没有新数据,就认为这一段落子基本完整,再统一渲染一次 Markdown。这样既保留了“打字机”观感,又不会出现渲染混乱。

5.3 停止生成与中断请求

用户点击“停止”按钮后,不只是前端停止渲染,要把底层请求也真正掐掉。这时用 fetch 流的优势出现了——AbortController.abort()一调,TCP 连接断开,后端生成器才会收到客户端断开,从而结束计算,释放算力资源。

const controller = new AbortController(); chatStream(messages, { token: 'xxx', onToken: (t) => appendToken(t), signal: controller.signal, }); // 用户点击停止 controller.abort();

之前在项目里见过一个 Bug:前端把“停止”做成了只停止渲染,请求还挂在后台跑,结果用户又发一个新的提问,遇到旧响应还没结束,两边抢着往屏幕上写。这就是没有真正 abort 的后果。

6. 常见问题速查与排查心得

6.1 一张表解决开发中高频报错

现象根因解决方案
前端什么都收不到,接口结束才一次性返回Nginx 缓冲proxy_buffering off;+X-Accel-Buffering: no
听到stream disconnected before completion/ idle timeout代理层空闲连接超时调大proxy_read_timeout,服务端发心跳注释行
中文出现乱码chunk 边界截断了多字节 UTF-8 字符TextDecoder('utf-8', { stream: true })
“data:” 数据解析不到服务端消息格式缺空行必须每条消息以\n\n结束
EventSource 无法带 token浏览器 API 限制改用 fetch 流,自定义请求头
偶发断线后不回数据缺少重连逻辑实现指数退避重连 + 续传机制
CORS 报错前端域名和服务端不一致服务端加Access-Control-Allow-Origin,如果带自定义头还要响应Access-Control-Allow-Headers

6.2 心跳注释行是保活的兜底

Nginx 或云厂商的负载均衡器,通常会设一个空闲连接超时时间,比如 60 秒。如果你的 AI 回答连续一两分钟没产生 token,代理层就可能以为连接闲置了,直接把连接断开,前端就会收到stream disconnected before completion: idle timeout waiting for sse。

后端要做的就是在流空闲时不断发送注释行:

while True: if not data_ready: yield ": keep-alive\n\n" await asyncio.sleep(15)

注释行以:开头,符合 SSE 规范,但不会在浏览器里触发message事件,只会让连接保持活跃。这是我在接第三方 AI 网关时实测踩坑后总结出的救命细节:如果你们用的是云厂商的网关,这不写,长回答必断。

6.3 调试工具与真机验证

  • 浏览器 Network 面板:查看响应类型必须是text/event-stream;请求状态码 200 但一直 pending,这是正常的,SSE 本身就是长连接。
  • curl -N:服务端是否到底在吐数据,一键了然。
  • 断点续传验证:模拟断网重连后,看请求头里有没有带上Last-Event-ID。

我之前遇到一个特别有迷惑性的情况:核心请求在 Chrome 里一切正常,但放到公司内部办公网络环境里就频繁断流。后来排查发现是网关设备主动空闲超时 30 秒,就是加了心跳注释行之后才稳定下来。前端调 SSE,真正考验的不只是浏览器,还有你这条链路上所有代理、网关、防火墙。

写在最后的一点经验

SSE 这套东西,实际搞懂之后你会觉得它并不神秘,它就是 HTTP 长连接加一个文本约定。但越是简单的东西,在生产环境越容易因为一堆代理配置、编码边界、超时策略给卡住。我的体会是:做 AI 流式问答,前端 40% 的代码在处理数据解析和渲染,剩下 60% 的精力都在跟中间链路斗智斗勇。希望这篇能帮你把前半段的原理补齐,也把后半段的弯路提前避掉。如果你正准备在项目里接一个大模型接口做流式对话,拿这篇文章当一个排查清单,会省掉你不少调试时间。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询