AI前端实战:SSE流式输出与断点续传的工程落地
2026/9/20 4:50:05 网站建设 项目流程

“AI 前端落地实战”这几个字,我盯了整整三个晚上才敢动笔。当时接手公司那个大模型对话项目,需求听起来很简单:页面上一个聊天框,用户发消息,AI 像人一样一个字一个字往外吐。结果真做起来才发现,这里面的水比想象深得多——SSE 流式输出怎么接、断线之后怎么续、Markdown 标签还没闭合就渲染到页面上怎么办、Nginx 报stream disconnected before completion: idle timeout waiting for sse又是谁干的,每一个问题都能让你从下午排查到凌晨。

这篇文章就把我踩过的坑、最后落地的方案和一些面试里能拿出来讲的细节,一次性整理出来。如果你也在做 AI 聊天对话前端,或者准备前端面试时被问到流式输出、断点续传、打字机渲染这块内容,照着往下看,应该能省掉不少弯路。

1. 项目概述:一个AI对话前端到底在忙什么

1.1 核心需求拆解

先说清楚项目是什么。这是一个面向内部业务方的大模型对话应用,用户在网页端输入问题,后端把问题转发给大模型服务,然后模型生成的答案需要实时展示在页面上。传统的一次性请求响应(post 之后等完整 JSON 返回)体验太差,尤其模型生成一段几百字的回复可能要十几秒,让用户盯着 loading 转圈,基本等于劝退。

所以需求拆出来是三个核心链路:

  • 流式输出:后端通过 SSE(Server-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: Record<string, 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 实现时,要用AbortController

const 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传给 fetch,abort 后 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 代码单元数量)。

流程是这样的:

  1. 前端发起对话请求,带上sessionId,服务端创建一个新的messageId
  2. 服务端在模型生成过程中,每生成一段内容,就把这段内容追加到缓存区,并通过 SSE 推送给前端。
  3. 如果连接中断,前端重连时带上sessionIdmessageId和本地已经收到的文本长度offset
  4. 服务端检查缓存里这条消息的完整内容,从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,执行两个动作:

  1. streamingText.value += chunk.content,把新文本追加进完整内容。
  2. 启动或者继续一个“打字机消费器”,让renderCount逐步逼近streamingText.length,把streamingTextrenderCount到当前位置的新增字符逐步显示出来。

打个比方,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; }

方案三:代码块和普通文本分开渲染。这是一个更彻底的思路。解析流式内容时,检测当前是否处于代码块内部,如果是,代码块部分用<pre><code>包裹,普通文本部分用 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 timeoutMarkdown 标签未闭合TextDecoder stream 模式这几个点讲清楚,面试官基本就能确认你是真上手做过,而不是只会背题。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询