ARTICLE DETAIL

资讯详情

深耕网站视觉设计与运营推广的一线实战洞察。

SSE流式输出实战:断点续传与打字机渲染指南

SSE流式输出实战:断点续传与打字机渲染指南 1. 从一次线上事故说起SSE断连把我逼成了全栈去年做AI对话类产品的时候上线第一天我就被SSE教育了一顿。用户反馈很统一打字机输出到一半转圈圈等十秒报错。后台日志里躺着一行让人挠头的错误stream disconnected before completion: idle timeout waiting for sse。那会儿我才刚开始把大模型流式输出接到前端以为用EventSource监听message事件就完事了结果生产环境狠狠给我上了一课。后来我去查了nginx配置发现proxy_read_timeout默认只有60秒。大模型生成长回答从用户点击到最后一个token返回动不动就超过一分钟。中间哪怕停顿个十几秒网关就认为连接闲置直接掐断。这个错误信息后来还成了搜索热词因为踩坑的人确实多。我当时就意识到一个问题AI前端的SSE流式输出绝对不是写几行onmessage那么简单它牵扯到服务端事件流设计、断点续传补偿机制、前端渲染性能、网关和代理服务器的流式配置是一条完整的链路。这篇文章把我在实践中踩过的坑、总结出来的方案以及可以直接抄走的代码都整理出来。核心围绕三个关键词SSE流式输出、断点续传、打字机渲染。写给两类人看一类是正在做AI应用的前端工程师另一类是准备面试时被问到“SSE和WebSocket的区别”“如何实现断点续传”这类题目的前端开发。内容偏实战尽量说人话不堆概念。2. 为什么选SSE而不是WebSocket先聊方案选型。AI大模型对话、内容生成这类场景服务器到客户端是典型的单向流式推送模型持续产出token持续推给页面展示。这个场景下SSEServer-Sent Events服务器推送事件是性价比很高的方案。2.1 SSE的核心优势SSE底层就是普通HTTP协议但服务端把响应头的Content-Type设为text/event-stream然后持续向客户端发送格式化的文本块。它和WebSocket最大的区别在于维度SSEWebSocket通信方向服务端单向推送客户端只能通过普通请求上报全双工双向通信协议基于HTTP天然兼容传统负载均衡独立的ws/wss协议需额外握手断线重连内置自动重连语义浏览器原生支持需自己实现重连逻辑二进制数据不支持只能传文本支持实现复杂度极低较高前端做AI对话绝大多数场景只需要接收服务端的流式输出。用户输入内容通过普通的POST接口发过去就行回复用SSE推回来。这种“一问一答”的模型根本不需要保持一条双向长连接。用WebSocket反而引入额外的心跳机制、二进制帧解析、连接状态管理等问题。2.2 前端两套接入姿势EventSource与fetch浏览器原生提供了EventSource这个API用起来极其简单const es new EventSource(/api/chat/stream); es.onmessage (event) { console.log(event.data); };但实际到AI场景里原生EventSource有两个很致命的限制只支持GET请求没法在请求体里传用户消息和复杂参数。不能自定义请求头鉴权token只能挂在URL query上既不安全又容易被网关日志记录。所以生产环境下我推荐直接用fetch配合ReadableStream来读取SSE流也就是所谓的“fetch流式读取”方案。代码长一点但可控性很强POST、header、断线重连全都可以自己掌控。const response await fetch(/api/chat/stream, { method: POST, headers: { Content-Type: application/json, Authorization: Bearer ${token}, }, body: JSON.stringify({ message: userInput }), }); const reader response.body.getReader(); const decoder new TextDecoder(utf-8); while (true) { const { done, value } await reader.read(); if (done) break; const chunk decoder.decode(value, { stream: true }); // chunk就是一段原始文本需要按SSE协议解析 }这里有个细节TextDecoder的stream: true参数很重要。因为网络包可能把一个完整SSE事件拆成两个chunk也可能把多个事件拼在一个chunk里。如果不开启stream模式跨chunk的多字节字符就可能被截断成乱码。3. 断点续传让流式输出“断而不丢”SSE断连这件事无法完全避免。网络抖动、代理超时、服务发布、用户锁屏导致连接被回收各种情况都可能发生。关键问题不是“会不会断”而是“断了之后怎么办”。3.1 服务端事件体系设计SSE协议本身就是为断线续传设计的。服务端每个事件可以携带一个id字段客户端重连时浏览器会自动在请求头里带上Last-Event-ID字段告诉服务端“我最后一个收到的事件是第几条”服务端从下一条开始继续推。所以服务端能做的事很明确为每个SSE连接维护一个递增的事件序号缓存最近N条事件收到带Last-Event-ID的重连请求时从对应位置继续发送。// Node.js 简化示例 let eventId 0; const eventCache []; // 环形队列保存最近200条事件 app.get(/api/chat/stream, (req, res) { res.writeHead(200, { Content-Type: text/event-stream, Cache-Control: no-cache, Connection: keep-alive, }); const lastEventId Number(req.headers[last-event-id] || 0); eventCache.filter(item item.id lastEventId).forEach(item { res.write(id: ${item.id}\n); res.write(data: ${item.data}\n\n); }); // 后续有新的token产出就调用res.write推送 });3.2 前端重连与补偿策略前端用fetch代替EventSource之后自动重连就没了得自己实现。我踩过坑之后总结出三条重连原则第一前端必须持久化已收到的事件ID。不能只存在内存里因为页面可能被刷新。建议写到localStorage键名带上会话ID比如chat_last_event_id_{sessionId}。第二重连要做指数退避不能每秒钟疯狂重试。第一次失败等1秒第二次等2秒第三次等4秒上限30秒。这样既能在网络短暂抖动时快速恢复又不会把服务端打爆。第三重连后必须做数据“补偿”。服务端通过Last-Event-ID接着推但前端还要判断一下如果重连续接成功后心跳事件到达而业务数据很久没来说明服务端可能已经清空了缓存这时候与其无限等下去不如提示用户重新提问。这里多说一句面试里如果被问到“断点续传”大部分人的第一反应是“文件上传分片续传”比如http协议里的Content-Range、If-Range这些头。但AI场景里的断点续传语义不太一样它指的是“流式事件补偿”靠的是Last-Event-ID这个请求头。两种场景的解决思路完全不同别答岔了。4. 打字机渲染从“能用”到“丝滑”SSE把数据流式地推给页面之后下一个核心问题就是用什么样的方式把文本渲染出来。Stream是流畅了如果渲染卡顿用户体验照样崩塌。4.1 增量渲染的三种写法第一种写法最粗暴也是很多人最开始会犯的错误每收到一个chunk就做一次text text chunk然后更新到DOM。当文本量变大、更新频率极高时这种做法会让页面不断触发重排很快就能感觉到明显的卡顿。第二种写法好一点内部维护一个文本缓冲区用requestAnimationFrame来节流渲染。核心思路是let buffer ; // 待渲染的增量文本 let isRendering false; function appendChunk(chunk) { buffer chunk; if (!isRendering) { requestAnimationFrame(flush); } } function flush() { const text buffer; buffer ; if (text) { renderer.append(text); } if (buffer) { // 理论上不会发生保险起见 requestAnimationFrame(flush); } else { isRendering false; } }requestAnimationFrame保证每帧最多渲染一次渲染频率大约60Hz足够肉眼感觉流畅又不会让主线程被频繁的字符串拼接和DOM操作占满。实测下来这个方案在长文本场景下稳定很多。第三种写法适合追求极致性能的场景预先创建整段空白占位DOM流式渲染只更新文本节点textNode避免整个容器节点频繁重建。对AI对话这种内容区域来说前两种方案已经完全够用。4.2 Markdown流式渲染的坑还有一个很多教程没讲透的坑AI回复通常是Markdown格式但Markdown渲染是“整体性”的一段不完整的三反引号代码块、一个没闭合的星号直接扔给markdown渲染库会得到错乱的结果。我的方案是“拆分渲染区”把已经接收并且“语义完整”的内容交给markdown渲染最后一块不完整的文本以纯文本临时显示。具体做法是记录当前文本中最后一个代码块开始标记的位置如果代码块未闭合就把代码块之前的内容送去渲染代码块本身及其后内容下次再处理。function splitIncompleteMarkdown(text) { const fenceIndex text.lastIndexOf(); const fenceCount text.split().length - 1; // 奇数个围栏说明还没闭合 if (fenceIndex 0 fenceCount % 2 1) { return { stable: text.slice(0, fenceIndex), pending: text.slice(fenceIndex), }; } return { stable: text, pending: }; }这个方案不完美但已经能覆盖绝大多数场景。毕竟AI生成的Markdown以段落、列表、代码块为主确认代码块闭合后再渲染是性价比比较高的做法。5. 完整链路源码与部署注意事项前面聊了原理这部分给出一个可以直接跑的前后端简化版本同时把生产环境部署时最容易出问题的地方单独拎出来说。5.1 前端封装Vue3组合式API版本下面是用Vue3组合式API封装的useSSEChat把SSE连接、断线重连、打字机渲染集中管理import { ref, onUnmounted } from vue; export function useSSEChat({ url, getAuthToken }) { const text ref(); const status ref(idle); // idle | connecting | streaming | error let controller null; let reader null; let reconnectAttempts 0; let lastEventId Number(localStorage.getItem(chat_last_event_id) || 0); let buffer ; let rafId null; const persistEventId (id) { lastEventId id; localStorage.setItem(chat_last_event_id, String(id)); }; const startRender () { const flush () { const delta buffer; buffer ; if (delta) text.value delta; rafId null; if (buffer) rafId requestAnimationFrame(flush); }; if (!rafId) rafId requestAnimationFrame(flush); }; const connect async () { status.value connecting; try { controller new AbortController(); const response await fetch(url, { method: POST, headers: { Content-Type: application/json, Last-Event-ID: String(lastEventId), Authorization: await getAuthToken(), }, body: JSON.stringify({ message: text.value }), signal: controller.signal, }); if (!response.ok) throw new Error(HTTP ${response.status}); reader response.body.getReader(); const decoder new TextDecoder(); let chunkBuffer ; while (true) { const { done, value } await reader.read(); if (done) { status.value idle; break; } chunkBuffer decoder.decode(value, { stream: true }); const events chunkBuffer.split(\n\n); chunkBuffer events.pop(); // 最后一个可能不完整留待下次 for (const rawEvent of events) { const idMatch rawEvent.match(/^id:\s*(\d)/m); const dataMatch rawEvent.match(/^data:\s*(.)/m); if (idMatch) persistEventId(Number(idMatch[1])); if (dataMatch) { buffer dataMatch[1]; startRender(); reconnectAttempts 0; } } } } catch (err) { if (err.name AbortError) return; status.value error; reconnectAttempts 1; const delay Math.min(1000 * 2 ** reconnectAttempts, 30000); setTimeout(connect, delay); } }; const abort () { controller?.abort(); reader?.cancel().catch(() {}); if (rafId) cancelAnimationFrame(rafId); status.value idle; }; onUnmounted(abort); return { text, status, connect, abort }; }这段代码我实际落地之后发现有几个点特别值得注意。chunkBuffer.split(\n\n)这里SSE协议规定事件之间用空行分隔但网络包边界不一定是完整事件边界所以最后的events.pop()要留到下次处理。另外getAuthToken设计成异步函数是因为很多场景下token需要动态刷新。5.2 nginx和网关的流式配置前端代码写好了后端接口也通了本地联调一切正常一上生产就断。这种场景九成是网关层没有配置流式转发。nginx要改三个关键配置location /api/chat/stream { proxy_pass http://backend; proxy_http_version 1.1; proxy_buffering off; proxy_cache off; proxy_read_timeout 300s; proxy_set_header Connection ; }proxy_buffering off和proxy_set_header Connection 缺一不可。前者是关掉nginx的响应缓冲后者是因为HTTP/1.1的keep-alive机制下Connection头要处理清楚。proxy_read_timeout 300s则是上面说的那个报错的关键——把闲置超时从默认60秒调到300秒给大模型思考留足时间。如果是云厂商的SLB/网关也要去控制台看有没有类似“读超时”的配置项。我实际遇到的idle timeout waiting for sse这个报错源头就是网关层读超时。调试SSE接口有个好用的方法直接用curlcurl -N -H Accept: text/event-stream \ -H Authorization: Bearer token \ https://your.domain/api/chat/stream-N参数禁用curl的缓冲让输出实时打出来。如果curl能持续输出而浏览器里断开问题在浏览器端如果curl也断问题在服务端或网关。这也是我第一次排查生产断连问题时最快定位原因的路径。6. 踩坑记录与排查技巧实录这部分整理几个高频坑和对应的排查思路都是我实际经历过的。6.1 坑点一中文内容被截断产生乱码现象SSE连接不断但有些回复里偶尔出现一个“”乱码字符。原因SSE是按data:行来解析的如果网络包把一个中文字符的UTF-8字节序列拆到两个chunk里而前端每收到一个chunk就立刻用TextDecoder.decode(value)没开stream去解析残缺的字节会被解析成替换字符。解决解码时加上{ stream: true }让解码器在内部维护状态跨chunk的多字节字符会自动拼完整。6.2 坑点二事件被代理缓冲客户端等不到数据现象接口有响应但页面始终白屏等了5秒甚至10秒才一次性出现整段内容。如果打开Network面板看响应体在那边转圈圈一直没有字节变化。原因某些网关或反向代理默认开启缓冲比如nginx的proxy_buffering默认是onCGI类型应用也可能有buffer设置。响应被攒够一整块才转发给客户端。解决在服务端响应头加X-Accel-Buffering: no或者改代理配置关掉缓冲。另外代码块里SSE的retry字段不能设置为0有些代理会直接认为连接异常。6.3 坑点三移动端锁屏导致连接断开现象手机锁屏一会儿再解锁发现回复只输出到一半没有报错也没有继续。原因移动端浏览器在后台被挂起JS定时器和网络请求都会被暂停SSE连接在操作系统层面被回收。解决前端监听visibilitychange事件页面重新可见时检查连接状态如果发现超过一定时间没有收到数据就主动重连并带上Last-Event-ID。这个场景下断点续传机制的价值体现得最明显。6.4 坑点四同一会话同时开两个标签页现象用户开着两个标签页提问两个页面各自建立SSE连接浪费服务端资源甚至可能导致同一个AI请求被重复触发。解决用BroadcastChannel或localStorage做标签页互斥同一会话同时只允许一个标签页活跃连接。其余标签页显示“该会话正在其他页面生成中”。这个细节在面试聊到SSE时也可以主动提一句属于加分项。6.5 排查速查表现象优先排查点处理建议连接被闲置断开网关/代理读超时配置调大proxy_read_timeout服务端加心跳注释行一直收不到数据代理缓冲关缓冲加X-Accel-Buffering: no中文乱码解码器未开流式模式decoder.decode(value, { stream: true })刷新后内容丢失未持久化事件IDlocalStorage存lastEventId发送Last-Event-ID锁屏后断连移动端后台挂起visibilitychange触发重连服务端补偿事件请求能通但没SSE格式Content-Type不是text/event-stream服务端检查响应头7. 一些实用小技巧总结SSE连起来那一刻可以顺手把数据流也封装成可取消的形式。用户在AI回复过程中不想等了点一下“停止生成”本质就是reader.cancel()controller.abort()这一点前端要处理干净否则连接会一直挂着。还有个小习惯服务端即使没有数据可推也可以每隔15秒发一行注释格式的SSE事件比如:\n\n。这种注释被称为“心跳”作用是维持连接活跃防止中间层设备因为“闲置”把这个连接干掉。proxy_read_timeout计算的是两次读取之间的间隔有心跳就不会触发超时。SSE的retry重连字段虽然原生支持但我建议生产环境尽量别依赖因为EventSource的重连语义比较简单它不区分“网络错误”和“业务错误”也不支持请求头。用fetch自己实现虽然代码量多一点但能精确控制重连时机、退避策略、错误上报长期维护起来反而安心。最后再说一个贴近面试的点如果你去面试前端岗位聊到SSE的时候能主动说出“生产环境SSE真正难的不是建立连接而是断线补偿、代理层流式配置、渲染性能这三件事”面试官大概率会停下来多问你几句。这不是背题是这类功能落地时真实存在的三大关。
返回列表