这几个词凑在一起基本就是当下AI应用前端接入最常见的三件套。我在实际项目里做过一版AI问答助手从服务端对接大模型到前端流式接收、断线重连再到最终打字机效果渲染完整跑通下来的经验今天一次性梳理出来。这篇内容不聊循环神经网络也不讲模型训练只关注前端怎么把AI大模型的流式输出接好、接稳、呈现得自然。适合正在做AI产品前端、或者准备往这个方向靠的开发者尤其是不想用别人封装好的SDK、想自己掌控关键链路的朋友。1. 整体设计与技术选型为什么偏偏是SSE1.1 需求拆解AI对话场景的核心体验瓶颈先还原一下项目场景。当时要做一个对话式AI助手用户输入问题后端对接大模型结果需要实时展示在页面上。最开始图省事让后端一次性返回完整文本等个十几秒页面才突然冒出一整段答案用户体验很差。真正的问题不在于慢而在于无反馈。人类阅读一段话本身就是流式的逐字生成、逐字阅读才是自然的交互态。所以核心诉求变成模型输出多少内容前端就尽量实时地展示多少内容延迟越低越好。连带着就有一堆衍生需求流中断了怎么办、网络抖动怎么办、用户切走了又回来怎么办、页面刷新了怎么办。这些加在一起就是断点续传和重建连接要解决的问题。1.2 三种推送方案对比轮询、WebSocket、SSE服务端向客户端推送数据常见方案就三种各有各的适用场景。轮询Polling是客户端每隔几秒主动问一次“有新数据了吗”实现简单但延迟取决于轮询间隔而且大量请求是空转服务端压力大不适合高频推流场景。WebSocket是全双工通道客户端和服务端都能主动发消息功能最全很适合聊天、游戏、协同编辑这类需要双向高频交互的应用。但代价是协议更复杂握手、心跳、状态管理、断线重连都要自己处理。SSEServer-Sent Events是单工通道服务端单向往客户端推数据恰好契合AI对话场景——用户只发一次请求后续全靠服务端持续推送。它基于HTTP协议可以把认证、代理、负载均衡的现成基础设施直接用起来不需要额外协议升级。当时选SSE还有一个现实理由后端对接大模型时模型本身也是SSE流式返回的网关层做一次透传就可以了链路非常顺畅。反观WebSocket还得把模型流的数据包一次一次手动塞进WebSocket帧里多一道转换工作。提示SSE适合服务端单向推送、客户端无需频繁上行消息的场景。如果产品里除了AI回复还有用户与AI实时下棋这类双向交互才需要考虑WebSocket——不要为了技术热度而做技术选型。1.3 整体链路架构整个项目的链路是前端页面通过POST请求向后端网关发起对话请求携带用户消息和会话上下文。后端网关收到请求后调用大模型服务的流式接口。大模型持续返回文本片段后端将片段转换为SSE格式通过HTTP响应流推给前端。前端通过fetch流式读取响应数据解析SSE帧。渲染层把接收到的增量文本缓冲起来通过打字机效果展示。若连接意外中断前端记录最后收到的消息序号自动发起带续传标记的重连请求。这个架构选型的要点是前端不直接连大模型而是通过自己的后端网关中转。原因有三个一是保护API密钥二是可以在网关层做鉴权、限流、日志三是可以统一处理模型供应商的差异——换一个模型只需要改网关的适配层前端完全无感。2. 协议细节与服务端对接SSE不是Stream API那么简单2.1 SSE帧格式规范SSE的格式比很多人想象中简单就是一段普通HTTP响应流Content-Type设成text/event-stream每个事件由若干字段和空行组成。id: 1 event: message data: {delta: 你} id: 2 event: message data: {delta: 好} event: done data: [DONE]字段说明id事件序号断点续传的关键依据。客户端断开后重连时会把这个值带给服务端。data消息内容可以是纯文本建议统一使用JSON字符串方便传递结构化信息。event自定义事件类型默认是message可以用done、error等来标记特殊事件。retry重连时间间隔毫秒为单位。浏览器原生EventSource会根据这个值确定重连频率。每个事件以两个连续换行符\n\n作为结束边界这是解析时拆帧的依据。2.2 网关层对接大模型流式接口后端网关怎么对接大模型这里以Node.js环境为例展示一个最基础的流式转发写法。const express require(express); const app express(); app.post(/api/chat/stream, async (req, res) { // 设置SSE响应头 res.writeHead(200, { Content-Type: text/event-stream, Cache-Control: no-cache, no-transform, Connection: keep-alive, X-Accel-Buffering: no // 重要禁止Nginx缓冲响应 }); // 从请求体获取消息列表 const { messages } req.body; // 调用大模型流式接口 const stream await llmChat({ messages, model: qwen-plus }); let seq 0; for await (const chunk of stream) { seq; const frame [ id: ${seq}, data: ${JSON.stringify({ delta: chunk })}\n ].join(\n); res.write(frame \n); } // 发送结束事件 res.write(event: done\ndata: [DONE]\n\n); res.end(); });这段代码有几个细节值得注意。第一X-Accel-Buffering: no这行是给反向代理特别是Nginx看的告诉它不要缓冲这个响应必须实时转发给客户端。否则Nginx默认会攒够一定字节再发送打字机效果就没了。第二每条数据都带递增的id序号。这就是断点续传的地基后面前端依赖它来告诉服务端“我收到了哪里请从这里继续发”。第三内容用JSON的{delta: ...}格式封装而不是裸文本。这样未来如果想传额外的元信息比如引用来源、思考过程、性能指标不需要改协议结构。注意SSE的Content-Type必须是text/event-stream否则前端fetch流程里能拿到响应体但EventSource会直接抛错。另外设置Cache-Control: no-cache能避免中间层和浏览器缓存确保拿到的是实时流。2.3 心跳机制与代理超时配置SSE连接本质是一个长时间不关闭的HTTP连接。实际部署时最常遇到的一个坑就是中间代理层Nginx、网关服务有默认的读超时时间比如60秒。如果大模型生成第一段内容前思考时间超过了这个时长代理层会直接断开连接。参考报错stream disconnected before completion: idle timeout waiting for sse这就是典型的超时被断开。解决方案分两方面一是服务端主动发注释行作为心跳。SSE规范允许发送以冒号开头的注释行客户端会忽略它但连接有数据流动能防止被判定为闲置。// 在等待模型首包的同时每15秒发送一次心跳 const heartBeat setInterval(() { res.write(: heartbeat\n\n); }, 15000); // 流结束后清除定时器 clearInterval(heartBeat);二是调整Nginx配置proxy_buffering off; proxy_read_timeout 300s; proxy_send_timeout 300s;proxy_buffering off解决缓冲问题proxy_read_timeout从默认60秒调到300秒给模型留足思考时间。这些踩坑细节放到后面第6节详细展开。3. 前端流式接收层fetch流式读取与SSE解析3.1 EventSource的局限性提到SSE很多人第一反应是用浏览器的EventSource API。事件流确实能收到但这个API有三个硬伤第一EventSource只支持GET请求。AI对话场景中用户消息通常较长塞进URL的query参数会撞上URL长度限制而且消息内容明文暴露在日志里安全性和体验都不好。第二无法自定义请求头。如果需要带Authorization做鉴权EventSource做不到。第三EventSource内部有自己的重连逻辑但它重连时只能带上Last-Event-ID这个固定的头无法在重连时携带新的请求体或业务参数。所以我在项目里放弃了EventSource改用fetch ReadableStream手动解析。虽然代码量多一点但可控性完全不同。3.2 fetch流式读取与SSE帧解析核心思路是通过fetch拿到Response对象然后调用response.body.getReader()获取流读取器循环读取数据块再按SSE的帧格式拆分解析。const controller new AbortController(); async function sendMessage(messages, onDelta, onDone, onError) { const res await fetch(/api/chat/stream, { method: POST, headers: { Content-Type: application/json }, body: JSON.stringify({ messages }), signal: controller.signal }); if (!res.ok) { throw new Error(HTTP ${res.status}); } const reader res.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 frames buffer.split(\n\n); buffer frames.pop(); for (const rawFrame of frames) { handleFrame(rawFrame); } } // 流结束刷新剩余数据 if (buffer.trim()) { handleFrame(buffer); } } function handleFrame(rawFrame) { const lines rawFrame.split(\n); let eventType message; let data ; let id ; for (const line of lines) { if (line.startsWith(:)) continue; // 心跳注释忽略 const colonIndex line.indexOf(:); if (colonIndex -1) continue; const field line.slice(0, colonIndex).trim(); const value line.slice(colonIndex 1).trim(); if (field event) eventType value; if (field data) data (data ? \n : ) value; if (field id) id value; } if (eventType done) { onDone(); return; } const payload JSON.parse(data); onDelta(payload.delta, id); }这段解析逻辑里有个关键细节decoder.decode(value, { stream: true })。中文在UTF-8编码下是3个字节流式读取时一个字符的字节可能被分到两个数据块里。如果不用流式解码直接在每次拿到value后调用new TextDecoder().decode(value)就会出现首尾乱码。这是流式场景下必踩的坑。3.3 错误分类与状态管理流式请求的异常比普通请求多我把错误分成四类分别处理错误类型示例处理策略HTTP错误401未授权、429限流提示用户不重试网络中断断网、服务器关闭连接进入断点续传流程解析错误JSON.parse抛异常记录日志跳过该帧主动取消用户点击停止生成调用controller.abort()这里特别说一下主动取消。用户点击“停止生成”按钮时调controller.abort()可以立即断开fetch流浏览器会抛出一个AbortError。这个异常要在catch里区分出来避免被当成网络错误而触发自动重连。try { await sendMessage(messages, onDelta, onDone, onError); } catch (err) { if (err.name AbortError) { // 用户主动取消不触发重连 return; } // 网络错误进入重连流程 handleDisconnect(); }4. 断点续传实现从Last-Event-ID到业务级恢复4.1 断点续传的原理断点续传的核心思路很简单客户端记录“我已经收到第几条消息”连接断开重连时把这个序号告诉服务端服务端从下一条开始继续发送。这里有一个容易混淆的点。SSE协议规范里断点续传的载体是Last-Event-ID请求头由EventSource自动处理。当手动用fetch时这个头不能直接自定义Fetch规范限制所以要么用X-Last-Event-ID这个非标准头要么直接把序号放在请求体里。实际上大多数后端网关对SSE续传的实现并不依赖协议本身的Last-Event-ID而是自己在业务层处理。原因是项目里两个模型API提供商都没有实现基于事件的续传模型本身不保存每次输出的状态。网关层只能在内存里维护一个临时的消息缓冲记录每次输出片段重连时统一补发。4.2 前端断点续传的具体实现前端部分的续传流程维护一个lastEventId变量每次收到一帧就更新。连接中断时记录当前lastEventId和未展示完的文本。自动发起重连请求把lastEventId传给服务端。服务端从对应位置开始补发消息。前端对补发的增量文本做拼接渲染不重复展示之前已有的内容。前端代码结构let lastEventId 0; let isReconnecting false; async function chatWithResume(messages) { try { await sendMessage(messages, handleDelta, handleDone, handleError); } catch (err) { if (err.name AbortError) return; scheduleReconnect(messages); } } function scheduleReconnect(messages) { if (isReconnecting) return; isReconnecting true; const delay calculateBackoff(retryCount); setTimeout(() { // 重连时把lastEventId放在请求体里 chatWithResume({ ...messages, lastEventId: lastEventId }); isReconnecting false; }, delay); }服务端收到lastEventId后从缓冲里取出索引大于该序号的所有片段一次性补发再继续新的输出。这里会遇到一个工程上的权衡。大模型的输出是不可暂停不可重放的如果服务端在客户端断开的瞬间没缓冲数据那重新连接后只能从“断线后生成的新内容”开始发中间的空档就补不回来了。为了尽量压缩这个空档网关层要尽可能延长客户端的重连窗口同时减少响应缓冲让数据尽快推出去。4.3 重连策略与指数退避重连不能是“死了就狂试”要做好节奏控制。我采用的策略是第一次重连等待1秒。第二次2秒第三次4秒以2的指数递增。最大等待时间封顶30秒避免长时间无意义重试。每次重连前检查网络状态如果navigator.onLine false直接等在线状态恢复再重连。function calculateBackoff(retryCount) { const capped Math.min(retryCount, 5); // 2^5 32秒但封顶30 return Math.min(Math.pow(2, capped) * 1000, 30000); }重连成功后界面上的状态要从“已断开正在恢复...”恢复到“生成中”并且要保持打字机效果的无缝衔接。经验不要把lastEventId存在普通变量里。页面刷新后重新发起对话时需要用历史会话ID从服务端拉取完整的已生成内容作为渲染基底然后新的生成流从末尾继续追加。否则刷新后就只能看到新内容之前生成的部分就丢了。5. 打字机渲染与交互细节性能、Markdown、关键词高亮5.1 缓冲与调度为什么不能每帧都操作DOM流式内容到达前端后如果每收到一个Token就直接innerHTML 一次性能会很快崩掉。原因有两个一是频繁操作DOM触发重排重绘二是大段Markdown文本的解析和渲染非常耗CPU。解决方案是在数据接收层和DOM渲染层之间加一个缓冲队列用requestAnimationFrame做帧调度。let pendingText ; let isRendering false; let currentRenderText ; let outputElement null; function appendDelta(delta) { pendingText delta; if (!isRendering) { isRendering true; requestAnimationFrame(renderFrame); } } function renderFrame() { // 累积到一定量才渲染减少DOM操作频率 if (pendingText.length 30 || !pendingText) { currentRenderText pendingText; pendingText ; outputElement.textContent currentRenderText; } if (pendingText.length 0) { requestAnimationFrame(renderFrame); } else { isRendering false; } }这里的阈值可以根据文本量动态调整。实测下来每次攒30~50个字符输出一次人眼感觉是流畅的打字机效果但DOM操作频率比每个字都渲染降低了至少一个量级。5.2 流式Markdown渲染的特殊处理如果AI回复包含Markdown格式代码块、表格、标题直接拼接文本再整体渲染Markdown会有个问题Markdown语法在流式过程中是不完整的比如代码块的三反引号刚输出两个此时解析会出错渲染出一堆奇怪的临时内容。我采用的策略是分段渲染已稳定的完整段落判断依据出现了明确的段落结束符比如两个换行、一个完整的代码块结束标记一次性交给Markdown渲染器处理。当前正在生成的半截内容用简化方式渲染比如普通文本或者检测到未闭合的三反引号时先按行内代码显示。等半截内容完整了再把整个段落交给渲染器。这样的好处是性能可控而且不会在流式过程中出现“Markdown重排抖动”。function renderMarkdownStream(text) { // 找出已闭合的代码块 const closedCodeBlock text.split().length % 2 1 ? text.slice(0, text.lastIndexOf()) : text; // 未闭合部分的简易渲染避免解析错误 const pendingPart text.slice(closedCodeBlock.length); safeContent.innerHTML marked.parse(closedCodeBlock); // pendingPart 用textContent方式展示不做markdown解析 }5.3 用户交互与体验细节打字机渲染不止是动画效果还牵扯一堆交互边界。用户在生成过程中点击“复制”应该复制全量文本不是已经渲染出来的那部分。点击“停止”用AbortController断开流保留已生成内容标记状态为“已停止”。点击“重新生成”清空当前会话重新发起请求。页面失焦时浏览器可能会降低定时器频率导致打字机变卡。需要监听visibilitychange事件从后台切回前台时立即刷新一次渲染。document.addEventListener(visibilitychange, () { if (!document.hidden isRendering) { cancelAnimationFrame(rafId); renderFrame(); } });如果产品需要给关键词加高亮、特殊字体不要在渲染前做正则替换。最好先把全量HTML渲染到容器再在不影响性能的前提下做增量高亮。或者退一步只对已经完整渲染的段落做后处理高亮当前正在流式输出的部分不做。6. 踩坑实录与优化建议从开发到上线的完整排雷6.1 idle timeout流式接口最常见的隐形杀手这是我自己实际部署时被卡得最久的一个问题。现象是AI回答生成到一半页面就卡住不再输出新内容并且控制台报错stream disconnected before completion: idle timeout waiting for sse。排查过程本地开发环境一切正常说明代码逻辑没问题。部署到测试环境后偶发断流Review环境必现。抓包发现连接是在60秒左右被对端关闭的而大模型思考时间长时首包返回时间往往在60秒附近。定位思路很简单本地没有经过Nginx代理测试环境多了代理层。Nginx的proxy_read_timeout默认60秒如果在这个时间内上游没有返回任何数据连接会被主动断开。解决方案就是前面提到的两类处理Nginx层调整超时和关闭缓冲服务端层加心跳机制兜底。提示生产环境排查SSE断流第一步永远先确认路径上的所有代理云负载均衡、API网关、Nginx逐一检查它们的buffering和timeout配置。只查前端代码是查不出来的。6.2 中文乱码与半包边界流式读取时中文被拆成两个字节分布在不同数据块里是第二个高频问题。不加{ stream: true }参数的后果是打字机效果里偶尔蹦出“”这样的乱码字符。解决方案已在前面的代码中体现但还有一个补充场景如果服务端不是按标准UTF-8返回而是带BOM或者数据中间混入了其他编码的字符解析端要做容错。实际情况中我在对接两个不同的大模型供应商时就遇到过一个是纯UTF-8文本另一个会偶尔返回一段带转义字符的JSON。统一处理方式是把所有非预期的二进制数据丢弃只解析文本帧。6.3 连接管理与浏览器并发限制SSE看起来是一条HTTP请求但它是长连接会占用浏览器对同一域名的并发连接数。HTTP/1.1下浏览器对同一域名默认6条TCP连接如果页面同时开着几个AI会话或者有别的接口长轮询新请求会被阻塞。解决方案生产环境启用HTTP/2多路复用解决连接数限制。打包静态资源和API走不同域名进一步隔离连接池。页面内限制同时只能发起一条SSE流其他会话排队等待。6.4 用curl快速验证服务端流排查问题的时候用代码调试不如直接命令行来得快。先用curl验证服务端是否正常推流再判断问题出在前端还是后端。curl -N -X POST http://localhost:3000/api/chat/stream \ -H Content-Type: application/json \ -d {messages:[{role:user,content:你好}]}如果终端里能看到逐字跳出的内容服务端就没问题。这时候打开浏览器Network面板看SSE请求的状态和响应流重点观察两个点一是响应头里是否有content-type: text/event-stream二是响应体是否按预期片段到达Network面板会实时显示chunk。如果浏览器里看不到而curl正常优先检查浏览器端代码的解析逻辑。6.5 降级方案与兜底策略虽然SSE方案在主流程上很顺但总要考虑极端情况。比如公司网络环境禁止长连接、代理改写响应头导致content-type不对这种情况下SSE会整个失效。我做的兜底策略是如果SSE建立失败降级为普通POST请求让服务端一次性返回完整文本前端直接渲染。用户侧的感知差异是答案不是逐字出现的而是稍等一会儿整段出现但至少功能是通的。降级判断逻辑const res await fetch(/api/chat/stream, { method: POST, ... }); if (!res.ok) { // 降级调用非流式接口 const data await fetchFallback(/api/chat, { messages }); renderFullText(data.answer); return; } const contentType res.headers.get(content-type); if (!contentType || !contentType.includes(text/event-stream)) { // 服务端没有以SSE格式返回降级为一次性渲染 const text await res.text(); renderFullText(text); return; }6.6 测试与监控流式接口的专项检查流式接口的测试和普通接口不一样做了一套专项检查。主动中断测试客户端断开TCP连接观察服务端是否正确释放资源。慢网络模拟用Chrome DevTools的Network Throttling模拟3G/4G网络观察断线重连表现。长文本测试连续返回超过5000字的输出观察打字机渲染的帧率和内存占用。并发测试同时多个用户请求观察代理层的连接数限制和内存峰值。监控层面服务端要记录SSE连接时长、平均推送间隔、断线重连次数这几个指标一旦断线率超过阈值说明代理或者网络链路出了问题。最后再分享一个实际操作的体会AI前端落地这件事技术难度并不在于某个单独的点SSE协议简单、打字机效果也不复杂真正的复杂度全在边界情况的处理和链路稳定性上。把断线重连、编码边界、代理透传这三大块打磨扎实了整个功能就稳了。以后再接新的模型供应商前端基本不用动改改网关适配层就能上新这才是落地的核心价值。