1. 先交代背景:我是怎么踩进这个坑的
最近在做一个 AI 对话前端改造,需要把大模型回答从“等半天一次性吐出来”改成“边生成边渲染”的流式效果。需求本身不复杂,但落地时却让我在fetchEventSource和原生fetch之间反复横跳,折腾了整整两天。最崩溃的一条报错长这样:
failed to fetch dynamically im 无法加载 agent 预设 client api: agentpresets/list failed: failed to fetch明明上一秒接口还能通,换掉请求方式之后水灵灵地就开始failed to fetch,而且只有流式相关接口跪了,普通 JSON 接口一切正常。后来我把整套链路从浏览器到服务端到 SSL 全查了一遍,最后才发现问题不是网络,不是网关,而是我跟fetchEventSource之间有层“没有说透的窗户纸”。
这篇文章不打算写那种“fetchEventSource 比 fetch 好”的结论帖,而是想把我这次真实的排查过程拆开,讲清楚两个东西在流式场景下的本质区别。如果你也在做 SSE 流式输出、大模型实时渲染,或者遇到failed to fetch、agentpresets/list failed、abort被莫名触发这类报错,这篇文章里的排查思路和结论应该能帮你少走不少弯路。
先说结论要点:原生fetch支持读流,但它只是“给了你水管”;fetchEventSource则是一套完整的水泵系统。两者在流式场景下的区别,主要集中在四个地方——事件解析、断线重连、请求头约束、以及中止信号的语义。搞懂这四点,几乎所有流式报错都能定位。
2. 重新认识两个读取方式:fetchEventSource 与 fetch 的本质差别
2.1 原生 fetch 的流式是“给了水管,但没给你水泵”
原生fetch从很早开始就支持读取流式响应了,核心就三个 API:
const response = await fetch('/api/chat', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ prompt: '你好' }), }); const reader = response.body.getReader(); const decoder = new TextDecoder(); let buffer = ''; while (true) { const { value, done } = await reader.read(); if (done) break; buffer += decoder.decode(value, { stream: true }); // 自己解析 buffer 里的数据 // SSE 格式通常是:data: {"content":"xxx"}\n\n const events = buffer.split('\n\n'); buffer = events.pop(); for (const event of events) { const dataLine = event.startsWith('data:') ? event.slice(5).trim() : ''; if (dataLine && dataLine !== '[DONE]') { const json = JSON.parse(dataLine); renderContent(json.content); } } }你看,原生fetch其实完全能干这事。它把response.body变成了一个ReadableStream,你每次reader.read()拿到一块 Uint8Array,然后自己解码、自己切分、自己解析事件。也就是说,原生 fetch 的能力边界是“给你一根水管,水流过来,你自己接”。
这里能满足基本的流式需求,而且足够轻量。但问题在于,它太“原生”了,很多坑留给了使用者。比如:
- SSE 协议规定事件之间用空行分隔,但网络分包可能把一个事件切成两半,你得自己维护 buffer;
- 如果服务端发了注释行(以
:开头的行,用于心跳保活),你得自己跳过; - 断线了不会自动重连,得自己写重试逻辑;
- 请求头虽然随便你加,但服务端 CORS 是否会暴露、预检请求能否通过,依然要自己处理。
这些“自己来”的部分看着都不难,但叠加在一起,就是典型的多处逻辑交织、边界问题频发的状态。我第一次用原生 fetch 写流式,60 行代码里有 30 行都在处理字符串切分和异常兜底。
2.2 fetchEventSource:可以理解为“配备了泵、阀门和仪表盘的成套方案”
fetchEventSource是微软出的一个库,本质是在fetch之上做了一层封装,专门针对 SSE 流式场景。它解决的核心痛点是:EventSource天然只支持 GET,不能用 POST 传业务参数,也不能自定义请求头(比如带上 Authorization 令牌),而 AI 对话类接口几乎都是 POST + JSON + 鉴权头。fetchEventSource用fetch重新实现了 SSE 的完整行为,保留了 EventSource 的事件语义,同时突破了它的请求约束。
它的基本用法很短:
import { fetchEventSource } from '@microsoft/fetch-event-source'; const ctrl = new AbortController(); await fetchEventSource('/api/chat', { method: 'POST', headers: { 'Content-Type': 'application/json', 'Authorization': `Bearer ${token}`, }, body: JSON.stringify({ prompt: '你好' }), signal: ctrl.signal, openWhenHidden: true, // 页面隐藏时保持连接 async onopen(response) { if (response.ok) { console.log('连接建立,状态码', response.status); } else { throw new Error(`HTTP ${response.status}`); } }, onmessage(event) { const data = JSON.parse(event.data); renderContent(data.content); }, onclose() { console.log('流正常关闭'); }, onerror(error) { console.error('流异常,尝试重连', error); // 返回非 void 时,库会自动重连 // 如果不想重连,可以 throw error }, });这套 API 看起来清爽多了。onmessage帮你把 SSE 的data:行解析好,event.data直接就是内容;onerror里返回一个值,库会帮你做自动重连;连接建立、打开、失败、关闭全都有回调钩子。还有openWhenHidden这个参数,处理了页面切换 Tab 时浏览器对连接的限制,原生 EventSource 在这个场景下有个痛点是隐藏页面会挂起,这库能绕开。
用一句生活化的话说:原生 fetch 给你一根水管让你自己装水泵、装水表、装阀门;fetchEventSource直接交付一套集成好的供水系统,你只需要打开龙头。
2.3 为什么说“自定义 header”是分水岭
很多人在选型时纠结“为什么不用 EventSource?它不也是 SSE 吗?”——这就是没踩过真实需求场景才会有的疑问。原生EventSource的问题非常致命:它不能带自定义请求头。你拿它调一个需要Authorization的私有化模型接口,直接就是 401 甚至跨域预检失败。现在不少 LLM 网关还要求请求里带api-key、trace-id,这玩意儿根本塞不进去。
所以这时候有两类选择:
- 用原生
fetch自己解析流,请求头管够,代价是解析逻辑、断线重连、心跳保护全手写; - 用
fetchEventSource,它内部用 fetch 实现,自定义 header、POST body 都支持,同时把 SSE 协议的上层语义补齐。
我在这次改造里选的是fetchEventSource,理由很简单——我们的服务端网关要求每个流请求都带内部api-key和request-id,原生 EventSource 直接出局;而项目里又要求快速交付,手写解析器的维护成本不低,用一个成熟封装更稳妥。
但正是这次选型,让一个问题暴露出来:fetchEventSource太好用了,以至于让我忽略了它内部对“错误响应”和“连接中止”有一套自己的处理逻辑,而这套逻辑在某些场景下和原生fetch的语义完全不一致。
3. 真实踩坑过程:一次 agent 预设加载失败引发的排查
3.1 现象复盘
先还原一下我当时的场景。
项目里有一个“智能体预设列表”的接口/api/agentpresets/list,用来给对话页加载可选的 AI 角色。这不是个大模型流式接口,而是普通参数列表接口。但当时前端统一把这类接口从fetch切换成了fetchEventSource——因为我天真地以为“既然都是走 HTTP,统一封装组件最省事”。
切换之后,一连串接口开始报错:
failed to fetch 无法加载 agent 预设 client api: agentpresets/list failed: failed to fetch注意最后一次报错,这是浏览器终端的原始信息,翻译过来就是:fetch在请求还没有拿到任何响应头之前,连接就被中止了。因为fetchEventSource的onopen回调只有在收到响应头之后才会触发,而这次请求连这一步都没走到。
我第一反应是服务端挂了。于是用 Postman 直接打同一个接口,200,秒回,数据完整。再用 curl 打,200,一切正常。那么问题就出在前端请求本身。
3.2 排查链路
我按下面这个顺序排除,写下来给同样踩坑的人参考:
第一层,看请求是否真正发出。打开 DevTools 的 Network 面板,在agentpresets/list请求上右键复制为 curl,命令行跑一遍。如果 curl 能通,说明服务端、网关、SSL 都没问题。剩下的问题集中在浏览器环境和请求库。
第二层,查 CORS 和预检。我们的接口带Authorization和api-key自定义头,浏览器会先发一个OPTIONS预检请求。看 Network 面板里是否有预检请求?预检是否返回了正确的Access-Control-Allow-Headers?这一步很关键,因为fetchEventSource内部即使设置了 headers,如果服务端没放行这些自定义头,请求在预检阶段就被浏览器拦截了,表现就是failed to fetch。这个坑非常经典,尤其是从 Postman 测不出问题的情况下,十有八九卡在这。
第三层,查代理层和网关是否对流式请求做了特殊处理。我们服务端有个 Nginx 网关,检查proxy_read_timeout、proxy_buffering这类配置。如果proxy_buffering开着,SSE 流的响应会被 Nginx 攒着不吐,客户端迟迟收不到第一个字节,容易触发表层超时。虽然这里报的是“预设列表”接口,但网关是统一入口,配置影响所有接口。
第四层,查 AbortController 与页面生命周期。我们的对话页在组件卸载时会调用ctrl.abort()取消未完成的流式请求。如果请求时序上组件先卸载、请求后返回,那么 abort 信号会导致 fetch 以AbortError结束,最终同样表现为failed to fetch。这个在所有异步请求中都可能发生,属于经典竞态。
四层查完,前三层都没问题,第四层嫌疑最大。于是我打开 Network 面板,盯着预设列表请求的 timing,发现 Grunt 一个巧合:这个接口发出的时机,和上一个流式请求 abort 的时机几乎重叠。
3.3 根因定位
到这里,真相就比较清晰了。
我们的对话页切换 agent 预设时,会先abort()上一个流式请求,再发起新的预设列表请求。而fetchEventSource有个重要特性:它内部维护的是同一个AbortSignal信号链。如果你在fetchEventSource的选项里传入某个signal,它内部的所有重连尝试都会复用这个信号。
问题出在我没有为每次请求创建独立的AbortController,而是模板里复用了同一个。第一次请求 abort 后,这个 controller 的 signal 状态变成了aborted,接下来所有复用这个 signal 的请求,fetch 都会立即拒绝,根本不会发出网络请求。
换句话说,fetchEventSource的signal一旦 abort 就永久失效,它是“一次性信号”。而原生fetch遇到同样的情况也一样是被 abort 拉住——这不算 fetchEventSource 独有的问题,但因为fetchEventSource内部消息循环和重连机制的存在,这种“被 abort 拒绝”的请求,其错误信息里没有明确的AbortError标记,而是被转换成了TypeError: Failed to fetch,所以排查时很容易误判成网络问题。
再进一步看,为什么这个报错会串到agentpresets/list这样完全无关的接口上?就是因为我把同一个AbortController传给了所有接口请求。表面上代码是这个样子的:
// 错误示范:所有请求复用一个 controller const sharedController = new AbortController(); async function loadPresets() { await fetchEventSource('/api/agentpresets/list', { signal: sharedController.signal, onmessage(msg) { /* 处理 */ }, }); } async function chatStream() { await fetchEventSource('/api/chat', { signal: sharedController.signal, onmessage(msg) { /* 处理 */ }, }); }一旦某个环节调用了sharedController.abort(),后面再发的任何复用请求都不再有意义。这不是fetchEventSource的问题,而是我对“AbortSignal 是一次性状态”这个底层语义理解不到位——所以我在标题里强调这是一次“本质区别”,本质不是 API 长什么样,而是状态语义。
修复办法非常简单:每次请求都 new 一个独立的AbortController:
async function loadPresets() { const ctrl = new AbortController(); await fetchEventSource('/api/agentpresets/list', { signal: ctrl.signal, onmessage(msg) { /* 处理 */ }, }); } async function chatStream() { const ctrl = new AbortController(); await fetchEventSource('/api/chat', { signal: ctrl.signal, onmessage(msg) { /* 处理 */ }, }); }如果你真的需要在某个页面级别统一取消所有请求,也建议维护一个 controller 集合,而不是共用一个AbortController。每次请求创建独立 controller,页面卸载时统一调用集合里的abort()。
注意:
AbortSignal一旦进入 aborted 状态,是无法恢复的。你没法把同一个 signal 取消后再复用。这是 web 平台的固定语义,跟库无关。
4. 避坑经验与报错速查表
4.1 选型建议:什么时候用 fetchEventSource,什么时候用原生 fetch
这次踩坑之后,我把“流式读取”的选型标准重新梳理了一遍。没有哪个方式是绝对正确的,只有更适合你当前场景的。
| 场景 | 推荐方式 | 原因 |
|---|---|---|
| 大模型对话,需要 POST + 自定义 header + SSE | fetchEventSource | 自动解析事件、自动重连、支持 POST 和 header |
| 后端就是标准 GET SSE,比如某些开源消息推送 | 原生EventSource | 浏览器原生能力,不需要引库,天然支持自动重连 |
| 只需要非常轻量的单次响应读取,不关心重连 | 原生fetch+ReadableStream | 依赖少,代码可控 |
| 涉及复杂的多遍流处理、事件类型多样、需要精细控制每类事件 | fetchEventSource | 它的onopen/onmessage/onerror/onclose钩子比原生fetch的裸流处理清晰得多 |
| 项目对依赖包体积极其敏感 | 原生fetch | 少一个运行时依赖,打包体积自然减小 |
我个人的经验是:如果你在做 AI 对话类功能,第一选择就是fetchEventSource。它让你把精力花在业务逻辑上,不用每次纠结字符串切拆和心跳处理。但代价是——它是个封装层,你踩的坑往往不是它本身不够好,而是你没搞懂它背后依赖的底层语义,比如 AbortSignal 的一次性特性、自动重连可能带来的重复数据问题。
4.2 常见报错速查表
把这次项目里遇到的和网上高频出现的问题整理成了一张速查表,按failed to fetch相关错误类型和排查路径给出来:
| 报错关键词 | 可能原因 | 排查顺序 |
|---|---|---|
failed to fetch | 跨域预检失败、服务端未响应、连接被 abort、网关 buffer | 1. Network 复制 curl 验证服务端 2. 检查 OPTIONS 预检 3. 检查 body 是否被 abort |
failed to fetch dynamically | 只用于动态导入,和运行时 fetch 无直接关系,但报错前常伴随网络不可达或单页应用资源加载失败 | 检查静态资源 CDN 可达性、路由目录是否正确 |
agentpresets/list failed: failed to fetch | 请求被 AbortSignal 拦截、或者自定义 header 未通过 CORS | 重点查 AbortController 是否被复用、预检响应头 |
connect econnrefused | 服务端端口未监听、防火墙拦截、服务未启动 | curl -v看握手过程,检查服务日志 |
failed to fetch version from claude.ai | 这是某些工具在检测网络或版本源时的通用错误,多数和代理/证书/网络隔离相关 | 换网络源看是否能通,检查系统代理设置 |
git fetch或git pull很慢 | 缓冲区容量、协议差异、DNS 解析慢 | git config --global http.postBuffer调大,检查https.sslVerify |
VS Code 服务器failed to fetch | 远程环境下载 server 包失败 | 手动下载vscode-server-linux-x64.tar.gz放到指定目录 |
这里必须强调,failed to fetch是前端最常见但又最没有信息量的错误。小技巧是:在onerror回调里加一层错误转换,把error.name和error.message都打出来。如果error.name === 'AbortError',说明是主动中止;如果error.message含NetworkError,说明是连接层面的问题;如果是TypeError: Failed to fetch但实际请求没有发出,大概率是 CORS 或 signal 问题。这一手能在你上 DevTools 之前先快速缩小范围。
另外一个小技巧,如果你需要排查“请求到底有没有发到服务器”,可以在组件里临时给fetchEventSource加一个onopen回调:
onopen(response) { console.log('HTTP 状态', response.status, '说明服务端已收到请求'); }只要onopen执行了,说明服务端已返回响应头,问题不在“服务端没收到请求”。如果onopen一直不执行,那就是请求没到服务端,优先查 CORS、DNS、证书、signal。
这个“响应头是否返回”的判断思路,能把你从“服务端到底通没通”的泥潭里拉出来。
4.3 一个额外的坑:重连造成的重复数据
除了 AbortSignal 的坑,fetchEventSource自动重连机制还会带来另一个问题:断线重连后,消息可能重复。
比如你调大模型接口,流式返回了一部分内容后网络闪断,fetchEventSource会自动重连并重新发送请求。如果服务端没有做“断点续传”或者“请求去重”,那前端就会再次收到从第一条开始的内容,界面上就出现了重复的渲染。
我当时调的是一个内部 LLM 网关,网关并不缓存历史输出,重连后从零开始生成,前端渲染里就出现了两遍回答拼接的诡异效果。
这类问题的处理思路有两个方向:
- 前端做消息幂等,靠
event.id或递增序号,重复内容直接丢弃; - 重连后让用户手动确认“是否继续上次回答”,而不是无感重放。
对于 AI 对话这种场景,自动重连不总是好事。服务端生成状态已经在第一轮请求里消耗过一遍了,重连不是在“继续生成”,而是在“重新生成”,此时自动重连反而制造混乱。所以我后来把onerror改成了手动控制:
onerror(err) { // 打印原始错误 console.error('流产生错误', err.name, err.message); // 如果是 AbortError,说明是用户/组件主动中止,不重连 if (err.name === 'AbortError') { throw err; } // 其他错误,默认自动重连,这里不返回具体值即可; // 如果你希望手动控制,直接 throw 出去 throw err; }实际项目里,我会区分错误类型来决策是否重连。主动 abort 的重试毫无意义;网络抖动且服务端支持幂等时,自动重连才值得开。
这样的决策能力,是裸fetch和fetchEventSource都很难替你做主的,都需要你对业务流有清晰判断。
5. 写在最后的个人体会
这次踩坑让我最深的感受是:凡是封装良好的库,都会在“易用性”和“可控性”之间做选择。fetchEventSource把 SSE 流式处理中繁琐的部分——事件解析、重连、打开关闭回调——全封装了,这是它的价值;但这也意味着,你对底层fetch行为、AbortSignal 语义、甚至是 HTTP 连接生命周期的理解,成了你能不能用好它的关键。
踩过几次坑之后,我现在写流式请求代码时一定会遵循几个铁律:
- 每个
AbortController只服务一个请求,绝不复用; onerror里至少要打一行错误日志,包含error.name和error.message;onopen回调里记录 HTTP 状态码,方便日后判断问题在“服务端”还是“连接”;- 服务端要支持幂等时再开自动重连,否则必须在业务层做去重;
- 依赖包升级后,重新过一遍
openWhenHidden、signal、onerror的默认行为是否变化。
如果让我对正在做 AI 应用、或者准备做流式渲染的朋友说一句掏心窝的话:别急着把所有接口都换到fetchEventSource,它不是万能的;也别因为一次failed to fetch就退回原生 fetch,那个坑更大。先想清楚你的业务究竟需要什么控制粒度,再决定用哪把工具。
最后再分享一个我在排查任何failed to fetch时的定式:先看error.name,再看onopen是否执行,然后复制 curl 验证服务端,最后检查 CORS 预检和 AbortSignal 状态。按这个顺序走,目前我还没遇到定位不出来的failed to fetch。希望这篇踩坑记录,能帮你少熬一个夜。