免费获取学习方案
ARTICLE DETAIL

资讯详情

深耕编程基础知识与建站技术分享的一线实战洞察。

AI前端实战:SSE流式输出与断点续传的工程落地

AI前端实战:SSE流式输出与断点续传的工程落地 “AI 前端落地实战”这几个字我盯了整整三个晚上才敢动笔。当时接手公司那个大模型对话项目需求听起来很简单页面上一个聊天框用户发消息AI 像人一样一个字一个字往外吐。结果真做起来才发现这里面的水比想象深得多——SSE 流式输出怎么接、断线之后怎么续、Markdown 标签还没闭合就渲染到页面上怎么办、Nginx 报stream disconnected before completion: idle timeout waiting for sse又是谁干的每一个问题都能让你从下午排查到凌晨。这篇文章就把我踩过的坑、最后落地的方案和一些面试里能拿出来讲的细节一次性整理出来。如果你也在做 AI 聊天对话前端或者准备前端面试时被问到流式输出、断点续传、打字机渲染这块内容照着往下看应该能省掉不少弯路。1. 项目概述一个AI对话前端到底在忙什么1.1 核心需求拆解先说清楚项目是什么。这是一个面向内部业务方的大模型对话应用用户在网页端输入问题后端把问题转发给大模型服务然后模型生成的答案需要实时展示在页面上。传统的一次性请求响应post 之后等完整 JSON 返回体验太差尤其模型生成一段几百字的回复可能要十几秒让用户盯着 loading 转圈基本等于劝退。所以需求拆出来是三个核心链路流式输出后端通过 SSEServer-Sent Events把模型生成的增量文本一段一段推给前端前端收到后立刻渲染。断点续传网络抖动、代理超时、后端重启都会导致连接中断。中断后不能让用户重发问题而是要在恢复连接后从断掉的地方把剩下的内容补回来。打字机渲染收到增量内容后不能一次性怼到页面上要让文本像打字机一样逐步显示同时还要兼容 Markdown 渲染。这三个模块表面上是独立的实际上互相影响。比如流式中断后断点续传要恢复的不仅是“剩余文本”还要让“打字机效果”从正确的进度继续走渲染层如果对不齐就会出现内容跳变或者重复。1.2 为什么我用 SSE 而不是 WebSocket这是项目一开始就遇到的技术选型问题。AI 对话场景确实常见两种方案WebSocket 和 SSE。我最终选了 SSE核心原因有三个。第一这个场景是单向服务端推送。用户发一条消息服务端持续返回内容前端在这个过程中除了“停止生成”之外不需要再向服务端发其他指令。SSE 天生就是干这个的而 WebSocket 是双向通信等于给一个单向管道配了个双向的闸门复杂了。第二SSE 基于 HTTP天然支持断线重连。EventSource 内置了reconnect机制连接断开后浏览器会自动重连还可以通过Last-Event-ID告诉服务端上次收到的消息 ID。WebSocket 断了就要自己处理重连逻辑心跳、退避、状态同步全部自己写。想省事SSE 赢一大截。第三穿透性和兼容性好。SSE 走普通 HTTP/HTTPS不涉及升级协议这一步经过 Nginx、负载均衡、CDN 这些中间层时比 WebSocket 省心得多。WebSocket 在代理层经常要单独配 upgrade 头稍微配置不对就握手失败。顺便把EventSource和 WebSocket 的对比整理成表格面试时也经常用到对比项SSEWebSocket通信方向服务端单向推送全双工双向通信协议基础普通 HTTP独立的 WebSocket 协议需要握手升级断线重连浏览器内置支持 Last-Event-ID需要自己实现二进制数据原生不支持需编码原生支持实现复杂度低较高典型场景实时通知、AI 流式输出、行情推送在线聊天、协同编辑、游戏当然如果需求是“用户和 AI 对话过程中还要支持随时打断、上传文件进度上报、多人协作同时编辑”那 WebSocket 更合适。但纯粹做 AI 对话流式返回SSE 是最省力的路径。2. SSE 流式输出从协议到前端落地2.1 SSE 的协议格式和 EventSource 的边界SSE 说白了一种基于纯文本的协议服务端返回的 Content-Type 是text/event-stream内容长这样id: 1 data: {content:你好} id: 2 data: {content:我是AI助手}每一条消息用空行分隔data:是数据内容id:是事件 ID浏览器会自动记录这个 ID。如果有多行data:浏览器会把它们用换行符拼起来。还可以用event:字段指定自定义事件类型默认是message。原生 EventSource 用起来特别简单const es new EventSource(/api/chat?message你好); es.onopen () console.log(连接已建立); es.onmessage (e) { // 这里拿到的 data 是字符串 const data JSON.parse(e.data); render(data.content); }; es.onerror () console.log(连接异常浏览器会自动重连);但项目做到一半我就发现EventSource 有两个硬伤。硬伤一只支持 GET 请求。EventSource 用new EventSource(url)创建连接这个请求只能是 GET。但 AI 对话场景里用户的问题可能很长而且往往需要带上会话 ID、用户 ID 等业务参数。把所有参数拼在 query 上既容易撞 URL 长度限制也不方便传 token 鉴权虽然也能放 header但 EventSource 无法自定义 header这是个更麻烦的点。硬伤二自定义事件处理比较笨。服务端如果发不同event:类型比如event: delta表示增量、event: done表示结束前端要一个个addEventListener去监听。如果后端协议不标准EventSource 的自动解析反而帮倒忙。所以项目里最终放弃了 EventSource改成用fetch自己读流。这个方案现在在 AI 应用前端里非常主流。2.2 用 fetch 流式读取摆脱 EventSource 的限制核心思路是用fetch发 POST 请求拿到response.body这个ReadableStream然后通过getReader()不断读取数据块再按 SSE 的格式手动解析。我封装了一个简单的读取函数代码如下async function fetchSSE( url: string, body: Recordstring, unknown, onMessage: (data: any, event: string) void, signal?: AbortSignal ) { const response await fetch(url, { method: POST, headers: { Content-Type: application/json, Accept: text/event-stream, // 这里可以自定义任何业务 header比如 Authorization }, body: JSON.stringify(body), signal, }); if (!response.ok) { throw new Error(HTTP ${response.status} ${response.statusText}); } if (!response.body) { throw new Error(当前浏览器不支持 ReadableStream); } 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 }); // SSE 消息以空行分隔这里按 \n\n 切分 const parts buffer.split(\n\n); buffer parts.pop() ?? ; for (const part of parts) { const parsed parseSSE(part); if (parsed.event) { onMessage(parsed.data, parsed.event); } } } } function parseSSE(block: string) { const lines block.split(\n); let event ; const dataLines: string[] []; for (const line of lines) { if (line.startsWith(:)) { // 注释行一般是心跳 continue; } const colonIndex line.indexOf(:); const field line.slice(0, colonIndex).trim(); const value line.slice(colonIndex 1).trim(); if (field event) { event value; } else if (field data) { dataLines.push(value); } // id、retry 字段按需处理 } if (dataLines.length 0) return { event: , data: null }; const raw dataLines.join(\n); let data: any null; try { data JSON.parse(raw); } catch { data raw; } return { event: event || message, data }; }这里有一个非常关键的细节buffer decoder.decode(value, { stream: true })。如果不加{ stream: true }当一段 UTF-8 字节流正好把一个中文字符编码拆成两截的时候TextDecoder会直接把后半截解码成乱码字符。加上stream: true后会把不完整的字节序列缓存在内部等下一段字节到达时再一起解码。这个坑我刚开始没注意AI 输出速度快的时候页面偶尔出现一个黑点乱码排查了好久才发现是解码方式不对。另一个细节是心跳处理。服务端可能会定期发送: ping这样的注释行防止中间代理层在空闲时掐断连接。SSE 协议规定以冒号开头的行是注释客户端要忽略。上面parseSSE里已经做了处理。2.3 请求中断与用户取消AI 对话还有一个绕不开的功能用户点了“停止生成”前端要能真正中止请求。用 fetch 实现时要用AbortControllerconst controller new AbortController(); async function startChat() { try { await fetchSSE(/api/chat, { message: input.value }, (data) { rawText.value data.content; }, controller.signal); } catch (error) { if (error.name AbortError) { console.log(用户主动取消); } else { // 其他异常 } } } function stopChat() { controller.abort(); }这里有个容易被忽略的点controller.abort()会抛出AbortError但读取流内部可能还在进行中。更稳妥的做法是reader.cancel()这样会直接关闭流。不过实际上如果用同一个signal传给 fetchabort 后 fetch 内部会自动取消流的读取reader.read()会 reject。实际开发中我在AbortError分支里做了标记确保停止后不再触发后续的重连或续传逻辑否则用户明明点了停止几秒后又自动重连把内容补上了体验很诡异。3. 断线重连与断点续传数据不丢、画面不乱3.1 断点续传到底续什么连接和内容要分开看“断点续传”这个词在 AI 对话场景里比传统文件上传那套复杂一点。文件上传断点续传续的是“第几个分片”而 AI 流式输出要续的是“模型回复生成到第几个字了”。我把它拆成两个层面连接层续传HTTP 连接断了浏览器能不能重连。SSE 的 EventSource 自带这个能力但用 fetch 实现后重连逻辑就得自己写。好在这个不难无非是捕获异常后按指数退避重新发起请求。内容层续传重连之后AI 回复不会被从头生成一遍那样太浪费也不行而是从上次发送的位置继续。也就是说前端要知道“我已经收到多少内容了”把进度传给后端后端从断点继续推。内容层续传还有一个隐藏问题渲染状态也要对齐。即使文本内容续上了打字机渲染如果还停留在旧的进度或者把已经渲染过的文本重复渲染一遍用户看到的画面就乱了。所以断点续传不仅仅是网络层的事还牵扯到状态管理。3.2 服务端配合session、messageId 与 offset前端做断点续传前提是后端要有对应的能力。当时我们和后端对了一套简单的协议核心是三个字段sessionId会话 ID、messageId消息 ID、offset偏移量单位是 UTF-16 代码单元数量。流程是这样的前端发起对话请求带上sessionId服务端创建一个新的messageId。服务端在模型生成过程中每生成一段内容就把这段内容追加到缓存区并通过 SSE 推送给前端。如果连接中断前端重连时带上sessionId、messageId和本地已经收到的文本长度offset。服务端检查缓存里这条消息的完整内容从offset位置开始继续推送剩余内容。服务端返回的数据结构我建议统一为{ sessionId: xxx, messageId: msg_123, offset: 256, content: 剩余的增量内容, finished: false }offset表示这条消息已经推送到第 256 个字符content是本次新增的增量。前端拿到后要做的不是覆盖而是追加同时更新自己的本地 offset。这样即使网络抖动导致前端重复收到同一段内容只要前端记录好“已经处理到哪个 offset”就能把重复内容过滤掉。3.3 前端恢复策略本地缓存拼接与服务端重放前端的断点续传状态管理我当时用了一个StreamState对象来维护interface StreamState { sessionId: string; messageId: string; receivedLength: number; // 已收到的文本长度 chunks: string[]; // 本地增量缓存 connected: boolean; finished: boolean; retryCount: number; }断线时核心原则是先保证已渲染的内容不回滚再保证未渲染的内容能续上。具体策略是这样前端每收到一个 chunk先写进chunks缓存再更新receivedLength再触发渲染。这个顺序很重要。如果先渲染再更新缓存断线后本地缓存和页面显示就不一致了。断线时页面保留当前已渲染文本不删除、不清空。同时显示一个“网络波动正在重连…”的状态提示。重连成功后带receivedLength请求服务端。服务端从断点返回剩余内容。前端先检查新内容里有没有重复的偏移量按 offset 过滤后再追加到rawText并让打字机渲染从正确的进度继续走。这里有个细节重连后收到的新内容的offset等于断线前的receivedLength说明没有丢失内容。如果新内容的 offset 小于当前 receivedLength说明流的开始位置更早这时候需要跳过前面重复部分只保留 offset 之后的部分。如果不做这个过滤用户会看到一段已读文本被重复渲染出来直播翻车现场。服务端重放还有一个好处前端本地缓存即使因为浏览器刷新丢失了只要 session 还在就可以重新从服务端拉取整条消息的残段。所以“断点续传”最稳妥的兜底永远在服务端。前端缓存只是用来保存渲染状态服务端缓存才是恢复数据的源头。4. 打字机渲染流畅、不闪烁、不抽搐4.1 增量渲染的核心数据结构打字机渲染是用户感知最直观的部分。理想的打字机效果是文字从左到右出现节奏稳定不跳字不闪烁。我见过很多同学是这么实现的用一个interval定时器每隔 50ms 从完整文本里截取前 N 个字符显示。如果fullText是个逐步增长的变量那问题还不大如果fullText是等流全部结束之后再一次性赋值的那打字机效果就变成了“等很久没反应然后突然全屏文本刷出来”非常吓人。正确的思路是用增量驱动渲染而不是“全量文本 定时截取”。数据结构很简单两个核心变量const streamingText ref(); // 已经流式收到的完整文本 let renderCount 0; // 已经渲染到第几个字符每次收到增量 chunk执行两个动作streamingText.value chunk.content把新文本追加进完整内容。启动或者继续一个“打字机消费器”让renderCount逐步逼近streamingText.length把streamingText中renderCount到当前位置的新增字符逐步显示出来。打个比方streamingText是水桶里攒着的水renderCount是水龙头每次流入新水后水龙头慢慢放水而不是等整个桶满了再一次性倒光。核心代码Vue3 组合式函数风格import { ref } from vue; export function useTypewriter(interval 30) { const displayedText ref(); const fullText ref(); let timer: number | null null; let renderIndex 0; function appendChunk(chunk: string) { fullText.value chunk; if (timer ! null) return; timer window.setInterval(() { if (renderIndex fullText.value.length) { clearInterval(timer!); timer null; return; } renderIndex; displayedText.value fullText.value.slice(0, renderIndex); }, interval); } function reset() { fullText.value ; displayedText.value ; renderIndex 0; if (timer ! null) clearInterval(timer); timer null; } return { displayedText, appendChunk, reset }; }这个方案的优点是把“流式接收”和“打字机显示”解耦了。网络快的时候fullText可能已经攒了很长但展示还是按固定节奏走用户看着舒服。网络慢的时候fullText增长慢打字机就跟随接收速度走不会因为定时器空转造成闪烁。4.2 Markdown 标签没闭合流式渲染最容易被问倒的细节如果说打字机节奏是第一关那 Markdown 渲染就是第二关也是面试官最爱追问的细节“AI 输出的是 Markdown但流式输出时内容是不完整的比如代码块的三反引号还没出现表格的竖线还没闭合你怎么处理”这个问题我真实踩过坑。最初直接把streamingText丢给 marked 或者 markdown-it 渲染结果用户看到的内容是代码块开始标签出现后页面先是渲染成了一段文字等后面的反引号补齐了又突然跳成代码块样式。这种“渲染结果反复横跳”的体验用户会以为是 bug。后来我总结了一套组合拳方案一延迟渲染尾部不完整片段。把文本分成“已稳定区”和“缓冲区”。比如只把renderIndex往前 200 个字符以内的内容当作“已稳定区”最后 200 个字符一定不渲染等它过了 200 个字符的阈值后再渲染。这样 Markdown 标签大概率已经闭合闪烁问题基本消失。代价是尾部始终有“余量”不是所有文本都立即展示不过对用户几乎无感知。方案二对未闭合标签做临时修补。如果产品要求必须全量实时渲染那就得写一个轻量的“闭合修复函数”针对常见的 Markdown 语法做兜底统计的出现次数如果是奇数就在文本末尾补一个避免代码块一直处于未闭合状态。检测到**未配对时在末尾补**。检测到[text](url这种未闭合的链接写法时补上)。渲染前把末尾几个不完整的单词暂时去掉避免出现“半个 token”导致排版错乱。这个修复函数不追求完美只求“渲染不崩、样式不跳”。我当时的实现是每渲染前做一次patchMarkdown核心逻辑大概是function patchMarkdown(text: string): string { let patched text; const fenceCount (patched.match(//g) || []).length; if (fenceCount % 2 1) { patched \n; } const boldCount (patched.match(/\*\*/g) || []).length; if (boldCount % 2 1) { patched **; } if ((patched.match(/\[/g) || []).length (patched.match(/\]/g) || []).length) { patched ](javascript:void(0)); } return patched; }方案三代码块和普通文本分开渲染。这是一个更彻底的思路。解析流式内容时检测当前是否处于代码块内部如果是代码块部分用precode包裹普通文本部分用 Markdown 渲染两者互不干扰。这样代码块中间的半行代码不会触发 Markdown 解析器报错。4.3 性能与体验优化打字机渲染看着简单内容一长就会暴露性能问题。主要有三个优化点。第一渲染节流。displayedText是一个响应式变量如果频繁更新整个组件都会跟着渲染。打字机每 30ms 更新一次其实还好但如果文本特别长还需要配合v-memo或者在计算属性里做缓存避免每次更新都全量 diff 大段 DOM。第二中文和 emoji 的处理。JavaScript 的slice(0, index)是按 UTF-16 码元切的emoji 占两个码元如果刚好切在中间会出现半个乱码字符。我当时写了一个简单的按码点切割函数function sliceByCodePoint(text: string, end: number) { return Array.from(text).slice(0, end).join(); }Array.from会把字符串转成码点数组这样 emoji 和生僻字都不会被切坏。对于中文按码点切比按词切更自然AI 输出也不存在严格的中文分词所以这里不需要引入分词库。第三超长文本的降级策略。如果一条回复特别长比如几千字打字机一直逐字渲染会显得拖沓。我的经验是接收速度大于每 30ms 一个字符时可以动态加大步长比如从1变成2让整体节奏保持稳定。用户体感是“输出流畅”而不是“一个字一个字数着蹦”。5. 常见问题与排查实录5.1 idle timeout waiting for SSE是谁掐断了连接先看一条我在项目中真实遇到过的报错也是搜索热词里的老朋友stream disconnected before completion: idle timeout waiting for sse这句话的意思是流还没结束连接就被关闭了原因是“idle timeout”——空闲超时。也就是说连接建立了但一段时间内没有数据流动被中间层判定为“闲置连接”然后主动断开了。谁干的大多数情况下是 Nginx 或者负载均衡器。Nginx 默认的proxy_read_timeout是 60 秒如果 60 秒内后端没有向客户端写入任何数据Nginx 就会主动断开这条连接。但 AI 场景恰恰容易踩中这个超时比如用户问了一个复杂问题模型需要“思考”几十秒而测试环境后端在思考阶段没有输出任何内容又比如服务端犯懒没发心跳再比如后端在做函数调用、工具选择迟迟没有生成文字。前端看起来就是“转圈 60 秒后突然报错”。排查思路有两层。第一层是让连接不空闲。服务端在生成阶段也要定期发送心跳。SSE 的注释行就是为这种情况设计的比如每 15 秒发一行: ping前端解析时要跳过注释行这个我前面已经提到了。第二层是调整代理层超时。如果是 Nginx需要在/api/chat对应的 location 里加location /api/chat { proxy_pass http://backend; proxy_set_header Connection ; proxy_http_version 1.1; proxy_buffering off; proxy_read_timeout 3600s; proxy_send_timeout 3600s; }proxy_buffering off也很关键。如果开着缓冲Nginx 会把后端吐出来的数据攒一大块再发给客户端流式体验会被破坏用户看到的是“憋了半天突然蹦出来一大段”。这个不关掉前面费劲做的打字机效果会大打折扣。5.2 其他高频问题速查表除了 idle timeout还有几个高频问题我整理成一个表方便你排查时对照现象根本原因解决方案页面出现乱码字符TextDecoder 未开启 stream 模式UTF-8 被截断decoder.decode(value, { stream: true })内容突然从中间重复显示断线重连后未按 offset 去除重复段直接追加按 offset 过滤重复部分只追加偏移量之后的内容Markdown 代码块样式来回跳未闭合标签直接渲染延迟渲染尾部内容或修补未闭合标签点击停止生成后仍在渲染未处理 AbortError 后才到达的 chunkabort 后立即置finished标志后续数据全部忽略打字机速度快慢不一步长固定网络快慢影响体验动态步长或让渲染节奏独立于接收节奏流式请求一直 pending 不返回代理缓冲未关闭或后端未刷新响应Nginx 关闭 buffering后端每生成一小段就 flush断线后浏览器不再重连用 fetch 实现后没写重连逻辑单独封装重连函数指数退避重试5.3 实测下来的一些建议最后分享几条实操建议都是被项目验证过的。第一前端收到数据后先在内存里做状态更新再触发渲染。不要直接依赖组件的响应式系统去“每收到一个 chunk 就重新渲染整棵树”。有一次我把 chunk 直接 push 到数组里每 push 一次整个聊天列表重新渲染一次模型输出快的时候页面明显卡顿。后来改成“接收缓存”和“渲染队列”分离接收进内存缓存渲染只读队列中的数据问题立刻消失。第二重连要控制频率别让服务端被打爆。我的重试策略是指数退避第一次 1 秒、第二次 2 秒、第三次 4 秒最多重试 5 次。超过 5 次就提示用户“连接不稳定请点击重试”而不是无限重试。无限重试在用户无感知的情况下会把服务端拖垮。第三调试 SSE 时先看 Network 面板再写代码。你可以直接打开浏览器的开发者工具刷新页面后发起一次对话看请求响应体。如果是text/event-stream响应体会像流水一样逐段增长每一段之间有空行。把这个原始数据一眼看明白了前端代码只是把这种格式翻译成 JavaScript 对象而已。调试的时候最容易犯的错误是后端返回的根本不是标准 SSE 格式而是把整段 JSON 一次性返回前端解析半天全是空。第四尽量把“连接管理”和“UI 渲染”拆成两个模块。连接层只管建立连接、解析消息、抛数据渲染层只管把数据消费成打字机效果。不要写一个巨型组件把所有事都干了不然断点续传的状态和渲染状态纠缠在一起出一轮问题改一轮越改越乱。我个人的体会是AI 前端这块并不需要什么高深的魔法核心就是把 HTTP、流、状态管理、渲染几个基础功打扎实。SSE 流式输出考的是你对协议和浏览器 API 的熟悉程度断点续传考的是对连接状态和数据一致性的控制能力打字机渲染考的是对交互细节的拿捏。这三个能力合在一起基本就是现阶段 AI 应用前端最值钱的那部分技能了。面试的时候如果能把idle timeout、Markdown 标签未闭合、TextDecoder stream 模式这几个点讲清楚面试官基本就能确认你是真上手做过而不是只会背题。
返回列表