news 2026/9/25 17:14:58

SSE流式传输实战:从AI对话打字机效果到fetch中断处理

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
SSE流式传输实战:从AI对话打字机效果到fetch中断处理

1. 从一次“打字机卡顿”说起:流式传输到底解决了什么

很多人第一次接触流式传输,是在做 AI 对话界面的时候。用户点下发送按钮,界面上转圈圈,等了七八秒,整段回答“啪”地一下全冒出来。体验上就像打电话时对方一直不说话,等你想挂断的时候,他突然把整段话一口气说完。流式传输要解决的,就是把这个“憋大招”的过程拆开,让内容像打字机一样一个字一个字往外蹦。

我最早做类似功能时,脑子里只有一个朴素的想法:后端生成一段文字,前端显示一段文字,中间用普通的 HTTP 请求不就行了?实测下来问题很明显——普通 HTTP 是“请求-响应”模型,服务端必须把完整响应体准备好,才能一次性发给客户端。大模型生成一段 500 字的回答可能要十几秒,这十几秒里前端什么都拿不到,只能干等。用户不知道后台是在正常工作还是已经挂了,体验非常糟糕。

流式传输的核心思路是:把一份完整的数据切成很多小块,服务端生成一块就发一块,客户端收到一块就渲染一块。这样用户看到的是内容在持续增长,心理上会觉得“系统在干活”,等待焦虑大幅降低。这个思路并不新鲜,视频网站早就在用流媒体,你拖动进度条时视频不是全部下载完才播放,而是边下边播。AI 对话场景只是把“视频帧”换成了“文本片段”。

那为什么大家总把流式传输和 SSE 协议绑在一起讲?因为 SSE(Server-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: no

X-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 会抛出一个AbortError,reader.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 FastAPI:StreamingResponse 的坑

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 f"data: {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_type="text/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有专门的展示),再配合服务端日志,基本能覆盖大部分问题。流式问题最怕的就是“黑盒”,你不知道数据卡在哪一层,有了这几个工具,排查效率会高很多。

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/9/25 17:07:21

基于照明色度表征的颜色恒常性的白平衡算法实现

以一下翻译至《Color constancy by characterization of illumination chromaticity》 摘要 计算颜色恒常性算法对数字相机实现理想色彩复现起到关键作用。若无法正确估计照明色度,图像会出现整体偏色,人眼观察者很容易察觉到。本文提出一种全新计算颜色恒常性算法。该算法计…

作者头像 李华
网站建设 2026/9/25 17:07:07

【FOC】 硬件运行VS Simulink仿真的速率及调度问题 ?

文章目录第一部分:实体硬件中的“软硬分工”(为什么10kHz能立即响应?)1. 慢速时间尺度:软件控制环(10kHz,周期100us)2. 快速时间尺度:硬件PWM外设(MHz级别&am…

作者头像 李华
网站建设 2026/9/25 17:03:55

UE5 Modeling Mode与Geometry Script:动态网格编辑实战指南

1. 从“37”这个编号说起:Modeling Mode 到底解决了什么痛点如果你在 UE5 里做过一段时间场景或道具,大概率经历过这样的循环:在外部 DCC 软件里建好模型,导出 FBX,导入引擎,发现比例不对,回 DC…

作者头像 李华