
如果你最近在做和 AI 相关的 Web 应用一定绕不开一个名字SSEServer-Sent Events服务端事件推送。在大模型对话、AI 写作、报表生成这类场景里前端最直观的体验就是用户刚把问题发出去答案并不是一次性砸下来的而是像人打字一样一个字一个字往外蹦。这种效果背后就是 SSE 流式输出在支撑。这篇实战总结我围绕 SSE 流式输出、断点续传、打字机渲染这三个点把我在真实项目里遇到的坑和最终落地的方案完整梳理一遍适合正在用 Vue/React 对接大模型接口、或者刚开始做 AI 应用前端的同学参考。说实话我刚接触 AI 前端的时候也踩过不少坑。第一次对接大模型接口我拿着传统 HTTP 请求的思维去写等了十几秒没响应然后就超时了。后来才知道AI 应用的交互逻辑和普通接口完全不同——服务端的内容是一点一点“流”出来的前端如果不用流式的思维去接体验一定做不好。这篇文章不会讲太多高深理论重点放在能直接抄走的代码、配置和排查思路大家按章节往下看就行。1. AI 前端为什么绕不开 SSE从长连接协议到推送格式1.1 SSE 到底是什么它是怎么把对话内容“吐”出来的SSE 全称是 Server-Sent Events翻译过来就是“服务端事件推送”。它是一个基于 HTTP 的轻量级长连接协议客户端发起一次请求服务端可以在这条连接上持续不断地把数据推给客户端。和普通接口最大的区别在于普通接口是一次请求一次响应响应完就断开SSE 是一条连接可以推送很多次直到服务端主动关闭。AI 对话场景天然适合 SSE。大模型的生成是一个逐 token可以粗略理解为一个字、一个词、一个片段输出的过程模型每生成一部分就通过 SSE 推给前端前端就能立刻渲染出来用户看到的就类似真人打字的效果。如果不用 SSE 而用普通接口要么只能等模型全部生成完再一次性返回用户等几十秒看不到东西要么单纯靠轮询每隔几秒请求一次看有没有新数据实现繁琐而且实时性差。SSE 推送的数据格式是text/event-stream我们看一下实际返回长什么样data: {id:msg_01,delta:你好} data: {id:msg_01,delta:很高兴} data: {id:msg_01,delta:认识你} data: [DONE]每两个空行之间是一个事件data:后面是这一帧的数据内容。以[DONE]结尾代表整个流结束。如果数据带 id 字段还可以在断线时通过Last-Event-ID告诉服务端“我收到哪了接着往后推”。这个机制等下讲断点续传的时候会详细展开。1.2 SSE 和 WebSocket选型不能只凭感觉很多人一听到“长连接”就想到 WebSocket然后问我为什么不用 WebSocket我的答案很直接AI 对话这个场景如果只需要服务端单向推给客户端SSE 的收益远大于 WebSocket。我把两者的对比整理成了一张表格方便大家直接判断。对比维度SSEWebSocket通信方向单向服务端推给客户端双向双方都能主动发消息协议基础基于 HTTP无需额外协议升级独立协议需要握手升级自动重连浏览器 EventSource 自带需要自己实现断点续传原生支持 Last-Event-ID自己设计方案网关/代理兼容性友好Nginx 配置简单需要额外处理反向代理升级实现复杂度低高AI 对话的主要数据流是模型生成内容给用户看用户在对话过程中输入问题是一次性的不需要多轮双向实时通信。用 WebSocket 等于杀鸡用牛刀反而带来更多麻烦比如服务端推送异常、代理层不兼容、重连机制要手写。SSE 直接复用 HTTP 的成熟生态服务端也不一定要用专门的库普通 Node.js 接口设置好响应头边生成边写入就行。当然 SSE 也不是万能的如果你的 AI 应用需要音视频通话、协同编辑这种高频双向交互那还是老老实实上 WebSocket。我的建议是先想清楚数据流方向再决定协议不要盲目追新。2. 拿到流式数据之后打字机渲染的三种姿势与选型2.1 最直观的做法是整体替换 DOM但问题也很明显拿到 SSE 推过来的数据以后第一反应通常是把累积的文本直接塞到聊天窗口的容器里。很多新手会写成这样// 伪代码示意而已 let fullText // 已收到的完整内容 sse.onmessage (event) { fullText event.data chatContainer.innerHTML marked.parse(fullText) // 每次全量渲染 }在小规模文本下这么做确实能跑但很快会出现两个问题。第一个是性能问题。AI 回答一篇长文可能有几千甚至上万字每次收到一个 token 就全量 parse 一遍 markdown再整体替换 innerHTML浏览器会频繁触发回流和重绘文本越长越卡。我实测过文章超过 3000 字时打字机效果已经开始掉帧用户能明显感觉到“蹦一下卡一下”。第二个是用户体验问题。整体替换 DOM 会导致光标位置被重置。如果用户正在看前面一段内容或者想选中复制某句话新的内容一进来整个区域重新渲染选中的高亮很可能就消失了滚动位置也可能被弹回顶部。所以打字机渲染不是简单的字符串拼接它的关键点在于“增量更新”。也就是说每次新数据到达只把新增的那部分渲染进去已经稳定的内容不要动。2.2 缓冲区加增量追加我实际采用的实现方案我采用过一种比较稳的方案维护一个缓冲区先把新到达的数据追加到缓冲区里再按“是否已经到了一个完整可展示的边界”来决定这次是只追加增量还是整体重渲染。先给大家一个 Vue 3 的简化实现template div refchatBox classchat-box !-- 已渲染为 HTML 的内容 -- div v-htmlrenderedHTML/div !-- 当前正在流式处理、尚未稳定渲染的增量内容 -- span refstreamingSpan/span /div /template script setup import { ref, nextTick } from vue const renderedHTML ref() const streamingSpan ref(null) // 累积的原始 markdown用于处理复杂渲染 let buffer // 已渲染过的原始文本长度用于计算增量 let renderedLength 0 function onSSEChunk(chunkText) { buffer chunkText // 如果包含完整的代码块结束标记说明代码块完整可以整体重渲 const codeBlockClosed countCodeFence(buffer) % 2 0 // 如果已经累积了大量内容强制进入渲染阶段 const bufferTooLong buffer.length - renderedLength 200 if (codeBlockClosed || bufferTooLong) { // 全量重渲已渲染区域 renderedHTML.value marked.parse(buffer) renderedLength buffer.length } else { // 增量渲染直接把新文本塞到 streamingSpan 里 if (streamingSpan.value) { streamingSpan.value.textContent chunkText } } // 滚动到底部 scrollToBottom() } function countCodeFence(text) { return (text.match(//g) || []).length } function scrollToBottom() { const box document.querySelector(.chat-box) if (box) { nextTick(() { box.scrollTop box.scrollHeight }) } } /script这段代码的思路是普通文本直接增量追加保证打字效果流畅一旦检测到 markdown 代码块的 标记闭合也就是 反引号数量是偶数说明这段代码块已经完整了才做一次全量重渲染确保代码高亮和缩进格式正确。这里有个小坑v-html重渲染后增量追加的streamingSpan和已经渲染的内容之间可能会出现视觉上的割裂感。解决办法是在重渲染完成后把streamingSpan里的内容清空并把最新已累积的文本整体交给renderedHTML再让后续的增量重新从空开始。2.3 处理 Markdown 流式渲染标签未闭合、代码块未完整怎么办这是 AI 前端最容易踩坑的地方。大模型返回的内容几乎都是 markdown 格式但流式返回时一个完整的标记往往会被拆成好几个 chunk比如新闻里说的“标签返回未完整怎么处理”就是这个问题。假设模型先推了**加粗后面的**还没推过来如果这时候就把**加粗渲染出去页面会显示一个不完整的加粗标签甚至导致后面所有内容都变了样式。我的处理原则是所有内容都先看作是“待定状态”只有到了断言它已经完整的时候才进入正式渲染。具体来说有几个办法第一个办法是“延迟重渲”。不是每个 token 都马上渲染而是等一个短时间窗口比如 100ms没有新内容进来或者累积了一小段完整文本后再统一渲染一次。这个办法最简单缺点是不够实时但用于普通聊天文本完全够用。第二个办法是“边界匹配”。在渲染前判断当前 buffer 里有没有未闭合的 markdown 标记比如反引号、**、_、[等。如果存在未闭合的内容不渲染那部分等后续 chunk 补全后再渲染。第三个办法是“分块渲染”。把内容按逻辑分段比如按空行拆成段落对识别为代码块的部分延迟到闭合后再渲染对普通段落则实时增量输出。我比较推荐这个办法实现成本适中且体验最好。强调一点如果使用 markdown-it 这类解析器建议关闭一些可能导致整篇重排的插件。尤其是代码高亮插件如果每次 parse 都重新高亮一遍全量代码卡顿会非常明显。我的做法是“流式阶段只显示纯文本代码块闭合后再做高亮”两者分开。3. 断线了不等于白聊断点续传要做两层3.1 第一层连接层续传为什么 EventSource 的 Last-Event-ID 不能满足所有场景先说一个我踩过的真实坑SSE 连接断了用户以为对话丢了实际上数据已经生成了一大半。重新连接后如果从头开始接收那么已经显示的内容会重复一遍用户体验很差如果从断点开始接收又没有记录断点位置服务端不知道从哪开始推。SSE 协议本身设计了Last-Event-ID机制如果服务端在推送事件时带了id字段客户端重连时会在请求头里自动带上Last-Event-ID告诉服务端“我处理到哪条了往后的可以继续推”。浏览器原生 EventSource 甚至不用手动处理这个逻辑自动重连时会自动携带。但实际项目中原生 EventSource 有两个硬限制只能使用 GET 请求不能带 body。很多大模型网关的流式接口是用 POST JSON body 传参数的。不能自定义请求头。想在 SSE 请求里带一个Authorizationtoken原生 EventSource 做不到除非用 query 参数但把 token 放 query 里既不安全又容易被网关日志记下。所以我最终选择了fetchReadableStream自己解析 SSE 流而不是使用原生 EventSource。这样做的好处是POST/GET 随便选、请求头随便加、还能自由控制断开时机。代价是不会自动重连需要自己实现断线重试逻辑。下面是我实际用的一个 fetch 解析 SSE 的实现骨架async function fetchSSE({ url, params, headers, onMessage, onDone, onError, signal }) { const res await fetch(url, { method: POST, headers: { Content-Type: application/json, Authorization: Bearer ${token}, Accept: text/event-stream, ...headers, }, body: JSON.stringify(params), signal, }) if (!res.ok) { throw new Error(HTTP ${res.status}) } const reader res.body.getReader() const decoder new TextDecoder(utf-8) let buffer while (true) { const { done, value } await reader.read() if (done) break // 注意decode 时第二个参数传 { stream: true }否则多字节中文字符可能被切碎 buffer decoder.decode(value, { stream: true }) // 按空行切分事件块 const chunks buffer.split(\n\n) buffer chunks.pop() // 最后一个可能是不完整的块留到下次再处理 for (const chunk of chunks) { const lines chunk.split(\n) for (const line of lines) { if (line.startsWith(data:)) { const data line.slice(5).trim() if (data [DONE]) { onDone onDone() return } try { const parsed JSON.parse(data) onMessage onMessage(parsed) } catch (e) { // 非 JSON 消息原样透传 onMessage onMessage(data) } } } } } onDone onDone() }这个方法里最关键的一行是buffer decoder.decode(value, { stream: true })这里有个容易被忽略的知识点SSE 流式返回时一个完整的中文字符在多字节编码下可能被拆到两个 chunk 里。我最早没加{ stream: true }结果经常出现中文乱码后来查了 TextDecoder 的文档才知道这个参数是让解码器把不完整的字节先缓存起来等后面的字节到达后再一起解码完美解决问题。3.2 第二层内容层续传把“已显示内容”缓存到底连接层的续传解决的是“SSE 事件从哪里继续推”的问题但咱们是前端还需要解决另一个问题断线重连期间用户看到的内容不能丢重新连接后最好能把之前已经显示的文本先恢复出来避免聊天记录闪烁或变空白。我的方案是“内存缓存 localStorage 持久化”。具体来说内存里维护一个conversationHistory数组记录当前会话里所有已经稳定显示的消息。每收到一个新事件更新完界面后把这个新事件追加到conversationHistory里。同时把整份conversationHistory防抖写入 localStoragekey 用会话 ID 区分。页面刷新或者连接断开重连时先检查 localStorage如果有存好的历史先渲染出来然后再发起新的 SSE 请求。这里有一个需要注意的问题如果直接缓存“已经显示的 HTML”会导致后续操作很难处理。所以我的建议是缓存“原始 markdown”或“原始事件的数组”重新渲染时再统一解析这样后续做编辑、重试、复制、导出都比较方便。关于写入 localStorage 的时机要说一下不能每个 token 都写否则频繁序列化会让主线程卡顿。我是用一个防抖函数等停顿 1 秒后再做一次持久化。这样即使突遇用户刷新页面最多丢失最后 1 秒的数据对聊天场景来说体验上是可接受的。3.3 重连、幂等与消息去重别把同一条内容显示两遍讲一个很容易忽略的问题断线重连时如果服务端没有精确恢复事件流客户端可能收到重复的数据。这就是为什么我们要在每条消息里给事件分配 ID 或者使用会话中的消息序号。我这里提供一个去重思路维护一个已处理消息 ID 的集合。每次收到事件先对比msgId如果已经处理过就直接跳过不更新界面也不追加历史。对于没有 ID 的接口可以在前端用“发送时间戳 内容 hash”生成一个临时 ID缺点是不够精确但至少能过滤明显重复。还有一个幂等问题用户点击“重新生成”按钮前端重新请求同一接口。这时候要记得先取消上一次的请求最简单的办法就是用AbortController。如果上一次请求还没断开又发起了新请求两条流同时往界面上渲染页面直接就乱了。我的做法是任何新请求发起前先abortController.abort()旧请求并且清空当前正在流式渲染的字段。切换到“事件时间流”的思维模式也很重要。传统开发是“发请求、等响应”而流式场景更像是“每隔一段时间收到一段事件然后把最新事件合并到当前状态里”。设计状态机的时候不要把每个事件当作完整消息处理而要看成一个增量补丁。这样重连、取消、续传这些操作本质上就变成在时间轴上从某个位置继续消费事件而不是把整个对话重新跑一遍。4. 常见问题与排查技巧实录4.1 curl 调试 SSE先确认服务端在推再怪前端遇到对接问题我一般先做一件事用 curl 直接打一下接口确认服务端是不是真的在推数据、数据格式对不对。SSE 接口拿到数据后控制台会不断输出data:行按 CtrlC 断开就行。curl -N -X POST https://api.example.com/v1/chat \ -H Content-Type: application/json \ -H Authorization: Bearer sk-xxx \ -d {prompt: 你好}这里-N参数表示禁用 curl 的缓冲SSE 数据一到达就立刻显示。如果不加-Ncurl 可能会等连接关闭后才一次性把内容打出来看起来就像“没有流式输出”。这个命令用来排查问题时先能确认服务端正常再回头查前端解析逻辑能省下不少时间。4.2 stream disconnected before completion: idle timeout waiting for SSE热搜词里有这句报错翻译过来就是“流在完成前已断开等待 SSE 时空闲超时”。我第一次遇到这个错误时第一反应是服务端代码写得有问题排查半天发现根本不是。这个报错的本质是连接存在但长时间没有任何数据往客户端推中间经过了 Nginx 或者其他反向代理层被代理的超时策略切断了。Nginx 默认的proxy_read_timeout是 60 秒如果服务端 60 秒内一个字节都没推送Nginx 就会断开这条连接。解决方向有两个第一个方案是调大代理的超时时间比如在 Nginx 配置里加上location /api/chat { proxy_pass http://backend; proxy_buffering off; proxy_cache off; proxy_read_timeout 3600s; proxy_send_timeout 3600s; }第二个方案是服务端主动发心跳包。SSE 协议里的注释行就是干这个用的比如每隔 15 秒发一行: heartbeat作用是维持连接不空闲。注意注释行是合法的 SSE 数据浏览器EventSource或 fetch 解析时都会忽略。就算没有实际数据心跳也能让代理层认为连接是活跃的从而避免 idle timeout。我的建议是生产环境两个方案都做。调大超时时间解决短时无数据的情况心跳包保证长时间无新内容时连接依然存活双保险最稳。4.3 中文乱码和字段截断多半是解码方式不对前面提到过 TextDecoder 的{ stream: true }参数这里再展开讲。SSE 的数据通过 TCP 传输会被切片成任意大小的二进制块所以一个中文字符的 UTF-8 编码可能被切到第二个 chunk 里。如果不用流式解码前一个 chunk 拿到半个字的字节数据解码出来就成了乱码。同样的道理适用于buffer.split(\n\n)切割事件块。最后一个事件可能只收到了半个必须留在内存里等下一个 chunk 到了再拼接。我见过一个老项目就在这里翻车每收到一块数据就立刻JSON.parse(整块)遇到中英文混排时偶尔报 JSON 解析错误原因就是半块数据被拿来解析了。排查这类问题的方法也很简单在开发调试阶段把前端收到的原始 chunk 打印到控制台看看是不是存在“一条 data 被拆到两个 chunk”的情况。如果确认有就在拼接 buffer 后再解析不要收到什么就解析什么。4.4 标签返回未完整怎么处理核心是“未闭合状态”感知再来细说“标签返回未完整”这个问题。我在 2.3 节提了一部分这里要把排查的思路和落地细节讲完。先做一个实验直接调一个大模型接口让它输出一个比较长的 markdown 代码块比如python def hello(): print(hello) 在流式返回的过程中你会发现前 80% 的内容都是只有开头没有结尾的比如def hello():出现了但后面还有一大段函数体没推完。如果每一帧都拿完整 buffer 去 parse前端很容易出现“代码块还没闭合标题突然变成一段奇怪的文本”之类的渲染错乱。我的处理方法是给渲染层加一个“稳定状态”概念初始状态拿到原始文本先做增量展示只显示纯文本不做 markdown 解析。检测状态每收到一段新数据检查 buffer 里是否包含完整的代码块闭合标记、是否有多余的未闭合的**、是否存在 blockquote 的符号。切换状态当检测到所有标记已经闭合或者超过一定时间没有新内容再触发一次完整 markdown 渲染。对于代码块这种结构可以在解析前单独抽出来处理。我的做法是如果当前 buffer 里存在未闭合的 标记就把代码块部分提取成纯文本展示等闭合后再交给marked。这样可以保证“代码块内容在流式过程中永远不被 markdown 语法误伤”。还有一个容易被忽略的地方marked这类解析器对异常输入是会有容错处理的不同的解析器处理方式还不一样有的遇到未闭合标签直接丢弃有的会强行补全。所以请不要只依赖解析器的容错能力前端最好自己做一层“未闭合状态”的感知才能真正把标签未完整的问题挡住。4.5 其他容易踩的坑代理缓冲、多请求竞态、渲染性能代理缓冲导致的不流式有些反向代理默认对响应做缓冲会先把服务端推过来的内容攒一部分再一次性发给客户端。这样前端看到的就不是流式效果了。在 Nginx 里可以通过配置proxy_buffering off;关闭缓冲同时确保响应头里有X-Accel-Buffering: no这个头可以逐请求地告诉中间层不要缓冲。多请求竞态导致内容串台前面提过用 AbortController 取消旧请求这里再强调一下。AI 对话里常见操作是“停止生成”和“重新生成”如果旧请求没有及时取消新请求的数据和旧请求的数据混进同一个渲染容器里那画面简直没法看。我建议把“取消旧请求”和“清空流式区域”两个操作绑定到一个函数里保证每次发起新请求前界面已经回到干净状态。长文本渲染性能AI 一次回答几万字的情况越来越多。把几万字的 markdown 全量渲染到 DOM 里再怎么优化也会有卡顿风险。我的经验是对于超长回答流式阶段只渲染普通文本代码块、表格这类重结构元素等结束再做完整渲染如果内容真的特别长可以考虑分页虚拟滚动但大多数聊天场景还用不上等你的界面开始被长回答卡住时再考虑不迟。收尾的一点个人体会做了一段时间 AI 前端我最大的感受是SSE、断点续传、打字机渲染这些技术单独拎出来都不复杂但组合在一起之后考验的反而不是会不会用 API而是能不能用“事件时间流”的思维去设计前端的状态管理。每一次数据到达都是一个事件前端要处理的是事件的合并、去重、中断、恢复而不是简单地把响应渲染到页面。如果你正在做类似的项目我给一个开发顺序上的建议先用 curl 把服务端接口调通确认数据格式再用最简单的 fetch TextDecoder 解析出流式数据做到文字能连续跑出来接下来再做打字机渲染和 markdown 的流式处理最后才做断点续传和重连优化。每一步都能单独验证出了问题也好定位会比一开始就搭建完整架构稳妥很多。