ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

大模型流式加载:Fetch + ReadableStream 实战指南

大模型流式加载:Fetch + ReadableStream 实战指南 1. 流式加载不是“多请求”而是“单连接持续吐数据”很多人第一次接触大模型 API 的流式响应时下意识会想“既然要边生成边显示那我开个定时器每隔200毫秒 fetch 一次不就行了”——这是典型的用传统 REST 接口思维去套流式场景结果必然是失败的。我去年在给一个教育类 SaaS 做 AI 答题助手时就踩过这个坑前端用轮询方式反复调用/v1/chat/completions每次只拿最新 token结果发现延迟翻了3倍、服务器 QPS 暴涨、用户看到的答案还频繁跳动重排。后来才明白流式加载根本不是“多次小请求”而是一次建立连接后服务端像打开水龙头一样把生成的 token 以 chunk 为单位持续不断地推送到客户端。这个过程依赖的是 HTTP 协议底层的分块传输编码Chunked Transfer Encoding和浏览器对ReadableStream的原生支持。Fetch API 是目前唯一能在浏览器环境里完整承接这种“单连接、长响应、分段读取”模式的标准方案。它返回的 Response 对象自带.body属性这个.body就是一个符合 WHATWG Streams 标准的 ReadableStream 实例。你可以用response.body.getReader()拿到一个 reader然后循环调用reader.read()每次拿到一个{ done: boolean, value: Uint8Array }结构的对象。value 是原始二进制数据需要手动解码成 UTF-8 字符串done 为 true 表示流已结束。整个过程是异步但线性的没有竞态、没有丢帧、没有重复请求开销。相比之下XMLHttpRequest 虽然也能处理流式响应但它缺乏对 ReadableStream 的直接映射你需要手动监听progress事件并拼接responseText而responseText在流式场景下是不可靠的——它可能截断在 UTF-8 多字节字符中间导致乱码也可能因缓冲策略滞后漏掉刚到达的 chunk。Axios 这类封装库更麻烦它默认把整个响应体当做一个整体来处理你得绕过它的拦截器直接操作底层 XMLHttpRequest代码复杂度陡增且不同版本行为还不一致。提示不要试图用fetch(url).then(res res.text())来处理流式接口。.text()是一个“全量读取并解码”的终结方法它会等待整个响应体下载完毕才返回 Promise完全违背流式设计初衷。一旦大模型生成耗时超过30秒用户界面就会卡死体验极差。真正关键的不是“用了 Fetch”而是“用了 Fetch 的.bodyReadableStream组合”。这组能力在 2015 年随 Fetch 规范一起进入主流浏览器但直到 2022 年大模型应用爆发才被大规模验证其不可替代性。它不是语法糖而是浏览器为流式数据专门铺设的高速公路。2. ReadableStream浏览器里被低估的“数据流水线”ReadableStream 这个概念听起来很抽象但把它想象成工厂里的传送带就非常直观服务端是上游的装配工人每组装好一个零件一个 token就把它放到传送带上浏览器是下游的质检员站在传送带旁一个一个地拿起来检查、贴标、入库。传送带本身不存储所有零件只负责按序传递质检员也不用等所有零件到齐才开工拿到一个就处理一个。这就是流式加载的核心价值——内存友好、响应及时、可控性强。ReadableStream 的设计哲学是“拉取pull-based”而非“推送push-based”。这意味着控制权在客户端手上你调用reader.read()它才从内部队列里取出下一个 chunk你不调数据就暂存在流的内部缓冲区里不会自动涌进来撑爆内存。这对大模型尤其重要。一个 4096 token 的回答如果全量加载JSON 格式下可能高达 200KB 以上而流式加载时前端可以做到只维持最近 50 个 token 的 DOM 节点老的节点及时销毁内存占用稳定在 1MB 以内。我实测过在一台 8GB 内存的 MacBook Air 上用res.text()加载一个 10000 token 的长回答Chrome 进程内存峰值会冲到 1.2GB而用 ReadableStream 逐块读取并渲染峰值始终压在 80MB 以下。ReadableStream 还提供了精细的控制能力。比如reader.closed是一个 Promise当流自然结束或被取消时它会 resolvereader.cancel()可以主动中断读取释放资源stream.tee()能把一个流“分叉”成两个完全独立的副本一个用于实时渲染另一个用于后台做 token 统计或敏感词过滤——这些能力在 XMLHttpRequest 时代是无法优雅实现的。更关键的是ReadableStream 是可组合的。你可以用stream.pipeThrough(new TextDecoderStream())直接把二进制流转成文本流省去手动new TextDecoder().decode(chunk)的步骤也可以用stream.pipeTo(writableStream)把数据直接导入到文件下载或 Web Audio API 中。这种函数式、管道化的数据处理方式正是现代前端工程化追求的高内聚、低耦合。注意ReadableStream 的value是Uint8Array不是字符串。很多初学者直接console.log(chunk.value)会看到一串数字误以为出错了。正确做法是new TextDecoder().decode(chunk.value)或使用TextDecoderStream。UTF-8 编码下一个中文字符占 3 个字节如果 chunk 刚好在字符中间被切开TextDecoder会缓存未完成的字节等到下一块数据到来再合并解码这是它内置的安全机制无需额外处理。3. 为什么 SSE 不是流式加载的“银弹”而 Fetch 是更普适的选择Server-Sent EventsSSE经常被拿来和 Fetch 流式加载对比网上很多教程甚至说“SSE 是专为流式设计的”。这话只说对了一半。SSE 确实是 HTTP 协议层面为服务端推送设计的轻量级方案它要求服务端响应头必须是Content-Type: text/event-stream每条消息以data: ...开头以双换行\n\n结尾。浏览器用EventSource对象监听非常简单。但问题在于SSE 有三个硬伤让它在大模型 API 场景中远不如 Fetch 灵活。第一SSE只支持 GET 请求。大模型的 chat 接口几乎全是 POST需要携带 JSON 格式的messages数组、model名称、temperature等参数。你无法用new EventSource(/api/chat?promptxxx)来发送复杂 body强行拼 query string 会遇到长度限制和编码难题。第二SSE缺乏对请求中断的细粒度控制。eventSource.close()只能关闭连接但无法像AbortController那样在 fetch 发起后任意时刻精准中止请求并触发reader.cancel()清理缓冲区。用户点击“停止生成”按钮时SSE 往往还会收到几条残留消息导致 UI 错乱。第三SSE错误恢复机制僵硬。EventSource默认会在连接断开后自动重试重试间隔固定为 3 秒且重试时会重新发起 GET 请求丢失上下文。而大模型对话是状态化的一次中断后你通常需要带着上次的conversation_id或continue_from参数重新发起请求SSE 无法满足这种定制化重连逻辑。Fetch 则完全没有这些限制。它天然支持 POST、PUT、DELETE 等所有方法通过AbortController可以在毫秒级精度上取消请求错误处理完全由开发者掌控——你可以捕获AbortError做优雅降级也可以捕获网络错误后自动重试并携带新参数。更重要的是Fetch 返回的 ReadableStream 是标准、通用、可移植的。同一个流式处理逻辑稍作调整就能用在 Node.js 的fetch如 undici 库、Deno 或 Cloudflare Workers 中而 SSE 的EventSource是浏览器专属 API在服务端环境根本不存在。我团队去年重构一个跨端 AI 助手时前端用 Fetch 流式后端用 Node.js 的fetch调用大模型 API核心的流解析和 token 合并逻辑如处理data: {delta: {content: hello}}完全复用只改了两行环境判断代码。如果是 SSE这套逻辑就得重写两套。提示某些大模型服务商如早期的 OpenAI确实提供 SSE 接口但这更多是历史兼容性考虑。其底层实现依然是基于 HTTP 分块传输SSE 只是套了一层固定的文本协议外壳。真正的高性能、高可控性流式交互必须直面 ReadableStream。4. 从零实现一个健壮的大模型流式渲染器核心代码与避坑指南光讲原理不够下面我给出一个经过生产环境验证的、最小可行的流式渲染器实现。它不依赖任何第三方库只用原生 Fetch 和 ReadableStream重点解决实际开发中最常遇到的五个坑token 解析、HTML 安全渲染、中断处理、错误重试、以及性能优化。// 初始化 AbortController用于后续中断 const controller new AbortController(); const signal controller.signal; // 发起流式请求 const response await fetch(/api/chat, { method: POST, headers: { Content-Type: application/json }, body: JSON.stringify({ model: gpt-4-turbo, messages: [{ role: user, content: 解释量子纠缠 }], stream: true // 关键告诉后端启用流式 }), signal // 传入中断信号 }); if (!response.ok) { throw new Error(HTTP ${response.status}: ${response.statusText}); } // 获取 ReadableStream 并创建 reader const reader response.body.getReader(); const decoder new TextDecoder(utf-8); let buffer ; // 缓冲区用于拼接不完整的 JSON 行 try { while (true) { const { done, value } await reader.read(); if (done) break; // 将二进制 chunk 解码为字符串并追加到缓冲区 buffer decoder.decode(value, { stream: true }); // SSE 格式data: {...}\n\n我们按行分割 // 注意有些服务端返回纯 JSON Lines每行一个 JSON 对象无 data: 前缀 const lines buffer.split(\n); // 保留最后一行到 buffer因为可能是不完整的 buffer lines.pop() || ; for (const line of lines) { if (!line.trim()) continue; // 跳过空行 let jsonStr line; // 兼容两种格式SSE 的 data: {...} 和纯 JSON Lines if (line.startsWith(data: )) { jsonStr line.slice(6).trim(); } try { const parsed JSON.parse(jsonStr); // 提取 delta.content这是 token 文本 const content parsed?.choices?.[0]?.delta?.content || ; // 安全渲染避免 XSS只允许纯文本 const safeContent content.replace(//g, lt;).replace(//g, gt;); // 追加到 DOM假设有一个 div idoutput/div document.getElementById(output).innerHTML safeContent; // 可选滚动到底部 document.getElementById(output).scrollTop document.getElementById(output).scrollHeight; } catch (e) { // 忽略解析失败的行如 event: ping 或注释行 console.warn(Skip invalid line:, line); } } } } catch (error) { if (error.name AbortError) { console.log(Stream aborted by user); } else { console.error(Stream error:, error); } } finally { reader.releaseLock(); // 必须调用否则流无法被垃圾回收 }这段代码背后藏着几个血泪教训缓冲区管理是核心decoder.decode(value, { stream: true })的stream: true参数至关重要。它告诉解码器当前 chunk 可能是 UTF-8 多字节字符的前半部分先缓存起来等下一块数据到来再合并解码。如果不加这个参数中文会大量乱码。行分割必须谨慎\n在不同系统下可能是\r\n但大模型 API 通常统一用\n。buffer.split(\n)后lines.pop()留下的buffer是为了处理跨 chunk 的换行符这是流式解析的基石。HTML 渲染必须转义大模型输出可能包含script标签直接innerHTML content是严重 XSS 漏洞。replace(//g, lt;)是最简方案生产环境建议用 DOMPurify 库。reader.releaseLock()不可省略这是 ReadableStream 的规范要求。如果你忘了调用reader 会一直持有流的锁导致后续无法再次读取或流无法被 GC内存泄漏风险极高。signal的生命周期要匹配AbortController应该在组件挂载时创建在卸载时调用controller.abort()。如果在 React 中它应该放在useEffect的 cleanup 函数里。5. 大模型流式加载的边界什么情况下 Fetch 也救不了你Fetch ReadableStream 是浏览器流式加载的黄金组合但它不是万能的。在实际项目中我遇到过三类 Fetch 也束手无策的场景必须从架构层面解决而不是在前端“硬刚”。第一类是服务端不支持流式。很多私有部署的大模型服务尤其是基于 Flask/FastAPI 的简易封装默认把整个生成结果攒在内存里最后一次性return JSONResponse(...)。这种服务返回的是普通 JSONresponse.body是null你根本拿不到 ReadableStream。此时唯一的办法是推动后端改造在 FastAPI 中用StreamingResponse在 Flask 中用Response的generator参数确保响应头包含Transfer-Encoding: chunked。前端再怎么优化 Fetch 代码面对一个非流式响应也只能退化为全量加载。第二类是网络链路不稳定。Fetch 的AbortController只能中断当前请求但无法解决弱网环境下 TCP 连接频繁断开的问题。用户在地铁里用 4G 看 AI 写作可能每 10 秒就断一次。这时单纯重试fetch会导致 token 丢失、上下文错乱。解决方案是引入客户端状态同步每次收到 token都记录当前已接收的index中断后带着resume_from_index参数发起新请求服务端从指定位置继续生成。这需要前后端约定一套断点续传协议Fetch 只是执行者不是协议制定者。第三类是超长上下文导致流速过慢。当 prompt 达到 32K tokens大模型的首 token 延迟Time to First Token, TTFT可能高达 5 秒以上。用户看到空白屏幕 5 秒会认为“没反应”进而反复点击。这时 Fetch 本身没问题但 UX 设计必须补位在fetch发起后立即显示“AI 正在思考...”的 loading 状态用骨架屏填充内容区域甚至预估 TTFT动态调整 loading 动画速度。技术上你可以用performance.now()记录 fetch 开始时间结合历史平均 TTFT在 3 秒后显示“预计还需 X 秒”大幅降低用户焦虑。最后分享一个小技巧在开发阶段用curl -N http://localhost:8000/api/chat | hexdump -C直接查看原始 HTTP 响应流。如果看到0d 0a即\r\n频繁出现且中间夹杂着7b 7d即{}说明服务端确实在流式输出如果hexdump输出长时间卡住直到最后才刷出一大片数据那就是服务端没做流式别在前端浪费时间了。我在本地部署 Llama3-70B 时就用这个curl hexdump方法快速定位了 FastAPI 的StreamingResponse配置错误——原来忘了加media_typetext/event-stream导致 Nginx 缓存了整个响应。这类问题再资深的前端工程师也得和后端一起查Fetch 只是工具不是黑魔法。
返回列表