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

📅 发布时间:2026/9/19 11:22:21
AI对话前端实战:SSE流式输出、断点续传与打字机渲染
最近在做一个 AI 对话功能核心是把大模型生成的流式回复接到前端页面上。我一开始以为无非就是把 POST 请求改成 fetch 后读流等代码上了线才发现SSE 流式输出、断点续传、打字机渲染这三件事每一件都能让页面在真实用户面前现出原形。最近“AI 大模型前端落地”“SSE 消息推送是什么意思”“前端面试题 2026”这些词的讨论度很高说明很多团队都在补这一课。这篇实战笔记把我从协议理解、方案选型到线上排障的完整过程写下来适合刚接触 AI 场景的前端、全栈工程师以及准备前端面试的同学参考。1. 方案选型为什么是 SSE而不是 WebSocket 或短轮询1.1 AI 对话场景对实时性的真实需求先看清楚这个场景的本质大模型生成一段回复通常要几秒甚至几十秒模型是一个 token 一个 token 往外吐的。用户等待期间最好的体验就是看着文字逐字出现而不是盯着一个 loading 转圈。这决定了我们需要的不是一次性的请求响应而是一条持续推送数据的通道。你可能会说那不就用 WebSocket 吗WebSocket 确实能做到双向实时通信但在 AI 对话这种“前端发一次、后端持续回多次”的场景里它属于杀鸡用牛刀。连接建立的成本更高服务端要维护状态还要处理心跳、重连、粘性会话等一系列问题。更何况很多 AI 服务后端就是基于 HTTP 接口做的封装硬上一套 WebSocket 网关运维和调试成本都会明显增加。短轮询就更不用说了前端每秒钟去拉一次接口问“生成了吗”不仅浪费请求也做不到逐字返回的流畅感。SSEServer-Sent Events是天然契合这个场景的协议它基于 HTTP 长连接服务端可以持续往同一个响应里写数据前端只负责读简单直接。1.2 EventSource 与 fetch stream 的取舍SSE 的浏览器原生接口是EventSource用起来非常方便const es new EventSource(/api/chat); es.onmessage e console.log(e.data);它有自动重连机制还能通过lastEventId实现断线续传听起来很完美。但用着用着就会碰壁EventSource只支持 GET 请求没法自定义请求头也没法在 POST body 里带参数。实际项目里AI 对话通常要把多轮上下文传给后端还要在请求头里放 token 鉴权这两个限制直接把原生EventSource卡死在大多数业务场景外。所以我在项目里用的是fetch ReadableStream方案。fetch 的response.body本身就是一个可读流我们手动去读、去解析、去处理重连逻辑虽然代码多一点但控制力完全不同。两者的取舍我整理成了一张表对比项EventSourcefetch ReadableStream请求方式仅 GET任意方法自定义请求头不支持完全支持POST 参数不支持支持自动重连内置需要自己实现自定义事件解析固定字段手动解析灵活与服务端配合自由度一般高最终选择 fetch stream 还有一个现实原因后端大模型接口本身就要求 POST 方式传递 messages 数组并且需要校验 Authorization 头。用EventSource就得让后端为前端单独开一个 GET 接口还得靠 cookie 鉴权这在前后端分离的团队里沟通成本很高。直接用 fetch stream后端只需要把原来的流式接口暴露出来就行。2. SSE 流式输出从协议格式到前端解析2.1 先把协议看清楚SSE 不是魔法它本质上就是一个Content-Type: text/event-stream的 HTTP 响应。服务端持续向响应体里写入特定格式的文本格式大概长这样id: 1 data: {content:你好} id: 2 data: {content:欢迎}每条消息由若干字段组成字段之间用空行分隔。data是消息正文多行data会被前端拼成一个完整消息id是消息编号浏览器断线重连时会自动带上event可以定义自定义事件类型retry告诉浏览器重连的间隔时间。服务端还可以发送以冒号开头的注释行比如: ping当作心跳保证连接不被中间设备误杀。这里要特别注意SSE 的响应头里一般都要带上Content-Type: text/event-stream; charsetutf-8、Cache-Control: no-cache并且要让代理服务器关闭缓冲。很多团队第一次接入时后端明明已经console.log出数据了前端却迟迟收不到八成就是 Nginx 那一层把数据缓冲住了。Nginx 默认会对上游响应做缓冲要等到一定大小或连接结束才转发给客户端这会让流式体验直接退化成“一次性输出”。解决办法是在 Nginx 配置里关掉代理缓冲location /api/chat { proxy_pass http://backend; proxy_buffering off; proxy_cache off; proxy_set_header X-Accel-Buffering no; proxy_read_timeout 300s; }X-Accel-Buffering: no这一行是给 Nginx 看的告诉它这个响应不要缓冲。如果你们的架构里还有 CDN、网关层也要记得检查它们是否支持流式透传。2.2 fetch 流式读取实现前端解析 SSE最核心的是处理两个问题一是二进制流解码二是按行切分消息。直接上代码这是我沉淀下来的工具类核心部分interface SSEMessage { id?: string; event?: string; data?: string; } async function readSSE( url: string, options: RequestInit, handlers: { onMessage: (msg: SSEMessage) void; onDone: () void; onError: (err: Error) void; } ) { const response await fetch(url, options); if (!response.ok) { throw new Error(HTTP ${response.status}: ${await response.text()}); } 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 lines buffer.split(\n); // 最后一行可能是不完整的先保留在缓存里 buffer lines.pop() ?? ; let data ; let id ; let event ; for (const line of lines) { const trimmed line.replace(/\r$/, ); if (trimmed ) { // 空行代表一条消息结束 if (data) { handlers.onMessage({ id, event: event || message, data }); } data ; id ; event ; continue; } if (trimmed.startsWith(:)) { // 注释行通常是心跳 continue; } const sepIndex trimmed.indexOf(:); const field sepIndex 0 ? trimmed.slice(0, sepIndex) : trimmed; const value sepIndex 0 ? trimmed.slice(sepIndex 1).trimStart() : ; if (field data) data (data ? \n : ) value; else if (field id) id value; else if (field event) event value; } } // 连接正常结束通知上层 handlers.onDone(); }几个容易踩坑的点TextDecoder.decode(value, { stream: true })里的stream: true很关键。它是告诉解码器“这段数据可能是不完整的字节”如果漏掉它中文等多字节字符在跨 chunk 传输时就会出现乱码比如把某个汉字拆到了两个二进制片段里。按\n切分后最后一段千万不能丢弃它可能是半行数据要留到下一次读取再拼上。很多人乱码或者丢事件就是这里处理不对。服务端如果按 SSE 规范发送行尾通常是\n但为了兼容各种后端框架最好把\r也去掉再做判断。2.3 服务端配合与心跳机制前端解析做得再好服务端不配合也白搭。实际项目里最常见的服务端问题有三个缓冲、超时、没有心跳。缓冲问题上面说了靠 Nginx 配置解决。超时问题要区分两种一种是连接建立后长时间没有数据可读比如某些大模型思考很久才吐出第一个 token另一种是生成过程中某个环节 hang 住了长时间没有新数据。前者可以靠调大proxy_read_timeout、load balancer的空闲超时解决后者必须靠心跳和应用层超时来兜底。心跳的标准做法是让服务端每隔 15 到 30 秒发送一行注释: ping前端收到注释行不会触发onmessage但能证明连接还活着也能让 Nginx、负载均衡器、浏览器这些中间层知道这个连接还在工作不会被空闲超时干掉。这里再补一个细节fetch 请求要支持取消避免用户停止生成后浏览器还在后台默默接收数据。最简单的做法是用AbortControllerconst controller new AbortController(); // 把 signal 传给 fetch 的 options // 用户点击“停止生成”时调用 controller.abort()取消后reader.read()会抛异常需要在finally里做清理。这一套下来SSE 的读写链路才算完整。3. 断点续传让长回复不因为网络抖动就前功尽弃3.1 用文件下载的思路理解流式续传断点续传听起来是个老话题很多前端面试题里都有“HTTP 断点续传和多线程下载”这种题目。它的核心逻辑其实一句话客户端告诉服务端“我已经有哪部分内容了你从那里继续发”。文件下载用的是Range头服务端通过Content-Range响应剩余内容。SSE 的断点续传思路一模一样只不过续传的不是文件字节而是“消息流”。SSE 规范里自带了一个机制客户端断线重连时可以在请求头里带上Last-Event-ID服务端根据这个 ID 判断应该从哪里继续推送。但在 AI 大模型场景下直接照搬这个机制会遇到一个问题大模型的生成过程是有状态的前文已经生成的内容在后端可能并不会刻意保存。服务端如果没做会话缓存即使收到Last-Event-ID也没法重新吐出后半段。所以落地断点续传时前端不能只依赖协议还要和后端约定一套业务级的续传方案。我当时和后端同事确定的方案是后端为每个对话会话生成sessionId每次开始生成时把输入消息和生成参数缓存起来并给每一条 SSE 消息分配递增的id。如果客户端带sessionId和lastEventId重连后端会从缓存中取出该会话的生成状态继续从指定位置推送。3.2 前端缓存与重连逻辑前端要做的事情可以拆成三块保存进度、检测断开、恢复请求。保存进度最简单的做法是把已经收到的纯文本和游标状态存起来。内容不多时用localStorage就够了内容特别长建议用IndexedDB避免在localStorage里塞了一个几 MB 的字符串导致页面卡顿。我个人的习惯是存以下结构interface StreamSessionCache { sessionId: string; lastEventId: string | null; content: string; // 已经渲染完成的正文 finished: boolean; // 是否已经完整结束 updatedAt: number; }每次收到一条新消息就把content拼接一次、更新lastEventId和updatedAt并写回缓存。写回操作很频繁可以用“节流”策略比如每 500ms 或每积累 20 条消息写一次不必每条都同步刷盘。检测断开和恢复的逻辑我在工具类里是这么设计的async function createChatStream( params: { sessionId: string; messages: Array{ role: string; content: string }; cache: StreamSessionCache | null; } ) { const controller new AbortController(); let attempt 0; async function connect(): Promisevoid { try { // 带上续传信息服务端据此决定从哪开始 const res await fetch(/api/chat/stream, { method: POST, headers: { Content-Type: application/json, ...(params.cache?.lastEventId ? { Last-Event-ID: params.cache.lastEventId } : {}), }, body: JSON.stringify({ sessionId: params.sessionId, messages: params.messages, fromEventId: params.cache?.lastEventId ?? null, }), }); if (!res.ok) throw new Error(HTTP ${res.status}); await readSSE(res, { onMessage: (msg) { attempt 0; // 增量拼接到页面同时更新缓存 }, onDone: () { /* 正常结束 */ }, }); } catch (err) { // 用户手动取消就不要再重连了 if (controller.signal.aborted) return; // 以指数退避的方式重试避免服务端被打爆 attempt 1; const delay Math.min(1000 * 2 ** attempt, 15000); setTimeout(connect, delay); } } await connect(); return () controller.abort(); }注意这里其实有两层续传一是Last-Event-ID请求头这是协议层面的二是fromEventId业务参数这是后端接口约定的。两者都传是为了让后端在拿到标准头的同时也能从业务逻辑上明白前端想要什么。3.3 重复内容与幂等性的坑重连最怕的不是连不上而是连上了以后从头开始推导致界面出现一段重复内容。这个问题本质上是接口不幂等。解决思路很明确重连请求发起前先读一下本地缓存。如果content已经有内容说明是“恢复”不是“全新生成”。此时前端需要明确告诉服务端“我有从开始到某个位置的内容了”服务端必须支持从该位置之后续推。如果服务端做不到续推那返回的状态码设计上就要有区分比如返回409 Conflict表示“无法续推请重新生成”前端收到这个状态码后应该清空缓存中已有内容改为从头开始生成而不是糊里糊涂地把新旧内容拼在一起。还有一个容易被忽略的点如果服务端已经在本地保存了生成结果只是网络断了那重连后其实完全没必要让模型重新算一遍。理想方案是后端查出缓存结果后直接把剩余内容批量推给前端速度会快很多。这个优化可以把“断点续传”从“省流量”升级成“省算力”对成本和用户体验都有帮助。4. 打字机渲染流式数据到了页面别拖后腿4.1 直接 setState 为什么不合适很多人第一次做流式渲染时会随手写成每收到一个 chunk 就setState一次。功能能跑但体验会很糟糕。原因在于 React 的状态更新是整体触发 reconcile 的每次 setState 都会重新 diff 整个组件树。大模型一条回复动辄几百上千字每来一个 chunck 就整体替换一次大字符串页面很容易出现输入卡顿、光标闪烁、滚动条乱跳。我用 React profiler 实测过一个场景一个包含 Markdown 表格的长回复如果每收到 20 个字符就 setState 一次渲染耗时能从几毫秒飙到几十毫秒而且随着文本越来越长每次替换的 diff 成本都在增加。用户那边看到的效果就是AI 说话一顿一顿的滚动条跟抽风一样。更聪明的做法是“增量渲染”。所谓增量就是只把新增的那一小段文字插入到 DOM 里不去触碰已经渲染好的内容。这就像往一篇文章末尾追加一句话而不是每次把整篇文章重新抄一遍。4.2 缓冲 requestAnimationFrame 批量写入我最终采用的是“缓冲队列 渲染调度”的方式。核心思路是收到 chunk 先放进一个队列然后用requestAnimationFrame统一调度每帧只处理一次渲染把这一帧内累积的所有增量一次性写入 DOM。如果连续收到大量数据就分多帧处理保证 UI 线程不被长时间占用。React 里我选择直接操作 DOM 而不是 setState原因就是上面说的 diff 成本。代码如下function ChatMessage({ sessionId }: { sessionId: string }) { const contentRef useRefHTMLDivElement(null); const pendingRef useRef(); // 待写入的缓冲文本 const timerRef useRefnumber(); const appendChunk useCallback((chunk: string) { pendingRef.current chunk; if (timerRef.current) { clearTimeout(timerRef.current); } // 用 setTimeout 做合并16ms 内多个 chunk 只触发一次渲染 timerRef.current window.setTimeout(() { if (!contentRef.current) return; const el contentRef.current; // 把增量追加到已有的文本节点里避免重新解析整段 innerHTML el.appendChild(document.createTextNode(pendingRef.current)); pendingRef.current ; // 自动滚动逻辑放在这里 scrollToBottom(el); }, 16); }, []); return div ref{contentRef} classNamechat-content /; }Vue 里的思路类似用ref拿到容器节点在watch到增量变化后用nextTick合并写入const contentRef refHTMLElement|null(null); let buffer ; function appendChunk(chunk: string) { buffer chunk; nextTick(() { if (contentRef.value) { contentRef.value.appendChild(document.createTextNode(buffer)); buffer ; } }); }如果对渲染频率有更高要求可以再加上“打字机速率控制”。也就是让 AI 内容不是一次性全显示而是按照设定好的速度比如每 30ms 出一个字逐字展示。这时候要注意别用setInterval去改整段文本正确的是把队列里的字符拆开每次只 append 一个字符并且把光标元素一起挪动。4.3 Markdown 渲染与光标定位实际业务里AI 回复往往不是纯文本而是带 Markdown 格式的。这里有一个经典难题流式过程里 Markdown 可能是半截的比如刚才收到# 标题的#代码块也只开了三个反引号这时候强行整段解析页面就会出现一闪一闪的解析错误。我的处理策略是把内容分成“稳定区”和“草稿区”。已经长时间没有更新的部分属于稳定区用 Markdown 解析器渲染成 HTML末尾正在持续增长的几行属于草稿区先以纯文本展示。每收到新的 chunk就重新判断一次边界稳定区解析一次草稿区只 append 文本。这样既保证了最终效果也避免了每一帧都全量解析 Markdown 的性能灾难。光标定位方面可以考虑在文本末尾插入一个独立的span classcursor渲染时把它始终保持在最后。逐字出现时光标跟着字符移动整段追加时光标保持在段落末尾。注意不要每次更新都重建整个 cursor 节点否则会频繁触发 layout 抖动。自动滚动也需要做个判断如果用户正在向上翻阅历史内容千万别强行把滚轮拉到底只有当用户接近底部时才跟随滚动。一般判断条件是function scrollToBottom(el: HTMLElement) { const container el.closest(.chat-scroll)!; const distance container.scrollHeight - container.scrollTop - container.clientHeight; if (distance 80) { container.scrollTop container.scrollHeight; } }阈值 80 到 120px 之间比较合适太严格了用户稍有向上动作就不滚了太宽松了又达不到“跟随”效果。5. 线上实录idle timeout 断流排查与修复5.1 故障现象上线后第二天用户反馈了一个很典型的问题AI 回复到一半突然卡住不动等几十秒后页面显示“连接中断”。我去浏览器 Network 面板看发现请求状态是stream disconnected before completion: idle timeout waiting for sse。这个报错信息只要搜一下就能找到大量相关讨论但每个项目的原因可能都不一样。我这次的排查过程比较有代表性记录下来供参考。5.2 排查链路第一步先确认是不是前端解析挂了。我在浏览器里看到 Network 面板的响应字节数还在增长只是 UI 不再刷新说明数据其实已经在客户端。那问题就出在渲染层。再往下查发现我把增量 append 的逻辑写在了onMessage里但由于之前为了做 Markdown 稳定区解析每次收到 chunk 都会重建整个 stable 区的 HTML长文本下越来越卡最后干脆卡死了。这是我这个项目里一个很隐蔽的 bug也是为什么我在第 4 节反复强调要增量写入、不要全量替换。第二步修复渲染卡顿后同样的报错还是偶发出现。这回我看服务端日志发现 Nginx 返回了 504且错误日志里有upstream timed out。对比时间点模型首 token 平均要 10 秒以上偶尔遇到复杂问题要 30 秒而 Nginx 默认的proxy_read_timeout是 60 秒。按理说不会触发但我们的负载均衡层又套了一层超时配置把空闲超时设成了 30 秒。也就是说只要模型思考时间超过 30 秒没有吐第一个 token中间层就会主动断开连接。第三步给 Nginx 和负载均衡器都调大超时并且让后端在流式生成期间每隔 15 秒发一次心跳包。这个心跳不只是让前端感知连接存活更是为了让所有中间设备认为这条连接还在活跃传输从而防止空闲超时。改完之后连续压测多轮报错消失。5.3 可复用的排障清单以后再遇到“流式输出断流”我会按这个顺序排查前端是否还在通过ReadableStream正常读取可以打开 Performance 面板看数据到达频率。浏览器 Network 响应字节数是否持续增长如果是问题在渲染层如果不是问题在网络/服务端。服务端访问日志和错误日志有无超时、断连记录重点看网关、负载均衡、反向代理三层超时配置。有没有心跳机制心跳间隔是否大于所有中间设备的空闲超时时间。模型首 token 延迟是否过高如果业务上允许可以在 UI 上先渲染“正在思考”的提示掩盖首 token 等待期。6. 常见问题速查表与避坑清单6.1 高频故障对照表问题现象可能原因解决方案前端长时间不显示内容结束后一次性冒出全部内容反向代理/网关缓冲关闭 proxy_buffering加 X-Accel-Buffering: no中文乱码TextDecoder 未使用 stream 模式使用decode(value, { stream: true })断线重连后内容重复接口不幂等服务端从头推送按 sessionId 清理缓存或要求服务端实现续推页面越用越卡流式渲染频繁 setState / 整段 innerHTML 重建改用增量 append 请求动画帧调度用户上滑时被强制拉到底无条件执行滚动到底判断距底部阈值再滚动流在几十秒后中断中间层空闲超时调大 timeout 服务端发心跳AI 回复到一半停止但无报错后端进程异常或模型接口 hang 住增加应用层超时和错误上报停止生成后仍收到数据未使用 AbortController 取消在 finally 中中断读取并清理6.2 几个值得长期保留的开发习惯这些是我做完这个项目后沉淀下来的一些习惯不一定适合所有团队但至少能帮你少走很多弯路。第一个写一个独立的 SSE 工具类不要把解析逻辑散落在组件里。解析、心跳、重连、缓存、取消这些逻辑需要统一管理组件只关心“收到一段文本”和“流结束了”这两个回调。第二个本地开发一定要有一个 mock 流式服务。最简单的实现是 Node.js 里起一个小服务每隔 100ms 往响应里写一段文本模拟大模型的输出。这样前端调试不需要每次真调大模型速度快也省钱。Mock 服务还可以模拟断流、模拟慢首包、模拟中途报错这些异常场景在真实环境里很难触发但又是必须验证的。第三个上线前做一次弱网测试。Chrome DevTools 里的 Network 面板可以模拟慢速网络和丢包在Slow 3G这种档位下把整个对话流程跑一遍很多流式渲染的抖动问题就能提前暴露出来。还有一个技巧是开着 Network 面板直接观察响应曲线如果数据到达是平缓上升的说明链路健康如果是一段一段的台阶状多半是缓冲或渲染卡顿造成的。7. 结尾补充几句实话如果你也在做 AI 前端不要把 SSE、断点续传、打字机渲染当成三个孤立的面试知识点。实际落地时它们是同一条链路里紧紧相连的三环流式输出负责数据能不能到断点续传负责断了以后怎么办打字机渲染负责到了以后怎么展示任何一个环节掉链子用户感知到的都是一句话这个 AI 页面不好用。我自己最大的体会是先把协议吃透再写代码比到处抄一段 fetch 代码要省心得多。搞明白text/event-stream的格式、搞明白心跳的意义、搞明白增量渲染的原理你在面对各种诡异故障时才有判断的依据而不是靠猜。最后再分享一个小技巧调试 SSE 时不要只依赖浏览器。用curl -N直接打一下接口数据有没有从服务端流出来、是不是被代理缓冲住了一眼就能看出来。命令行里看到的是源源不断的字符还是憋着一口气吐出来对应的结论完全不同。