
如果你用过ChatGPT、DeepSeek或者任何一款AI助手应该都见过那个经典场面问题刚发出去答案就开始一个字一个字往外蹦像有人在对话框里实时打字。这个“打字机效果”背后真正干活的其实是SSE——Server-Sent Events也就是服务器推送事件。作为前端搞懂SSE你就能看懂AI产品的流式交互是怎么设计的也能在公司里真正落地一个自研AI对话框而不是觉得这玩意高深莫测。这篇我会从协议原理讲到前端实战再把我实际项目里踩过的坑一次倒干净。无论你是接到AI流式需求的前端、想在面试里系统性回答SSE问题的候选人还是刚转行想搞明白这块的学习者这篇应该都够用了。1. 打字机效果的本质AI流式输出与SSE的关系1.1 为什么AI回答非要一个字一个字蹦先回答最基础的问题AI回答为什么不是一次性给完整段文字而要一个字一个字蹦核心原因在于大语言模型的生成机制。模型在生成文本时是逐个token可以理解为令牌或词元往后预测的它每预测完一个token再基于前面的所有token继续预测下一个。如果你等全部token都生成完再一次性返回给前端用户等待时间会长到难以接受。以现在主流模型的输出速度一段三百字的中文回复可能要生成十几秒甚至更久让用户盯着空白页面等十几秒交互体验几乎等于报废。所以产品层必须做流式输出模型每生成一部分就立刻推给前端显示用户看到的是“内容在不断出现”体感上会觉得这个AI“正在思考而且马上能看到结果”。这就是打字机效果的由来。传输层上这种“服务器主动、持续地往前端推数据”的需求自然催生出了SSE技术。SSE全称Server-Sent Events它是HTML5标准中专门为服务器到客户端的单向实时推送设计的一套机制。AI对话场景恰好只用到单向推送用户发一条消息服务器把模型吐出来的增量结果不断推回来。方向单一、数据量顺手、还要支持长时间保持连接SSE几乎就是为了这类场景量身定做的。1.2 选型对比轮询、WebSocket、SSE到底选哪个在公司里做实时推送方案通常会在这三样之间做选择轮询Polling、WebSocket、SSE。很多前端同学一听到“实时”就想到WebSocket其实AI流式场景里WebSocket往往是杀鸡用牛刀。三者的核心区别用一个表就能看明白方案通信方向实现复杂度自动重连典型场景轮询单向客户端反复拉取低无低频通知、后台任务状态WebSocket双向高无在线聊天、联机游戏、协同编辑SSE单向服务器持续推送中内置AI流式回答、实时日志、股票行情为什么AI对话不推荐WebSocket因为AI对话本质上不需要客户端给服务器“实时反向推送”前端只是在刚开始发一次请求而已。而WebSocket要处理协议升级、二进制帧、心跳保活、断线重连逻辑复杂度远高于SSE。SSE只要一次HTTP请求建立连接然后服务器按住连接持续写数据就行浏览器原生EventSource还自带断线自动重连省掉大量手工逻辑。打个不那么严谨但好理解的比方WebSocket像双向语音电话两边随时都能说话SSE像收音机的电台广播主播单向对你说话但信号断了收音机会自动重新调台。AI流式回答就是典型的“电台广播”服务器是主播前端是收音机。1.3 SSE凭什么在AI场景最好用SSE在AI场景还有一个隐藏优势就是它跑在HTTP协议之上。这意味着所有HTTP生态的现成能力都能直接复用比如CORS跨域配置、Cookie鉴权、HTTPS加密、网关限流、日志监控。相比WebSocket需要单独处理跨域和鉴权策略SSE的开发成本要低一大截。另外SSE支持事件类型。服务器可以在同一条连接里推送不同类型的事件前端按事件名分别监听和处理。比如AI场景里可以用默认message事件推送模型生成的增量token用事件error推送限流信息用事件done推送结束标志非常方便。基于这些原因现在市面上主流的大模型API底层流式接口多数都采用SSE协议或者提供兼容SSE的流式格式。前端只要掌握SSE相当于拿到了跟各路大模型对接的通用能力。2. SSE协议细节前端必须吃透的流式传输2.1 一条SSE消息长什么样SSE协议最核心的内容其实特别朴素它就是在HTTP响应体内按固定格式不断追加文本。看一个最简单的服务端写法const http require(http); http.createServer((req, res) { res.writeHead(200, { Content-Type: text/event-stream, Cache-Control: no-cache, Connection: keep-alive, Access-Control-Allow-Origin: * }); let count 0; const timer setInterval(() { res.write(data: 第 ${count} 条消息\n\n); count 1; }, 1000); req.on(close, () clearInterval(timer)); }).listen(3000);重点看写出去的那行数据data: 第 1 条消息\n\n。这个格式里有两层意思。第一层以data:开头冒号后面跟着真正的数据内容。第二层每条SSE消息以两个换行符\n\n结尾表示“这一条发送完毕下一条从这里开始”。前端解析SSE流时正是靠\n\n来切分消息边界的。除了data:SSE规范里还定义了其他几个字段字段作用示例data携带数据内容可多行拼接data: helloevent声明消息类型前端可单独监听event: doneid消息ID断线重连时用id: 42retry指定重连间隔毫秒数retry: 3000冒号开头注释不触发任何事件通常做心跳: keep-alive这里有一个经常被忽视的细节data字段可以连续出现多行浏览器会把它们拼接成一条消息中间用换行符分隔而最终拼接结果末尾的那个换行会被去掉。比如data: 第一段 data: 第二段前端收到的实际内容是第一段\n第二段。如果你自己用fetch写解析器这个行为得跟规范对齐否则数据会莫名其妙多出换行或丢失内容。2.2 EventSource与fetch流式该选谁说到前端接收SSE很多文档会先介绍EventSource这是浏览器原生提供的SSE客户端const source new EventSource(/api/stream); source.onmessage (event) { console.log(event.data); }; source.addEventListener(done, (event) { console.log(流结束); source.close(); });EventSource的优点很多自动重连、自动解析\n\n分隔、按事件类型分发、API简洁。但它有一个致命短板——只支持GET请求且无法自定义请求头。回到真实AI对话场景前端给后端发用户消息通常需要POST一个JSON请求体还要带Authorization鉴权头。这两件事EventSource都干不了。所以在真实的AI流式对话里前端实战选型几乎都是fetch ReadableStream把请求完整控制权握在自己手里。fetch可以指定method、headers、body然后拿到响应的response.body它是一个可读流用getReader()逐块读取服务器推回来的内容。const response await fetch(/api/chat, { method: POST, headers: { Content-Type: application/json, Authorization: Bearer your-token }, body: JSON.stringify({ message: 你好介绍一下你自己 }) }); const reader response.body.getReader();拿到reader之后就是一个典型的流式消费过程反复调用read()每次拿到一个Uint8Array数据块再用TextDecoder转成字符串。这个流程很好记真正坑人的地方在后面——解析边界我会放到踩坑实录部分详细说。2.3 心跳、重连与断点续传EventSource模式下浏览器是有自动重连机制的。连接断开后浏览器会等一段事件自动重新发起请求这个等待时间默认由服务器返回的retry字段指定如果在服务端没指定浏览器按默认策略处理。重连时还可以带上Last-Event-ID请求头告诉服务器“我最后收到的消息ID是多少”服务器可以从那之后继续推实现断点续传。这套逻辑非常适合日志推送、实时通知这类功能。但如果你用fetch流式模式这些能力全都要自己实现。拿重连举例当读取流抛错或读到done但消息还没推完时你得自己封装一个重试循环并且要控制重试频率避免对服务器发起“请求风暴”。还有一个容易被忽略的点服务器端如果不主动发心跳某些中间层Nginx、负载均衡网关、云厂商的代理会在连接空闲一段时间后主动掐断。心跳在SSE里的实现非常简单服务器定时发送一行注释正合适: still alive因为:开头的内容在SSE规范里是注释浏览器解析时会直接忽略不会触发任何事件但连接上的数据活动却能让中间层知道“这个连接还是活的”。这个技巧我后面会再提到先记住结论长连接场景必须有心跳机制。3. 实战手写一个AI流式对话前端3.1 后端最小透传服务为了把前端逻辑讲透我先搭一个最小可用的后端透传服务。这里的思路是前端POST用户消息给后端后端转发给大模型API拿到API的流式响应后通过SSE格式原样推给前端。后端代码用Node.js写不依赖框架看清楚原理最重要。const http require(http); // 这里用setTimeout模拟大模型流式返回 // 真实项目中这一步会变成你的AI SDK调用。 function fakeLLMStream(onToken) { const text 欢迎来到SSE实战教程我们一步步来。; let i 0; const timer setInterval(() { if (i text.length) { onToken(text[i]); i 1; } else { clearInterval(timer); onToken(null); // 表示结束 } }, 80); } http.createServer(async (req, res) { if (req.method ! POST || req.url ! /api/chat) { res.writeHead(404); res.end(); return; } // 读取请求体拿用户输入 let body ; for await (const chunk of req) { body chunk; } const { message } JSON.parse(body || {}); console.log(收到用户消息:, message); // 设置SSE响应头 res.writeHead(200, { Content-Type: text/event-stream; charsetutf-8, Cache-Control: no-cache, Connection: keep-alive, Access-Control-Allow-Origin: * }); // 把大模型流式结果按SSE格式转发给前端 fakeLLMStream((token) { if (token null) { res.write(event: done\ndata: [DONE]\n\n); setTimeout(() res.end(), 100); } else { res.write(data: ${JSON.stringify({ token })}\n\n); } }); req.on(close, () { // 客户端断开时及时清理定时器 clearInterval(); }); }).listen(3000, () { console.log(SSE服务已启动: http://localhost:3000); });这段代码的关键点有三个。第一响应头Content-Type必须是text/event-stream这是SSE的身份标识。第二每次写消息都是data: ...\n\n格式中间带一个空行。第三结束时要推一个event: done的特殊事件前端容易区分“正常结束”和“连接异常中断”。3.2 前端流式读取与解析接下来是前端的重点用fetch读取SSE流并正确解析。前面说过不能贪方便用EventSource因为我们需要POST和自定义请求头。直接用原生fetch的写法是基础下面是带完整解析的代码async function chatWithAI(userMessage) { const response await fetch(http://localhost:3000/api/chat, { method: POST, headers: { Content-Type: application/json }, body: JSON.stringify({ message: userMessage }) }); if (!response.ok) { throw new Error(HTTP ${response.status}); } if (!response.body) { throw new Error(当前环境不支持流式读取); } const reader response.body.getReader(); const decoder new TextDecoder(utf-8); let buffer ; let fullText ; while (true) { const { done, value } await reader.read(); if (done) break; // 解码时必须加上 {stream: true} // 这个参数能避免多字节字符被拆成两半导致的乱码下面细说 const chunk decoder.decode(value, { stream: true }); buffer chunk; // 按SSE消息分隔符切出完整消息 const parts buffer.split(\n\n); buffer parts.pop(); // 最后一段可能是不完整的半条留在buffer里 for (const part of parts) { const result parseSSEPart(part); if (result.event message result.data) { try { const parsed JSON.parse(result.data); if (parsed.token) { fullText parsed.token; onTokenDelta(parsed.token, fullText); // 这里更新UI } } catch { fullText result.data; onTokenDelta(result.data, fullText); } } else if (result.event done) { return fullText; } } } return fullText; } function parseSSEPart(part) { let data ; let event message; const lines part.split(\n); for (const line of lines) { if (line.startsWith(data:)) { data line.slice(5).trim() \n; } else if (line.startsWith(event:)) { event line.slice(6).trim(); } } return { event, data: data.trim() }; }这段代码里buffer的处理是最容易让人懵的地方。因为流式传输中一次read()返回的数据块长度完全不可预测它可能包含半条消息、一条完整消息甚至好几条消息揉在一起。解决办法就是维护一个buffer字符串每次用\n\n当作分隔符拆分把拆出来的完整消息处理掉剩下的半截留到下一轮继续拼。这个模式是SSE解析器通用的核心逻辑。我这里的前端代码没有马上渲染到DOM而是通过回调onTokenDelta把增量文本交出去。这样业务层可以自由决定怎么渲染比如显示在聊天框、配合Markdown解析器或者做一个流式输出的日志面板。保持解析器纯逻辑、跟UI解耦实际项目里维护起来会舒服得多。3.3 增量渲染与停止生成流式数据解析出来之后最自然的展示方式就是往一个DOM容器里不断追加内容。考虑到AI回复里通常有换行、代码块、列表直接塞innerHTML会影响格式我一般先做一个简单的转义和换行处理再叠加Markdown渲染库。如果你只需要一个纯文本版本可以像下面这样快速完成const chatContainer document.getElementById(chatContent); let assistantReply ; function onTokenDelta(token, fullText) { // 实时把新增token追加到显示容器 const line document.createElement(span); line.textContent token; chatContainer.appendChild(line); }需要注意的是如果你用textContent而不是innerHTML可以避免AI输出内容里的HTML片段被当成元素解析这是基本的XSS防护不能省。再来说“停止生成”。用户点了“停止回答”按钮前端要中断这个流带上AbortController就行const controller new AbortController(); async function chatWithAI(userMessage, { signal } {}) { const response await fetch(/api/chat, { method: POST, headers: { Content-Type: application/json }, body: JSON.stringify({ message: userMessage }), signal // 传进去 }); // 后续读流逻辑不变 } // 用户点击停止按钮 document.getElementById(stopBtn).onclick () { controller.abort(); };abort()执行后正在等待的reader.read()会立刻抛出一个AbortError你要在catch里捕获并区分处理如果错误是abort导致的就正常展示“已停止生成”如果是其他网络错误则提示用户稍后重试。这个细节在真实交互里特别重要否则一中断就弹报错观感会很差。4. 踩坑实录SSE实战中的那些坑4.1 中文乱码别小看TextDecoder的stream参数我第一版前端代码是这么写的const text decoder.decode(value);当时测试英文内容一切正常但换成中文后AI回复里时不时出现一个“”字符。排查了半天问题就出在decoder.decode缺了{ stream: true }。原因是中文在UTF-8编码下通常占3个字节而网络数据块的边界是按传输包大小切的不关心字符边界。某个汉字的前两个字节可能落在前一个网络包里最后一个字节落到下一个网络包里。如果你每轮都独立调用decoder.decode(value)遇到被拆开的字符它就会因为编码不完整而输出替换字符。解决办法就是前面代码里写的decoder.decode(value, { stream: true })。这个参数告诉TextDecoder我的数据可能是不完整的你先把能解的部分解出来剩下的字节缓存到内部等下一轮数据来了再接着解。这个参数一定要带上否则AI对话带中文必踩雷。4.2 数据被拆分和拼接buffer解析器的正确写法除了字符被拆开SSE消息边界也可能被拆开。一次read()可能只读到半行data: {...还没等到尾部的\n\n也可能一次读到好几条完整消息。没有buffer机制直接处理数据要么丢了要么解析错乱。正确逻辑我在实战部分已经实现过用buffer拼接所有chunk每次先按\n\n切分切出的是完整消息就处理留在末尾的半截消息积攒到下一轮。这个模式是所有SSE解析器的基础VueUse的useEventSource、axios的适配器、各家AI SDK底层解析逻辑本质上都是这套。另外还要注意有些服务端不是每发一个token就写一次\n\n而是攒了一批token才flush一次。后端要确保每次res.write()都带上消息结束分隔符否则前端会一直等buffer凑齐产生明显的卡顿。我在自测时发现Node端如果用了res.end()之前一直不flush浏览器里往往要等到最后才能看到所有内容。记得在每次写完数据后手动flush多数云框架和框架SDK会自动flush但原生实现需要留意。4.3 idle timeout waiting for SSE心跳与代理配置搜过这个报错的人不在少数“stream disconnected before completion: idle timeout waiting for sse”。它翻译过来就是“流在完成前断开等待SSE数据时空闲超时”。这个问题的本质是连接中间件Nginx、云网关、负载均衡认为这条连接太长时间没有数据传输按规则把连接回收了。AI模型在做复杂推理时可能几十秒都不吐一个token。在这段静默期内如果没有心跳数据流动网关就会误会连接已死触发断开。解决思路有两个方向。第一个方向服务端加心跳。在SSE流里定时发送注释行: keep-alive\n\n比如每隔15秒发一次。注释行不产生任何业务事件但能让网络链路持续有流量。这是兜底方案接的中间层再多也不怕。第二个方向调整代理层配置。如果你们用的是Nginx要把这块流式接口单独加上几行配置location /api/chat { proxy_pass http://backend; proxy_buffering off; proxy_cache off; proxy_read_timeout 3600s; proxy_send_timeout 3600s; }proxy_buffering off是关键它禁止Nginx把后端返回的数据缓存到自己的缓冲区里一次性转发而是让数据按到达顺序立刻流给浏览器。如果没有这一行即使后端在streamNginx也会攒够缓冲区才推给前端造成肉眼可见的延迟。proxy_read_timeout则是把空闲超时拉长到一小时给AI留足思考时间。4.4 三条调试经验与压测建议最后分享几个我实际摸索出来的调试习惯。先用curl验证后端。不看前端代码之前先用命令行确认后端真的在按预期输出SSE流curl -N -X POST http://localhost:3000/api/chat \ -H Content-Type: application/json \ -d {message:你好}-N参数很重要它禁止curl对响应做缓冲让流式内容在终端里实时显示。如果curl能看到数据一个字节一个字节冒出来但浏览器前端表现异常说明问题出在前端解析如果curl也看不到流那就是后端或代理层的问题排查范围一下子就缩小了。浏览器Network面板里的EventStream标签页是检查SSE的利器。打开它能看到每一条SSE消息的原始内容、时间线、事件类型。前端如果出现乱码、消息丢失、延迟先用这个面板看原始流长什么样再判断是解析问题还是网络问题。压测的时候要特别注意并发连接数。HTTP/1.1下浏览器对同一域名的连接数有限制一般在6个左右。SSE是长连接只要建立就一直占着一个连接。如果页面同时开了多个SSE流又把其他请求挤在同一条连接里很容易出现连接排队甚至请求阻塞。遇到这个问题一是检查是否真的需要同时开多条SSE二是考虑上HTTP/2多路复用能大幅缓解连接数限制。还有一点值得提醒大部分AI SDK会自己做重试和心跳但底层依赖的仍然是这套SSE协议。当你需要排查问题时别一头扎进框架源码里先从协议层把原始流看明白往往五分钟就能定位。协议通了任何封装SDK的问题都好查。最后再说两句我在实际项目里上手SSE时最深刻的体会是这个协议本身不难难的是一些不起眼的边缘细节——中文编码、缓冲拆分、空闲超时、代理配置。这些问题单独看都不大但合在一起能在排期最紧的时候浪费你整整一天。所以建议你在正式开发AI对话功能之前先用最少代码把后端和前端的最小闭环跑通确认编码、心跳、结束标志都正常再往上面叠业务逻辑。我自己的做法是保留一个带curl -N测试命令和空前端页面的SSE调试模板每次新项目都要复用。这套东西在关键时刻能省下大把时间也推荐你直接抄走。