尧图精选

AI对话流式输出实战:SSE断点续传与打字机渲染

🕒 发布时间:2026/9/19 8:25:43 📁 来源:尧图网络
做AI对话类前端第一周你大概率就会撞上一个场景后端一次返回完整JSON用户对着loading转圈等三四十秒体验差到没法看。流式输出是AI产品的底线体验而SSE就是这条底线里最实用的一条技术路径。这篇文章不聊PPT上的架构图就聊我在AI对话、代码生成、长文总结场景里落地SSE流式输出、断点续传、打字机渲染时踩过的坑以及最终跑通的方案。适合第一次写AI前端的同学也适合被stream disconnected这类问题折磨过的朋友看完你至少能少走两三个大弯。1. SSE凭什么成为AI对话的默认传输方案1.1 三个实时方案对比做AI对话前端拿数据的方式无非三种轮询、WebSocket、SSEServer-Sent Events服务器发送事件。我最早带团队做AI聊天的时候组里一半人默认要上WebSocket理由是实时性最好。但真动手后我发现对于绝大多数AI对话场景SSE比WebSocket更合适而且工程量小一个量级。它们的核心差异我建议收藏下面这张表面试和方案评审都挺管用维度轮询WebSocketSSE连接方向单向请求响应双向全双工单向服务器推送到客户端实时性取决于轮询间隔最优优秀协议复用HTTP天然穿透代理独立协议要单独升级握手HTTP天然穿透代理HTTP缓存正常支持不支持支持重连机制自己实现自己实现EventSource内置自动重连服务端复杂度低高帧协议、心跳、状态低普通HTTP响应流典型场景接口还不支持流式时实时互动、IM双向聊天AI流式输出、服务端事件通知轮询在AI场景最大的问题是延迟和冗余请求。你每隔两秒问一次A答案生成完没生成了还好没生成就是纯浪费而且并发一多接口压力会指数级上涨。WebSocket强在双向但AI对话场景里用户发起问题后有大量数据回流客户端到服务端的消息只有发起、取消、参数调整这几类双向通道的优势根本用不出来。而WebSocket带来的额外成本是实打实的连接升级、帧掩码、断线重连要自己维护一套状态机稍不留神就是连接泄漏或消息乱序。SSE最打动我的一点是长在HTTP上。AI后端的网关、鉴权、负载均衡、日志链路全都走同一套基础设施前端甚至不需要引入任何额外库浏览器原生EventSource就能连。对前端团队来说这几乎等于零成本接入。1.2 SSE在AI场景下的协议细节SSE协议本身很简单服务器返回一个Content-Type: text/event-stream的HTTP响应并且不关闭连接往里面持续写文本。规范的格式长这样id: 1 event: message data: {token: 你好} id: 2 event: done data: {type: finish, reason: stop}每条消息用空行分隔字段有data数据内容、event事件类型、id消息ID、retry重连间隔。这就是全部了理论上你不需要懂更多就能跑通。但AI场景里有个容易被忽略的东西你的消息体里如果包含换行符比如后端生成的Markdown带换行必须把它转义否则前端解析会被截断。我见过不少项目是后端直接把换行塞进data字段前端收到的流直接裂成两半。规范的处理方式是普通文本把换行换成\n字面量或者用JSON序列化让换行在字符串内部。另一个容易被忽略的坑是 HTTP 响应头。后端返回SSE时至少要设置三个头Cache-Control: no-cache Connection: keep-alive Content-Type: text/event-stream第一个头尤其关键。没有no-cache某些反代或者CDN会主动帮你缓存整个响应导致前端拿到的不是流而是一整坨缓存文件。第二个头保证HTTP连接能复用第三个头是SSE的基础。还有一个细节一个完整的SSE流服务端正常结束后应该主动关闭连接。前端看到done事件可以主动判断这轮对话讲完了而要不要关闭连接取决于你的业务设计。如果这个连接只服务一次对话让服务端结束响应后就断开即可如果连接要服务多轮对话服务端就该保活连接而不是关闭。2. 别一开始就EventSource先想清楚协议谁来定2.1 EventSource简单但撞上这仨需求就得换很多人上手SSE第一反应是new EventSource(url)文档短到一分钟能读完。它内置了断线自动重连和事件分发确实爽。但我在实际项目里发现一旦业务要求超过这三条EventSource就会变成枷锁需要自定义请求头。EventSource只能用GET请求只能用浏览器默认带的Header你没法塞token到Authorization头里除非用Cookie或者后端从URL参数里取。需要POST传参。大模型的对话上下文经常很长长文本参数塞进URL容易被网关限制长度GET方式根本不合适。需要精细控制连接生命周期。EventSource的自动重连策略是一个黑盒你想在特定场景下主动断开、或者捕获完整的请求状态码它都不给力。我们的方案是所有AI流式接口统一用fetchReadableStream手写流解析EventSource只用来做内部监控大屏等无状态通知场景。2.2 fetch ReadableStream手写一个流解析器手写SSE解析其实不难核心是两件事正确读取底层字节流然后按空行分割消息再把字段解析出来。我用一个极简的示例说明核心思路const response await fetch(/api/chat, { method: POST, headers: { Content-Type: application/json, Authorization: Bearer ${token}, }, body: JSON.stringify({ message: 你好, stream: true }), }); if (!response.ok) { throw new Error(HTTP error: ${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 buffer decoder.decode(value, { stream: true }); // 以空行为分隔把完整的SSE事件切出来 const events buffer.split(/\r?\n\r?\n/); buffer events.pop(); // 最后一段可能是不完整事件留到下一轮 for (const rawEvent of events) { handleRawEvent(rawEvent); } }handleRawEvent要做的是把类似下面这段原始文本拆成字段event: token\ndata: {content:你}按行解析就行以:分隔行前缀和值组装成一个事件对象再交给上层处理。你可能要问为什么不直接用response.text()或者response.json()因为这两个API都会等流结束才返回那流式就没有意义了。只有response.body.getReader()这种方式才能一边读一边处理这也是手写SSE解析的首要原则。我习惯把上面这段封装成一个通用的createSSEStream(requestConfig, handlers)函数handlers里可以按事件类型分别注册onToken、onDone、onError回调。这样前端页面其实不需要关心底层是EventSource还是fetch只负责消费流式token和结束信号。提示如果你用curl验证SSE接口记得加-N参数--no-buffer否则curl会等整个请求结束才把内容吐出来。这是我调试后端时最常用的命令curl -N http://localhost:8000/api/chat -H Content-Type: application/json -d {message:hi}3. 打字机渲染真正决定体验的最后一公里3.1 半截字符的坑TextDecoder的stream模式流式数据到了前端第一件要做的事是把内容渲染到页面上。这时候你会遇到一个在搜索引擎里反复出现的坑标签返回未完整怎么处理。这个问题的本质是网络包在传输过程中把一个多字节字符拆成了多个chunk。举个具体的例子中文你好在UTF-8里是\xe4\xbd\xa0\xe5\xa5\xbd一个你要占3个字节。服务端输出你时一次socket write可能只发出\xe4一个字节剩下的两个字节跟后面的好一起发出来。如果你直接用普通方式把每个chunk转成字符串就会在最前面出现一个乱码的然后在下一段开头又多出来半个字节的乱码。解法特别简单用TextDecoder.decode(value, { stream: true })。它在流式模式下会把未完整的多字节序列缓存起来等后续字节到位后一起解码不会产生半个字符的问题。const decoder new TextDecoder(utf-8); const part1 decoder.decode(new Uint8Array([0xe4]), { stream: true }); // const part2 decoder.decode(new Uint8Array([0xbd, 0xa0]), { stream: true }); // 你这是一个必须养成的习惯只要是流式数据解码一律用{ stream: true }。如果后端用的是JSON格式发送每个chunk是一段独立的JSON字符串跨chunk的JSON对象也会被拆开这时候你需要做的是维护一个buffer等到能解析出完整JSON对象时再消费方式跟上一节拆分SSE空行事件一样。3.2 增量渲染从每个token都setState到节流渲染把token拿到之后如果你直接setState(prev prev token)在React里每个token都会触发一次组件更新。一次对话几百个token就是几百次渲染肉眼不一定卡但DevTools的性能面板会非常难看。如果是Vue或者Angular同样有类似的问题毕竟高频更新DOM是真实的开销。我的做法是缓冲区定时器两层节流让DOM更新频率稳定在每秒30帧左右而不是跟着网络包的频率走let buffer ; let flushTimer null; let currentContent ; function appendChunk(chunk) { buffer chunk; if (!flushTimer) { flushTimer requestAnimationFrame(() flush()); } } function flush() { if (buffer) { currentContent buffer; renderContent(currentContent); // 更新DOM buffer ; } flushTimer null; }这里有个细节requestAnimationFrame在页面切到后台时会自动停止如果你有后台也要持续消费token的需求就得换setInterval或者直接在所有token到达后再统一渲染。实操里我一般用requestAnimationFrame因为停止渲染对后台场景通常不是坏事还能节省电量。另一个体验细节是打字机光标。AI输出过程中评论区经常有用户反馈怎么没有光标我以为卡死了。所以一定要在文本末尾加一个闪烁光标。这个光标不要用JS动画用一个CSSkeyframes的opacity闪烁就行了省心且流畅.typing-cursor::after { content: ▍; animation: blink 1s steps(1) infinite; } keyframes blink { 50% { opacity: 0; } }这个光标配合打字机节奏用户能立刻感知到它在输出而不会因为中间停顿几秒就误以为网络断了。4. 断点续传设计一个不会丢字的协议4.1 Last-Event-ID为什么扛不住AI场景SSE协议自带的断点续传方案很简单客户端收到每条消息里的id字段断线重连时浏览器会自动加上Last-Event-ID请求头告诉服务端我收到哪条了你从下一条开始发。听起来完美但在AI对话场景它有两个问题第一EventSource的自动重连不保证你拿到整个事件的顺序。如果一条消息体里有换行、传递不完整浏览器自己解析就会出问题。第二更关键的是大模型生成过程中客户端一般只考虑我收到多少个字而不是我收到第几条消息。服务端到底生成到哪一步、缓存了哪些中间结果只有业务层知道。你简单把id发给服务端服务端并不清楚该从哪个token重新开始。尤其对于AI场景你要的不是重放所有历史而是从断点继续生成把缺失的中间内容补回来。这需要前后端共同设计一套业务级的断点续传机制而不是依赖协议的默认行为。4.2 业务级断点续传requestId offset 缓存补帧我最终采用的方案可以用三个词概括requestId、offset、缓存补帧。这套方案的思路是不论连接怎么断客户端都能重建出完整的消息内容并且让大模型继续生成而不是从头再来。具体流程分五步前端发起对话时生成一个唯一的requestId我习惯用UUID通过URL参数或POST body传给后端。后端收到请求后为大模型的每次增量输出分配一个自增的index从0开始每生成一个token就把{ index, content }追加到内存/Redis缓存里同时通过SSE推给前端。前端在每次收到消息时记录两个东西最后一次收到的index以及当前累计的完整文本receivedText。这两个值可以放在SessionStorage或IndexedDB跨刷新也能读到。网络断开重新连接时前端带着requestId和offset上次收到的index 1重新请求同一个/api/chat/stream接口。后端收到重连请求后先从缓存里取出index offset的所有消息立刻补发给前端然后再把生成器继续跑下去。因为大模型生成不是等前端重连的所以后端缓存是必要的否则中间的token就丢了。前端在收到补帧数据时的渲染策略也很有讲究先快速把缺失内容一次性渲染完再恢复逐个token追加的打字机效果。否则用户会看到内容从中间开始打字心理上会很奇怪。这一步就是热搜里说的缓存补帧本质上是用一口气补上继续打字来模拟从头一直在输出的连贯感。补帧的效果验证也很简单你可以在前端控制台手动断网然后恢复网络。正常情况下用户应该看到的是内容没有任何丢失地继续输出而不是重新加载或者重新生成。4.3 重连后的幂等与重复提交防护断点续传还会带出一个经典问题用户断网后心急反复点击发送按钮前端是不是会发出两条消息实话实说会而且我在生产环境真实遇到过用户对话列表里同时出现两条相同提问的翻车现场。解决办法有两个层面。第一层是UI层发送按钮在提交完成后进入等待首包响应状态时立刻禁用直到收到done事件或者失败才恢复。第二层是协议层前端哪怕发起了两次请求用的也是同一个requestId后端在处理时先查一下这个requestId是否已经存在如果存在直接复用已有的生成状态不再重复排入生成队列。之前有个热搜词问前端点两次算是发两条消息吗答案取决于你有没有做幂等。没做就是两条做了就是一条。我强烈建议在AI对话的前端统一要求请求体里必须带客户端生成的requestId这是整个断点续传方案的地基。5. 排查SSE断流的完整链路别再只盯着前端代码5.1 先确认断点在哪一层很多人遇到流式输出到一半断了这种问题第一反应是前端解析有Bug结果查了半天发现是Nginx超时把连接掐了。我的排查顺序是先确认断点在哪一层再对症下药。最简单的手段就是浏览器开发者工具。打开Network面板找到SSE请求选中EventStream子标签能看到每一帧数据的时间线。这里有两个信息很关键一是最近一帧是什么时候收到的二是HTTP状态码是什么。如果最后一帧之后过了很长时间才报net::ERR_INCOMPLETE_CHUNKED_ENCODING基本可以断定是连接被中端设备或者网关切断了。再用curl -N直连后端服务对比一下同样一个SSE请求在服务端直连时是否正常。如果直连正常、走网关后断流问题大概率在中间层。这个分层验证的排查链路能帮你把排查范围缩小一大半省下无数调Bug的时间。5.2 反向代理和网关的常见流杀手我印象最深的一次线上事故问题描述就是热搜词里那句stream disconnected before completion: idle timeout waiting for sse。服务端生成一个长回答需要80秒但Nginx的proxy_read_timeout默认是60秒时间一到Nginx直接断开上游连接前端收到的就是这个错误。当时定位花了大半天最后只改了一行配置location /api/chat/stream { proxy_pass http://backend; proxy_buffering off; proxy_cache off; proxy_read_timeout 300s; proxy_send_timeout 300s; }proxy_buffering off是本段最重要的配置项。如果不关闭缓冲Nginx会把上游吐出来的小块数据攒齐到一定大小再一次性发给前端SSE就失去了流式效果用户会看到内容卡顿式蹦出来。另外如果Nginx开启了gzip压缩流式数据要边收边压很容易因为缓冲触发同样的问题所以SSE接口建议关闭压缩或至少在location里指定gzip off。还有一个容易踩的点是HTTP/2。HTTP/2的多路复用对浏览器来说是好事但有些网关在处理长连接流式响应时会有额外的缓冲逻辑如果条件允许SSE接口走HTTP/1.1反而更稳。这个问题在不同环境表现差异很大属于排查完才发现是环境问题的经典类型。5.3 后端空转与心跳保活有些时候SSE断流不是因为代理层超时而是后端生成器确实卡住了。大模型偶尔会长时间不吐token但连接还活着中间的反向代理可能因为数据量过少、空闲时间过长判断这条连接已经死了就主动断开。解决空转断开的标准做法是发送SSE心跳注释行。协议规定以冒号开头的行是注释客户端可以忽略。所以后端可以在超过N秒没有新token输出时主动往连接里写一行注释: ping这一行数据能让中间代理感知到连接还在活跃也就不会因为空闲而掐断连接。前后端配合时前端解析SSE事件时也要主动忽略:开头的注释行。这个约定不属于SSE标准协议的核心但在真实环境里能不能正确处理心跳注释就是线上稳不稳的分水岭。后端在实现时还应该留意不要在生成器里同步等待每个token最好把token产生和网络写入解耦。比如Python后端用队列把token塞给一个独立的SSE Writer线程哪怕大模型卡了10秒Writer依然能稳定吐心跳连接就不会断。这个设计在长对话、长文生成场景下尤其重要。6. 上线前最后的工程化收尾6.1 用户取消、组件卸载与连接生命周期AI对话的流式请求一旦发出去用户可能随时想停止生成。所以每个流式请求都必须是可取消的。用fetch实现时核心是AbortControllerconst controller new AbortController(); const stream await fetch(/api/chat, { signal: controller.signal }); // 用户点击停止时调用 controller.abort();有个细节我特别想提醒组件卸载时如果你不主动abort()请求还会继续在后台跑完直到收到完整结果才结束甚至可能触发闭包里的状态更新导致组件卸载后调setState的警告。所以在useEffect的清理函数里一定要调用abort()同时捕获AbortError别让它在控制台输出那种吓人的红色报错。另一种互相配合的优化是点击停止生成后前端把已经收到的内容原样保留只是不再接收后续token。这个语义跟中断连接还是正常结束不同因此在协议设计里最好有个event: abort或者前端本地给连接打一个终止标记两种状态要能区分清楚。6.2 多轮对话与增量滚动AI对话页面的内容会越来越长流式输出过程中如果用户当前在阅读的不是最新内容你强行把滚动条拽到底部会非常打扰。我采用的方案是判断用户是否已经接近底部距离底部小于80px是才自动跟随滚动否则只更新内容不动滚动条并在底部显示一个回到底部的小按钮。这个逻辑不难但在产品体验上价值很大。很多AI助手第一个版本都是永远滚到底被吐槽没法看历史内容就是因为没做这个跟随判断。另外流式输出的内容在结束后往往要触发保存对话记录的请求。建议在收到done事件之后再整体保存而不是每个token保存一次否则会给后端造成大量无关的写操作。如果内容特别长可以分页或者做增量保存但这属于后话前端先把done事件处理好是正经。6.3 留一个快照日志的调试入口最后分享一个很实用的习惯在所有流式接口里前端保留一个环形日志Ring Buffer记录最近200条事件留一个“快照日志”的调试入口重新用词。这个调试入口的具体实现很简单一个全局数组每次收到token、出错、重连时往里面推一条带时间戳的日志超过200条就shift掉最老的那条。线上出问题时让用户按一个快捷键或者你主动远程拿到这个日志基本可以还原当时的传输链路。我在排查线上断流问题时几乎每次都靠这个日志定位是前端断了还是后端没推。如果你用的是Vue或者React这类框架这个日志还可以绑定到开发者面板里方便随时导出。这个习惯养成了遇到疑难问题会轻松很多。最后再提醒一遍SSE的整个链路里前端代码往往是最不容易出Bug的一层优先检查代理、超时、心跳这些看不见的中间层能省下好几个通宵。
上一篇/下一篇内容由系统自动关联 返回资讯列表 →