ARTICLE DETAIL

建站实战干货

来自一线的建站与推广经验沉淀,每一条都经过真实交付验证。

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

2026/9/20 8:01:14 拓冰建站 浏览量
SSE流式输出、断点续传与打字机渲染:AI对话前端实战指南 1. 先想清楚AI 对话场景里这三件事为什么必须绑在一起最近两年前端圈聊 AI 落地绕不开一个画面对话框里大模型的回答不是一次性弹出来的而是一个字一个字往外蹦像有人在实时打字。这个体验背后就是三件事——SSE 流式输出、断点续传、打字机渲染。标题把这几个词放在一起本身就说明了它们不是孤立的技术点而是一条完整链路里的三个环节。我见过不少团队做 AI 前端第一批需求通常是“把流式接进来”但等到联调才发现光有流式远远不够。用户网络一抖连接断了内容停在半句话上服务端返回的 JSON 被切成了碎片前端一解析就报错好不容易数据全拿到了直接 setInterval 往 DOM 里塞文字页面卡成 PPT。这些问题单独看都不复杂但凑在一起就是一个 AI 对话功能能不能真正交付的分水岭。这篇文章想拆的就是从一条 SSE 连接建立开始到消息完整展示在屏幕上为止中间所有值得注意的细节。内容包括服务端怎么配响应头、前端怎么读流、断线怎么恢复、数据怎么解析、渲染怎么节流以及我踩过的坑和排查思路。适合正在做 AI 聊天、智能客服、Copilot 类产品的前端同学参考框架用 Vue 还是 React 都能用上核心逻辑跟框架没关系。1.1 为什么是 SSE而不是轮询或 WebSocket先回答一个我经常被问到的问题既然要实时拿数据为什么不用 WebSocketAI 对话这个场景有个特点请求方向基本是单向的。用户发一条消息然后服务端把生成过程中的文本一段一段推回来。WebSocket 能做这件事但它的能力远大于需求代价也更大。要处理握手协议、二进制帧、双向消息路由、心跳机制还要考虑网关、防火墙对长连接的兼容。轮询也是一个选项但代价更直接。大模型生成一段几百字的回答可能要几十秒如果每秒轮询一次几十个请求里绝大多数都是在问“好了没还没”。服务端的数据库和接口全被这种无效查询打满流式本来就是为了解决这个问题才出现的。SSEServer-Sent Events服务器发送事件属于专为这种单向文本推送设计的协议。它建立在 HTTP 之上不用额外握手服务端把响应头设置成text/event-stream然后像写文件一样持续往连接里写内容就行。客户端用浏览器原生的EventSource或fetch的 ReadableStream 就能读。类比一下就是WebSocket 是双向对讲机SSE 是广播电台大模型生成内容这种“一个发一个收”的场景广播电台刚好够用还更省事。1.2 前端的三个关键动作接入、恢复、渲染把 SSE 接进前端项目表面上是调一个接口、监听一个事件实际上要解决三件事。第一是接入也就是建立连接并正确读取流。这里要处理响应头、事件格式、心跳消息还有服务端是否真的在做流式输出。很多所谓“流式”接口实际是服务端把整段内容生成完了一次性返回前端感觉不到递进这个问题只靠前端调不出来。第二是恢复也就是断线续传。手机切了个网络、电脑休眠唤醒、代理超时连接随时可能断。如果断一次就要用户重新发一遍提示词体验非常糟糕。前端要做的是记住已经拿到了多少内容重连后从断点继续而不是从头再来。第三是渲染也就是把拿到的文字流以合适的节奏呈现出来。这里牵扯到文本缓冲、节流、Markdown 解析、代码高亮处理不好会出现页面卡顿、内容乱跳、光标闪烁。这三步是一条线接入决定能不能拿到数据恢复决定断网后怎么办渲染决定用户看得顺不顺。下面按这条线往下拆。2. SSE 流式输出落地从响应头到前端读取的完整链路SSE 的官方标准很简单但实现过程中的细节往往不在标准里。这一部分我按服务端和前端两侧把关键配置和代码逐段说明。2.1 服务端配置与事件格式EventSource 的默认约定先看服务端。无论你是用 Node.js、Java 还是 Python只要遵循 SSE 的文本协议前端就能解析。一个标准的 SSE 响应至少要有这些响应头Content-Type: text/event-stream; charsetutf-8 Cache-Control: no-cache Connection: keep-alive在这个基础上我习惯再加一个X-Accel-Buffering: no。这是给 Nginx 看的告诉它不要对这条响应做缓冲否则内容会被攒在代理层前端拿不到实时数据。如果你在用 CDN 或网关也得确认它们不会缓存或缓冲 EventStream。数据格式是文本协议用几个关键字开头空行分隔事件。最常用的是data:表示数据内容id:表示事件序号event:表示事件类型另外还有一个容易忽略的细节——冒号开头的一行会被当作注释常用作心跳。一个典型的 SSE 消息块长这样id: 1 data: {token: 你好}注意每两条消息之间必须有一个空行也就是两个换行符\n\n。前端就是靠这个空行来切分事件的。Node.js 的 Express 里实现一个最简版本大概是这样const express require(express); const app express(); app.post(/api/chat, async (req, res) { res.writeHead(200, { Content-Type: text/event-stream; charsetutf-8, Cache-Control: no-cache, Connection: keep-alive, X-Accel-Buffering: no }); let eventId 0; // 心跳防止代理空闲超时 const heartbeat setInterval(() { res.write(: heartbeat\n\n); }, 15000); for (const token of generateTokens(req.body.message)) { res.write(id: ${eventId}\ndata: ${JSON.stringify({ token })}\n\n); } // 结束标记前端靠这个判断流是否结束 res.write(id: ${eventId}\ndata: [DONE]\n\n); clearInterval(heartbeat); res.end(); });generateTokens这里只是示意实际后端通常是从大模型接口拿到流式结果再一段一段往响应里写。这里最容易出问题的不是代码本身而是“写一段”这个动作。如果服务端等全部结果生成完才写那前端拿到的还是整段内容SSE 就名存实亡。2.2 前端接入方式EventSource 与 fetch 流的取舍前端读 SSE 有两条路一条是EventSource一条是fetch加 ReadableStream。很多教程默认用EventSource实际项目里反而经常是 fetch 方案更顺手。EventSource最大优势是省事自动重连都内置了代码就几行const es new EventSource(/api/chat?message encodeURIComponent(text)); es.onmessage (event) { if (event.data [DONE]) { es.close(); return; } const payload JSON.parse(event.data); appendContent(payload.token); }; es.onerror () { // 浏览器会自动重连这里主要做状态提示 console.warn(SSE 连接异常尝试重连); };但限制也很明显只能用 GET 请求不能自定义请求头想带 token 只能拼在 query 里。POST 和自定义鉴权头做不了HEAD 请求一多还会撞到某些网关的连接数限制。所以更多时候我会用 fetch 方案因为它的控制力强很多const response await fetch(/api/chat, { method: POST, headers: { Content-Type: application/json, Authorization: Bearer ${token} }, body: JSON.stringify({ message: 你好 }) }); if (!response.ok || !response.body) { throw new Error(SSE 请求失败); } 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 }); // 按空行切分事件 const parts buffer.split(\n\n); buffer parts.pop() || ; // 最后一段可能不完整留在缓冲里 for (const part of parts) { const dataLine part.split(\n).find(line line.startsWith(data:)); if (!dataLine) continue; const data dataLine.slice(5).trim(); if (data [DONE]) { finishMessage(); continue; } const payload JSON.parse(data); appendContent(payload.token); } }注意decoder.decode(value, { stream: true })这个参数。UTF-8 的中文字符可能被拆到两个 chunk 里如果不做流式解码中文就会出现乱码。这是流式传输里最隐蔽的坑之一。2.3 心跳与超时避免 idle timeout 断开连接SSE 连接建立之后如果长时间没有数据流动中间任何一层——本地代理、公司网关、云负载均衡、Nginx——都可能把它当作空闲连接掐掉。热搜词里那句 “stream disconnected before completion: idle timeout waiting for sse” 就是典型症状服务端还在生成但连接被中间层超时切断了。解决办法就是心跳。服务端每隔一段时间往连接里写一行注释注释不包含数据前端可以忽略但足够让中间层认为连接还活着。间隔一般取 15 到 30 秒太频繁浪费资源太久又起不到保活作用。客户端如果用的是EventSource断线后浏览器会自动带Last-Event-ID重连非常方便。如果是 fetch 方案就要自己在重连时把上次拿到的事件 id 带过去让服务端从断点继续返回。我会在客户端同时做一层兜底断线重试三次每次递增延迟比如 1 秒、2 秒、4 秒。重试之前先检查本地缓存判断恢复还是重新发起。2.4 “data 只给了一半”流式解析的正确姿势很多初学者第一次写 SSE 解析会直接对每个 chunk 做JSON.parse然后就在线上遇到“JSON 解析失败”的报错。原因很简单reader.read()返回的字节块和数据事件边界并不对齐可能一个事件被拆成两半也可能两个事件挤在一个数据块里。正确的做法是维护一个全局 buffer先拼起来再按事件分隔符\n\n切分。切出来的最后一段可能存在不完整必须留回 buffer 里等下一个数据块到达再继续拼。上面 fetch 代码里的parts.pop()就是干这件事的。有两点要提醒。第一data:前缀后面有个空格解析时要处理第二如果消息体本身是嵌套 JSON比如{choices:[{delta:{content:你好}}]}里层的内容也要按同一套缓冲逻辑处理不能在事件边界上假设它完整。3. 断点续传不只是重连而是把消息流和文件上传都做稳断点续传在 AI 对话里有两个层面的含义。一个层面是消息流本身的恢复即连接断开后从已经收到的位置继续拿未生成的内容另一个层面是文件上传下载比如前端要传大附件、模型要读大文档断线后重传整个文件不现实。这俩场景原理相通但实现方式不同分开说。3.1 消息流断线恢复用游标换增量而不是重新生成先说消息流。场景很典型用户问了个复杂问题模型已经生成了一半手机切到微信再切回来连接断了。这时候如果让用户重新提问、重新生成体验会非常糟。要支持断点续传前端至少要记录两样东西消息 ID 和已接收游标。消息 ID 用来标识“我是哪条消息的连接”游标表示“我已经消费到哪段内容了”。重连时把这两个值发给服务端服务端从游标位置往后继续写前端把新内容和旧内容拼接而不是清空重来。游标用什么当可以是 SSE 事件的id也可以是服务端生成内容的字符偏移量。关键是服务端返回的消息要按可排序的序列分发。举个例子服务端把每次生成的文本切片都存在内存或 Redis 里键是messageId值是切片数组。客户端说“我从第 42 片之后继续”服务端直接返回list.slice(42)就行。前端实现上建议维护一个小的状态机至少包含这几个状态idle、connecting、streaming、interrupted、done。网络断开进入interrupted重试成功回到streaming完成后进入done。UI 上可以根据状态显示“连接中断正在重试”之类的提示而不是让用户傻等。这里要特别强调幂等。重连之后服务端可能把已经发过的内容再发一遍如果前端无脑 append就会出现文字重叠。所以前端在拿到新内容前最好把内容截断到游标位置再拼接或者服务端在响应头里带上messageId和startCursor字段前端校验过再渲染。3.2 刷新页面后怎么接着聊本地缓存与状态恢复比断线更棘手的是刷新页面。用户辛苦等了半天的答案刷新一下就全没了这在 AI 应用里特别容易引发差评。我的做法是把“已接收但未完成的消息”存到sessionStorage里存储内容包括 messageId、游标、已经渲染出来的文本、当前状态。用户刷新后前端读取 sessionStorage恢复对话界面的显示然后自动用上次的游标重新请求服务端继续接收剩余内容。有人可能会问直接用localStorage不行吗可以但要注意尺寸。一个大模型回答的文本通常几十 KBlocalStorage存几条问题不大但如果是多轮长对话量大了还是会超出 5MB 限制。这种情况下建议改用IndexedDB存储量大得多也支持结构化存储适合把整段对话历史持久化。刷新恢复还有一个细节如果连接在页面加载之前就已经结束了那只需要恢复显示不需要重新请求。判断方式就是状态机里done标记如果已经完成直接展示缓存内容即可。3.3 文件断点续传分片下载与分片上传的实现思路文件场景下的断点续传技术点其实比消息流更成熟网上的资源也多这里就说两个关键点。下载场景用的是 HTTP 的Range头。前端先发一个请求拿到文件总大小然后从上次中断的位置发Range: bytes起始偏移-服务端返回206 Partial Content前端把返回的字节拼到已有 Blob 后面。拼完可以再校验文件完整性用大小或者哈希都行。上传场景用分片。前端把大文件用File.prototype.slice()按固定大小比如 2MB切成若干片逐片或并发上传服务端记录每片的上传状态。断线后前端查询哪些片没传成功只重传失败的片全部传完再通知服务端合并。注意不要过度并发浏览器对同域名连接数有限制我们项目里并发控制在 3 到 5 个稳定性最好。前端对分片状态的管理可以用一个简单的数组const CHUNK_SIZE 2 * 1024 * 1024; const chunks []; const total Math.ceil(file.size / CHUNK_SIZE); for (let i 0; i total; i) { const start i * CHUNK_SIZE; const end Math.min(file.size, start CHUNK_SIZE); chunks.push({ index: i, blob: file.slice(start, end), uploaded: false, uploading: false }); }每次网络恢复先过滤uploaded false的片重新上传。这就是断点续传的最小实现。更复杂的方案会把uploaded状态上报给服务端下次回话直接查服务端哪些片齐全能省掉不少流量。4. 打字机渲染把生硬的文本流变成顺滑的阅读体验数据拿到了断线也能恢复了接下来是用户直接感知的部分——文字怎么蹦出来。这一节讲渲染层的节奏控制和性能优化。4.1 打字机渲染的核心控制入队节奏而不是逐字改 DOM最早我写打字机效果用的是setInterval每 20 毫秒往innerHTML里加一个字。数据量小的时候还行一旦消息长度超过几百字页面就开始掉帧。原因有两个一是setInterval的触发频率和屏幕刷新率没有对齐可能在两次绘制之间改了很多次 DOM二是每次都触发重排重绘累积开销非常大。后来我把方案改成“待渲染队列 requestAnimationFrame”。核心思路是网络层拿到 token 后不直接渲染而是丢进一个队列渲染循环每帧从队列里取一小批内容一次性写入 DOM。这样渲染节奏由浏览器的绘制帧率驱动不会出现积压或闪烁。这样的好处很多切后台时requestAnimationFrame会自动暂停节约资源单帧内只做一次 DOM 写入减少 layout 抖动队列的积压还能起到天然背压的作用网络快的时候渲染不会被吞掉网络慢的时候也不会空转。4.2 一个可复用的打字机渲染器下面这个类是我在实际项目里抽出来的简化版本逻辑不依赖框架Vue 和 React 都能用class TypewriterRenderer { constructor({ onBatch, charsPerFrame 2, batchSize 3 }) { this.queue []; this.charsPerFrame charsPerFrame; this.batchSize batchSize; this.rafId null; this.onBatch onBatch; } push(text) { for (let i 0; i text.length; i this.charsPerFrame) { this.queue.push(text.slice(i, i this.charsPerFrame)); } if (!this.rafId) { this.rafId requestAnimationFrame(this.tick.bind(this)); } } tick() { const batch this.queue.splice(0, this.batchSize).join(); if (batch) { this.onBatch(batch); } if (this.queue.length 0) { this.rafId requestAnimationFrame(this.tick.bind(this)); } else { this.rafId null; } } clear() { if (this.rafId) { cancelAnimationFrame(this.rafId); } this.rafId null; this.queue.length 0; } }使用的时候网络层拿到数据后调用renderer.push(token)渲染回调里把文本 append 到页面容器。charsPerFrame和batchSize两个参数可以调节速度想要“打字感”更明显可以改成每帧取 1 个字符。不要忘了在组件卸载时调用clear()否则渲染循环会一直跑造成内存泄漏。这个坑我调试了很久才发现页面都关了控制台还在打印渲染日志。4.3 Markdown 内容的渲染策略别把标签渲染成乱码AI 模型返回的内容基本是 Markdown这给打字机渲染出了个难题如果逐字渲染原文## 标题、这些符号会直接暴露在界面上用户在阅读时看到的就是“残缺”的标签。“标签返回未完整怎么处理”这个热搜词说的就是这类问题。最常见的做法有两种。第一种流式期间只展示纯文本流结束后再一次性渲染完整 Markdown。优点是简单可靠缺点是在输出结束前代码块、加粗这些效果看不到体验打折。第二种缓冲后分段渲染。服务端返回的文本先进入另一个比渲染队列大一些的缓冲池等待几十毫秒再解析渲染。这样做的好处是文本块到达时通常已经是一个完整的句子Markdown 结构不容易被截断。我实测下来300ms 的延迟用户基本感知不到但解析的稳定性提升了非常多。代码块的处理我倾向于特殊化检测到当前缓冲里包含未闭合的代码块标记就先不渲染拦截部分等代码块内容完整后再一次性渲染并高亮。这样可以避免代码高亮插件在流式过程中反复重复计算。4.4 长内容与低端机的性能调优AI 回答动辄几千字加上代码高亮低端机上很容易卡。调到什么程度算润我通常用 Chrome DevTools 的 Performance 面板录制一段滚动和输入过程看 Main 线程有没有超过 50ms 的长任务。如果每帧都有长任务说明渲染逻辑拖累了主线程。几个性能优化的方向DOM 节点数量要控制。一条消息几千字如果每个字符都是一个 span节点数是灾难级的。正确做法是批量追加文本节点而不是逐字创建节点。我用上面的 TypewriterRenderer就是靠batchSize一次追加几个字符而不是一个字符一个节点。避免在渲染循环里读取布局。不要在tick里访问offsetHeight、scrollHeight这类属性它们会强制刷新布局和写入交错进行就会导致 layout thrashing。需要知道高度的话用ResizeObserver在渲染结束后统一处理。光标闪烁别用 JS 定时器。在光标元素上用 CSS 动画keyframes完全不占主线程。长文本滚动建议用虚拟列表的思路只渲染可视区域附近的消息。不过这个改动比较大一般等聊天记录很多时再做。5. 高频故障排查连接断开、内容截断、渲染卡顿的实战记录这一部分记录我在实际联调和线上排查中遇到的问题。每一个都真实踩过很多问题光看文档发现不了。5.1 高频问题idle timeout、Nagle、代理缓冲先聊热搜词里最典型的报错“stream disconnected before completion: idle timeout waiting for sse”。这句话的意思是连接因为空闲等待超时被断开了。常见于服务端用了异步 Servlet、网关或负载均衡设置了过短的 idle timeout。解决方案分三步走服务端加心跳注释客户端做好自动重连重连时带上最后一个事件 ID。心跳间隔建议 15 秒有些云环境要求更短可以实测调整。还有一个隐蔽的问题叫 Nagle 算法。TCP 层的小包可能被合并发送导致数据延迟到达。对 SSE 来说服务端写入小片段后要立刻 flush否则 UI 上表现为时延忽大忽小。排查方法很简单看网络面板数据包的到达时间是不是均匀的如果不均匀检查服务端有没有调用 flush。代理缓冲的问题上面提到过这里再强调一次任何中间层都可能缓冲响应。排查时可以直接 curl 接口观察输出是逐步到达还是一次性到达如果 curl 本身是流式的但前端不行基本就是代理层在缓冲。5.2 重复消费与内容错乱的排查思路断线重连后内容重复是第二个高频问题。场景是这样的EventSource 自动重连时带了Last-Event-ID但服务端没有实现按 ID 续传的逻辑于是从第一条重新发。前端拿到重发的内容无脑 append消息瞬间变长一倍。排查这种问题先抓两条请求日志一条是首次请求一条是重连请求对比请求头和响应头里的消息 ID、游标。如果重连请求根本没有带 ID说明客户端配置有问题如果带了但服务端不回传对应游标说明服务端业务逻辑没有实现增量返回。内容错乱还有一种可能是前端只按行切分事件但data内容里本身包含了换行符。JSON 字符串里的换行符在序列化后通常会被转义成\n但如果你自己拼接响应内容很容易踩这个坑。标准做法是严格按照 SSE 协议以空行作为事件分隔符而不是以换行符。5.3 排查实录从“前端没反应”到“其实卡在服务端”有一个项目让我印象很深。现场反馈说界面上一个字符都出不来我打开 DevTools 看 Network发现请求一直处于 pending但接口日志显示内容已经生成了。第一反应是响应头问题检查 Content-Type 没错再查 Nginxproxy_buffering 已经关了。后来发现服务端在写第一段数据之前花了几秒钟去做了鉴权和初始化模型期间连接上没有任何字节流动。前端把这段时间当作“还没开始”不会报错也不会显示看起来就像卡死了一样。这个问题的解法是服务端在连接建立后立刻写入一个空注释作为“握手成功”信号同时前端设置一个超时提示。如果 10 秒内没有收到任何事件就提示用户“服务端响应超时正在重试”。这种即时反馈对 AI 对话类产品特别重要用户等 3 秒没反应就会怀疑产品坏了。5.4 问题速查表症状可能原因处理方案连接不到 1 分钟就断开中间层 idle timeout服务端每 15 秒写心跳注释页面迟迟不出现内容服务端或代理缓冲关闭缓冲配置 X-Accel-Buffering: no中文乱码chunk 拆分导致编码断裂使用 TextDecoder 的 stream 模式内容到一半标签不完整事件边界解析错误按空行切分data 行单独解析断线重连后文字重复缺少游标或服务端不续传记录游标服务端按游标返回增量流式期间页面掉帧逐字生成 DOM 节点改为批量追加文本节点代码块高亮闪烁Markdown 被逐字解析缓冲一段再解析代码块整体渲染最后再分享一下我个人的工程体会。SSE、断点续传、打字机渲染这三个能力单独拎出来任何一个都可以写一大堆但在真实项目里它们更像是一套组合拳。如果把接入、恢复、渲染三条链路都做成可复用的模块而不是每次接到需求临时拼后面接新业务会省下大量时间。我自己会在项目里把这套逻辑封装成一个 hook对外只暴露sendMessage、messageList、status三个接口内部再拆成网络层、缓冲层、渲染层。这样无论是接新模型、换消息协议还是适配不同的 UI 框架都只需要替换局部不用动整体。工程化到最后拼的不是谁懂得多而是谁把复杂的事情拆得更清楚。这套组合值得每个做 AI 前端的人认真过一遍。