
1. 项目概述1.1 核心需求解析AI 前端和传统后端接口最大的区别之一就是“流式输出”。用户在页面上提问模型一个字一个字地往外蹦这种打字机渲染的体验不是后端一次性返回 JSON 然后前端把整段文本替换上去而是通过 SSEServer-Sent Events把 token 增量地推给浏览器。我一直在做 AI 助手相关的前端收到过不少类似的合作需求希望对话框能像 ChatGPT 一样逐字生成同时遇到弱网和服务器超时时能自动续上而不是让用户面对“生成一半就断了”的空白。这个项目要解决的核心问题有三块。第一怎么稳定接收服务端的流式数据并且把它实时渲染成打字机效果。第二网络抖动、网关超时导致长连接断开后怎么做到断点续传。第三在前端工程中如何封装一套通用、可复用的 SSE 方案既支持对话模型又能对接 Agent 工作流这类事件推送更频繁的场景。如果你也在做 AI 对话、智能客服、Agent 工作流可视化这篇文章的代码和经验可以直接拿过去落地。我自己踩过的坑正好覆盖了这三个方向。最初版本用 EventSource 接数据直接在回调里做字符串拼接和 innerHTML 更新上线后出现三种问题接入层空闲超时导致连接突然断开中文字符被拆包后渲染乱码后端偶尔把事件滞留在 Nginx 缓冲里页面看起来像卡死过几十秒后整段内容突然一起出现。排查到最后才发现问题不只在前端代码还涉及网关配置和协议解析细节。所以下文里我不只给前端代码也会把后端和代理层需要配合的点一起说清楚。1.2 为什么选 SSE 而不是 WebSocket 或轮询先回答一个几乎每个前端新人都要问的问题聊天机器人的输出为什么不用 WebSocket“WebSocket 是全双工能双向推送看起来更高级”这个直觉没问题但从 AI 流式输出的实际需求看SSE 更轻。AI 对话的核心是“服务端持续推一段数据客户端被动接收”这本质是单向的。WebSocket 的双向能力在这里几乎用不上反而带来连接管理、心跳维护、二进制/文本混合帧处理等额外复杂度服务端也要额外维护长连接状态。轮询就更不合适了。如果每 500ms 请求一次“有没有新 token”一方面对后端模型服务是极大的压力另一方面轮询间隔内的数据延迟会直接体现在用户体验上很难做到接近实时的打字机效果。SSE 是 HTTP 协议上的长连接服务端可以持续向客户端发送数据天然支持文本增量、自动重连、事件 ID 和自定义事件类型。它还有一个很关键的优点不需要额外协议实现服务端只要按规范输出text/event-stream响应体客户端用 fetch 就能接住。在 AI 请求-响应型的对话交互里SSE 几乎是最优先的方案。后面我会讲到为什么实战中我最终选的是 fetch 流式读取而不是 EventSource。1.3 方案整体结构整个方案分成三层来看理解清楚之后再逐层编码比直接抄代码靠谱得多。第一层是连接层负责发起 SSE 请求、接收数据、处理取消和异常。这一层要解决“用 GET 还是 POST”“认证怎么做”“连接断了要不要重连”的问题。第二层是数据层负责把网络包解析成事件再把事件的 payload 解析成业务数据。很多团队在这一层只做了JSON.parse就上正式环境结果被粘包、半包和空闲超时打个措手不及。第三层是渲染层负责把已收到的文本通过打字机效果展示出来同时协调用户滚动和 Markdown 重解析等操作。2. 核心细节解析SSE 协议与前端接入2.1 SSE 协议的基本格式SSE 不是一个新的二进制协议它是普通 HTTP 响应上的一种流式文本约定。服务端设置Content-Type: text/event-stream然后把消息按固定格式写到响应体里data: {delta: 你} data: {delta: 好}每条消息以空行分隔data开头表示数据内容客户端会拿到完整的 payload。除了dataSSE 还支持这些字段event自定义事件类型比如event: done客户端可以监听不同类型。id消息 ID断线重连时客户端会把Last-Event-ID带给服务端。retry服务端建议的重连间隔毫秒。以冒号开头的注释行用于心跳保持连接客户端会自动忽略。在 AI 流式输出中服务端通常会这样返回id: 1 data: {code: 0, data: {content: 你}} id: 2 data: {code: 0, data: {content: 好}} id: [DONE] data: [DONE]注意[DONE]这个结束标记在 OpenAI 兼容接口里它是一个没有 JSON 结构的占位消息前端要单独判断不能拿去JSON.parse。这是我从多个模型服务里总结出来的通用约定业务状态码 增量片段 结束标记。2.2 用 EventSource 还是 fetch 流式读取原生 EventSource 确实最简单new一个对象绑定onmessage就能接收 SSE。但在实际项目里EventSource 有三个绕不过去的限制。第一需要自定义 Header 才能认证比如Authorization: Bearer xxx或者需要传X-App-Id。原生 EventSource 只能带 URL无法自定义请求头。第二AI 请求一般是 POST需要把用户的提问放在请求体里而 EventSource 只支持 GET。有些团队会把参数拼在 query 上绕过但长文本 prompt 会被 URL 长度限制卡住还不利于签名。第三需要对流式响应做更精细的取消、超时和错误控制。EventSource 的close()只能关闭连接没法在收到特定事件后马上释放资源并重连。所以实战中我推荐fetch response.body.getReader()。fetch 支持 POST、自定义 Header、AbortController取消还能拿到ReadableStream来读取流式数据配合TextDecoder和缓冲区遍历解析 SSE 事件基本上是标准答案。2.3 实际处理 SSE 数据流的通用代码看一段完整的前端解析代码兼容 OpenAI 格式async function requestSSEStream({ url, body, headers, onDelta, onDone, onError, signal }) { const response await fetch(url, { method: POST, headers: { Content-Type: application/json, Accept: text/event-stream, ...headers }, body: JSON.stringify(body), signal }); if (!response.ok) { throw new Error(HTTP ${response.status}: ${response.statusText}); } const reader response.body.getReader(); const decoder new TextDecoder(utf-8); let buffer ; while (true) { const { value, done } await reader.read(); if (done) break; buffer decoder.decode(value, { stream: true }); const events buffer.split(/\r?\n\r?\n/); buffer events.pop(); for (const rawEvent of events) { const payload parseSSEEvent(rawEvent); if (!payload.data) continue; if (payload.data.trim() [DONE]) { onDone onDone(); return; } try { const json JSON.parse(payload.data); const delta json.choices?.[0]?.delta?.content || ; if (delta) onDelta(delta, payload.id); } catch (e) { onError?.(new Error(parse sse data failed: payload.data)); } } } onDone onDone(); } function parseSSEEvent(raw) { const result {}; for (const line of raw.split(/\r?\n/)) { if (!line || line.startsWith(:)) continue; const index line.indexOf(:); const field line.slice(0, index).trim(); let value line.slice(index 1); if (value.startsWith( )) value value.slice(1); result[field] value; } return result; }这段代码的核心有两个地方需要重点解释。第一decoder.decode(value, { stream: true })的stream: true很重要。它表示当前 chunk 可能只是半个 UTF-8 字符的编码切片TextDecoder 会先把不完整字符缓存起来等下一个 chunk 到位后再完整拼接。很多同学直接decoder.decode(value)中文场景下会出现隔一段中文就乱码一次就是因为一个多字节字符被 split 到了两个网络包中。第二存在buffer的原因是网络层返回的 chunk 粒度不一定等于 SSE event 粒度。一次reader.read()可能返回多个事件也可能只返回半个事件。所以我用split(/\r?\n\r?\n/)把完整事件取出来最后一段events.pop()把没凑齐的半个事件留在 buffer 里等下一轮继续拼。先拼 buffer再按空行切事件这是 SSE 前端解析的标准动作。2.4 异常与取消AbortController 的实战用法流式请求不能只用 Promise必须让用户能随时打断“正在生成”的状态。AbortController是个好工具。const abortController new AbortController(); function stopStreaming() { abortController.abort(); } async function start() { try { await requestSSEStream({ url: /api/chat, body: { messages }, signal: abortController.signal, onDelta(delta) { renderDelta(delta); }, onDone() { console.log(stream finished); } }); } catch (err) { if (err.name AbortError) { console.log(user cancelled); } else { handleError(err); } } }踩过一次坑abort 后网络请求虽然断了但服务端可能还在推理浪费后端资源。更稳妥的做法是前端 abort 后再向后端发一个 cancel 事件让服务端提前终止生成。如果用的是 SSE 长连接也可以在 abort 之前发送一条表示“取消生成”的 POST 请求让模型网关释放资源。当然是否能做到这步取决于后端是否提供取消接口前端至少要保证本地状态能立刻回到可操作状态。3. 打字机渲染从数据流到逐字跳动3.1 朴素追加和高效渲染的区别拿到 delta 之后最直接的方式是把文本拼到状态里再 setState。假设用 ReactsetText((prev) prev delta)这样在 AI 流式场景下会逐渐出现问题。一方面模型产生 token 的速度可能远快于 React 渲染的节拍重复触发 setState 会产生多余渲染。另一方面如果文本还需要解析 Markdown 或代码高亮每收到一个 token 就全量解析一次计算量会随文本长度线性膨胀最后把页面拖卡。优化思路大体分两类。第一类是限制渲染频率。把 delta 先推入一个可变缓冲区每攒够一定时间或一定长度再统一更新一次渲染。第二类是分层渲染。原始增量拼接用一个轻量文本容器Markdown、代码高亮等重组件等到数据流结束后再处理。两者可以同时使用前者保证渲染频率稳定后者保证重组件不至于频繁重建。具体到前端场景可以用requestAnimationFrame做一个“渲染节流阀”确保一帧最多更新一次 DOMlet pendingText ; let rafId null; function pushChunk(chunk) { pendingText chunk; if (rafId) return; rafId requestAnimationFrame(() { domNode.textContent pendingText; pendingText ; rafId null; }); }3.2 基于文本切片的打字机渲染实现更贴近 AI 聊天体验的打字机通常不是“每来一个 token 直接写”而是先把 token 存到完整响应区再由一个调度器逐字或逐词渲染到可视区。为什么要多此一举因为模型推一个 token 可能只用几十毫秒而人的阅读速度追不上这种频率。如果把内容瞬间补全会丢失打字机的临场感。所以产品上往往要控制输出速度让内容像正在被撰写一样显示出来。一个比较稳的模式是“数据区 显示区”分离数据区保存流式收到的完整文本这是可靠的数据源。显示区由调度器从数据区读取待渲染内容按设定的屏幕间隔追加到页面。const state { fullText: , displayedLength: 0, timer: null }; function onDelta(delta) { state.fullText delta; if (!state.timer) startTypewriter(); } function startTypewriter() { state.timer setInterval(() { const next state.fullText.slice(0, state.displayedLength 3); domNode.textContent next; state.displayedLength next.length; autoScrollBottom(); if (state.displayedLength state.fullText.length) { clearInterval(state.timer); state.timer null; } }, 50); }这里每次追加 3 个字符、间隔 50ms是我在类似场景常用的参数。如果产品目标是“尽量贴近真实生成速度”可以把追加间隔调到 20ms 以下配合requestAnimationFrame避免定时器在后台被节流如果目标是让用户读得舒服保持在 50ms 上下即可。3.3 与前端框架的集成细节用 React 或 Vue 这类框架时最容易犯的错误是把fullText放进 state然后在setInterval闭包里反复读它结果拿到的永远是旧值。核心思路是把“完整文本”放在 ref 或组件的可变对象里显示层只管读和写不依赖 state 同步。React 里大致这样组织function ChatItem({ message, isStreaming }) { const textRef useRef(); const [displayText, setDisplayText] useState(); const timerRef useRef(null); useEffect(() { textRef.current ; setDisplayText(); return () clearInterval(timerRef.current); }, [message.id]); function handleDelta(delta) { textRef.current delta; if (!timerRef.current) startTypewriter(); } function startTypewriter() { timerRef.current setInterval(() { const full textRef.current; setDisplayText((prevDisplay) full.slice(0, prevDisplay.length 3)); }, 50); } return div{displayText}/div; }setDisplayText的函数式更新写法比较巧妙它不依赖外部变量读取直接拿上一次的显示长度加 3从fullText里切出来。这样即使闭包捕获了旧 state也只会得到“继续追加”而不是“回到旧值”。3.4 渲染过程中的注意事项一是不要把整个聊天区域的 innerHTML 每次都重写一遍。在流式输出中消息列表往往是多个对话块叠加重写全部会把用户正在查看的上文滚动位置破坏。我的做法是最新一条消息单独用一个容器用textContent增量更新其他消息保持现状。二是 Markdown 渲染的节奏要控制好。如果使用marked、react-markdown这类库建议只在停止流式输出后对完整文本做一次解析而不是每个 token 都解析。如果产品要求实时高亮代码块至少也要做防抖停止收到 delta 500ms 后再触发重解析。三是滚动容器要处理好“用户在向上翻历史”的场景。如果用户正在查看上文最新 token 不断把滚动条拖到底部体验会很糟。简单方案每次准备自动滚动前判断容器是否处于接近底部状态只有距离底部小于 80px 时才自动滚动。function autoScrollBottom() { const el container; const distance el.scrollHeight - el.scrollTop - el.clientHeight; if (distance 80) { el.scrollTop el.scrollHeight; } }4. 断点续传与断线重连4.1 为什么 AI 流式输出会断在流式场景里连接断开是常态而非异常。我会把断线原因分成四类方便排查时对症下药。第一类服务端主动断开。模型生成完毕返回[DONE]这是正常结束不需要重连。第二类网关或反向代理超时。很多 AI 接口前面还有一层 Nginx、云负载均衡或网关它们默认的空闲超时可能是 30 秒或 60 秒。如果服务端超过一定时间没发新数据中间层会直接断开 TCP 连接。这就是stream disconnected before completion: idle timeout waiting for sse的典型来源。第三类客户端网络抖动。Wi-Fi 切换、手机信号弱、电脑休眠都会让长连接中断。第四类浏览器或服务端内存压力大、CPU 繁忙导致进程卡顿无法正常输出心跳。4.2 通过 Last-Event-ID 或游标实现断点续传断点续传的第一原则是客户端要有“已经消费到哪条消息”的标记断线恢复时把标记带给服务端服务端从标记之后继续推。SSE 原生支持Last-Event-ID但 EventSource 会自动带用 fetch 方案时如果服务端在 SSE 事件中带了id我们可以把它存下来重连时放进自定义 Header 或业务 body。let lastEventId 0; let isReconnecting false; function start() { requestSSEStream({ url: /api/chat, body: { messages, lastEventId: lastEventId || null, cursor: displayedTextLength }, onDelta(delta, id) { if (id) lastEventId id; state.fullText delta; }, onDone() { markFinished(); }, onError(e) { scheduleReconnect(e); } }); }这里“游标”和Last-Event-ID的区别要说清楚。如果服务端每个事件都有严格递增的 ID直接用Last-Event-ID最语义化服务端也方便实现“从第 N 个事件继续”。但部分 AI 服务端并不会维护全局事件 ID更多是每次请求生成一个新的会话 ID 和步数序号。这时更稳妥的做法是业务游标客户端把“我已经拿到多少字符/多少步”传给服务端服务端基于这个游标重新生成未完成的部分。需要注意断点续传不能解决“服务端根本没有保存生成中间状态”的问题。如果后端是纯无状态模型调用一旦断开生成上下文可能已经丢了。这种情况下断线重连通常只能做“重新发起一次带用户上下文的问答”然后前端手动拼接历史文本。不要想当然以为前端把游标传过去就一定能续上要提前和后端同学确认清楚“生成状态是否可恢复”。这块是我实际项目里花时间最多的地方比前端代码更值得提前沟通。4.3 指数退避重连与超时控制重连不是越快越好也不是无限重试。一个高频断线的临时故障如果你每秒重连一次可能会把已经压力过大的服务端拖得更垮。推荐用指数退避 最大重试次数const MAX_RETRY 5; let retryCount 0; let timer null; function scheduleReconnect(err) { if (isFinished) return; if (retryCount MAX_RETRY) { notifyUser(连接失败请重试); return; } const delay Math.min(1000 * Math.pow(2, retryCount), 15000); console.warn(SSE reconnect in ${delay}ms, reason:, err); retryCount; if (timer) clearTimeout(timer); timer setTimeout(() { startStream(); }, delay); }指数退避的延迟序列是 1s、2s、4s、8s、15s这样短时抖动能快速恢复大规模故障又不会给后端制造太多连接风暴。还要加一层前端“人工重试”按钮因为自动重试次数耗尽后用户通常愿意手动点一下重试而不是看到白屏就刷新页面。在实际系统里还要注意“重连时用户输入是否要保留”。断点续传一般是复用同一个会话 ID 和用户输入不要让用户重新输入。如果做了基于对话 ID 的缓存重连后还要把已渲染的文本和新文本做拼接去重避免重复内容。4.4 服务端配合要点前端做得再完善如果服务端不配合断点续传也做不通。这里列几个后端需要关注的配置和返回规范。首先服务端要设置正确的响应头。Content-Type必须是text/event-streamCache-Control建议用no-cacheConnection保持keep-alive。有些框架默认会对响应做 buffering要关闭。在 Node.js 里响应res.write直接写入即可但要确认没有经过压缩中间件或强制 flush 的处理。// Node.js 服务端示例适合 Koa/Express 风格调整 const sseResponse (res) { res.setHeader(Content-Type, text/event-stream); res.setHeader(Cache-Control, no-cache); res.setHeader(Connection, keep-alive); res.flushHeaders res.flushHeaders(); }; function sendSSE(res, event) { res.write(id: event.id \n); res.write(event: message\n); res.write(data: JSON.stringify(event.data) \n\n); }其次如果生成过程会持续较长时间建议服务端每隔 15 到 30 秒发一条注释行: keep-alive这能有效防止中间层的空闲超时误杀连接。最后要支持基于事件 ID 或游标的续传逻辑意味着服务端在生成过程中需要把已发送的事件缓存起来至少在内存里保留最近一段时间的缓冲区断线重连时可以直接从指定位置补推。5. 常见问题与踩坑记录5.1 频繁出现的 idle timeout 问题很多人拿到 SSE 代码后本地开发一切正常一部署到服务器就出现stream disconnected before completion: idle timeout waiting for sse基本都指向“中间层超时”。排查思路如下。先用curl -N直接请求后端看是否正常。如果 curl 没问题说明后端 SSE 正常问题大概率出在网关、代理或 CDN。检查 Nginx 配置里的proxy_read_timeout、proxy_send_timeout默认 60 秒长文本生成很容易超出。检查上层负载均衡比如阿里云 SLB、腾讯云 CLB的空闲超时和会话保持设置有的云网关把空闲超时固定为几秒必须调整。确认服务端是否定期发送心跳注释。Nginx 的超时是“读超时”如果服务端隔一段时间就发一行注释中间层就认为连接还有活动不会掐断。下面是一段常见的 Nginx 配置location /api/chat { proxy_pass http://backend; proxy_http_version 1.1; proxy_set_header Connection ; proxy_set_header Host $host; proxy_set_header X-Real-IP $remote_addr; proxy_read_timeout 300s; proxy_send_timeout 300s; proxy_buffering off; }其中proxy_buffering off很关键。Nginx 默认会缓冲后端响应这会让 SSE 流式数据积累到一定大小才往客户端发导致前端长时间收不到数据或连接被误判为空闲。关闭缓冲后数据一到就转发打字机效果才能在整条链路中成立。CDN 如果开着同样可能把事件流缓冲成整包应用层需要再检查一下。5.2 SSE 数据流被粘包或拆包导致解析失败用 fetch 流式读取时response.body.getReader()读到的 chunk 边界和 SSE 事件边界不是一回事。一次 read 可能返回 10 个事件也可能只返回半个事件。我第一次实现时没做 buffer 拼接直接把每轮字符串塞进JSON.parse半小时内收到一堆奇怪的错误。后来把解析改成了“先拼接 buffer再按空行拆事件剩余部分留待下一轮”问题立刻消失。这类问题的经典表现包括报错Unexpected end of JSON input中文内容偶尔出现第一个字丢失某个 delta 被截成两半后两半都拼不上建议所有接 SSE 的团队在前端封装一个createSSEParser工具单元测试覆盖“半包”“粘包”“多行 data 合并”“注释行忽略”四种情况。如果偷懒不封装后续每个对接 AI 接口的开发都要重复踩同样的坑。5.3 浏览器兼容性、编码与跨域问题EventSource 支持所有现代浏览器ReadableStream方面 Safari 的兼容性略有差异但主流版本基本可用。编码方面统一使用 UTF-8TextDecoder必须显式指定utf-8避免某些浏览器默认解码成 ISO-8859-1。跨域方面SSE 本质还是 HTTP 请求需要服务端配置Access-Control-Allow-Origin且因为请求带自定义 Header、可能还带Authorization所以Access-Control-Allow-Headers也要包含对应字段。注意一点很多反向代理和浏览器插件会拦截text/event-stream的响应导致本地联调时明明后端正常却迟迟收不到数据。可以先打开浏览器 DevTools 的 Network 面板看响应 Content-Type 是不是text/event-stream再看响应体是不是持续在追加文本。如果响应体一直在变但页面没渲染问题在前端解析或状态更新如果响应体攒到很大才出现问题在缓冲中间层。5.4 常见问题速查表表现可能原因排查方向收到 [DONE] 后页面没结束回调结束标记被当成普通 JSON 去解析先判断 data [DONE]中文乱码或首字缺失TextDecoder 没带 stream: true改用 decode(value, { stream: true })一直不出打字机效果最后整段一起出来Nginx 或 CDN 缓冲未关闭设置 proxy_buffering off检查 CDN 配置连接 60 秒左右必断反向代理 proxy_read_timeout 太短调大超时增加心跳注释断线重连后内容重复前端游标没传给后端基于事件 ID/游标续传EventSource 无法发起认证 Header 不能自定义改用 fetch ReadableStream页面滚动位置被强行拉到底部自动滚动判断不完善距离底部大于 80px 时不自动滚动6. 一些实操上可以再打磨的点最后分享几个我在实际项目中打磨的细节不一定对所有项目通用但对体验的提升非常直观。第一流式输出时建议额外维护一个 status 状态机connecting、streaming、done、error等。UI 组件根据状态渲染不同的发送按钮和重试入口避免用户在生成途中误点发送按钮导致重复请求。用AbortController把上一个请求 abort 掉再发起新请求状态机标记为connecting。第二重连逻辑不要自嗨最好在后端也做一层幂等。比如对话 ID 加游标加用户消息摘要三段组合成一个请求签名服务端可以判断是否是同一会话的重试请求避免重复计费和重复调用模型。如果后端没有这层能力前端至少要做到“重试按钮只显示在失败的那条消息上”。第三如果数据流里有 tool call 或 function call 事件前端往往需要按event字段区分类型。比如 OpenAI 兼容接口中函数调用参数也可能通过delta.tool_calls增量返回。这时候不要把这些内容盲目拼进文本 DOM要拆出独立的 tool_call 渲染区。我的经验是凡是 event 类型可能超过一种的接口前端的parseSSEEvent要保留event字段上层用 switch 分流。踩了几年 AI 前端的坑我的体会是SSE 流式输出、断点续传、打字机渲染这三个技术点必须一起做好才能给用户一个真正“流畅生成中”的体感。连接稳定性是底层骨架数据解析是中间层渲染体验是用户能直接看到的上层表现。任何一个环节没做好都会表现为“生成到一半卡住”或者“内容一下子全出来”的怪异行为。先用这篇文章里的基础代码跑通一条链路再根据业务场景叠加认证、重连、Markdown 解析、函数调用等能力基本就能覆盖绝大多数 AI 前端产品的需求。