AI流式输出前端落地:SSE选型、打字机渲染与断点续传全攻略

发布时间:2026/9/19 6:56:30
AI流式输出前端落地:SSE选型、打字机渲染与断点续传全攻略 前阵子接手一个 AI 对话产品的重构发现流式输出这块儿表面上看就是“SSE 接收数据 状态更新渲染”两行字真要落地却处处是坑。连接动不动就断、生成到一半用户刷新页面就白等、打字机效果把浏览器卡到掉帧。这次我把整套方案重新捋了一遍从 SSE 选型、打字机渲染、断点续传到问题排查把能踩的坑都踩了一遍今天把最终能稳定跑的方案完整拆给大家正在做 AI 聊天、AI 写作、类 Copilot 功能的前端同学可以直接抄作业。1. 为什么 AI 流式输出要先选 SSE先说结论在 AI 对话、内容生成这类“服务端生成、前端展示”的场景里SSEServer-Sent Events服务器推送事件就是比 WebSocket 更合适。很多同学一上来就 WebSocket实际上是杀鸡用了牛刀。SSE 本质是 HTTP 连接上的一段持续响应流服务端把数据分块往下推前端用事件监听的方式逐段拿。它的核心优势是基于 HTTP、单向推送、自带重连和 AI 接口“用户问一句服务端算半天、吐一堆 token”的模型天然匹配。有人可能会问WebSocket 不行吗行但没必要。AI 对话场景里用户和服务端的交互是典型的“一问一答”用户不用频繁往服务端推数据真正高频的是服务端往客户端推生成结果。这种单向流式场景用 WebSocket 等于自己给自己找事——要做心跳、要做重连、要处理二进制帧、要管理连接状态而 SSE 在浏览器里一个EventSource就能搞定服务端实现也更简单。1.1 SSE 和 WebSocket 的本质区别先看一张对比图文字版大家感受一下差异维度SSEWebSocket传输方向服务端→客户端单向全双工双向底层协议HTTPtext/event-stream独立的 WS 协议握手后升级浏览器 APIEventSource / fetchWebSocket自动重连内置断线自动重连需要自己实现自定义请求头EventSource 不支持fetch 方案可支持支持传输格式文本UTF-8文本或二进制服务端复杂度很低普通 HTTP 接口就能写较高需要协议处理实际开发中我首选 SSE 还有一层原因它能直接复用现有的 HTTP 体系。鉴权可以走 token 请求头用 fetch 方案网关、日志、监控全部走现成链路出了问题排查起来链路短。WebSocket 是长连接服务端要保持连接状态多实例部署时还要考虑连接粘滞复杂度直接上一个台阶。AI 场景还有个微妙的地方模型生成是逐步的、不可预测的用户看到“正在输入”的反馈能极大提升体验而 SSE 天然支持这种“流式反馈”——不需要像轮询那样按固定间隔请求而是服务端有内容就推没内容就保持连接。这也是为什么各大 AI 厂商的开放接口基本都支持 SSE而不是让你用 WebSocket 对接。1.2 EventSource 的三个局限与 fetch 流式方案很多人知道 SSE 就先写new EventSource(url)但实际项目里 EventSource 有硬伤只能发 GET 请求无法携带自定义 Header。AI 接口普遍需要 Authorization 鉴权用 EventSource 就只能把 token 拼在 URL 上既难看又有日志泄露风险。重连行为不可控。EventSource 断线会自动重连但重连后从哪开始、要不要重发上次没接收完的内容很多时候需要业务层自己控制默认行为不满足需求。对连接取消的处理不友好。用户点击“停止生成”eventSource.close()确实能断开但服务端感知到断开需要依赖 TCP 超时处理不好连接会挂很久。所以我的推荐是用 fetch ReadableStream 手动解析 SSE 流把主动权握在自己手里。核心代码如下const response await fetch(/api/chat/stream, { method: POST, headers: { Content-Type: application/json, Authorization: Bearer ${token} }, body: JSON.stringify({ sessionId, message }), signal: controller.signal // 用于手动取消 }); 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 }); // SSE 事件以空行分隔按事件块解析 const events buffer.split(/\r?\n\r?\n/); buffer events.pop() || ; // 最后一段可能不完整保留到下一轮 for (const eventBlock of events) { const dataLines eventBlock .split(/\r?\n/) .filter(line line.startsWith(data: )) .map(line line.slice(6)); const payload dataLines.join(\n); if (payload) handleChunk(JSON.parse(payload)); } }这里有两个关键点一是decoder.decode(value, { stream: true })不传stream: true会导致多字节字符比如中文在跨 chunk 时被切断乱码这是新手最容易踩的坑二是按空行切分事件块SSE 规范里事件之间用空行分隔不能只按\n切。提示用 fetch 方案后自动重连就没有了需要自己实现。后文第 4 部分会讲怎么做可靠的自动重连和断线恢复。2. 打字机渲染让 AI 回复“看起来”更快SSE 数据拿到手以后下一步就是渲染。如果服务端每推送一个 token 你就 setState 一次用户大概率会看到一个疯狂闪烁的聊天窗口——因为 React/Vue 的状态更新和 DOM diff 都是有开销的每秒几十次更新很容易把主线程占满。打字机效果的本质就是把“收到数据”和“渲染数据”解耦用可控的频率把累积的数据“放”到屏幕上制造出逐字输出的丝滑感。2.1 打字机效果的核心逻辑与最小实现先上代码一个最小可用的打字机 HookReact 示例function useTypewriter(streamingText: () string, speed 30) { const [displayText, setDisplayText] useState(); const textRef useRef(); const timerRef useRefnumber | null(null); useEffect(() { if (timerRef.current) return; // 已有定时器则复用 timerRef.current window.setInterval(() { const target streamingText(); const current textRef.current; if (current.length target.length) { // 每次定时器触发最多追加若干字符 const step Math.max(1, Math.floor((target.length - current.length) / 5)); textRef.current target.slice(0, current.length step); setDisplayText(textRef.current); } else { // 已经追上最新内容清空定时器避免空转 if (timerRef.current) { clearInterval(timerRef.current); timerRef.current null; } } }, speed); return () { if (timerRef.current) clearInterval(timerRef.current); }; }, [speed, streamingText]); return displayText; }几个设计要点定时器只维护一个不管流里来了多高频的数据渲染频率恒定由speed控制避免高频 setState。每次渲染不一定只加一个字符如果当前累积的数据已经很多一次取一个字符会导致“打字机跟不上读秒”用户会觉得输出很慢。所以我按剩余长度的 1/5 步进既保持打字机的节奏感又能快速追上最新内容。textRef 保存最新已渲染文本避免在 setState 异步回调里读旧值。如果直接在setDisplayText(prev ...)里处理定时器和渲染函数之间很容易产生闭包陷阱。这个 Hook 的输入streamingText是一个函数每次调用返回最新的完整文本从 SSE 收到的累积内容渲染层只负责控制展示进度两者互不干扰。2.2 滑动窗口与渲染性能优化打字机本身优化好了但如果 AI 输出的是超长代码块、长 Markdown 文档还有两个问题一是每次 setDisplayText 都会全量 diff 整个消息文本文本越长越卡二是浏览器渲染长文本节点本身就会掉帧。对第一个问题我的方案是滑动窗口裁剪 DOM。聊天窗口里通常有历史消息和当前生成消息随着内容变长把已滚出可视区域的历史消息 DOM 节点做“轻量化”处理——只保留纯文本摘要或首屏片段真实完整内容存到 JS 对象里。实际操作时可以在渲染组件里加一个阈值if (message.length 8000 !isVisibleInViewport()) { return div{message.slice(0, 200)}...已折叠/div; }滚动到该消息附近时再展开完整内容。对第二个问题长文本渲染的瓶颈主要在于浏览器对连续文本节点的排版和绘制一个实用的技巧是给打字阶段的 DOM 节点设置contain: content或者content-visibility: auto通知浏览器该区域独立渲染减少重排影响范围。实测下来长 Markdown 文档从持续卡顿提到基本流畅。如果内容实在太长比如 10 万 token 以上的报告那打字机效果就要分页或分区渲染先让用户看到当前区块历史区块保留展开按钮。这也是很多 AI 写作产品实际采用的方案无脑全量渲染在低端设备上确实扛不住。2.3 Markdown 流式渲染的特殊处理AI 生成的内容大多是 Markdown但流式阶段直接对不完整 Markdown 调用marked/remark/markdown-it会出现一个很丑的现象代码块没闭合时整个后续内容被吞进代码块里或者列表、标题样式闪来闪去。用户视角就是“内容一直在跳”体验很差。我的方案是分层处理打字机阶段不做完整 Markdown 渲染只做轻量转义和纯文本展示把末尾未闭合的代码标记、链接标记处理成普通文本。流结束后再用完整的 Markdown 渲染器做一次全量渲染替换掉打字阶段的轻量内容。对代码块加一个闭合检测就很实用function sanitizePartialMarkdown(md: string): string { // 统计未闭合的代码块围栏 const fenceMatches md.match(//g) || []; if (fenceMatches.length % 2 ! 0) { // 末尾补一个闭合围栏防止整段被误判为代码块 return md \n; } return md; }同样的思路也适用于链接、图片语法。这类不完整语法在流式阶段可以先显示为纯文本不影响用户阅读速度流结束后再精确渲染。个人经验是不要在打字机渲染阶段追求“每一步都好看”保证“最终一定好看”就够了。3. 断点续传崩溃之后用户不能重头开始SSE 连接是长连接长连接就一定会断。断网、服务端重启、网关超时、浏览器切后台被系统回收连接……AI 生成一条长回答可能要几十秒甚至几分钟中途断线的概率在我看来至少 5%~10%。如果没有断点续传用户就只能重新生成既浪费时间又可能重复计费。所以这块必须在一开始就纳入设计。3.1 断点续传的适用场景与设计思路先说清楚这里说的“断点续传”不是把生成结果从客户端传给服务端而是把“模型生成进度”持久化在服务端断线后客户端能接着消费。类比一下就是这样——文件断点续传是记录“文件上传到第几个分片”AI 断点续传是记录“模型生成到第几个 token”本质都是“从上次中断的位置继续”。适用场景主要有这些生成长内容AI 写作、周报生成、代码生成单次输出几千上万字中途断线非常常见。移动端弱网环境网络频繁切换Wi-Fi 和 4G 切换必有一小段时间断流。服务端多实例部署某个节点挂了、重启连接迁移后要能从历史记录继续。设计上需要服务端和前端各承担一部分责任。服务端要把“生成了多少内容、存到哪了、当前状态是什么”记录下来前端需要记录“我已经消费到哪个位置了”并在重连时把游标传回服务端。3.2 服务端配合游标设计示例服务端至少需要一张流式生成记录表核心字段如下字段说明session_id对话会话 ID关联用户和聊天记录message_id当前生成消息的 IDgenerated_text已生成的完整文本边生成边持久化token_offset已生成 token 数相当于游标statusgenerating / completed / failed服务端把已生成文本和游标持续写入比如每生成一段或每 2 秒落盘一次。客户端断线重连时带上session_id和message_id服务端判断status generating就返回“当前已生成内容 游标”客户端从游标继续请求新的生成结果。在 SSE 协议层面服务端可以在事件里携带id字段浏览器端可以用Last-Event-ID做续传。但因为我们用的是 fetch 方案这个能力就需要自己实现直接把游标作为请求参数传给服务端即可。3.3 前端恢复流程与 UI 状态处理前端恢复流程我总结为三步检测断开SSE 流异常结束网络错误、读不到数据、收到服务端失败标记不要立刻把 UI 标记为“失败”先标记为“中断”。查询恢复点请求一个恢复接口例如GET /api/chat/:sessionId/messages/:messageId拿到已生成文本和游标。续传渲染把已生成文本作为初始渲染值然后带上游标重新发起 SSE 生成请求。服务端从游标处继续推送前端从已有内容的基础上接着打字机渲染。核心伪代码async function resumeGeneration(sessionId, messageId) { // 1. 查询恢复点 const { content, cursor } await fetch(/api/chat/${sessionId}/messages/${messageId}).then(r r.json()); // 2. 先把已有内容渲染出来 appendMessage({ role: assistant, content, streaming: true }); // 3. 从游标处恢复 SSE 流 const stream connectGenerateStream({ sessionId, messageId, cursor }); for await (const chunk of stream) { appendChunk(chunk.text); } }这里有个 UI 细节容易被忽略中断发生后如果自动恢复失败或者恢复按钮被用户忽略生成状态一直挂着会很奇怪。我的做法是提供**“继续生成”按钮 底部状态条**状态条展示“生成已中断点击继续/重新生成”不让用户困惑到底完没完。另外幂等设计一定要有。断线重连时如果用户的原始请求被服务端重复处理就可能重复调用模型、重复扣费。服务端收到带游标的续传请求时要能识别出“这是同一个 message_id 的续传”不再新建生成任务而是返回同一任务已经生成的内容。这个由后端把关但前端也要在重试逻辑里加maxRetries限制避免死循环重试。4. 实战中的坑与排查技巧实录最后这部分全是实战经验。标题里提到的那些异常关键词——stream disconnected before completion、idle timeout waiting for sse、标签未返回完整——正好对应我实际踩过的三种典型问题网关超时断连、心跳保活缺失、文本切割不完整。逐个拆解。4.1 高频报错stream disconnected before completion / idle timeout如果你用了像 Nginx 这类反向代理SSE 长连接非常容易被打断经典报错就是stream disconnected before completion: idle timeout waiting for sse。原因很直接Nginx 默认proxy_read_timeout是 60 秒如果 60 秒内上游没有任何响应连接就被切了。解决办法有两层调大超时时间proxy_read_timeout 300s;配合proxy_buffering off;关闭缓冲区确保流式数据实时转发而不是攒一批再发。加心跳注释行SSE 规范里以冒号开头的行是注释会被客户端忽略但能让负载均衡器和代理服务器认为连接还在活跃。后端每隔 15~20 秒发一个: keep-alive\n\n就能有效避免 idle timeout。如果前端用的是 EventSource超时时间由浏览器控制一般不会主动断但 fetch 方案里如果读流的间隔太久某些代理也会判定空闲。所以前端也可以兜底发心跳比如每 10 秒reader.read()一直有数据或者收到注释行就重置空闲计时器。4.2 网络中断与标签未返回完整的处理网络中断的表现很多样TypeError: Failed to fetch、连接读不到数据、页面切后台回来连接已挂。关键在于中断和完成必须能区分开。SSE 规范里服务端可以在所有数据推完后发送一条特殊事件比如data: [DONE]前端看到这个标记才认为生成结束否则一律视为“中断”。实操里我见过不少团队把“连接断开”直接当成“生成完成”导致用户看到的回答少半截还找不到原因。正确姿势是if (payload [DONE]) { setIsCompleted(true); return; } // 正常数据处理 handleChunk(payload);在reader.read()返回done但没收到[DONE]时前端应进入“中断处理”流程提示用户继续生成或者自动重连。“标签未返回完整”这个问题常见于后端把生成文本按固定长度切片后塞进 SSE 数据帧或者前端把内容按标签比如 HTML 标签做高亮。如果断流发生在标签中间前面提到的不完整 Markdown 闭合检测同样适用。如果做的是富文本标签渲染可以维护一个标签栈把未闭合的标签在后端补齐或者前端兜底关闭避免整个页面布局被一个未闭合的div撑坏。4.3 稳定性设计重试、指数退避与手动续传自动重试不是简单的“断了就重连”要带上指数退避否则服务端一抖动成百上千个客户端同时重连直接把服务端打挂。一个实用的退避策略const retryDelays [500, 1000, 2000, 4000, 8000, 15000]; let attempt 0; async function connectWithRetry() { try { await connectStream(); } catch (e) { if (attempt retryDelays.length) { setStatus(failed); showRetryButton(); // 手动续传兜底 return; } await sleep(retryDelays[attempt]); attempt; await resumeGeneration(sessionId, messageId); // 幂等续传 } }注意重试要做幂等校验每次重试带上游标服务端判断是否为同一任务避免重复扣费和重复生成。重试超过最大次数后建议转人工操作——给用户一个“继续生成”按钮比无限自动重试更稳妥。还有一个经常翻车的地方浏览器的并发连接数限制。HTTP/1.1 下浏览器对同一域名并发连接数有限制通常是 6 个如果你的页面上同时打开了多个 SSE 连接会自动阻塞后续请求。解决方案是后端支持 SSE 连接复用或者部署时走 HTTP/2实测下来效果立竿见影。最后再分享一个亲测有用的技巧把断点续传和打字机渲染的状态统一收敛到一个 store不要一个组件管数据、另一个组件管渲染。我在重写之前就是这样断线恢复时 UI 要么闪一下要么多出一段重复内容后来把所有流式状态集中到一个小型 store状态机里什么中断、重连、完成、失败都变成状态流转问题立刻清晰了很多。AI 前端流式落地没什么黑魔法把基础协议吃透、状态设计清楚稳定性自然就上来了。