SSE流式传输实战:从AI对话打字机效果到fetch中断处理
发布时间:2026/9/25 17:15:05来源:尧图网络
1. 从一次“打字机卡顿”说起流式传输到底解决了什么很多人第一次接触流式传输是在做 AI 对话界面的时候。用户点下发送按钮界面上转圈圈等了七八秒整段回答“啪”地一下全冒出来。体验上就像打电话时对方一直不说话等你想挂断的时候他突然把整段话一口气说完。流式传输要解决的就是把这个“憋大招”的过程拆开让内容像打字机一样一个字一个字往外蹦。我最早做类似功能时脑子里只有一个朴素的想法后端生成一段文字前端显示一段文字中间用普通的 HTTP 请求不就行了实测下来问题很明显——普通 HTTP 是“请求-响应”模型服务端必须把完整响应体准备好才能一次性发给客户端。大模型生成一段 500 字的回答可能要十几秒这十几秒里前端什么都拿不到只能干等。用户不知道后台是在正常工作还是已经挂了体验非常糟糕。流式传输的核心思路是把一份完整的数据切成很多小块服务端生成一块就发一块客户端收到一块就渲染一块。这样用户看到的是内容在持续增长心理上会觉得“系统在干活”等待焦虑大幅降低。这个思路并不新鲜视频网站早就在用流媒体你拖动进度条时视频不是全部下载完才播放而是边下边播。AI 对话场景只是把“视频帧”换成了“文本片段”。那为什么大家总把流式传输和 SSE 协议绑在一起讲因为 SSEServer-Sent Events服务器推送事件是浏览器原生支持的一种“服务端持续向客户端推送文本”的机制它天然适合“服务端生成、客户端展示”这种单向数据流。相比 WebSocket 的双向通信SSE 更轻、更简单用普通 HTTP 连接就能跑不需要额外协议升级。对于大模型回答这种“我问一句、你答一长段”的场景SSE 的匹配度非常高。这篇文章我会从实际项目出发把流式传输的原理、SSE 协议的细节、前后端怎么配合、Abort 中断怎么处理、以及我踩过的那些坑一层一层拆开讲。如果你正在做 AI 对话、实时日志、进度推送这类功能或者只是单纯想搞明白“为什么别人的回答能一个字一个字往外蹦”这篇内容应该能帮你省下不少查资料的时间。2. SSE 协议的真实面目它不是什么黑科技就是一段有格式的文本2.1 SSE 的报文格式四个字段撑起整个协议很多人觉得 SSE 很神秘其实它简单到有点“简陋”。SSE 的本质是服务端保持一个 HTTP 连接不关闭然后按照固定格式往这个连接里写文本。浏览器收到这些文本后按照同样的格式解析触发对应的事件。整个协议的核心字段只有四个字段作用是否必需data消息内容可以多行是event自定义事件类型默认是 message否id消息编号用于断线重连时定位否retry重连等待时间毫秒否一条典型的 SSE 消息长这样event: message id: 1 data: {content: 你} data: {content: 好}注意几个细节每个字段后面跟一个冒号和一个空格然后才是值一条消息以两个换行符结束data可以出现多次浏览器会把它们用换行符拼起来。我第一次手写 SSE 服务端时就是因为少写了一个换行前端死活收不到消息排查了半小时才发现是格式问题。2.2 和 WebSocket 的取舍为什么 AI 对话场景更偏爱 SSE刚接触这两个技术的人经常会问既然 WebSocket 能双向通信看起来更强大为什么 AI 对话场景大多用 SSE我自己的判断逻辑是这样的通信方向AI 对话是典型的“客户端发一次请求服务端持续返回”。请求只有一次返回有很多次。SSE 的单向推送刚好匹配WebSocket 的双向能力在这里是浪费的。实现成本SSE 用普通 HTTP 就能跑服务端就是往响应流里写字符串客户端用EventSource几行代码就能接。WebSocket 需要协议升级、心跳保活、重连逻辑复杂度高一个量级。基础设施兼容SSE 走的是标准 HTTP现有的网关、负载均衡、日志系统基本都能直接处理。WebSocket 的升级握手在某些代理环境下容易被拦截排查起来很头疼。自动重连EventSource内置了断线重连机制服务端可以通过retry字段控制重连间隔。WebSocket 的重连得自己写。当然 SSE 也有明显短板它只能服务端推客户端客户端要发消息得另开一个 HTTP 请求它传输的是文本二进制数据需要额外编码浏览器对同一域名的 SSE 连接数有限制HTTP/1.1 下通常是 6 个。所以如果你的场景是聊天室、协同编辑这种双向高频通信WebSocket 更合适。但如果是“请求一次、持续接收”SSE 是更省事的选择。2.3 浏览器端的 EventSource好用但有边界浏览器原生提供了EventSource对象来接收 SSE 流用法简单到离谱const es new EventSource(/api/chat/stream); es.onmessage (event) { console.log(收到消息:, event.data); }; es.onerror (err) { console.error(连接出错:, err); };但EventSource有几个让人难受的限制我在项目里都遇到过第一它只支持 GET 请求。这意味着你没法在请求体里放复杂的参数只能把参数拼在 URL 上。对于 AI 对话这种需要传对话历史、模型参数、系统提示词的场景URL 长度很容易超限而且把敏感信息放在 URL 里也不安全。第二它不能自定义请求头。你没法加Authorization头做鉴权只能靠 Cookie 或者 URL 参数传 token。第三它不能中断请求。EventSource只有close()方法关闭连接但没有“主动取消”的语义服务端可能还在继续生成资源就浪费了。正因为这些限制现在很多 AI 对话项目并不直接用EventSource而是用fetch配合ReadableStream手动解析 SSE 流。这样既能用 POST 传参、自定义请求头又能通过AbortController随时中断。代价是你得自己写解析逻辑不能白嫖浏览器的自动重连。3. 用 fetch 手动接管 SSE把控制权拿回自己手里3.1 为什么放弃 EventSource 转向 fetch前面说了EventSource的三个硬伤其中“不能中断”和“不能 POST”对 AI 对话来说是致命的。用户点了发送等了三秒觉得不对想取消EventSource做不到对话历史有十几轮全塞 URL 里也不现实。所以我在实际项目里基本都用fetch来手动处理 SSE 流。fetch的优势在于它返回的response.body是一个ReadableStream你可以一块一块地读读到什么就处理什么。同时fetch支持AbortController想中断随时中断。请求方法、请求头、请求体全都自由。缺点就是 SSE 的解析得自己写但这段逻辑并不复杂封装一次就能到处用。3.2 手动解析 SSE 流的关键代码先看服务端返回的响应头必须设置正确否则浏览器可能会缓冲整个响应Content-Type: text/event-stream Cache-Control: no-cache Connection: keep-alive X-Accel-Buffering: noX-Accel-Buffering: no这个头是给 Nginx 看的告诉它不要缓冲这个响应。我踩过一次坑本地开发一切正常部署到有 Nginx 的服务器后流式效果消失了所有内容一次性冒出来。排查半天才发现是 Nginx 默认开启了代理缓冲把 SSE 流攒着一起发了。加上这个头或者在 Nginx 配置里关掉proxy_buffering问题就解决了。客户端解析的核心逻辑大概是这样async function streamChat(url, body, onChunk, signal) { const response await fetch(url, { method: POST, headers: { Content-Type: application/json, Accept: text/event-stream, }, body: JSON.stringify(body), 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 }); // 按双换行切分消息 const parts buffer.split(\n\n); buffer parts.pop(); // 最后一段可能不完整留到下次 for (const part of parts) { const lines part.split(\n); for (const line of lines) { if (line.startsWith(data: )) { const data line.slice(6); if (data [DONE]) return; onChunk(data); } } } } }这段代码有几个关键点值得展开说。decoder.decode(value, { stream: true })里的stream: true很重要它保证多字节字符比如中文被正确拼接。如果不加这个参数一个中文字符被切成两个字节分别解码就会出现乱码。buffer的作用是处理“半条消息”——网络传输不会按照你的消息边界来切可能一条消息只到了一半你得把它留到下一轮再拼。3.3 中断请求AbortController 的正确用法AbortController是配合fetch实现中断的标准方案。创建一个 controller把它的signal传给fetch需要中断时调用controller.abort()const controller new AbortController(); // 发起请求 streamChat(/api/chat, { message: 你好 }, onChunk, controller.signal); // 用户点击停止按钮 stopButton.onclick () { controller.abort(); };调用abort()后fetch的 promise 会抛出一个AbortErrorreader.read()也会立即结束。你需要在代码里捕获这个错误避免它冒泡到全局try { await streamChat(...); } catch (err) { if (err.name AbortError) { console.log(用户主动中断了请求); } else { console.error(请求出错:, err); } }这里有个容易忽略的点客户端中断了服务端不一定知道。fetch断开连接后服务端往响应流里写数据会失败但服务端代码如果没做检查可能还在傻傻地调用大模型接口白白消耗 token。所以服务端也要监听连接关闭事件及时停止生成。在 Node.js 里可以监听req.on(close)在 Python 的 FastAPI 里可以监听request.is_disconnected()。4. 服务端怎么把流“推”出去不同技术栈的落地方式4.1 Node.js 原生写法res.write 就够了Node.js 的http模块天然支持流式响应核心就是设置好响应头然后不断调用res.write()app.post(/api/chat/stream, async (req, res) { res.setHeader(Content-Type, text/event-stream); res.setHeader(Cache-Control, no-cache); res.setHeader(Connection, keep-alive); res.setHeader(X-Accel-Buffering, no); // 监听客户端断开 let aborted false; req.on(close, () { aborted true; }); const stream await callLLM(req.body.message); for await (const chunk of stream) { if (aborted) break; res.write(data: ${JSON.stringify({ content: chunk })}\n\n); } res.write(data: [DONE]\n\n); res.end(); });注意res.write()的格式data:前缀加上内容然后两个换行符。少一个换行前端就解析不出来。[DONE]是一个约定俗成的结束标记OpenAI 的接口就是这么干的前端收到它就停止读取。4.2 Python FastAPIStreamingResponse 的坑FastAPI 提供了StreamingResponse来简化流式输出from fastapi import FastAPI, Request from fastapi.responses import StreamingResponse app FastAPI() async def event_generator(request: Request, message: str): async for chunk in call_llm(message): if await request.is_disconnected(): break yield fdata: {json.dumps({content: chunk})}\n\n yield data: [DONE]\n\n app.post(/api/chat/stream) async def chat_stream(request: Request): body await request.json() return StreamingResponse( event_generator(request, body[message]), media_typetext/event-stream, headers{ Cache-Control: no-cache, X-Accel-Buffering: no, }, )这里有个坑我印象很深FastAPI 的StreamingResponse默认会经过一些中间件如果中间件对响应做了缓冲流式效果就没了。另外request.is_disconnected()的检测不是实时的它依赖于底层连接状态有时候客户端已经断了服务端还要再写一两次才发现。所以更稳妥的做法是结合超时机制别让生成任务无限跑下去。4.3 大模型接口的流式返回OpenAI 格式的解析现在主流大模型接口都支持流式返回返回格式基本都遵循 OpenAI 的规范。每个 chunk 长这样data: {choices:[{delta:{content:你},index:0}]} data: {choices:[{delta:{content:好},index:0}]} data: [DONE]注意delta字段它表示“增量内容”而不是完整内容。有些接口在第一个 chunk 里会返回role字段后续 chunk 只有content。解析的时候要判断delta.content是否存在不存在就跳过。我见过有人直接把delta整个渲染出来结果界面上出现了一堆{role:assistant}的 JSON 字符串非常尴尬。如果你用的是国内的大模型服务格式可能略有差异但核心思路一致每个 chunk 是一个 JSON里面有本次新增的文本片段。你需要把这些片段按顺序拼接起来才能得到完整回答。5. 那些让我加班到深夜的坑SSE 实战排错记录5.1 消息被“攒”着一起发缓冲区的锅这是最常见的问题没有之一。本地开发时流式效果完美一部署到服务器就变成“一次性返回”。原因通常是中间有代理或网关开启了缓冲。排查链路是这样的先看响应头有没有X-Accel-Buffering: no没有就加上。然后检查 Nginx 配置proxy_buffering默认是on要改成off。如果用的是云服务商的负载均衡也要确认它有没有对text/event-stream做特殊处理。最后检查应用层有些框架的中间件会自动缓冲响应体比如某些日志中间件会等响应结束才记录这就把流式给堵死了。提示排查流式问题时先用curl -N命令直接请求接口。-N参数会禁用 curl 的缓冲如果 curl 能看到逐块输出说明服务端没问题问题出在浏览器到服务端之间的某一层。5.2 中文乱码TextDecoder 的 stream 参数前面提过decoder.decode(value, { stream: true })里的stream: true这里再展开说一下。UTF-8 编码的中文字符占 3 个字节网络传输时可能把这三个字节切到两个 chunk 里。如果每个 chunk 独立解码第一个 chunk 拿到不完整的字节序列就会解码成乱码。stream: true告诉解码器“这不是最后一块把不完整的字节缓存起来等下一块来了再一起解”。这个参数不加中文场景必出问题。5.3 连接数限制HTTP/1.1 下的 6 连接瓶颈浏览器对同一域名的 HTTP/1.1 连接数限制通常是 6 个。SSE 连接是长连接会一直占着这个名额。如果你在页面上同时开了多个 SSE 连接比如多个对话窗口第 7 个就会被阻塞。解决方案有几个升级到 HTTP/2多路复用不受这个限制或者把 SSE 请求分散到不同子域名再或者用 WebSocket 替代。我在一个多标签页场景里遇到过这个问题用户开了 7 个标签页第 7 个死活加载不出来排查了好久才定位到连接数限制。5.4 断线重连EventSource 自动重连的副作用EventSource内置了自动重连连接断了会按照retry指定的间隔重试。这个特性在普通场景下是优点但在 AI 对话场景下可能是灾难服务端正在生成回答网络抖了一下EventSource自动重连服务端以为是新请求又从头生成一遍用户看到回答重复了。所以用EventSource时服务端要配合Last-Event-ID做断点续传或者前端在重连时主动带上上下文标识让服务端知道这是续传而不是新请求。用fetch手动处理的话重连逻辑完全自己控制反而更省心。6. 把流式交互做得更顺滑几个提升体验的细节6.1 前端渲染节奏别每个字符都触发重排流式输出时如果每收到一个字符就更新一次 DOM页面会频繁重排性能很差。我的做法是用一个缓冲区每隔 50 毫秒左右批量更新一次界面。这样既保持了“打字机”的视觉效果又不会让浏览器疯狂重绘。具体实现可以用requestAnimationFrame或者简单的定时器节流。另外Markdown 渲染在流式场景下要特别小心。如果每个 chunk 都重新解析整段 Markdown开销很大而且未闭合的代码块会导致渲染错乱。比较稳妥的做法是流式过程中先用纯文本展示等[DONE]之后再整体做一次 Markdown 渲染。或者用支持增量解析的 Markdown 库但这类库通常对未闭合语法的处理也不完美。6.2 错误处理流中断了怎么给用户交代流式请求比普通请求更容易中断网络波动、服务端超时、用户主动取消都会导致流提前结束。前端需要区分几种情况如果是用户主动abort界面应该显示“已停止生成”保留已经生成的内容如果是网络错误应该显示“连接中断”并提供重试按钮如果是服务端返回了错误信息要把错误内容展示出来。最忌讳的是流断了但界面还在转圈用户完全不知道发生了什么。6.3 性能与成本流式不等于免费流式传输本身不增加太多服务器成本但它会让连接保持更久。如果并发量大长连接会占用更多文件描述符和内存。另外前面提到客户端中断后服务端要及时停止生成否则大模型接口的调用费用照付。我在项目里加了一个“生成超时”机制超过 60 秒还没生成完就强制结束避免异常情况下资源泄漏。7. 写在最后一些个人体会流式传输和 SSE 这套东西刚接触时觉得概念很多真正跑通一个 Demo 之后会发现核心就那么几件事服务端按格式写、客户端按格式读、中间别让代理缓冲、中断要前后端配合。难点不在协议本身而在各种环境下的兼容性和边界情况处理。我自己的经验是先把最简单的EventSource版本跑通理解 SSE 的报文格式和事件机制然后再换成fetch手动解析把中断、重连、错误处理这些补上。不要一上来就追求完美架构流式这东西调试成本不低先用最小可用版本验证链路通畅再逐步加功能。还有一个建议多准备几个调试工具。curl -N看服务端原始输出浏览器开发者工具的 Network 面板看 SSE 流Chrome 对text/event-stream有专门的展示再配合服务端日志基本能覆盖大部分问题。流式问题最怕的就是“黑盒”你不知道数据卡在哪一层有了这几个工具排查效率会高很多。
网站建设高端定制企业官网