
你平时用 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所以有一个社区共识需要鉴权时优先用fetchReadableStream自己实现事件解析。现代浏览器里 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 分片解析、结束标记、中断取消、鉴权方式必要时用fetchReadableStream做更底层的控制。如果面试官追问 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 的缓冲服务端推一条你就能看到一条比浏览器里排错直观得多。如果你打通了这一层再回头看前端代码里那些看似玄学的报错基本上都能一眼定位问题出在前端解析、网络代理还是后端推送。