ARTICLE DETAIL

资讯详情

深耕网站视觉设计与运营推广的一线实战洞察。

AI对话流式输出方案对比:原生fetch流读取与fetchEventSource实战

AI对话流式输出方案对比:原生fetch流读取与fetchEventSource实战 前两周接手一个 AI 对话项目需求很朴素用户发一句话大模型生成回答页面要像 ChatGPT 那样一个字一个字地蹦出来。我一开始以为这不就是调个接口的事前端拿到响应渲染就行。真做起来才发现凡是牵涉到流式两个字事情就没那么简单。我把两种主流做法都真实跑了一遍一种是直接用原生fetch去读response.body这个ReadableStream自己解析 SSE 格式另一种是引入microsoft/fetch-event-source这个库把整个 SSE 会话管理交给它。两条路都走通之后回头对比很多东西才彻底想明白——fetch流读取和fetchEventSource底层原理是同一套但它们在帮开发者多做多少事这件事上差距非常大。这篇文章不打算讲高深理论就是把我踩坑的过程、两种方案的完整代码、以及最后总结出来的本质区别记录下来。适合正在做 AI 对话、实时输出、日志流推送这类需求的前端同学看完你至少能少踩我踩过的那几个坑。1. 先说场景为什么流式成了 AI 对话的标配1.1 需求拆解当时的需求是做一个聊天机器人页面前端用 React后端是标准的 SSE 接口。用户提交问题后后端调大模型返回结果不是一次性 JSON而是一段一段往外面吐的文本流。前端要做的事情是接到第一块数据就开始渲染后续每收到一块数据就追加到页面上直到收到结束标记。这个需求放在传统的 HTTP 接口上很难做。普通fetch请求要等服务器全部处理完才返回响应体用户可能等上十几秒屏幕上只有加载转圈体验非常差。AI 对话场景里有一个指标叫 TTFTTime To First Token意思是用户从发出请求到看到第一个 token 的时间这个时间越短用户感知上的流畅度越高。要做到低 TTFT就必须流式返回。和我一起做的后端同事用的是 Spring AI 那套体系接口天然就是text/event-stream的输出方式。也就是说前端不需要改变传输层协议只要会用流的方式去读响应就行。1.2 流式方案的三条技术路线做实时输出前端常见的选择有三条路WebSocket全双工通信客户端和服务端可以互相推消息。适合聊天室、协作编辑、需要服务端主动给客户端推送且客户端也要频繁上报的场景。缺点是协议复杂、需要单独建连握手、消息格式要自己定义。SSEServer-Sent Events基于 HTTP 的单向推送协议服务端往客户端推数据客户端不需要做什么额外操作。浏览器原生支持EventSource。缺点是原生EventSource只支持 GET不能传自定义 Header。fetch ReadableStream不算一种独立协议它是用 fetch 去读一个流式响应。服务端只要返回text/event-streamfetch的response.body就会是一个可读流你可以边读边渲染。AI 对话其实就是用户问一句服务端推一串这种单向场景根本用不上 WebSocket 的双向能力。直接用 SSE 系方案最省事。我一开始图省事直接用原生fetch去读流后面才切换到fetchEventSource。下面先讲原生fetch读流的方式因为理解了这个你才能真正看懂fetchEventSource帮你做了什么。2. 先把手伸进 fetch 流里ReadableStream 到底怎么读2.1 fetch 返回的不再是一次性 JSON普通接口我们习惯这么写const res await fetch(/api/user); const data await res.json();这里res.json()会等整个响应体接收完再一次性解析成 JSON。你可以类比成你在一家餐厅点了一桌菜服务员非要等所有菜都炒齐了才一次端上来中间你就只能干等着。流式接口不一样。服务器是炒好一盘端一盘fetch的response.body就是一个ReadableStream对象相当于一个传送带数据一个 chunk 一个 chunk 地往你面前送。你需要自己去传送带上一个个拿。为了演示我写了一个最简单的 Node 后端模拟大模型一点一点吐字// server.js const express require(express); const app express(); app.post(/api/chat, (req, res) { res.setHeader(Content-Type, text/event-stream; charsetutf-8); res.setHeader(Cache-Control, no-cache); res.setHeader(Connection, keep-alive); const chunks [你好, , 我是, AI, 助手]; let index 0; const timer setInterval(() { if (index chunks.length) { // SSE 格式data: 内容 空行 res.write(data: ${JSON.stringify({ text: chunks[index] })}\n\n); } else { res.write(data: [DONE]\n\n); clearInterval(timer); res.end(); } }, 200); }); app.listen(3000);注意后端返回的Content-Type是text/event-stream每一条消息都以data:开头以两个换行符结束。这就是 SSE 协议的基本形态。2.2 用原生 fetch 读流的最小实现前端对应的读取代码长这样async function streamChat() { const res await fetch(/api/chat, { method: POST, headers: { Content-Type: application/json }, body: JSON.stringify({ question: 你好 }) }); 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; // 注意一定要传 { stream: true }否则多字节字符可能被切坏 buffer decoder.decode(value, { stream: true }); const lines buffer.split(\n); // 把最后一行先存起来等下一块数据到了再拼 buffer lines.pop(); for (const line of lines) { const trimmed line.trim(); if (!trimmed.startsWith(data:)) continue; const data trimmed.slice(5).trim(); if (data [DONE]) return; const payload JSON.parse(data); appendMessage(payload.text); } } }这段代码看起来不长但每一行都有讲究res.body.getReader()拿到一个流读取器之后用read()方法一次拿一块数据。read()返回一个 Promiseresolve 出来的是{ done, value }。done为true表示流结束了value是一个Uint8Array也就是原始字节。TextDecoder负责把字节解码成字符串。如果你用decoder.decode(value)而不传{ stream: true }遇到某个多字节字符被拆在两个 chunk 里解码就会出乱码。传了{ stream: true }解码器会缓存未完成的字符等下一块数据来了再补全。buffer变量存的是半行数据。因为网络传输不保证一次read()拿到的数据刚好是一整行可能一行被切成两半也可能一个 chunk 里包含好几行。我们先把淹在buffer里按\n切成数组最后一段lines.pop()留到下一轮拼接。2.3 走通之后我发现的坑并不在代码量而在协议细节用原生fetch读流最核心的问题不是你写不出这段循环而是你写完之后会发现SSE 协议远不止data:这一种形式。它还有event:字段表示事件类型id:字段表示事件 IDretry:字段告诉客户端重连间隔。一条消息里还可以有多个data:行这些行要拼在一起才算完整的数据。服务端还可以发以冒号开头的注释行用于保活连接。这些全都需要你自己处理。如果你只处理了data:前缀后面遇到event: ping这类消息你的if (line.startsWith(data:))直接就把它跳过了看起来没什么问题但你再往下想一层——重连呢错误分类呢HTTP 状态码超过 400 怎么处理呢这些都得自己写。我第一版原生实现写完之后感觉自己不像是写了段前端代码而是在写半个 SSE 客户端。也就是这时候我开始看fetchEventSource。3. fetchEventSource一个把脏活干完的库3.1 为什么不用原生 EventSource很多知道 SSE 的人第一反应是浏览器不是有原生EventSource吗直接用它不就行了。问题在于原生EventSource的使用限制在 AI 对话场景里非常致命它只支持 GET 请求。AI 对话接口基本都是 POST因为要把用户的问题放在 body 里有的还要带很长的上下文。它不能自定义 Header。这意味着你没法加Authorizationtoken没法加自定义业务字段。它对 HTTP 状态码的处理很粗暴。服务端返回 401 或者 500它不会把状态码给你只会默默进入重连逻辑你连判断的机会都没有。它不受fetch的credentials、mode这些参数控制跨域和鉴权的灵活性很差。microsoft/fetch-event-source这个库解决的就是这些问题。它底层用的仍然是fetch所以 GET/POST、Header、Body、AbortController全都支持只是在fetch之上封装了一个完整的 SSE 客户端自动解析协议、自动管理连接生命周期、自动重连。3.2 核心 API 与生命周期fetchEventSource的使用方式很直接传一个 url 和配置对象import { fetchEventSource } from microsoft/fetch-event-source; const ctrl new AbortController(); async function startStream() { await fetchEventSource(/api/chat, { method: POST, headers: { Content-Type: application/json }, body: JSON.stringify({ question: 你好 }), signal: ctrl.signal, // 页面切到后台也保持连接AI 对话场景有这个需求 openWhenHidden: true, // 连接建立后、读取响应体之前会先走到这里 async onopen(response) { if (response.ok response.headers.get(content-type)?.includes(text/event-stream)) { return; } throw new Error(连接异常状态码 ${response.status}); }, // 每解析出一条完整 SSE 事件就会触发 onmessage(event) { if (event.data [DONE]) { ctrl.abort(); return; } const payload JSON.parse(event.data); appendMessage(payload.text); }, onclose() { console.log(连接正常关闭); }, onerror(error) { console.error(连接出错, error); // 注意如果不 throw这个库会认为你想重试自动进入重连流程 // 如果 throw重连会被打断直接终止 } }); }这里有几个细节很多文章不会讲透onopen的返回值是void或者Promisevoid官方文档里没有返回 false 来阻止这种用法。你判断连接不符合预期时正确做法是throw一个错误。抛了之后后续就不会继续读取流了这个错误会交给onerror。onmessage收到的event不是浏览器原生MessageEvent而是一个普通对象里面有data、event、id、retry四个字段。库已经帮你把多行data:合并好了。onclose只在连接正常关闭时触发。如果是异常断开比如网络中断、服务端主动掐断走的是onerror。onerror里如果你只是console.error打日志不 throw那么库会按退避策略自动重连。如果你希望出错后立刻停止在onerror里把错误重新 throw 出去即可。3.3 它内部默默帮你做的事结合我读源码和实测的结果这个库在你上面那几行配置背后至少替你做了四件事第一SSE 协议解析器。它内部维护了一个状态机会把\n\n分隔的事件块逐条解析出来处理多行data:拼接识别event:、id:、retry:然后组装成一个事件对象丢给你的onmessage。你不需要自己维护 buffer不需要自己 split。第二自动重连与退避。当连接异常中断而且你的onerror没有 throw 时它会自动重新发起请求。重试间隔从 1 秒开始每次失败后翻倍到 30 秒封顶。如果服务端在返回的 SSE 里带了retry:字段它会优先使用那个值。如果你不希望它无限重试可以在onerror里自己计数。第三HTTP 状态码的分流。服务端返回非 2xx 时默认会走onerror流程而不是onopen。所以你可以在onopen里提前判断如果response.ok为 false说明这不是一条正常的 SSE 流直接 throw 掉不给它重连的机会。第四对 fetch 的完整转发。你传的method、headers、body、signal最后都会被转发给底层的fetch调用。这意味着所有fetch能做的事它都能做。它还支持传一个自定义的fetch函数比如在 Node 环境或者测试环境注入 mock。4. 两者本质区别不是哪个好用而是协议复杂度谁来承担4.1 核心对照表我用一个表格把两者的差异列清楚维度原生 fetch 读流fetchEventSource底层机制fetch ReadableStream同样是 fetch ReadableStream内部封装SSE 协议解析完全自己写内置自动解析半行数据处理自己维护 buffer内置多行 data 拼接自己实现内置事件类型分发自己 if/else按 event 字段分发到 onmessage自动重连无有指数退避HTTP 状态码处理自己校验onopen/onerror 协作控制自定义 Header/Body支持支持主动取消AbortControllerAbortController需要写的业务代码量较多很少4.2 一句话理解本质区别fetch流读取是手动挡浏览器只交给你一个原始字节流怎么切行、怎么拼接、怎么解析、怎么重连全都要自己来。fetchEventSource是自动挡它把流里的内容按照 SSE 协议解构成一个又一个事件对象再把连接生命周期、重连策略、异常处理这些状态机全部管理好你的业务代码只需要关心收到一条事件后干什么。两者的差异不在于谁能读流——两者都能读真正的差异是协议解析和连接管理的复杂度是你自己承担还是交给库承担。4.3 这个区别在 AI 场景里会被无限放大AI 对话有一个特点数据是程序化推进的不能丢帧不能错序。一个 buffer 处理错后面所有文本拼接就全乱了。而且对话链路里天然伴随着取消用户停止生成、重试、中断恢复、超时这些边界操作。手动挡写个 demo 没有问题写生产级你就会发现核心的read()循环虽然只有那几行但它带出来的问题不止那几行用户点停止生成你需要 abort 掉流还要区分主动取消和意外断开。服务端在生成过程中突然报错错误可能不是 HTTP 状态码而是连接被直接重置。页面切后台浏览器可能挂起连接你需要openWhenHidden这种策略。弱网环境断开后要不要自动重连重连多少次要不要提示用户这些问题在fetchEventSource里都有对应的钩子和默认策略在原生实现里全都要自己从零写。这是我切换方案的最直接原因。5. 实战踩坑实录这一节建议直接收藏5.1 坑一fetch 流读取时的半包问题我第一次用原生fetch写流读取时代码长这样const { done, value } await reader.read(); const text decoder.decode(value); const lines text.split(\n); for (const line of lines) { // 处理 data: 前缀 }看起来逻辑没问题但跑起来就发现有时候一行data: {text:你好}被切成了两半前一半在上一个 chunk 末尾后一半在下一个 chunk 开头。我用split(\n)处理时第一块后半行没有换行符不会被解析出来第二块前半行开头是ata: ...也匹配不上data:前缀。结果就是消息凭空丢失。修复方式就是我前面写的那个buffer方案每次把当前 chunk 拼进 buffer按\n切分把最后一段留下来等下一个 chunk 到了再拼上。这个思路不复杂但如果你没踩过这个坑很容易漏掉。5.2 坑二中文乱码问题有一次我把decoder.decode(value, { stream: true })里的第二个参数漏了结果页面输出时不时出现一个。原因很简单TextDecoder默认情况下如果你不告诉它这是流式解码它会把每个value当成独立的数据块来解码。如果一个 UTF-8 中文字符的字节被拆到两个 chunk 里第一块末尾的字节单独解码就变成了乱码字符。加了{ stream: true }之后解码器内部会缓存未完成的字节序列等下一块到了再一起解码。实测下来这个参数必加没有任何理由不加。5.3 坑三fetchEventSource 的 onopen 里return false不生效网上有些文章写着在onopen里返回 false 可以阻止连接我照着试了一下发现根本没用。实际源码里onopen的返回值并不会被用作判断它只关心你抛不抛错。正确用法是async onopen(response) { if (response.ok) return; throw new Error(HTTP error: ${response.status}); }这个坑特别典型因为很多人的直觉是回调函数返回一个标志位控制流程但在这个库的设计里不是这样。它遵循的是异常驱动逻辑你抛出异常它就知道当前连接不可用会停止正常读取流程把异常交给onerror。另外一个相关的坑是onerror里你不 throw它就会自动重连。如果服务端返回 500 而你希望重试那没问题。但如果返回的是 401token 过期或者 400参数错误重连一万次结果也不会变反而会变成死循环。我现在的策略是在onerror里判断error instanceof TypeError和错误信息如果是业务状态码错误就 throw 掉如果是网络抖动就计数重试。5.4 坑四AbortController 的时序问题用AbortController主动取消时有一个很容易忽略的细节ctrl.abort()之后底层fetch会抛一个AbortError这个错误会一路走到fetchEventSource的onerror。如果你的onerror里统一弹一个请求失败的提示用户点停止生成时就会看到一个莫名其妙的报错弹窗。我的处理方式是在onerror里先判断一下onerror(error) { if (error.name AbortError) { console.log(用户主动取消); return; } // 其他错误才提示 showError(error); }另一个时序问题出现在连接建立之前。比如 React 组件卸载时调用abort()但此时fetchEventSource可能还没真正建立连接onopen仍然会先触发一次。如果onopen里没有检查signal.aborted它可能向一个已经取消的流程抛错误产生多余的 UI 反馈。稳妥做法是在onopen开头加一句async onopen(response) { if (ctrl.signal.aborted) throw new Error(aborted); if (response.ok) return; throw new Error(HTTP error: ${response.status}); }5.5 坑五React StrictMode 下的重复请求我项目里开了 React 18 的StrictMode开发环境下useEffect会执行两次。第一次的 effect 发起了一个流式请求cleanup 里调了abort()但紧接着第二次 effect 又发起一个请求。因为 abort 也需要一个异步过程两个请求叠加后端可能收到两条相同的问题AI 生成内容翻倍页面出现重复输出。解决方式是在组件外用模块级变量做锁或者用一个 ref 记录上一次的请求状态。我的做法是封装了一个useChatStream钩子内部维护一个isStreamingRefconst streamingRef useRef(false); useEffect(() { if (streamingRef.current) return; streamingRef.current true; startStream().finally(() { streamingRef.current false; }); return () ctrl.abort(); }, []);这样即使 StrictMode 触发两次 effect也只有一个流在跑。5.6 坑六failed to fetch到底是谁的锅这个报错在控制台里出现频率极高而且经常不带任何状态码排查起来非常费劲。我总结下来failed to fetch的核心含义就是请求没有得到一个有效的 HTTP 响应。常见触发原因有这么几类服务端根本没起来或者端口写错。表现是ECONNREFUSED前端报错极其快。用 curl 直接打一下接口就能确认。CORS 或者预检请求失败。表现为请求被浏览器直接拦截Network 面板里能看到请求标红但后端可能根本没收到。排查时重点看响应头里有没有Access-Control-Allow-Origin以及预检OPTIONS请求是否通过。服务端响应被网关掐断。比如服务端处理超时、代理中间层主动断开、或者服务端返回了不完整响应。这种情况 curl 打一次可能成功但前端因为某个时长限制被打断。网络本身不稳定。请求发出去了但连接在某个阶段被重置浏览器无法拿到最终响应。本地开发代理配置错误。请求打到了错误地址或者代理规则把请求吞了导致一直是 pending 状态然后超时。排查思路我建议按顺序来先开 F12 看 Network 面板里请求的完整状态区分是没发出去、发出去没响应还是响应被中断然后用 curl 直接模拟同参数请求排除后端问题再看后端日志有没有收到这个请求收到说明问题在回包阶段没收到说明问题在网关或者路由最后检查 CORS 相关响应头。有的场景里failed to fetch是远端服务主动拒绝连接导致的比如请求方的地址被服务端的连接策略限制。这种情况前端能做的不多最重要的是别做成无提示的无限重试要把错误抛出来在界面上给用户一个明确反馈同时做好退避策略避免把服务端打得更紧张。6. 常见问题速查与选型建议6.1 问题速查表症状可能原因解决方向页面一直不输出控制台无报错chunk 到达但解析逻辑没匹配上 / read 循环 pending检查后端是否真正 flush确认onmessage有触发输出到一半中断后端连接断开 / 客户端被 abort查后端日志确认是否误调用了abort()出现乱码TextDecoder 没传{ stream: true }补上参数一行消息丢失半行数据 buffer 处理不对用切片后保留最后一段的方式fetchEventSource 无限重连onerror 里没有 throw明确业务场景业务错误抛掉网络错误计数重连StrictMode 下重复输出useEffect 双执行用 ref 锁住并发请求主动取消还弹错误提示AbortError 被当成普通错误onerror 里判断error.name为AbortError时静默处理failed to fetch网络 / 后端未启动 / CORS / 代理按 5.6 步骤逐一排查6.2 我现在的选型原则跑完这两个方案之后我心里有了一套相对清晰的取舍标准第一如果只是做一个演示 demo、内部工具、或者临时接口联调后端返回的是简单流式文本那直接用原生fetch读流就够了。代码量在 40 行以内而且你能精确控制每一步不需要引入额外依赖。第二如果是生产级的 AI 对话、需要带鉴权 Header、需要 POST body、需要处理断线重连、需要区分事件类型、需要主动取消和错误分级直接上fetchEventSource。它已经是行业里经过验证的方案别重复造轮子。第三如果项目用的不是 React 而是 Vue或者你需要更上层的状态管理可以考虑ai-sdk/vue、ai-sdk/react这些基于 AI SDK 的封装它们内部本质上也是用流式读取 状态管理帮你完成了整套聊天逻辑。你要是用了这些前面的原理照样用得上因为排查问题的时候最终还是要回到这个流到底怎么被读取的。最后再分享一个我个人的小习惯我现在每接一个新的流式需求都会先用原生fetch写一遍最小读取逻辑跑通了再决定要不要换成库。这样做不是多此一举而是因为只有亲手处理过一次ReadableStream、亲手被半包问题和编码问题坑过你才能真正理解fetchEventSource那些自动行为背后的意义。等你在两种方案之间切换过一遍看到Content-Type: text/event-stream就知道该往哪个方向排查流式这个黑盒对你来说就彻底透明了。
返回列表