2026/11/4 21:44:35

大模型流式响应:fetch与ReadableStream协同原理与实战

大模型流式响应:fetch与ReadableStream协同原理与实战 1. 这不是“一起出现”而是大模型前端流式响应的必然搭档你第一次在大模型 Web 应用的控制台里看到fetch和ReadableStream同时报错或者在调试 Network 面板时发现 Response 的body类型是ReadableStream心里大概率会冒出一句“这俩怎么又凑一块儿了”——尤其当错误信息是TypeError: Failed to fetch或ReadableStream is locked时那种“明明只写了三行代码却像在解一道编译原理题”的挫败感特别真实。这不是巧合也不是框架故意设的障眼法。fetch是发起请求的“门卫”ReadableStream是接收响应的“流水线工人”——而大模型的输出天生就是一条不能停、不能倒带、必须边产边送的流水线。你调用fetch(/api/chat)后端返回的不是一整块 JSON 字符串而是一段持续涌出的、以\n分隔的data: {...}块SSE或直接裸字节流chunked transfer encoding。这时候fetch把连接通道交出来ReadableStream就立刻接管开始一帧一帧地读、解码、分发。它不等全部数据落盘也不缓存整条消息体——因为大模型的 token 生成速度可能高达 50 tokens/秒等它吐完 2000 个 token 再解析用户界面早就卡成 PPT 了。我去年重构一个本地 LLM 聊天界面时就栽在这点上。最初用await response.json()结果每次提问都要等模型完全生成完毕才渲染用户反馈“像在等煮开水”。改成response.body.getReader()decoder.decode()后首 token 延迟从 3.2 秒压到 480ms且每 80ms 就能刷新一次 UI。这不是优化技巧而是对大模型输出本质的尊重它不是文件下载是实时语音转文字式的流式生产。关键词fetch和ReadableStream在大模型场景下高频共现根本原因在于 HTTP 协议层与 AI 推理层的天然耦合——HTTP/1.1 的Transfer-Encoding: chunked和 HTTP/2 的流式 multiplexing为 token 级别响应提供了传输基础而浏览器原生Streams API则是唯一能在主线程安全、可控地消费这种流的机制。跳过ReadableStream直接text()或json()等于让流水线工人把整条传送带拆下来扛回家再分拣——物理上就不可行。提示很多初学者误以为fetch返回的Response对象自带“自动流式解析”能力。事实是response.text()、response.json()、response.blob()全部是consume 操作它们会隐式调用response.body.getReader().read()直到done true并把所有 chunk 合并成单一大字符串/对象。这对小数据友好对大模型输出就是灾难性阻塞。2. 为什么fetch必须配ReadableStream从 HTTP 协议底层看流式响应的刚性约束要真正理解fetch和ReadableStream的绑定关系得回到 HTTP 协议本身。大模型 API 的响应头里你几乎总能看到这两行Content-Type: text/event-stream Transfer-Encoding: chunked或者更现代的Content-Type: application/json Content-Encoding: identity但关键不在类型而在传输方式。HTTP/1.1 的chunked编码是服务器在不知道总长度时把响应体切成若干块chunk每块前带长度标识末尾用0\r\n\r\n标记结束。浏览器收到第一个 chunk 就能开始处理无需等待全部数据到达。HTTP/2 更进一步每个 stream 可以独立发送多个 DATA 帧天然支持多路复用和流式推送。问题来了浏览器如何把这种“边收边传”的字节流变成前端 JavaScript 可操作的数据答案只有一个ReadableStream。它是 WHATWG Streams 标准定义的、浏览器原生支持的异步迭代器接口专为处理“不可知长度、持续到达、需逐段消费”的数据设计。fetch的Response.body属性正是这个标准的落地实现——它不是一个缓冲区而是一个可读流控制器ReadableStreamDefaultReader的入口。我们来拆解一次典型的大模型流式请求生命周期fetch(/v1/chat/completions, { method: POST, body: JSON.stringify({ model: qwen2.5-7b, messages: [...] }) })发起请求服务端收到后立即返回200 OK响应头并开始向 TCP 连接写入数据块如data: {id:chat-xxx,object:chat.completion.chunk,delta:{content:你},index:0}\n\n浏览器网络栈接收到第一个 TCP 包解析出 HTTP 头创建Response对象其body属性被初始化为一个ReadableStream实例此时response.body并未读取任何数据它只是“准备好接收”就像打开水龙头但还没拧开阀门当你调用const reader response.body.getReader()才真正获取到读取器reader它持有对底层流的引用reader.read()返回一个 Promiseresolve 时携带{ value: Uint8Array, done: false }—— 这个Uint8Array就是当前收到的 chunk 原始字节你用new TextDecoder().decode(chunk)把字节转成字符串按 SSE 规则分割data:行JSON.parse 内容字段提取delta.content更新 UI循环reader.read()直到done true服务端关闭连接或发送终止标记。整个过程里fetch只负责建立连接、发送请求、接收响应头并创建Response对象真正的数据搬运工、解码器、分发器全是ReadableStream及其 reader 在干活。如果你跳过它用await response.text()那么第 6 步会被封装成一个黑盒fetch内部会自动调用reader.read()直到done把所有Uint8Array合并、解码、拼接最后返回一个完整字符串。这对 1KB 的 JSON 是秒级完成但对 50KB 的流式响应含数百个 chunk意味着 UI 线程被阻塞无法响应滚动、输入等交互。注意ReadableStream的locked状态是核心陷阱。一旦你调用response.text()、response.json()或response.blob()response.body就被标记为 locked后续再调getReader()会抛TypeError: Cannot read property getReader of null。这是浏览器强制的单消费者约束——防止多个 reader 同时读取导致数据错乱。所以选了text()就别想再流式读选了getReader()就绝不能再碰其他 consume 方法。3. 实战中的三大典型错误链从failed to fetch到stream is locked的完整排查路径我在帮三个不同团队做大模型前端集成时发现 92% 的流式响应失败都集中在三个相互嵌套的错误模式里。它们不是孤立的报错而是一条清晰的因果链。下面我用真实调试日志还原整个过程告诉你怎么一步步定位根因。3.1 错误链起点Failed to fetch—— 表面是网络问题实则是服务端流式中断现象控制台报TypeError: Failed to fetchNetwork 面板里该请求状态为(cancelled)或Pending后消失Response 标签页为空。初学者第一反应是“网络不好”于是反复刷新、换浏览器、关防火墙……其实这是最典型的服务端主动断连信号。大模型服务如 Ollama、vLLM、FastChat在流式响应中如果遇到以下任一情况会直接关闭 TCP 连接模型推理超时如--timeout 300设置过短而长文本生成耗时 320s显存不足触发 OOM Killer 杀死 worker 进程中间件如 Nginx配置了proxy_read_timeout 60但模型生成需 90s客户端发送了Connection: close头服务端礼貌回应后断连。验证方法打开 Network 面板点击该请求 → Headers → 查看Request Headers是否有Connection: close再看Response Headers若缺失Content-Type或Transfer-Encoding基本确认服务端未进入流式响应阶段而是直接返回了 5xx 错误但被 fetch 框架吞掉。解决方案服务端检查日志确认是否Killed process或timeout exceeded调高--timeout和--num-gpu-layers减少 CPU 推理压力代理层Nginx在location /api/块中添加proxy_http_version 1.1; proxy_set_header Connection ; proxy_read_timeout 300; # 必须 ≥ 模型最大生成时间 proxy_buffering off; # 关闭缓冲确保 chunk 立即透传客户端fetch选项中显式设置keepalive: true虽非强制但明确意图。3.2 错误链中段ReadableStream is locked—— 你以为在流式读其实已偷偷 consume现象UI 无响应控制台报TypeError: ReadableStream is lockedresponse.body.getReader()调用失败。这是最隐蔽的坑。我见过最多的情况是开发者为了“保险”在流式读取前先尝试if (response.ok) { await response.json() }做状态校验——这一行就锁死了 stream。response.json()内部会调用response.body.getReader().read()直到done即使你没 await 它Promise 创建即触发消费。另一个常见场景使用 Axios。默认配置下axios.post()返回的response.data是response.body经transformResponse处理后的结果而transformResponse默认包含JSON.parse这就等同于调用了response.json()。排查步骤在fetch后立即打印response.body.locked—— 若为true说明已被 consume检查代码中是否出现response.text()、response.json()、response.blob()、response.arrayBuffer()任意调用若使用封装库如 SWR、RTK Query查看其transform或parse配置确认未开启自动 JSON 解析。修复方案二选一彻底放弃所有 consume 方法从fetch开始只走getReader()路线用TextDecoder手动解码改用response.clone()response.clone().json()不影响原response.body但注意 clone 会复制整个流内存开销翻倍仅适用于小响应体校验。3.3 错误链终点AbortError: The operation was aborted—— 用户手速太快流被主动掐断现象用户快速连续发送多条消息某次响应中途报AbortErrorUI 停止更新。这是AbortController的正常行为。当你为fetch配置signal并在新请求发起时调用abortController.abort()所有监听该 signal 的 Promise包括reader.read()都会 rejectAbortError。问题在于很多代码没做错误隔离// ❌ 危险写法未捕获 AbortError导致整个组件崩溃 const reader response.body.getReader(); while (true) { const { done, value } await reader.read(); // 这里可能 throw AbortError if (done) break; appendToUI(decode(value)); }正确做法是将流读取封装为独立可取消任务并优雅降级async function readStream(reader, decoder, onChunk) { try { while (true) { const { done, value } await reader.read(); if (done) break; onChunk(decoder.decode(value, { stream: true })); } } catch (error) { if (error.name AbortError) { console.log(流读取被用户中断忽略); return; // 优雅退出 } throw error; // 其他错误仍需上报 } } // 使用时 const controller new AbortController(); fetch(/api/chat, { signal: controller.signal }) .then(response { const reader response.body.getReader(); const decoder new TextDecoder(); readStream(reader, decoder, appendToUI); }); // 用户点击“停止生成”时 controller.abort(); // 不会炸掉页面这三条错误链本质是 HTTP 流式协议、浏览器 Streams API、前端业务逻辑三者之间的摩擦。解决它们不需要新学框架只需要理解fetch是信使ReadableStream是流水线而你的代码必须成为这条流水线上的合格调度员。4. 手把手实现一个健壮的大模型流式读取器从 raw bytes 到可渲染 delta光说原理不够我们来写一个生产环境可用的流式读取器。它要解决五个实际痛点① 正确处理 SSEServer-Sent Events格式② 兼容纯文本流如text/plain③ 自动处理 UTF-8 多字节字符边界避免 符号④ 支持 abort 且不崩溃⑤ 提供 token 级别回调便于 UI 实时渲染。下面是我在线上项目中稳定运行 11 个月的LLMStreamReader类已去除所有框架依赖纯原生 JSclass LLMStreamReader { constructor(response, options {}) { this.response response; this.decoder new TextDecoder(utf-8); this.onToken options.onToken || (() {}); this.onError options.onError || console.error; this.onComplete options.onComplete || (() {}); this.sseMode response.headers.get(content-type)?.includes(event-stream); this.buffer ; // 存储未完成的 UTF-8 字符 } // 主入口启动读取 async start() { if (!this.response.body) { throw new Error(Response has no body); } const reader this.response.body.getReader(); try { while (true) { const { done, value } await reader.read(); if (done) { this.onComplete(); break; } this.processChunk(value); } } catch (error) { if (error.name AbortError) { this.onError(Stream aborted by user); } else { this.onError(Stream read error:, error); } } finally { reader.releaseLock(); } } // 处理单个 chunk 的字节 processChunk(chunk) { // Step 1: 将 Uint8Array 解码为字符串处理 UTF-8 边界 let str this.decoder.decode(chunk, { stream: true }); // Step 2: 合并上一次残留的 buffer处理跨 chunk 的 UTF-8 字符 str this.buffer str; this.buffer ; // Step 3: 根据模式解析 if (this.sseMode) { this.parseSSE(str); } else { // 纯文本流按行分割每行视为一个 token const lines str.split(\n); for (let i 0; i lines.length; i) { const line lines[i].trim(); if (line !line.startsWith(:)) { // 忽略 SSE 注释行 this.onToken(line); } } // 保存最后一行不完整部分到 buffer如 hel lo 跨 chunk if (lines.length 0 str.endsWith(\n) false) { this.buffer lines[lines.length - 1]; } } } // 解析 SSE 格式data: {...}\n\n parseSSE(str) { const lines str.split(\n); let currentEvent ; for (const line of lines) { if (line.startsWith(data: )) { currentEvent line.substring(6).trim(); } else if (line currentEvent) { try { const json JSON.parse(currentEvent); if (json.delta?.content) { this.onToken(json.delta.content); } } catch (e) { this.onError(Invalid SSE JSON:, currentEvent); } currentEvent ; } // 忽略 event:、id:、retry: 等行 } // 未结束的 data 行存入 buffer if (currentEvent !str.endsWith(\n)) { this.buffer currentEvent; } } } // 使用示例 async function chatWithModel(messages) { const controller new AbortController(); const response await fetch(/api/chat, { method: POST, headers: { Content-Type: application/json }, body: JSON.stringify({ messages }), signal: controller.signal, }); if (!response.ok) { throw new Error(HTTP ${response.status}); } const reader new LLMStreamReader(response, { onToken: (token) { document.getElementById(output).textContent token; // 这里可加光标闪烁、打字机效果等 }, onError: (msg, ...args) console.warn(msg, ...args), onComplete: () console.log(Stream finished), }); await reader.start(); return controller; // 返回 controller 以便外部 abort } // 用户点击停止时 // const abortController await chatWithModel([...]); // abortController.abort();这个实现的关键细节UTF-8 边界处理TextDecoder.decode(chunk, { stream: true })的stream: true参数至关重要。它告诉解码器“这可能不是完整的 UTF-8 序列”避免将0xE4 0xBD 0xA0“你”字的前两个字节0xE4 0xBD当作无效字符输出 。未设置时跨 chunk 的汉字必乱码。SSE 状态机不依赖正则全局匹配易漏匹配而是用currentEvent缓存未结束的data:行确保{content:hello和world}跨 chunk 也能正确拼接。buffer 机制this.buffer存储不完整行或不完整 JSON是流式处理的基石。没有它你好\n世界可能被切成你好和世界两次回调破坏语义。releaseLock()reader.releaseLock()在 finally 块中调用确保无论成功失败流锁都被释放避免内存泄漏。提示不要试图用response.body.pipeThrough(new TextDecoderStream())替代手动解码。TextDecoderStream是 TransformStream它会创建新流增加一层抽象且对stream: true支持不如原生TextDecoder稳定。实测在 Chrome 120 中TextDecoder.decode(chunk, { stream: true })的性能和兼容性均优于流式解码器。5. 进阶当fetch不够用——WebSocket 与自定义 HTTP Client 的取舍实战fetchReadableStream是当前最主流的大模型流式方案但它并非万能。当你的场景出现以下任一条件时就得考虑替代方案需要双向实时通信比如用户边说边改提示词real-time prompt editing服务端需即时调整生成策略长连接稳定性要求极高fetch的 HTTP 连接在移动端易被系统回收3G/4G 网络下断连率超 15%定制化传输协议如用 Protocol Buffers 序列化 token而非 JSON/SSE追求极致带宽压缩服务端不支持 chunked某些老旧网关或企业防火墙会 stripTransfer-Encoding头强制转为Content-Length导致流式失效。这时WebSocket和自定义 HTTP Client如基于http模块的 Node.js 服务成为备选。我用真实项目对比它们的适用边界5.1 WebSocket适合“对话态强交互”场景但增加运维复杂度WebSocket 的优势在于全双工、低延迟、连接保活机制成熟。我曾为一个金融投研助手接入 WebSocket实现“用户语音输入 → 实时转文字 → 模型流式生成 → 结果同步高亮原文段落”的闭环。整个链路端到端延迟压到 800ms 以内fetch方案做不到。但代价显著服务端改造成本高需部署 WebSocket server如 ws 库并维护连接池、心跳、重连逻辑CDN/反向代理穿透难Cloudflare、阿里云 SLB 默认不支持 WebSocket upgrade需额外配置浏览器兼容性陷阱iOS Safari 对WebSocket.binaryType arraybuffer支持不一致导致 Protobuf 解析失败。关键决策点如果你的 UI 有“实时协作编辑”、“多人共享会话”、“语音打断重生成”等强交互需求WebSocket 是刚需如果只是“发消息→等回复→显示”fetch更轻量可靠。5.2 自定义 HTTP ClientNode.js 后端的终极控制权前端fetch受限于浏览器安全策略CORS、mixed content而 Node.js 服务可以用http或undici模块直连大模型服务完全掌控流式处理// Node.js 服务端示例使用 undici性能优于原生 http import { request } from undici; export async function streamToClient(req, res) { const { messages } req.body; // 直连 vLLM 服务绕过浏览器 CORS const { body, statusCode } await request(http://localhost:8000/v1/chat/completions, { method: POST, headers: { Content-Type: application/json }, body: JSON.stringify({ model: qwen2.5-7b, messages }), }); // 将 vLLM 的流式响应透传给前端 res.writeHead(statusCode, { Content-Type: text/event-stream, Cache-Control: no-cache, Connection: keep-alive, }); // pipe 透传零拷贝 body.pipe(res); }这种方式的优势100% 控制流式头可精确设置Content-Type、Cache-Control避免浏览器代理篡改无缝集成鉴权在 Node.js 层统一处理 API Key、JWT前端无需暴露密钥错误熔断undici支持retry、maxRedirections比fetch的重试更可控。但必须接受你承担了反向代理的所有责任。包括连接池管理、超时控制、错误日志、负载均衡。一个没配置好的undiciclient可能在高并发下创建数千个 TCP 连接拖垮服务。5.3 终极建议80% 场景用fetchReadableStream20% 特殊需求才升级我的经验是先用fetchReadableStream实现 MVP跑通流式渲染、错误处理、用户中断。当真实用户反馈“在地铁切换基站时总断连”、“多人同时提问响应变慢”再评估 WebSocket 或 Node.js 代理。过早引入复杂方案只会让你在调试连接池和心跳包上浪费两周而用户真正关心的是“回答准不准”。最后分享一个血泪教训某客户坚持用 WebSocket结果上线后发现 iOS 16.4 的 Safari 存在一个 bug——WebSocket 连接在后台标签页恢复时readyState保持OPEN但实际已断send()不报错却无响应。我们花了 3 天定位最终降级回fetch用navigator.onLinesetTimeout模拟心跳问题消失。技术选型永远服务于真实场景而非技术炫技。我在实际使用中发现最可靠的流式方案往往是最朴素的那个fetch建立连接ReadableStream接收字节TextDecoder解码onToken更新 UI。它不酷但足够稳它不新但经得起百万次并发考验。