☰
AI流式输出揭秘:SSE协议原理与前端接入实战
2026/10/8 6:23:45 网站建设 项目流程

你平时用 AI 聊天、写代码、翻译文章的时候,有没有好奇过:为什么 AI 的回答不是一次性“啪”地整段蹦出来,而是一个字一个字、一段一段地往外冒?这个效果在行业内叫流式输出,大部分 AI 产品背后用的协议就是 SSE。很多前端同学第一次接触“AI 打字机效果”时,第一反应是“前端写了个打字机动画”,第二反应是“这数据是不是轮询拿的”,这两种理解都不算错,但离真相都差一步。真相是:AI 生成的内容本来就是一个 token 一个 token 产生的,后端一旦生成一部分就立刻往客户端推,前端负责把不断到来的文字渲染到页面上。这篇文章我就从协议原理、前端接入、真实坑位、面试考点四个维度,把 AI 流式回答这事聊明白。无论你是刚入门的前端,还是准备 AI 方向的面试,这篇文章都值得你一口气读完。

1. 打字机效果的本质:不是动画,是数据在流动

1.1 大模型生成方式的天然特性

先说个底层事实:大语言模型和传统接口不一样。传统后端接口是一个完整的 JSON,查完数据库一次性返回,前端拿到整个 body 再渲染。大模型不是这样,它是一步一步预测下一个 token 的,生成完整回答可能需要几秒、十几秒甚至更久。如果按照传统的“等全部生成完再返回”,用户会盯着空白页面等好久,体验非常差。

于是“边生成边返回”就成了必然选择。后端每生成一小段文本,就立刻作为数据发出去,前端收到一点就渲染一点。你看到的是一个字一个字蹦出来,本质上不是前端做的“打字机定时器”在表演,而是真实的数据在持续到达。前端在流式场景里更多是“接收者 + 增量渲染者”,核心工作就是把不断到达的分片拼起来、解析好、渲染上去。

这里顺带回答一个经常被问到的点:为什么有的 AI 产品是逐字蹦,有的是逐词蹦,还有的是一小段一小段蹦?差异其实在服务端生成策略和流式数据的粒度,有的服务端按 token 推,有的按句子或者按 chunk 推,前端的渲染循环只要按收到的事件增量追加就行。前端能控制的只是渲染节奏,数据本身的粒度由后端决定。

1.2 传统 HTTP 为什么也能“流”起来

很多前端会困惑,HTTP 不是一问一答吗?怎么还能持续推数据?关键在于,HTTP 的响应 body 并不是必须一次性发送完的。服务端可以先发送一部分数据,保持连接不断开,后续继续发送更多数据,客户端则一边接收一边处理。这个机制叫 chunked transfer encoding,也是大部分 SSE 服务的传输基础。

我觉得用快递来类比特别合适。普通接口请求就像快递柜取件,快递到了,你说一声,比如调一次接口,柜门开了,你一次性搬走所有包裹。流式响应则是送货上门,快递员把第一箱货送到你家门口,你先搬进屋,他继续开车去送第二箱,送完又给你放门口,你继续搬。整个过程中你和快递员保持着联系,货物一点一点到齐。

SSE 就是建立在这样一个思路上的协议,它规定了服务端用什么格式把数据推给客户端,客户端怎么识别一条完整的消息。理解到这一层,再去看 SSE 的字段定义,就会觉得非常自然。

1.3 流式体验不只是“看起来快”

流式输出解决了表面上的等待焦虑,但从工程角度讲,它的价值远不止这个。

第一,首字节时间大幅缩短。用户第一次看到文字的时间,从“整个回答生成完”变成“第一个 token 生成完”,这个提升往往是数量级的。第二,交互阶段可以提前开始。有些 AI 产品在流式输出过程中,用户就可以点“停止生成”,这是因为前端提前拿到了数据流,也就能中断数据流。第三,结合 Agent 场景,前端可以在文本流里收到事件消息,比如“正在调用工具”“搜索完成”“生成结果”,用不同类型的事件驱动 UI 更新。丹尼尔之后你会发现,SSE 的数据格式设计其实天生就支持这种多事件场景。

所以结论很明确:SSE 不只是让文字“蹦”出来,它是整个 AI 交互体验的前端基础设施。

2. SSE 的核心工作原理

2.1 本质还是一次 HTTP 请求

SSE 全名叫 Server-Sent Events,翻译过来是“服务端发送事件”。从名字就能看出来,它是单向的,服务端主动往客户端推送事件,客户端不需要反复发请求。它底层就是一次普通的 HTTP 请求,只是响应头特别一点。

核心响应头有两个,一个是Content-Type: text/event-stream,告诉浏览器这不是普通 JSON,要按事件流解析;另一个是Cache-Control: no-cache,避免中间代理把流式响应缓存起来。还有一点通常会被忽略,就是连接不能开启压缩,响应的数据要一直保持可读流状态,因此一些网关配置会特意跳过 gzip。

服务端在建立连接后,持续往响应流里写特定格式的文本就行了。客户端收到的不是一个完整的 JSON,而是一行一行的消息,每条消息以空行分隔。下面是一个最基本的响应体示例:

data: 你好 data: 世界

上面的意思是服务端推送了两条消息,第一条内容“你好”,第二条内容“世界”。注意data:后面有一个空格,这是规范写法,每条消息后面跟一个空行,也就是两个换行符\n\n。如果消息内容比较长,可以用多个data:行拼接,比如:

data: 你好, data: 世界。

浏览器会自动把同一事件里的多个data:行按换行符拼成一条完整消息,解析结果是“你好,\n世界。”,是不是很像邮件头格式?这套格式简单到让人怀疑,但正是这种极简设计让调试变得非常方便。

2.2 协议字段逐个拆解

SSE 的协议字段不算多,但每个都有实际用途。我用表格把最常用几个整理一下:

字段作用使用场景
data:消息内容,可多行拼接推送实际数据,通常是 JSON 字符串
event:自定义事件类型区分消息类型,如文本、状态、错误
id:事件 ID断线重连时携带,服务端可据此续传
retry:重连时间(毫秒)控制客户端自动重连的间隔
:注释注释行,以冒号开头常用于心跳,防止连接被判定空闲

平时看 AI 产品的前端代码,最常见的可能就是data:和event:这两个字段。data承载文本内容,event可以用来区分不同逻辑。比如文本回答用默认的message事件,工具调用状态可以用自定义的tool_call事件,前端监听对应事件后分别处理。

retry字段也很有意思。服务端可以在任意时刻下发一个retry: 10000,告诉浏览器“如果断了,10 秒后再重连”。默认情况下 EventSource 在断线后会立即重连,或者等待几秒,但生产环境里我们通常希望间隔更长,避免断线时大家都疯狂撞服务端。

至于id字段,在 AI 场景里主要是用来做续传的。比如服务端知道现在已经生成了 100 个 token,断线重连时客户端带着Last-Event-ID请求头重连,服务端就可以从第 100 个 token 之后继续推,而不是让用户从头再等一遍。这个机制相当实用,但很多团队因为复杂度原因没有做,属于优化项。

2.3 为什么不选 WebSocket 或轮询

聊到 SSE 时,一个逃不开的问题是“为什么不用 WebSocket”。我觉得应从几个维度来对比,这样脑子里会比较清晰:

维度SSEWebSocket
传输方向服务端到客户端的单向推送全双工,客户端和服务端双向收发
底层协议HTTP,天然穿透代理和防火墙独立的 ws 协议,需要额外握手和运维配置
断线重连内置自动重连,原生支持需要自己实现
数据格式文本流,按事件解析文本或二进制帧
实现复杂度低,浏览器原生支持较高,需要状态管理和心跳维护
适用场景通知、AI 流式输出、消息推送聊天、多人协作、游戏等双向高频场景

AI 对话这个场景,其实是“客户端发起一次请求,服务端流式响应”,本质上是单向的。用户只有在键入框里输入的那一刻才需要往服务端发数据,而且那是一次普通 POST 请求,不是建立在 WebSocket 上的。所以 SSE 是更轻、更合适的选择。

再说轮询。轮询是客户端每隔几秒去问一次“有没有新数据”,在没有数据的时候白白浪费请求,有了数据之后还会产生几秒延迟。SSE 则是一条连接挂着,有数据立刻推,无数据空着也不占用额外请求。两者的资源消耗和时效性完全不在一个量级。

当然,WebSocket 不是没有价值。如果产品里需要服务端频繁给客户端推二进制数据,或者需要客户端在连接建立期间不断发数据给服务端,那 WebSocket 依然是正确选项。但就“AI 回答为什么能一个字一个字蹦出来”这个问题而言,答案就是 SSE。

提示:现代浏览器也支持用fetch配合ReadableStream消费流式响应,这种方式能拿到更多的控制权,比如自定义请求头、手动中断。但 EventSource 的原生自动重连、事件分发能力是 fetch 不能直接替代的。

3. 前端接入实战:从 EventSource 到完整封装

3.1 最简单的接入代码

前端接入 SSE 最直接的方式是使用浏览器自带的EventSource构造函数。下面这段代码,就已经能实现一个最基础的流式输出:

// 创建一个 EventSource 实例,指向服务端的 SSE 接口 const es = new EventSource('/api/chat-stream'); // 收到消息时触发 es.onmessage = (event) => { console.log('收到数据:', event.data); document.getElementById('output').textContent += event.data; }; // 连接打开时触发 es.onopen = () => { console.log('SSE 连接已建立'); }; // 连接出错时触发 es.onerror = (err) => { console.error('SSE 连接异常:', err); // 注意:EventSource 内部会自动重连,这里不要手动 new 一个 };

这段代码运行时,服务端每推一条data:消息,前端就会立刻在页面上追加对应的文字。所以你会看到“一个字一个字蹦出来”的真实数据驱动效果,不需要任何定时器。

EventSource 还有几个原生属性值得记一下。es.readyState是连接状态,0表示正在连接,1表示已连接,2表示已断开。调试时可以通过它快速判断到底是连接没建立、正常工作,还是断了重连中。

关于中断,如果用户点了“停止生成”,你要调用es.close()主动关闭连接。这里有个容易踩的坑:close()之后,如果服务端那端还在生成,连接看着是断了,但服务端的一轮生成仍然会跑完,所以理想做法是服务端设计一个 abort 接口,前端关闭连接的同时再通知后端“别再浪费计算资源了”。

3.2 流式数据解析与增量渲染

实际业务中,服务端推的不是纯文本,而是一段一段 JSON。比如一条消息可能是这样:

data: {"type": "delta", "content": "你好"} data: {"type": "delta", "content": ","} data: {"type": "done", "content": ""}

前端收到的event.data是 JSON 字符串,需要JSON.parse之后取字段。但如果服务端把一个 JSON 分割成了两片推过来,直接 parse 就会抛异常。我在实际项目里遇过不少次这种问题,原因往往是服务端框架的流式缓冲策略把一条消息切割了,或者中间代理做了分包。

稳妥的做法是维护一个buffer变量,先把收到的原始数据存起来,然后尝试按完整消息边界解析。这里给你一个可复用的解析模式:

let buffer = ''; function handleChunk(chunk) { buffer += chunk; // 按照 SSE 分隔符切割,\n\n 或 \r\n\r\n 都算间隔 const parts = buffer.split(/\r?\n\r?\n/); // 最后一个元素可能是不完整的半条消息,留下继续等 buffer = parts.pop(); for (const part of parts) { // 每部分再按行解析 const lines = part.split(/\r?\n/); for (const line of lines) { if (line.startsWith('data:')) { const payload = line.slice(5).trim(); try { const json = JSON.parse(payload); // 渲染增量内容 if (json.type === 'delta') { setContent((prev) => prev + json.content); } } catch (e) { console.warn('JSON 解析失败,等待更多数据', payload); } } } } }

这个模式在 React 里可以直接配合useState使用,在 Vue 里配合ref使用,本质没有区别。渲染开销方面,每次增量都更新真实 DOM 会有一点性能损耗,但文本流更新远没有高频可视化图表那么夸张。只有当消息吞吐量极大、单条事件特别频繁时,才需要考虑把多次更新合并成一帧渲染。我建议用一个小技巧,也可以直接用 16ms 到 50ms 的节流,配合requestAnimationFrame批量更新 DOM,体验会顺滑很多。

3.3 断线重连与核心逻辑封装

EventSource 自带自动重连,但它毕竟不是万能的。我们在生产环境中主要面临两个问题:一是需要自定义请求头,尤其是带 token 鉴权的时候;二是服务端下发了结束标识时,前端需要判断“是正常结束还是异常断连”,不能一味重连。

自定义请求头的情况,老版本浏览器下EventSource不支持设置 headers,所以有一个社区共识:需要鉴权时优先用fetch+ReadableStream自己实现事件解析。现代浏览器里 EventSource 已经支持withCredentials和初始化参数里的 headers,但为了兼容老环境,我一般还是会提供一个fetch流式版本的封装。

下面是一个简化版的封装函数,支持自定义 header,也支持收到[DONE]标记后手动关闭连接:

async function createChatStream({ url, token, onDelta, onDone }) { const response = await fetch(url, { method: 'POST', headers: { 'Content-Type': 'application/json', 'Authorization': `Bearer ${token}`, }, body: JSON.stringify({ message: '你好' }), }); if (!response.ok) { throw new Error(`HTTP ${response.status}`); } const reader = response.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 }); const parts = buffer.split(/\r?\n\r?\n/); buffer = parts.pop(); for (const part of parts) { const dataLines = part.split(/\r?\n/) .filter((line) => line.startsWith('data:')); for (const line of dataLines) { const payload = line.slice(5).trim(); if (payload === '[DONE]') { onDone(); return; } onDelta(payload); } } } }

上面代码中有几个关键细节。第一,TextDecoder.decode(value, { stream: true })很重要,它能把多字节字符拆散后正确拼起来,避免中文乱码;第二,[DONE]是 OpenAI 风格接口的通用结束标记,如果你接的是自研服务,也可以用其他结束事件,但一定要有,否则前端不知道什么时候收工;第三,reader.read()返回的done是流的正常结束信号,而异常中断会抛异常,需要try/catch包一层。

断线重连策略方面,我建议用指数退避的方式:第一次重连等 1 秒,失败后等 2 秒、4 秒、8 秒,最大间隔可以设 30 秒。随机加入 0 到 1000 毫秒的抖动,防止多个客户端同时重连撞车。EventSource 自带的自动重连实现不了这个精细控制,这也是我们封装自己的流式客户端的重要原因。

注意:如果你发现 AI 流式输出里中文偶尔出现乱码、半字,先别怀疑 SSE 协议有问题,优先查TextDecoder是否按流模式逐步解码。直接用decode()不解流模式,分片边界上的多字节字符就会被拆成乱码。这是前端流式解析最常见的坑之一,没有之一。

4. 高频报错与真实避坑记录

4.1 idle timeout waiting for SSE 到底是谁在超时

有段时间我负责对接一个 AI 推理服务,前端频繁报stream disconnected before completion: idle timeout waiting for SSE,当时排查得挺头疼。这个报错的信息已经点出原因:空闲超时。就是说,服务端在一段时间内没有往连接里写任何数据,中间某个环节认为这条连接“闲着没用”,就把连接掐断了。

这里要澄清一个容易误判的点:一个 AI 回答从发起到最后显示完,看起来一直有数据在流动。但实际场景里,大模型为了组织一个长句子,可能已经推理了好几秒;或者服务端开启了流式缓冲,要先攒够一定数据才往外发。这段“没有任何字节”的时间,对网关或者代理来说就是“空闲期”。

我踩过坑后的标准排查路径是三层:先抓看是不是服务端本身没有数据可推,比如模型卡住了;再看是不是代理层把连接闲置超时设得太短,比如 Nginx 的proxy_read_timeout默认只有 60 秒;最后看浏览器端 EventSource 有没有因为重连而重新拉起连接。解决方案通常是双管齐下:服务端加心跳,每隔 15 秒发一行注释数据;同时把代理超时时间调长到 300 秒以上,或者干脆关闭该项限制。

心跳这招我喜欢用一个看起来有点“无厘头”的方式,就是往流里写一行以冒号开头的注释行:: heartbeat\n\n。这一行不会被 EventSource 当作有效数据,但它确实让连接上有了字节流动,网关就不会觉得这条连接空闲了。任何基于响应的文本流式服务,这招都适用。

4.2 stream disconnected before completion 的完整排查思路

stream disconnected before completion这个报错几乎是所有 AI 流式产品开发者的老朋友。字面意思就是:流式响应还没走完,连接就断了。触发它的原因比我们想象得复杂得多,我整理一下最常见的几类:

第一类是客户端超时。浏览器或者代理层设置了连接最大生命周期,到达时限后主动断开。表现通常是一条长回答跑到一半突然停止。处理办法跟上面的 idle timeout 类似,但更偏向于调大读取超时、关闭强制断开策略。

第二类是服务端异常退出。比如模型推理进程崩溃、后端抛异常没捕获、Docker 容器 OOM,都会导致响应流戛然而止。处理办法是查服务端日志,看有没有堆栈和退出码;然后把流式接口的完整错误处理做好,即使中途出错,也要先输出一个error事件再关闭连接,让前端能拿到明确错误信息。

第三类是网络波动。移动端切换 Wi-Fi、代理连接被重置、机房网络抖动,都会造成 TCP 层面连接断开。这种场景下,前端要做的不是报错,而是主动重试。我在封装里会区分两种结果:收到服务端error事件是“业务错误”,不重试直接提示;而连接异常断开则自动重试,重试前携带最后一条消息的 id,尽量从断点续传。

我在真实项目里还碰到过一个很隐蔽的情况,服务端用异步生成,但 HTTP 响应体没及时 flush,框架默认把大量数据缓冲在内存里,攒到最后一次性吐出来。前端看起来就是卡了很久然后一次性全量显示,完全失去流式效果。这种问题要看后端框架和网关的 buffer 配置,比如 Nginx 的proxy_buffering off,以及 Python 框架里的参数调整。

4.3 其他几个容易被忽略的坑

流式接口开发中,坑远不止超时和断连。我先说一个很多人都会栽跟头的点:JSON 被拆成半个。SSE 的data:行是文本行的拼接,如果服务端把一个很大的 JSON 分多次写,理论上它们会被拼接成一个完整消息,但如果中间代理把一条消息拆成多个网络包,前端按包来解析就会出问题。这也是为什么我在上一节反复强调,要用 buffer 按\n\n去切消息边界,而不是拿到一段数据就立刻JSON.parse。

再一个是跨域问题。SSE 接口和前端不在同一个域名时,需要配置 CORS,允许的头部中至少要包含Content-Type,并且由于 EventSource 默认不带凭证,如果你依赖 cookie 鉴权,需要把withCredentials打开。服务端这边也要把Access-Control-Allow-Origin配置成实际调用的域名,不能随意使用通配符加凭证的组合,否则浏览器会很干脆地拒绝响应。

还有一个关于浏览器的问题:EventSource 对 HTTP/2 的连接数有限制,对同一域名并发连接数也有上限。你在一页里同时打开七八个 SSE 连接,部分浏览器会直接报错或排队。如果产品里需要同时订阅多个事件流,建议后端做一个聚合网关,前端只建立一条 SSE 连接,里面用event:字段区分消息类型。

安全性也得提一下。流式消息的文本内容如果来自 AI 生成,直接把它当 HTML 插入页面是不安全的。渲染时优先用textContent、innerText或者前端框架的插值语法,如果确实需要渲染 Markdown,也要经过白名单过滤。我在生产环境里见过有人直接把 AI 返回内容通过v-html插渲染,结果遇到模型输出了一段不安全的 HTML 片段,好在只是影响了布局没有造成大事故,后来我全部改成先 sanitize 再渲染。

5. 进阶与面试:把 SSE 讲成你的加分项

5.1 从单个 SSE 到完整的流式架构设计

如果你只是接一个 AI 聊天接口,那看懂 EventSource 就够了。但要想把前端这块做好,尤其是面对复杂 Agent 产品时,你需要把 SSE 放进一个更大的架构里来设计。

一个成熟的前端流式架构,至少要包含这几部分:认证与鉴权、连接管理、消息解析、事件分发、异常处理、中断取消、断线重连、UI 增量渲染。这些模块不应该散落在组件里,而应该抽成独立的流式客户端。比如我在团队里封装过一个StreamClient,它对外暴露connect、send、stop、on这些方法,内部统一处理连接建立、心跳、重连、事件路由。业务组件只需要关心“收到文本增量就渲染,收到工具调用事件就展示进度”,代码会干净很多。

SSE 的event:字段在复杂场景里的价值会体现得非常明显。比如一个 AI Agent 在回答过程中需要调用搜索工具,后端可以推这样几条消息:

event: tool_start data: {"tool": "search", "status": "running"} event: tool_result data: {"tool": "search", "result": "共找到 3 条结果"} event: message_delta data: {"content": "根据搜索结果"}

前端分别监听不同事件,就可以实现“用户看到 AI 正在搜索资料,然后才看到回答”这种交互,比单纯显示“正在输入...”要高级得多。这也是为什么现在很多 AI 会话前端控件,本质上都是在做 SSE 事件的梳理和封装,那些 UI 只是外层壳。

5.2 面试官想听到的“SSE 完整链路”

前端面试里,如果被问到“AI 回答为什么能一个字一个字蹦出来”,你不要只回答“因为用了 SSE”。面试官想考察的是你对整个链路的理解深度。我给一个可以直接照用的回答结构:

第一层,从大模型的生成方式说起。大模型是逐 token 生成的,为了降低用户等待时间,后端采用边生成边返回的策略,这不是前端的动画效果。第二层,从 HTTP 流式传输说起。响应头为Content-Type: text/event-stream,服务端持续输出以data:开头的事件流,消息之间用空行分隔。第三层,从前端接收方式说起。浏览器原生 EventSource 负责建连与自动重连,前端拿到data:内容后按增量渲染。第四层,从工程化细节说起。需要考虑心跳、断线重连、JSON 分片解析、结束标记、中断取消、鉴权方式,必要时用fetch+ReadableStream做更底层的控制。

如果面试官追问 SSE 和 WebSocket 的区别,就把第 2.3 节那张对比表里的话用你自己的逻辑复述一遍。核心论点就一句话:AI 对话是“客户端发起一次请求,服务端持续响应”的单向流,SSE 更轻、基于 HTTP、无需额外协议握手、自带重连,因此在绝大部分 AI 集成场景下是更优选。

如果面试官继续追问 EventSource 的缺点,也别慌,这是送分题。EventSource 不支持服务端到客户端的自定义请求头(兼容性方案受限),对二进制数据不友好,连接数有限,且本身不能像fetch那样精细控制请求体和响应的每个环节。所以很多 AI SDK 在需要鉴权、中断、和精细控制时会绕过 EventSource 直接基于fetch封装流式客户端。这个回答不仅显示了广度,还体现了你真做过多套方案比对。

提示:面试回答的节奏很重要,建议先讲“这个效果来自真实数据流”,把场景定位准确;再讲“SSE 的协议字段与客户端接入方式”,把技术方案说清;最后讲“生产实践中的断线重连、心跳、分片解析怎么做”,把深度和工程经验立住。这样一套下来,基本能覆盖大多数前端岗位对 AI 流式输出的考察点。

我自己做了几年 AI 产品前端,最大的体会是:SSE 这套东西乍一看简单,就是个new EventSource(url),但真正把它用在生产环境里,各种边界情况和框架行为会让你慢慢意识到,流式不是“连接上了就完了”,而是一整套关于数据边界、连接生命周期、异常恢复的工程命题。最后再分享一个实测的小技巧:调试 SSE 接口时,别急着打开 DevTools,先用命令行curl -N http://localhost:8080/api/chat-stream试一下。-N参数会禁用 curl 的缓冲,服务端推一条你就能看到一条,比浏览器里排错直观得多。如果你打通了这一层,再回头看前端代码里那些看似玄学的报错,基本上都能一眼定位问题出在前端解析、网络代理还是后端推送。

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

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

立即咨询