前阵子接手一个 AI 对话产品的重构,发现流式输出这块儿,表面上看就是“SSE 接收数据 + 状态更新渲染”两行字,真要落地却处处是坑。连接动不动就断、生成到一半用户刷新页面就白等、打字机效果把浏览器卡到掉帧。这次我把整套方案重新捋了一遍,从 SSE 选型、打字机渲染、断点续传到问题排查,把能踩的坑都踩了一遍,今天把最终能稳定跑的方案完整拆给大家,正在做 AI 聊天、AI 写作、类 Copilot 功能的前端同学可以直接抄作业。
1. 为什么 AI 流式输出要先选 SSE
先说结论:在 AI 对话、内容生成这类“服务端生成、前端展示”的场景里,SSE(Server-Sent Events,服务器推送事件)就是比 WebSocket 更合适。很多同学一上来就 WebSocket,实际上是杀鸡用了牛刀。SSE 本质是 HTTP 连接上的一段持续响应流,服务端把数据分块往下推,前端用事件监听的方式逐段拿。它的核心优势是基于 HTTP、单向推送、自带重连,和 AI 接口“用户问一句,服务端算半天、吐一堆 token”的模型天然匹配。
有人可能会问:WebSocket 不行吗?行,但没必要。AI 对话场景里用户和服务端的交互是典型的“一问一答”,用户不用频繁往服务端推数据,真正高频的是服务端往客户端推生成结果。这种单向流式场景,用 WebSocket 等于自己给自己找事——要做心跳、要做重连、要处理二进制帧、要管理连接状态,而 SSE 在浏览器里一个EventSource就能搞定,服务端实现也更简单。
1.1 SSE 和 WebSocket 的本质区别
先看一张对比图(文字版),大家感受一下差异:
| 维度 | SSE | WebSocket |
|---|---|---|
| 传输方向 | 服务端→客户端单向 | 全双工双向 |
| 底层协议 | HTTP(text/event-stream) | 独立的 WS 协议,握手后升级 |
| 浏览器 API | EventSource / fetch | WebSocket |
| 自动重连 | 内置,断线自动重连 | 需要自己实现 |
| 自定义请求头 | EventSource 不支持,fetch 方案可支持 | 支持 |
| 传输格式 | 文本(UTF-8) | 文本或二进制 |
| 服务端复杂度 | 很低,普通 HTTP 接口就能写 | 较高,需要协议处理 |
实际开发中我首选 SSE 还有一层原因:它能直接复用现有的 HTTP 体系。鉴权可以走 token 请求头(用 fetch 方案),网关、日志、监控全部走现成链路,出了问题排查起来链路短。WebSocket 是长连接,服务端要保持连接状态,多实例部署时还要考虑连接粘滞,复杂度直接上一个台阶。
AI 场景还有个微妙的地方:模型生成是逐步的、不可预测的,用户看到“正在输入”的反馈能极大提升体验,而 SSE 天然支持这种“流式反馈”——不需要像轮询那样按固定间隔请求,而是服务端有内容就推,没内容就保持连接。这也是为什么各大 AI 厂商的开放接口基本都支持 SSE,而不是让你用 WebSocket 对接。
1.2 EventSource 的三个局限与 fetch 流式方案
很多人知道 SSE 就先写new EventSource(url),但实际项目里 EventSource 有硬伤:
- 只能发 GET 请求,无法携带自定义 Header。AI 接口普遍需要 Authorization 鉴权,用 EventSource 就只能把 token 拼在 URL 上,既难看又有日志泄露风险。
- 重连行为不可控。EventSource 断线会自动重连,但重连后从哪开始、要不要重发上次没接收完的内容,很多时候需要业务层自己控制,默认行为不满足需求。
- 对连接取消的处理不友好。用户点击“停止生成”,
eventSource.close()确实能断开,但服务端感知到断开需要依赖 TCP 超时,处理不好连接会挂很久。
所以我的推荐是:用 fetch + ReadableStream 手动解析 SSE 流,把主动权握在自己手里。核心代码如下:
const response = await fetch('/api/chat/stream', { method: 'POST', headers: { 'Content-Type': 'application/json', 'Authorization': `Bearer ${token}` }, body: JSON.stringify({ sessionId, message }), signal: controller.signal // 用于手动取消 }); 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 事件以空行分隔,按事件块解析 const events = buffer.split(/\r?\n\r?\n/); buffer = events.pop() || ''; // 最后一段可能不完整,保留到下一轮 for (const eventBlock of events) { const dataLines = eventBlock .split(/\r?\n/) .filter(line => line.startsWith('data: ')) .map(line => line.slice(6)); const payload = dataLines.join('\n'); if (payload) handleChunk(JSON.parse(payload)); } }这里有两个关键点:一是decoder.decode(value, { stream: true }),不传stream: true会导致多字节字符(比如中文)在跨 chunk 时被切断乱码,这是新手最容易踩的坑;二是按空行切分事件块,SSE 规范里事件之间用空行分隔,不能只按\n切。
提示:用 fetch 方案后,自动重连就没有了,需要自己实现。后文第 4 部分会讲怎么做可靠的自动重连和断线恢复。
2. 打字机渲染:让 AI 回复“看起来”更快
SSE 数据拿到手以后,下一步就是渲染。如果服务端每推送一个 token 你就 setState 一次,用户大概率会看到一个疯狂闪烁的聊天窗口——因为 React/Vue 的状态更新和 DOM diff 都是有开销的,每秒几十次更新很容易把主线程占满。打字机效果的本质,就是把“收到数据”和“渲染数据”解耦,用可控的频率把累积的数据“放”到屏幕上,制造出逐字输出的丝滑感。
2.1 打字机效果的核心逻辑与最小实现
先上代码,一个最小可用的打字机 Hook(React 示例):
function useTypewriter(streamingText: () => string, speed = 30) { const [displayText, setDisplayText] = useState(''); const textRef = useRef(''); const timerRef = useRef<number | null>(null); useEffect(() => { if (timerRef.current) return; // 已有定时器则复用 timerRef.current = window.setInterval(() => { const target = streamingText(); const current = textRef.current; if (current.length < target.length) { // 每次定时器触发,最多追加若干字符 const step = Math.max(1, Math.floor((target.length - current.length) / 5)); textRef.current = target.slice(0, current.length + step); setDisplayText(textRef.current); } else { // 已经追上最新内容,清空定时器,避免空转 if (timerRef.current) { clearInterval(timerRef.current); timerRef.current = null; } } }, speed); return () => { if (timerRef.current) clearInterval(timerRef.current); }; }, [speed, streamingText]); return displayText; }几个设计要点:
- 定时器只维护一个,不管流里来了多高频的数据,渲染频率恒定由
speed控制,避免高频 setState。 - 每次渲染不一定只加一个字符,如果当前累积的数据已经很多,一次取一个字符会导致“打字机跟不上读秒”,用户会觉得输出很慢。所以我按剩余长度的 1/5 步进,既保持打字机的节奏感,又能快速追上最新内容。
- textRef 保存最新已渲染文本,避免在 setState 异步回调里读旧值。如果直接在
setDisplayText(prev => ...)里处理,定时器和渲染函数之间很容易产生闭包陷阱。
这个 Hook 的输入streamingText是一个函数,每次调用返回最新的完整文本(从 SSE 收到的累积内容),渲染层只负责控制展示进度,两者互不干扰。
2.2 滑动窗口与渲染性能优化
打字机本身优化好了,但如果 AI 输出的是超长代码块、长 Markdown 文档,还有两个问题:一是每次 setDisplayText 都会全量 diff 整个消息文本,文本越长越卡;二是浏览器渲染长文本节点本身就会掉帧。
对第一个问题,我的方案是滑动窗口裁剪 DOM。聊天窗口里通常有历史消息和当前生成消息,随着内容变长,把已滚出可视区域的历史消息 DOM 节点做“轻量化”处理——只保留纯文本摘要或首屏片段,真实完整内容存到 JS 对象里。实际操作时可以在渲染组件里加一个阈值:
if (message.length > 8000 && !isVisibleInViewport()) { return <div>{message.slice(0, 200)}...(已折叠)</div>; }滚动到该消息附近时再展开完整内容。
对第二个问题,长文本渲染的瓶颈主要在于浏览器对连续文本节点的排版和绘制,一个实用的技巧是给打字阶段的 DOM 节点设置contain: content或者content-visibility: auto,通知浏览器该区域独立渲染,减少重排影响范围。实测下来,长 Markdown 文档从持续卡顿提到基本流畅。
如果内容实在太长(比如 10 万 token 以上的报告),那打字机效果就要分页或分区渲染:先让用户看到当前区块,历史区块保留展开按钮。这也是很多 AI 写作产品实际采用的方案,无脑全量渲染在低端设备上确实扛不住。
2.3 Markdown 流式渲染的特殊处理
AI 生成的内容大多是 Markdown,但流式阶段直接对不完整 Markdown 调用marked/remark/markdown-it,会出现一个很丑的现象:代码块没闭合时,整个后续内容被吞进代码块里,或者列表、标题样式闪来闪去。用户视角就是“内容一直在跳”,体验很差。
我的方案是分层处理:
- 打字机阶段:不做完整 Markdown 渲染,只做轻量转义和纯文本展示,把末尾未闭合的代码标记、链接标记处理成普通文本。
- 流结束后:再用完整的 Markdown 渲染器做一次全量渲染,替换掉打字阶段的轻量内容。
对代码块,加一个闭合检测就很实用:
function sanitizePartialMarkdown(md: string): string { // 统计未闭合的代码块围栏(```) const fenceMatches = md.match(/```/g) || []; if (fenceMatches.length % 2 !== 0) { // 末尾补一个闭合围栏,防止整段被误判为代码块 return md + '\n```'; } return md; }同样的思路也适用于链接、图片语法。这类不完整语法在流式阶段可以先显示为纯文本,不影响用户阅读速度,流结束后再精确渲染。个人经验是:不要在打字机渲染阶段追求“每一步都好看”,保证“最终一定好看”就够了。
3. 断点续传:崩溃之后,用户不能重头开始
SSE 连接是长连接,长连接就一定会断。断网、服务端重启、网关超时、浏览器切后台被系统回收连接……AI 生成一条长回答可能要几十秒甚至几分钟,中途断线的概率在我看来至少 5%~10%。如果没有断点续传,用户就只能重新生成,既浪费时间又可能重复计费。所以这块必须在一开始就纳入设计。
3.1 断点续传的适用场景与设计思路
先说清楚:这里说的“断点续传”不是把生成结果从客户端传给服务端,而是把“模型生成进度”持久化在服务端,断线后客户端能接着消费。类比一下就是这样——文件断点续传是记录“文件上传到第几个分片”,AI 断点续传是记录“模型生成到第几个 token”,本质都是“从上次中断的位置继续”。
适用场景主要有这些:
- 生成长内容:AI 写作、周报生成、代码生成,单次输出几千上万字,中途断线非常常见。
- 移动端弱网环境:网络频繁切换,Wi-Fi 和 4G 切换必有一小段时间断流。
- 服务端多实例部署:某个节点挂了、重启,连接迁移后要能从历史记录继续。
设计上需要服务端和前端各承担一部分责任。服务端要把“生成了多少内容、存到哪了、当前状态是什么”记录下来;前端需要记录“我已经消费到哪个位置了”,并在重连时把游标传回服务端。
3.2 服务端配合:游标设计示例
服务端至少需要一张流式生成记录表,核心字段如下:
| 字段 | 说明 |
|---|---|
| session_id | 对话会话 ID,关联用户和聊天记录 |
| message_id | 当前生成消息的 ID |
| generated_text | 已生成的完整文本,边生成边持久化 |
| token_offset | 已生成 token 数,相当于游标 |
| status | generating / completed / failed |
服务端把已生成文本和游标持续写入,比如每生成一段或每 2 秒落盘一次。客户端断线重连时,带上session_id和message_id,服务端判断status = 'generating',就返回“当前已生成内容 + 游标”,客户端从游标继续请求新的生成结果。
在 SSE 协议层面,服务端可以在事件里携带id字段,浏览器端可以用Last-Event-ID做续传。但因为我们用的是 fetch 方案,这个能力就需要自己实现,直接把游标作为请求参数传给服务端即可。
3.3 前端恢复流程与 UI 状态处理
前端恢复流程我总结为三步:
- 检测断开:SSE 流异常结束(网络错误、读不到数据、收到服务端失败标记),不要立刻把 UI 标记为“失败”,先标记为“中断”。
- 查询恢复点:请求一个恢复接口,例如
GET /api/chat/:sessionId/messages/:messageId,拿到已生成文本和游标。 - 续传渲染:把已生成文本作为初始渲染值,然后带上游标重新发起 SSE 生成请求。服务端从游标处继续推送,前端从已有内容的基础上接着打字机渲染。
核心伪代码:
async function resumeGeneration(sessionId, messageId) { // 1. 查询恢复点 const { content, cursor } = await fetch(`/api/chat/${sessionId}/messages/${messageId}`).then(r => r.json()); // 2. 先把已有内容渲染出来 appendMessage({ role: 'assistant', content, streaming: true }); // 3. 从游标处恢复 SSE 流 const stream = connectGenerateStream({ sessionId, messageId, cursor }); for await (const chunk of stream) { appendChunk(chunk.text); } }这里有个 UI 细节容易被忽略:中断发生后,如果自动恢复失败或者恢复按钮被用户忽略,生成状态一直挂着会很奇怪。我的做法是提供**“继续生成”按钮 + 底部状态条**,状态条展示“生成已中断,点击继续/重新生成”,不让用户困惑到底完没完。
另外,幂等设计一定要有。断线重连时如果用户的原始请求被服务端重复处理,就可能重复调用模型、重复扣费。服务端收到带游标的续传请求时,要能识别出“这是同一个 message_id 的续传”,不再新建生成任务,而是返回同一任务已经生成的内容。这个由后端把关,但前端也要在重试逻辑里加maxRetries限制,避免死循环重试。
4. 实战中的坑与排查技巧实录
最后这部分全是实战经验。标题里提到的那些异常关键词——stream disconnected before completion、idle timeout waiting for sse、标签未返回完整——正好对应我实际踩过的三种典型问题:网关超时断连、心跳保活缺失、文本切割不完整。逐个拆解。
4.1 高频报错:stream disconnected before completion / idle timeout
如果你用了像 Nginx 这类反向代理,SSE 长连接非常容易被打断,经典报错就是stream disconnected before completion: idle timeout waiting for sse。原因很直接:Nginx 默认proxy_read_timeout是 60 秒,如果 60 秒内上游没有任何响应,连接就被切了。
解决办法有两层:
- 调大超时时间:
proxy_read_timeout 300s;,配合proxy_buffering off;关闭缓冲区,确保流式数据实时转发而不是攒一批再发。 - 加心跳注释行:SSE 规范里,以冒号开头的行是注释,会被客户端忽略,但能让负载均衡器和代理服务器认为连接还在活跃。后端每隔 15~20 秒发一个
: keep-alive\n\n,就能有效避免 idle timeout。
如果前端用的是 EventSource,超时时间由浏览器控制,一般不会主动断;但 fetch 方案里如果读流的间隔太久,某些代理也会判定空闲。所以前端也可以兜底发心跳,比如每 10 秒reader.read()一直有数据或者收到注释行,就重置空闲计时器。
4.2 网络中断与标签未返回完整的处理
网络中断的表现很多样:TypeError: Failed to fetch、连接读不到数据、页面切后台回来连接已挂。关键在于:中断和完成必须能区分开。SSE 规范里服务端可以在所有数据推完后发送一条特殊事件,比如data: [DONE],前端看到这个标记才认为生成结束,否则一律视为“中断”。
实操里我见过不少团队把“连接断开”直接当成“生成完成”,导致用户看到的回答少半截,还找不到原因。正确姿势是:
if (payload === '[DONE]') { setIsCompleted(true); return; } // 正常数据处理 handleChunk(payload);在reader.read()返回done但没收到[DONE]时,前端应进入“中断处理”流程,提示用户继续生成或者自动重连。
“标签未返回完整”这个问题,常见于后端把生成文本按固定长度切片后塞进 SSE 数据帧,或者前端把内容按标签(比如 HTML 标签)做高亮。如果断流发生在标签中间,前面提到的不完整 Markdown 闭合检测同样适用。如果做的是富文本标签渲染,可以维护一个标签栈,把未闭合的标签在后端补齐或者前端兜底关闭,避免整个页面布局被一个未闭合的<div>撑坏。
4.3 稳定性设计:重试、指数退避与手动续传
自动重试不是简单的“断了就重连”,要带上指数退避,否则服务端一抖动,成百上千个客户端同时重连,直接把服务端打挂。一个实用的退避策略:
const retryDelays = [500, 1000, 2000, 4000, 8000, 15000]; let attempt = 0; async function connectWithRetry() { try { await connectStream(); } catch (e) { if (attempt >= retryDelays.length) { setStatus('failed'); showRetryButton(); // 手动续传兜底 return; } await sleep(retryDelays[attempt]); attempt++; await resumeGeneration(sessionId, messageId); // 幂等续传 } }注意重试要做幂等校验,每次重试带上游标,服务端判断是否为同一任务,避免重复扣费和重复生成。重试超过最大次数后,建议转人工操作——给用户一个“继续生成”按钮比无限自动重试更稳妥。
还有一个经常翻车的地方:浏览器的并发连接数限制。HTTP/1.1 下浏览器对同一域名并发连接数有限制(通常是 6 个),如果你的页面上同时打开了多个 SSE 连接,会自动阻塞后续请求。解决方案是后端支持 SSE 连接复用,或者部署时走 HTTP/2,实测下来效果立竿见影。
最后再分享一个亲测有用的技巧:把断点续传和打字机渲染的状态统一收敛到一个 store,不要一个组件管数据、另一个组件管渲染。我在重写之前就是这样,断线恢复时 UI 要么闪一下要么多出一段重复内容,后来把所有流式状态集中到一个小型 store(状态机)里,什么中断、重连、完成、失败都变成状态流转,问题立刻清晰了很多。AI 前端流式落地没什么黑魔法,把基础协议吃透、状态设计清楚,稳定性自然就上来了。