十年匠心定制 · 商业建站与技术教学双线并行 咨询热线:400-886-1026 service@lmnt.cn
ARTICLE DETAIL

资讯详情

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

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

AI对话流式输出实战:SSE断点续传与打字机渲染全解析 做AI对话类的前端绕不开流式输出。用户敲完问题大模型少说要算好几秒长一点几十秒都有你要是等全部生成完再把结果怼到页面上用户早就跑了。所以现在几乎都是SSEServer-Sent Events来接大模型吐出来的token一有增量就推到前端配合打字机渲染让内容像真人打字一样一句句蹦出来。这套东西看着简单真落地的时候一堆细节断线怎么续传、idle timeout怎么破、markdown标签被截断怎么处理……这篇就用实际项目里的做法把SSE流式输出、断点续传、打字机渲染这条链路完整捋一遍。1. 为什么AI对话场景要选SSE而不是WebSocket或轮询1.1 需求本质从“请求响应”到“边算边写”AI对话的耗时不像普通接口不是查询数据库几十毫秒而是模型推理按token生成可能几秒到几十秒。如果前端发起请求后干等用户只看到一个loading转圈完全不知道后台在干嘛。流式输出的核心诉求是让用户看到“正在生成”的过程AI回答是逐渐变长的页面就得跟着逐渐变长。有的同学会问那我把整个答案拆成好几段前端轮询接口行不行可以但轮询意味着前端要定时去问“好了没”没好了你要继续等请求之间还有间隔实时性天然差。用WebSocket行不行也行但AI回答的场景是“单向为主少量控制”WebSocket是全双工服务端想主动发就发、客户端想主动发就发七嘴八舌的协议复杂度和维护成本都比SSE高不少。我现在的选择标准很简单如果只是“服务端持续把内容推给前端”这种单向流SSE就是最贴合HTTP语义的方案。它建立在HTTP之上不需要额外握手前端还有一个原生EventSource对象可以直接用连第三方库都不用装。如果说WebSocket像一条双向隧道SSE更像一扇单向窗口大模型生成内容从窗口递出来前端只负责接。1.2 SSE和WebSocket的选型对比选型不能只看“能不能用”。我之前也纠结过后来把两者放在一起比了一下。对比项SSEWebSocket连接方式普通HTTP长连接先HTTP握手再升级为WebSocket协议数据方向服务端单向推送到客户端全双工协议复杂度低text/event-stream格式高需要处理帧、掩码、心跳、关闭断线重连原生支持浏览器自动重连需要自己实现自定义事件支持event字段可以区分不同类型需要自定义消息协议二进制数据不支持只能文本支持服务端资源普通HTTP连接Nginx层好处理需要长连接管理多一层代理配置对于AI对话场景二进制数据基本用不到方向又是服务端单向推送所以SSE在成本上比WebSocket低一大截。如果你只是想实现一个“流式打字机”千万别一上来就上WebSocket后面维护够你喝一壶的。1.3 前端原生的EventSource到底能用多少原生EventSource用法很简单const es new EventSource(/api/chat/stream); es.onmessage (event) { console.log(收到消息, event.data); }; es.addEventListener(delta, (event) { const data JSON.parse(event.data); console.log(增量内容, data.content); }); es.onerror (event) { console.log(连接出错EventSource会自动重连); };这里有个点EventSource默认只支持GET请求。AI对话场景里我们通常需要把用户的prompt、历史记录、参数传给后端如果这些内容很长塞在URL query里既丑又容易超限。所以你会看到很多实现不用EventSource而是用fetch读ReadableStream。这个后面讲前端实操时我会重点展开先记住结论原生EventSource适合简单Demo生产环境我更推荐fetch流式读取因为能发POST、能自己控制重连逻辑、能拿到更细粒度的状态。2. 从零搭建一条能用的SSE流式链路2.1 后端接口设计Content-Type、响应头与心跳后端只要设置正确的响应头和输出格式就能让浏览器或fetch把它当SSE流解析。下面这几个响应头是必须的Content-Type: text/event-streamCache-Control: no-cacheConnection: keep-aliveX-Accel-Buffering: no如果用了Nginx这个头告诉Nginx别缓冲响应否则SSE会被卡住SSE的数据格式是“事件块”。每个事件块用空行分隔常见的字段是data、id、event、retry。例如id: 1 event: delta data: {content: 你} id: 2 event: delta data: {content: 好}后端代码我用Node.js写过一版大概是这样的const express require(express); const app express(); app.get(/api/chat/stream, (req, res) { res.writeHead(200, { Content-Type: text/event-stream, Cache-Control: no-cache, Connection: keep-alive, X-Accel-Buffering: no, }); let index 0; const timer setInterval(() { // 心跳防止代理层空闲超时断开 res.write(: ping\n\n); if (index answer.length) { res.write(id: ${index}\nevent: done\ndata: {finished: true}\n\n); clearInterval(timer); res.end(); return; } const chunk answer.slice(index, index 2); res.write(id: ${index}\nevent: delta\ndata: ${JSON.stringify({ content: chunk })}\n\n); index 2; }, 50); });注意心跳行: ping\n\n。不是所有网关都一样有的反向代理如果一分钟没收到响应体就会报idle timeout你的连接就被切了。之前我在日志里看到stream disconnected before completion: idle timeout waiting for sse排查到最后就是网关空闲超时。加心跳是成本最低的解决办法。另外如果AI回答里需要查询数据库后端也要注意别一把把ResultSet全捞到内存里尤其数据量大的时候。可以使用JDBC的流式查询设置fetchSize然后逐行处理再把每行结果转成SSE事件推出去。流式不是只是前端的事后端从数据源到响应出口整条链路都要“流式”起来否则前面再快也被内存拖死。如果你用的是Java Spring Boot可以用SseEmitter或者WebFlux的FluxServerSentEvent原理一样响应头同理。Node、Java、Go都能做SSE不存在语言层面的限制核心就是把数据按text/event-stream格式一点点写出去。2.2 前端怎么收消息、断线重连和idle timeout处理生产环境我推荐用fetch ReadableStream原因前面说了EventSource不能POST而且重连策略太“自动”有时候反而不灵活。用fetch读流大概是这样的async function fetchSSE(prompt, callbacks) { const response await fetch(/api/chat/stream, { method: POST, headers: { Content-Type: application/json }, body: JSON.stringify({ prompt }), }); if (!response.ok) throw new Error(HTTP ${response.status}); const reader response.body.getReader(); const decoder new TextDecoder(utf-8); let buffer ; while (true) { const { value, done } await reader.read(); if (done) break; buffer decoder.decode(value, { stream: true }); // 按空行分割SSE事件 const frames buffer.split(\n\n); buffer frames.pop(); for (const frame of frames) { const event parseSSEFrame(frame); callbacks.onEvent?.(event); } } } function parseSSEFrame(frame) { const lines frame.split(\n); const event { id: , data: , event: message }; for (const line of lines) { if (line.startsWith(id:)) event.id line.slice(3).trim(); else if (line.startsWith(data:)) event.data line.slice(5); else if (line.startsWith(event:)) event.event line.slice(6).trim(); } return event; }这里有个很容易踩的坑TextDecoder.decode要用{ stream: true }。因为网络包不一定按SSE事件边界切分可能半个字符、半个事件混在一次read里。这个参数会保留未解码完的字节等下次再处理不然中文多字节字符容易乱码。断线重连时手动实现一个带退避的版本let lastEventId 0; let retryDelay 1000; function connectStream() { fetchSSE(/api/chat/stream, { onEvent(event) { if (event.event done) { cleanup(); return; } if (event.id) lastEventId Number(event.id); renderChunk(event.data); }, }).catch(() { setTimeout(() { retryDelay Math.min(retryDelay * 2, 10000); connectStream(); }, retryDelay); }); }这里lastEventId就是断点续传的锚点后面第4节专门讲。2.3 开发调试用curl验证SSE流写前端之前先确认后端到底是不是真的在“流式”推数据。直接用curl最方便curl -N http://localhost:8080/api/chat/stream-N是--no-buffer让curl不缓冲每收到一点就打印一点。如果这条命令能看到内容一行一行蹦出来说明后端逻辑没问题前端报错就是前端的事。如果加了-N还是一下子全部打印十有八九是响应头少了Content-Type或者被代理层缓冲了先去看Nginx配置。curl还能模拟带Last-Event-ID重连curl -N -H Last-Event-ID: 120 http://localhost:8080/api/chat/stream这能帮我们验证后端“从第120个事件继续推”的逻辑是否正常不用等前端把所有流程跑一遍再调试效率高很多。3. 打字机渲染不只是“拿到就显示”3.1 渲染层要做的事缓冲、解析、防抖、光标SSE推到前端的内容不是完整段落而是一小块一小块文本。如果直接把这些小块扔给innerHTML拼接会遇到两个问题一是DOM频繁更新页面卡顿二是AI返回的是markdown代码块、表格、列表都是跨多个chunk的一截断就会渲染出残缺标签。所以前端要建立一条“渲染流水线”收到原始块后先放到缓冲区对缓冲区做“只渲染完整部分”的处理用节流或requestAnimationFrame控制DOM更新频率维护一个光标状态让用户知道还在生成。打字机效果本质上不是“逐个字打印”而是“控制UI刷新节奏”。大模型生成速度不稳定有时候一次推一大段有时候一次一个字。如果你完全跟着网络节奏走页面会一会儿跳一下一会儿卡一下。我的做法是维护一个pendingText每次收到chunk就往里追加然后用requestAnimationFrame去刷新显示区把“网络到达频率”平滑成“人眼舒适频率”。我做过Vue版的聊天对话AI流式输出思路完全一样只要确保把流式buffer和渲染函数拆开不要在watch里直接频繁赋值给响应式字段否则长文本会卡到你怀疑人生。3.2 “标签返回未完整怎么处理”流式片段解析的坑这是AI流式渲染里最经典的坑。举个例子模型返回一个markdown代码块以三个反引号加个语言名开头中间是代码最后是三个反引号闭合。但SSE推送可能分好几次到第一次只到了三个反引号第二次才到语言名最后一次才是闭合反引号。如果你每次收到消息就直接把全部内容交给markdown解析器渲染解析器会在某一刻看到开头的三个反引号没有闭合输出一个残缺的代码块或者直接把你后面所有内容都吞进去。我踩过几次坑之后总结出来的处理方式是不要把不完整的内容交给渲染器而是等它“看起来完整”了再渲染。具体做法是维护一个缓冲区每来一段内容先追加进去然后尝试找到“最后一个完整块”。对markdown来说常见策略是检测最后一条分割线、代码块标记、表格分隔符等。更通用的做法是利用解析器的能力只取完整块渲染。一个简化示例function getRenderableContent(buffer) { // 简单做法统计三个反引号出现次数奇数说明代码块还没闭合 const codeBlockOpen buffer.split().length - 1; if (codeBlockOpen % 2 ! 0) { const lastIndex buffer.lastIndexOf(); return buffer.slice(0, lastIndex); } return buffer; }这段代码只是个示例真实场景还会遇到markdown表格的行没齐、列表符号开头等。我建议把“渲染”和“流式追加”拆开流式追加维护原始buffer渲染时只处理完整片段残缺部分留在buffer里等下一个chunk到了再拼起来重试。另一个更省心的方案是先用纯文本展示等整段生成完再统一渲染markdown。缺点是你失去了“实时看到格式”的体验折中的办法是在流式过程中只渲染纯文本同时用很浅的高亮来撑住观感。具体取舍看你的产品要求不是所有场景都必须实时渲染完整markdown。3.3 性能优化减少频繁DOM更新如果一条回答有几千字每个token都触发一次渲染哪怕只是innerHTML赋值也会让页面明显卡顿。我的性能优化三板斧合并渲染。用requestAnimationFrame或者setTimeout把100ms内的所有chunk合并成一次DOM更新。减少重排。渲染目标区域固定宽高图片、代码块出现时预留空间避免每个字符都引起布局抖动。尾部光标用CSS动画。不要用JS每秒加一个字符或减一个字符用CSS的::after配合blink动画零开销。Vue、React里还要注意状态更新的粒度。别把完整回答塞进一个大的响应式对象里然后每次更新都触发整颗组件树渲染。拆成“纯文本buffer 渲染版本号”这种结构只有版本号变化时组件才重渲染渲染函数内部再把buffer渲染出来。这样状态更新频率降下来了UI也不容易掉帧。4. 断点续传让一次中断的AI回答能接着读4.1 为什么需要断点续传网络抖动、超时、刷新SSE虽然基于HTTP长连接但连接不可能永远稳定。移动端切网络、代理空闲超时、服务端发布都可能导致连接断开。如果不做任何处理用户看到的就是一句话说到一半没了刷新后又得重新生成一遍既费钱又费时间。这里的“断点续传”和文件下载里的断点续传不太一样。文件下载是按字节偏移量HTTP Range续传像MinIO分片上传那种属于文件字节级AI流式续传是按“事件序号”或“文本位置”续传。本质都是“记住上次读到哪了”但粒度不同。我做AI对话的续传至少会记录两个东西事件IDSSE协议里每个事件都可以带id字段表示这是第几个事件已展示文本长度记录用户已经看到哪了重连后从断点继续渲染。只记事件ID还不够因为同一批事件里可能包含多个chunk而用户看到的文本长度和事件ID不一定完全对应。所以我一般用一个递增的cursor这个cursor同时作为事件的id也作为文本输出的字符计数值。4.2 前端如何记录消费位置lastEventId与Message IDSSE协议本身支持Last-Event-ID。浏览器断线重连时如果之前的流里带过id字段EventSource会自动在重连请求头里带上Last-Event-ID: lastId后端看到这个头就知道“接着发”。但如果用fetch ReadableStream这个头是不可能自动带的因为fetch完全是手动重连。所以我在前端自己存lastEventId存到sessionStorage或localStorage每次收到事件就更新function saveCursor(cursor) { localStorage.setItem(chat_cursor, String(cursor)); } function loadCursor() { return Number(localStorage.getItem(chat_cursor) || 0); }为什么存在localStorage而不是纯内存因为用户可能手动刷新页面。刷新后JS变量全丢了但localStorage还在至少能恢复一部分。另外要强调一下记录的位置必须是“用户实际看到的内容位置”而不是“服务端发了多少内容”。有时候服务端发了100个事件但前端因为缓冲、渲染策略用户只看到了前80个事件对应的文本。如果你用事件ID续传前端要能把缺失部分补回来如果直接用文本长度又要避免“补齐内容覆盖掉用户已经看到的文本”的问题。最稳妥的协议是服务端返回的是“从cursor开始的增量文本”前端收到的每一条都带起始位置然后前端维护一个renderedLength只取renderedLength之后的内容渲染。4.3 后端如何实现续传把流式输出变成“可回放事件流”要让续传真正可用后端就不能只把AI返回的内容“实时转发”而要把它当作一个“可回放的事件流”来存储。也就是说AI生成的每个chunk都落库或写进缓存生成完还要保留一段时间这样用户重连时后端能根据游标把后面的内容重新推一遍。以Node为例我可以先把chunk写到Redis列表里async function pushChunk(cursor, content) { await redis.rpush(chat:${sessionId}, JSON.stringify({ cursor, content })); }等到断线重连后端从请求里拿到Last-Event-ID或query参数里的cursor直接遍历Redis列表从大于这个cursor的位置开始重新推app.get(/api/chat/stream, async (req, res) { let cursor Number(req.get(Last-Event-ID) || req.query.cursor || 0); const events await redis.lrange(chat:${sessionId}, cursor, -1); for (const event of events) { res.write(id: ${cursor}\nevent: delta\ndata: ${event}\n\n); cursor; } // 然后继续订阅AI产生的新chunk });这里的关键是数据生成要“先存后推”或“边存边推”。只推不存的话断线后历史没了续传无从谈起。落库的另一个好处是前端刷新后可以直接从最近一次游标开始不用重新调用大模型节省成本。4.4 重连时的UI策略不闪烁、不重复、不丢字断点续传不只是后端的事。前后端都做好了前端UI策略没做对一样白搭。重连瞬间最容易出现三种毛病闪一下空白再填充已经显示过的内容重复显示最后几个字被吞掉。我处理这几个毛病的经验是重连期间不清空现有内容界面保持“正在重连”的提示新数据到达后先和本地renderedLength比较只渲染比它长的部分如果服务端返回的第一条是“从某个cursor开始的完整补发”前端不要直接替换内容而是追加缺失的部分给重连状态设置一个最大超时比如15秒超了就用“重试”按钮让用户手动操作不要无限重试浪费用户流量。UI上我还会做一个“拖尾光标”效果内容中断时在末尾显示一个闪烁光标一旦重连成功、新内容接上光标继续往下走。这个细节对体验提升很明显用户能感觉到系统还在干活而不是死掉了。5. 实战中常见的坑和排查技巧5.1 用AI辅助前端开发时任务怎么拆才不翻车既然标题是“AI前端落地实战”这里顺带聊聊我们前端怎么用AI写代码。很多人让AI直接生成一个大功能结果经常前后矛盾原因是任务太大、上下文太模糊。我现在的做法是把一套SSE流式链路拆成几个独立可交付的步骤先让AI生成一个最简后端接口返回固定字符串的SSE流前端实现一个只能读流并console.log的调试页面再把AI生成的markdown内容接到打字机渲染组件里最后做断线重连和续传给它补充具体场景。每个步骤都是一个小任务AI能专注在一个窄主题上代码质量明显更高。这其实和多人协作是一样的明确输入、明确输出、明确验收条件任务才不容易跑偏。这种思路也可以理解为以时间流的方式来开发代码每个时间片只解决一个明确问题从“能跑通”到“健壮”再到“体验好”层层递进。5.2 问题速查表SSE流式链路常见错误把这段时间碰到的典型问题整理成一张表方便你排查。现象可能原因排查顺序页面收到不了SSE流只等在最后响应被缓冲先看Content-Type和X-Accel-Buffering再用curl-N验证日志出现stream disconnected before completion: idle timeout waiting for sse代理空闲超时服务端加心跳调大网关idle超时中文乱码TextDecoder没加{stream: true}检查decode参数Markdown标签显示残缺截断块直接交给解析器做缓冲区只渲染完整块断线重连后内容重复游标记录不准确前后端统一事件ID和renderedLength打字机卡顿每个chunk都更新DOM合并渲染使用requestAnimationFramefetch流在Nginx后面不生效Nginx缓冲响应设置proxy_buffering off;或者后端返回X-Accel-Buffering: no5.3 我的几点工程心得最后分享几个我在项目里坚持的小习惯。第一个SSE格式一定要和后端定成契约。事件类型、data的JSON结构、id的递增规则、结束事件长什么样都要提前定义好最好出一个简单的协议文档。不然前后端联调的时候今天用delta明天用chunk你改我也改全是无效沟通。第二个所有流式接口都必须有超时和取消机制。用户可能随时不想等了组件卸载时要能中断fetch后端也要能感知到连接断开停止继续生成。别让用户刷新了页面服务端还在傻傻跑模型消耗算力。第三个生产环境多留日志。SSE连接很容易出现“偶发断线”但问题复现不了。我在服务端会把每个session的开始时间、事件数、结束原因都打日志前端也会记录lastEventId和renderedLength。两边日志一对照很多诡异问题能很快定位。这里面的第二个和第三个习惯是我被线上事故教育出来的。后来我们把协议文档、超时取消、日志三件套补上之后SSE这条链路的线上故障率明显降了下来希望你不用再踩一遍。
返回列表