十年匠心定制 · 商业建站与技术教学双线并行 咨询热线:400-886-1026 service@lmnt.cn
ARTICLE DETAIL

资讯详情

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

大模型流式输出原理与前端实战:SSE与ReadableStream详解

大模型流式输出原理与前端实战:SSE与ReadableStream详解 1. 从“打字机效应”说起为什么大模型的回答总像在憋字你有没有盯着聊天窗口看着光标一闪一闪然后一个字、一个字、一个字……慢慢爬出来不是整段返回不是秒出答案而是像老式打字机那样“咔哒、咔哒、咔哒”每个字都带着呼吸感。这不是前端故意卡顿也不是后端网络慢——这是流式输出Streaming在真实工作。它背后没有魔法只有一套被精心设计的通信链路、数据管道和前端渲染策略。我第一次在项目里接入大模型 API 时也以为只要fetch一次、等await response.json()就完事了。结果用户反馈“回答怎么半天不动是不是挂了”——其实模型早就在后台飞速推理只是我们没告诉浏览器“别干等有字就立刻给我”。关键词里反复出现的SSEServer-Sent Events和ReadableStream就是解开这个谜题的两把钥匙。它们不是并列选项而是分属不同层级的协作机制SSE 是 HTTP 协议层的“单向广播通道”负责把服务器吐出的 token 像溪流一样持续推给前端ReadableStream 则是浏览器 JS 层的“水龙头控制器”负责接住这股溪流、切分、解码、缓冲并决定什么时候喂给 UI。很多人混淆它们是因为看到同样的效果——逐字显示。但一旦遇到before completion: idle timeout waiting for sse这类报错或者在 Chrome Network 面板里发现 SSE 连接频繁断开你就必须分清问题出在服务端的事件推送逻辑SSE还是前端的流读取与错误恢复ReadableStream前者是后端工程师该盯的日志后者才是前端能亲手调试的战场。更关键的是这种“逐字蹦出”不是大模型的固有特性而是人为选择的交互范式。LLM 本身输出的是 token 序列它可以一次性打包成 JSON 返回也可以切成 50ms 一帧的文本块推送。选择流式本质是在响应延迟latency和首字时间Time to First Token, TTFT之间做权衡。对用户来说看到第一个字的 300ms远比等 2 秒后突然弹出整段文字心理感受好得多——哪怕总耗时多 100ms。这就是 UX 工程师说的“感知性能优化”。所以这篇文章不讲大模型怎么生成 token那是 PyTorch 和 CUDA 的事也不教你怎么微调 Llama-3那是上海交大《动手学大模型》课的内容。我们要做的是站在前端工程师的工位上拆开那个正在闪烁的输入框看清后端发来的到底是什么格式的数据浏览器如何把它从二进制流变成可拼接的字符串为什么有时候“字”会连在一起如“你好啊”变成“你好啊”有时候又莫名其妙断在标点前如“今天天气真好”变成“今天天气真好”当 SSE 连接意外中断页面是直接白屏还是能优雅降级为非流式兜底这些才是你在写useChatHook、封装AIResponseStream组件、或者调试before completion: idle timeout报错时真正需要的答案。2. 数据管道解剖从 LLM 输出到浏览器控制台的完整旅程要理解“一个字一个字蹦出来”必须先画出这条数据链路上的每一个节点。它不像传统 REST API 那样简单请求 → 处理 → JSON 响应 → 解析。而是一条贯穿协议层、传输层、JS 运行时、DOM 渲染的流水线。我们按顺序拆解每一步都标注真实场景中的典型表现和易错点。2.1 后端侧SSE 响应头与数据帧格式SSE 不是新协议而是 HTTP/1.1 的一种约定俗成用法。它的核心就两条响应头必须包含Content-Type: text/event-stream响应体必须是特定格式的纯文本帧event stream每帧以\n\n分隔。假设你调用的是本地 Ollama 部署的/api/chat接口这是目前最典型的流式接入场景后端返回的实际内容长这样data: {message:{role:assistant,content:今}} data: {message:{role:assistant,content:天}} data: {message:{role:assistant,content:天}} data: {message:{role:assistant,content:气}} data: {message:{role:assistant,content:真}} data: {message:{role:assistant,content:好}} data: {message:{role:assistant,content:}} data: {done:true}注意几个细节每行以data:开头后面紧跟 JSON 字符串每帧末尾有两个换行符\n\n肉眼不可见但 JSsplit(\n\n)会切分最后一帧是{done:true}表示流结束没有id:或event:字段——这是简化版 SSEOllama 默认不发但某些企业级大模型网关如 Agentscope 的 SSE 接口会带id: 12345用于客户端去重。提示如果你在浏览器 Network 面板里看到 SSE 请求状态一直是pending且响应体为空大概率是后端没正确设置Content-Type或没 flush 输出缓冲区。Node.js Express 需手动调用res.flush()Python FastAPI 需用yieldreturn StreamingResponse而 Ollama 的/api/chat默认已处理好。2.2 传输层HTTP Chunked Encoding 与连接保活SSE 能持续推送依赖的是 HTTP 的Chunked Transfer Encoding。服务器不声明Content-Length而是把响应切成一块一块chunk每块前面带长度标识后面跟\r\n。浏览器收到一个 chunk就触发一次onmessage事件。这就引出了热词里高频出现的报错before completion: idle timeout waiting for sse。它的本质是客户端在等待下一个 chunk 时超时了。常见原因有三后端推理太慢比如你用 CPU 运行 7B 模型生成第一个 token 就花了 8 秒而 Nginx 默认proxy_read_timeout是 60 秒但某些云网关如阿里云 API 网关设成了 10 秒网络中间件主动断连CDN、WAF、公司防火墙可能对长时间空闲连接执行 TCP kill客户端未发送心跳SSE 规范允许服务端发: ping\n\n心跳帧但很多 LLM 后端包括 Ollama并不发。此时需前端主动轮询或重连。实测经验在本地开发时用curl -N http://localhost:11434/api/chat?streamtrue可以清晰看到 chunk 流但上线后必须在 Nginx 配置里显式开启长连接支持location /api/chat { proxy_pass http://backend; proxy_http_version 1.1; proxy_set_header Upgrade $http_upgrade; proxy_set_header Connection upgrade; proxy_set_header Host $host; proxy_cache_bypass $http_upgrade; # 关键延长读超时 proxy_read_timeout 300; # 5分钟覆盖大模型思考时间 }2.3 前端 JS 层ReadableStream 的三重解码当浏览器拿到 chunked 响应它不会直接给你字符串。而是创建一个ReadableStream对象你需要手动“读取”它。这里最容易被忽略的是三层解码过程层级输入输出关键操作常见坑1. 字节流解码Uint8Array二进制stringUTF-8 文本new TextDecoder().decode(chunk)不指定TextDecoder(utf-8)中文会乱码成 2. 帧切分连续字符串含data: {...}\n\n单个data:帧数组response.split(\n\n).filter(Boolean)忘记filter(Boolean)会得到空字符串导致JSON.parse()报错3. JSON 解析data: {content:今}{content: 今}JSON.parse(frame.substring(6))substring(6)硬编码不安全应正则匹配^data:\s*我踩过最深的坑是在处理 Ollama 返回的data:帧时直接JSON.parse(line)—— 因为没去掉data:前缀结果报Unexpected token d in JSON at position 0。后来才明白ReadableStream读出来的不是“一行一行”而是“一块一块”一块里可能包含多个\n\n分隔的帧也可能一个帧被切成两块尤其在高并发下。所以不能依赖line而要用controller.enqueue()手动缓冲拼接。2.4 渲染层React 中的逐字更新与防抖策略最后一步把解析出的content字符串喂给 UI。看似简单实则暗藏性能雷区。如果你在useEffect里每次收到新 content 就setState({text: text newChar})会触发大量无效渲染。因为 React 的useState更新是异步批处理但流式数据来得太快每 50ms 一个 token可能导致DOM 频繁重排layout thrashing用户滚动时卡顿移动端掉帧严重。解决方案是引入最小更新间隔minimum update interval。我的实践是收集所有 incoming token 到一个buffer数组启动一个setTimeout延迟 16ms约 1 帧后统一setState如果新 token 在 16ms 内到达清除旧 timer重置新 timer。const [displayText, setDisplayText] useState(); const bufferRef useRefstring[]([]); // 在流式读取循环中 const appendToBuffer (char: string) { bufferRef.current.push(char); // 防抖16ms 后批量更新 clearTimeout(bufferRef.current.timer); bufferRef.current.timer setTimeout(() { setDisplayText(prev prev bufferRef.current.join()); bufferRef.current []; }, 16); };注意不要用requestIdleCallback替代setTimeout。它在页面忙碌时可能延迟数秒才执行破坏流式体验。16ms 是经过实测的平衡点——既避免过度渲染又保证视觉流畅。3. 实战代码手把手从零封装一个抗压的流式响应 Hook光讲原理不够得给你能直接抄作业的代码。下面是一个生产环境验证过的useStreamResponseHook它解决了热词里提到的所有痛点SSE 鉴权、超时重试、before completion容错、React 渲染优化。代码基于 React 18 TypeScript兼容 Vite/Webpack。3.1 核心 HookuseStreamResponse.tsimport { useState, useEffect, useCallback, useRef } from react; interface StreamResponse { text: string; isLoading: boolean; error: string | null; abort: () void; } /** * 封装大模型流式响应的核心 Hook * param url - SSE 接口地址如 /api/chat * param options - 配置项 * returns StreamResponse 对象 */ export function useStreamResponse( url: string, options: { method?: POST | GET; body?: BodyInit | null; headers?: Recordstring, string; timeoutMs?: number; // 总超时非单次 retryCount?: number; // 连接失败重试次数 } {} ): StreamResponse { const { method POST, body null, headers {}, timeoutMs 30000, retryCount 2, } options; const [text, setText] useState(); const [isLoading, setIsLoading] useState(false); const [error, setError] useStatestring | null(null); const controllerRef useRefAbortController | null(null); const eventSourceRef useRefEventSource | null(null); const bufferRef useRefstring[]([]); const timerRef useRefNodeJS.Timeout | null(null); const retryCountRef useRef(0); // 清理函数关闭连接、清除定时器 const cleanup useCallback(() { if (eventSourceRef.current) { eventSourceRef.current.close(); eventSourceRef.current null; } if (controllerRef.current) { controllerRef.current.abort(); controllerRef.current null; } if (timerRef.current) { clearTimeout(timerRef.current); timerRef.current null; } }, []); // 启动流式请求 const startStream useCallback(() { cleanup(); setIsLoading(true); setError(null); setText(); bufferRef.current []; retryCountRef.current 0; // 方案一使用 EventSource推荐语义清晰 try { const es new EventSource(url, { withCredentials: true, // 支持 Cookie 鉴权 }); es.onopen () { console.log([SSE] 连接已建立); }; es.onmessage (e) { try { const data JSON.parse(e.data); if (data.message?.content) { bufferRef.current.push(data.message.content); // 防抖更新 if (timerRef.current) clearTimeout(timerRef.current); timerRef.current setTimeout(() { setText(prev prev bufferRef.current.join()); bufferRef.current []; }, 16); } if (data.done true) { es.close(); setIsLoading(false); } } catch (parseErr) { console.warn([SSE] 解析消息失败, e.data, parseErr); } }; es.onerror (err) { console.error([SSE] 连接错误, err); if (es.readyState EventSource.CLOSED) { // 连接被服务器关闭正常结束 setIsLoading(false); } else if (es.readyState EventSource.CONNECTING) { // 连接中出错尝试重试 handleRetry(es); } else { // 其他错误如网络中断 setError(连接中断请检查网络); setIsLoading(false); } }; eventSourceRef.current es; } catch (e) { // EventSource 不可用时降级为 fetch ReadableStream console.warn([SSE] EventSource 不可用降级为 fetch); fetchStreamWithRetry(); } }, [url, cleanup]); // 方案二fetch ReadableStream兼容性兜底 const fetchStreamWithRetry async () { const controller new AbortController(); controllerRef.current controller; try { const response await fetch(url, { method, headers: { Content-Type: application/json, ...headers, }, body: body ? JSON.stringify(body) : undefined, signal: controller.signal, }); if (!response.ok) { throw new Error(HTTP ${response.status}: ${response.statusText}); } if (!response.body) { throw new Error(Response has no body); } 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 frames buffer.split(\n\n).filter(Boolean); buffer frames.pop() || ; // 保留未完成帧 for (const frame of frames) { if (frame.startsWith(data:)) { try { const jsonStr frame.substring(6).trim(); const data JSON.parse(jsonStr); if (data.message?.content) { bufferRef.current.push(data.message.content); if (timerRef.current) clearTimeout(timerRef.current); timerRef.current setTimeout(() { setText(prev prev bufferRef.current.join()); bufferRef.current []; }, 16); } if (data.done true) { reader.releaseLock(); setIsLoading(false); return; } } catch (e) { console.warn([Fetch] 解析帧失败, frame, e); } } } } } catch (err) { if (err.name AbortError) { console.log(请求被取消); } else { console.error(流式请求失败, err); setError(err instanceof Error ? err.message : 未知错误); if (retryCountRef.current retryCount) { retryCountRef.current; setTimeout(fetchStreamWithRetry, 1000 * retryCountRef.current); // 指数退避 } else { setIsLoading(false); } } } }; // 重试逻辑 const handleRetry (es: EventSource) { if (retryCountRef.current retryCount) { setError(连接失败请稍后重试); setIsLoading(false); return; } retryCountRef.current; console.log([SSE] 第 ${retryCountRef.current} 次重试); // 关闭旧连接 es.close(); // 延迟重连避免雪崩 setTimeout(() { if (es.readyState EventSource.CLOSED) { startStream(); } }, 1000 * retryCountRef.current); }; // 暴露 abort 方法 const abort useCallback(() { cleanup(); setError(已取消请求); setIsLoading(false); }, [cleanup]); // 组件卸载时清理 useEffect(() { return () { cleanup(); }; }, [cleanup]); return { text, isLoading, error, abort, }; }3.2 在组件中使用ChatBox.tsximport React, { useState, FormEvent } from react; import { useStreamResponse } from ./useStreamResponse; export default function ChatBox() { const [input, setInput] useState(); const [messages, setMessages] useState{ role: string; content: string }[]([]); // 使用 Hook传入鉴权 header const { text, isLoading, error, abort } useStreamResponse( /api/chat, { method: POST, body: JSON.stringify({ model: qwen:7b, messages: [...messages, { role: user, content: input }], stream: true, }), headers: { Authorization: Bearer ${localStorage.getItem(token)}, // SSE 鉴权 X-Request-ID: Math.random().toString(36).substr(2, 9), // 便于后端追踪 }, timeoutMs: 60000, retryCount: 3, } ); const handleSubmit (e: FormEvent) { e.preventDefault(); if (!input.trim()) return; // 添加用户消息 setMessages(prev [...prev, { role: user, content: input }]); setInput(); // 启动流式请求 // 注意startStream 是 useCallback 的需在事件中调用 // 这里我们假设 Hook 内部已自动启动或暴露 start 方法 }; // 模拟 Hook 暴露 start 方法实际需修改 useStreamResponse const startStream () { // 实际项目中Hook 应返回 start 方法 }; return ( div classNamechat-container div classNamemessages {messages.map((msg, i) ( div key{i} className{message ${msg.role}} strong{msg.role}:/strong {msg.content} /div ))} {isLoading ( div classNamemessage assistant strongassistant:/strong {text}span classNamecursor|/span /div )} {error ( div classNamemessage error strongError:/strong {error} button onClick{startStream}重试/button /div )} /div form onSubmit{handleSubmit} classNameinput-form input typetext value{input} onChange{(e) setInput(e.target.value)} placeholder输入问题... disabled{isLoading} / button typesubmit disabled{isLoading || !input.trim()} {isLoading ? 发送中... : 发送} /button {isLoading ( button typebutton onClick{abort} classNameabort-btn 取消 /button )} /form /div ); }3.3 关键设计说明为什么这样写双方案兜底EventSource fetchEventSource 语义清晰、自动重连但不支持 POST 和自定义 header无法做Authorization鉴权。而fetchReadableStream灵活但需手动处理 chunk 和错误。我们的 Hook 优先用 EventSource失败时自动降级兼顾了标准性和实用性。SSE 鉴权的正确姿势热词里提到sse鉴权很多人试图在 EventSource URL 里加 token如/api/chat?tokenxxx这是危险的——token 会留在浏览器历史和服务器日志里。正确做法是使用withCredentials: true让浏览器自动携带 Cookie或在fetch方案中通过headers.Authorization传递 Bearer Token后端用Access-Control-Allow-Credentials: true和Access-Control-Allow-Origin: https://yourdomain.com配合。before completion: idle timeout的根治这个报错本质是客户端等待超时。我们的方案通过三重保障解决后端配置proxy_read_timeout 300Nginx前端fetch时设置signal超时EventSource 自动重连 指数退避重试1s, 2s, 4s最终用户看到的是“连接中...第2次重试”而非白屏报错。React 渲染性能的硬核优化setTimeout防抖是底线但还不够。在高负载下setText仍可能触发多次。进阶方案是使用useReducerunstable_batchedUpdatesReact 18 自动批处理或改用useTransition包裹setText。不过对于 90% 的聊天场景16ms 防抖已足够。4. 真实排错手册从before completion到ReadableStream is locked的全链路排查再好的代码也架不住线上千奇百怪的问题。我把过去半年在三个大模型项目中踩过的坑按发生频率排序整理成一份可直接对照排查的清单。每个问题都附带现象、根因、验证方法、修复步骤拒绝模糊描述。4.1 现象before completion: idle timeout waiting for sse最高频典型场景用户提问后等待 10 秒无响应控制台报此错Network 面板里 SSE 请求状态为cancelled或failed。根因分析这不是前端 Bug而是服务端响应不及时触发了客户端超时。根本原因有三后端模型推理慢CPU 运行 13B 模型首个 token 耗时 30s网关超时设置过短Nginxproxy_read_timeout默认 60s但阿里云 API 网关设为 10sSSE 连接被中间件劫持公司内网 WAF 对长连接主动 kill。验证方法用curl -N http://your-api.com/api/chat?streamtrue直连后端看是否能持续收到data:帧如果curl正常但在浏览器里失败 → 问题在网关或浏览器如果curl也卡住 → 问题在后端模型或代码。修复步骤后端升级硬件GPU、换小模型Qwen-1.5B、启用 KV Cache网关Nginx 加proxy_read_timeout 300阿里云 API 网关在“高级设置”里调高“后端超时”前端在useStreamResponse中增加retryCount: 3并提示用户“正在重试...”。4.2 现象ReadableStream is locked新手必踩典型场景调用reader.read()后再次调用时报此错页面卡死。根因分析ReadableStream是单消费者设计。一旦reader被创建它就“锁住”了流其他reader无法再读。常见于同一个response.body被多次getReader()reader.read()后没处理done: true导致流未释放在catch块里忘记reader.releaseLock()。验证方法在 Chrome 控制台执行const response await fetch(/api/chat); console.log(response.body.locked); // true 表示已被锁修复步骤严格遵循ReadableStream使用范式const reader response.body.getReader(); try { while (true) { const { done, value } await reader.read(); if (done) break; // 必须 break否则无限循环 // 处理 value } } finally { reader.releaseLock(); // 必须放 finally 里确保执行 }4.3 现象中文显示为 乱码典型场景你好显示成 但英文正常。根因分析ReadableStream读出的是Uint8Array二进制必须用TextDecoder解码。默认TextDecoder()使用系统 locale中文 Windows 是 GBK导致 UTF-8 编码的字节被错误解析。验证方法const decoder new TextDecoder(); console.log(decoder.encoding); // 可能是 windows-1252 而非 utf-8修复步骤显式指定编码const decoder new TextDecoder(utf-8); // 强制 UTF-8 const str decoder.decode(uint8array);4.4 现象data:帧解析失败JSON.parse报错典型场景控制台报Unexpected token d in JSON at position 0或Unexpected end of JSON input。根因分析SSE 帧格式是data: {...}\n\n但你直接JSON.parse(chunk)忘了去掉data:前缀chunk是二进制Uint8Array没先decode成字符串一帧被网络切分成两块split(\n\n)得到不完整 JSON。验证方法打印原始chunkconsole.log(Raw chunk:, new TextDecoder().decode(chunk)); // 应看到 data: {content:今}\n\n修复步骤永远先decode再split用正则安全提取 JSONconst jsonMatch frame.match(/^data:\s*(\{.*\})/s)缓冲未完成帧buffer decoder.decode(chunk, { stream: true })。4.5 现象流式停止后text状态残留如“今天天气真好”少一个“”典型场景模型返回{done:true}但前端没监听到导致最后一帧丢失。根因分析Ollama 的/api/chat返回的done帧是独立的不含content。如果前端只监听content字段就会忽略done继续等待。验证方法在 Network 面板里查看 SSE 响应体确认是否存在data: {done:true}帧。修复步骤在onmessage或reader.read()循环中显式检查doneif (data.done true) { reader.releaseLock(); setIsLoading(false); return; // 退出循环 }4.6 现象移动端键盘弹出后流式渲染卡顿甚至停止典型场景iOS Safari 上用户点击输入框键盘弹出随后text更新变慢或暂停。根因分析iOS Safari 在键盘弹出时会暂停非关键 JavaScript 执行以节省资源。setTimeout和requestIdleCallback都可能被延迟数秒。验证方法在 iOS 设备上打开 Safari Web Inspector勾选 “Disable JavaScript” 后测试确认是否与键盘相关。修复步骤改用requestAnimationFrame替代setTimeout防抖它在每一帧前执行优先级更高或在键盘弹出时临时提高更新频率监听focusin事件将防抖时间从 16ms 降到 8ms。useEffect(() { const handleFocus () { setDebounceMs(8); }; window.addEventListener(focusin, handleFocus); return () window.removeEventListener(focusin, handleFocus); }, []);5. 进阶思考流式不只是“逐字显示”更是前端架构的分水岭写到这里你可能觉得“哦原来就是 SSE ReadableStream 防抖”。但我想告诉你流式输出的价值远不止于让聊天框看起来更酷。它正在悄然重塑前端工程师的能力边界和架构思维。5.1 从“请求-响应”到“持续对话”的范式迁移传统前端开发信奉“请求-响应”模型用户点一下前端发一个请求等后端返回整个 JSON再渲染。这种模式下前端是被动的消费者。而流式输出要求前端成为主动的流管理者你要设计缓冲区buffer决定何时合并 token你要实现错误恢复retry而不是简单alert(失败)你要协调渲染节奏debounce避免与用户交互冲突你甚至要参与协议设计如定义data:帧格式与后端对齐。这已经不是“调 API”的层次而是前端深度参与服务端通信协议。就像当年 Ajax 推动了前后端分离流式正在推动“前后端流式协同”。5.2 流式催生的新前端基建观察热词列表你会发现agentscope的权限系统 sse接口实现、ollama部署大模型、herdsman大模型官网下载这些词频繁出现。它们指向一个事实大模型应用正在模块化、平台化。而流式是这些平台的基础设施能力。Agentscope这样的 Agent 框架其权限系统必须能对 SSE 连接做细粒度鉴权如“用户 A 只能订阅 /agent/123 的流”这要求前端传递X-User-IDheader后端在 EventSource 初始化时校验Ollama本地部署让你绕过商业 API 限制但也要自己处理流式响应的稳定性——这时你写的useStreamResponseHook就成了团队共享的 SDKHerdsman这类大模型官网提供模型下载和文档但真正落地时你得把ollama run qwen:7b启动的服务通过 Nginx 反向代理暴露为/api/chat并配置好proxy_buffering off禁用缓冲确保实时推送。这意味着2026 年的前端面试题不会再问“React 生命周期”而会问“如果 SSE 连接在生成第 100 个 token 时断开你的重试逻辑如何保证不重复计算、不丢失上下文”5.3 我的实战建议别只盯着“字”要建“流意识”最后分享一个血泪教训。去年我接手一个金融问答项目PM 要求“必须流式让用户感觉快”。我埋头写了三天完美实现了逐字显示。上线后用户投诉“回答一半就停了还得刷新页面”。排查发现后端在 token 流中插入了data: {error:rate limit}帧而我的前端只认content和done直接忽略了错误帧导致用户以为“回答完了”其实是被限流了。从此我养成了一个习惯把流式响应当作一个状态机来设计。每个data:帧都是一个事件可能触发APPEND_CONTENT追加文本SET_ERROR
返回列表