ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

大模型前端流式输出实战:SSE与ReadableStream深度优化

大模型前端流式输出实战:SSE与ReadableStream深度优化 1. 这不是“打字机”而是前端与大模型之间的一场精密协同你有没有在 ChatGPT、文心一言或者自己搭的本地大模型网页里盯着光标等第一个字跳出来那个“啊……嗯……”的停顿接着是“我”、“我认”、“我认为”、“我认为这个”——字一个接一个往外“蹦”像被推着走又像呼吸一样有节奏。很多人第一反应是“哦这是流式输出嘛”然后就点开控制台看 network 面板里那条长长的 SSE 请求看到一堆 data: 字段再配上 status 200 就觉得“懂了”。但真正在一线做过大模型 Web 应用开发的人知道这根本不是“开了个开关”那么简单。它背后是一整套从前端渲染逻辑、网络协议适配、错误恢复机制到用户心理预期管理的系统工程。核心关键词——大模型、前端、流式输出、SSE、ReadableStream——每一个都不是孤立存在。大模型决定输出节奏token 生成耗时、首 token 延迟、吞吐波动前端不是被动接收者而是要主动缓冲、节流、防抖、分段渲染、处理中断、回滚状态流式输出是目标形态但实现路径不止一条SSE 是最常用但不是唯一SSEServer-Sent Events是 HTTP 层的“单向广播协议”它天然适合“服务端持续推送”但对前端来说它意味着没有 request-id 关联、没有标准重试语义、没有内置鉴权头而ReadableStream则是现代浏览器提供的底层流式数据处理能力它让前端能真正“按需消费”而不是等整包响应再 parse。这五个词串起来本质是在问当后端还在算第37个 token 的时候前端怎么让用户感觉“已经在回答了”这个问题不只出现在面试题里。它直接决定你做的 AI 工具是否“丝滑”用户输入问题后3秒没动静80%的人会刷新页面如果首字延迟稳定在800ms以内配合骨架屏打字动画留存率能提升22%我们团队在三个内部工具上线前后AB测试数据。它也决定你能否支撑高并发场景一个未做背压控制的 SSE 前端在1000并发下可能瞬间创建上万个 pending 的 EventSource 实例把浏览器内存拉到2GB以上。更关键的是它关系到你能不能做真正的 Agent 协作——比如一个“写周报Agent”需要先调用天气API再查日历最后生成文本中间每一步都要流式透出给用户这时候单纯的 SSE 就不够用了必须和 ReadableStream TransformStream 深度耦合。所以这篇内容不是讲“怎么写一行 eventsource.onmessage ...”而是带你从真实项目现场出发拆解一个字一个字“蹦”出来的全过程它从哪里来后端协议设计怎么传SSE 的坑与补丁前端怎么接ReadableStream 的三阶段消费模型怎么稳超时、断连、重试、降级怎么快首字优化、token 合并策略以及最关键的——怎么让用户觉得“它真的在思考”。我会用我们团队为某金融客户落地的“智能投研助手”Web 端为例所有代码、配置、监控指标、线上报错截图都来自真实环境不虚构、不简化、不回避那些文档里绝不会写的细节。2. 流式输出的本质不是“推”而是“拉-缓存-吐”的三级流水线很多人误以为流式输出就是“后端一边算一边发前端一边收一边显示”听起来很美但现实是网络不可靠、浏览器渲染有开销、用户眼睛有延迟、键盘可能随时打断。真正的流式体验是前端主动构建的一条“拉-缓存-吐”三级流水线。它和后端的 token 生成节奏异步解耦这才是稳定、可维护、可扩展的基础。2.1 为什么不能直接监听 EventSource——SSE 的三大原生缺陷我们先直面现实原生EventSourceAPI 在大模型场景下是“半残废”。这不是浏览器的问题而是协议设计使然。我们团队在早期版本中直接使用new EventSource(/api/chat)上线三天就收到27起用户投诉“回答卡在‘我’字不动了”、“突然整个回答消失重来”、“手机上刷一下就断”。抓包一看全是 SSE 的锅提示SSE 规范本身不定义重试间隔、不携带请求上下文、不支持自定义 header如 Authorization这些都得前端自己兜底。缺陷一无连接上下文断连即失忆SSE 是纯单向通道。一旦网络抖动导致连接关闭哪怕只有200msEventSource 会自动重连但重连后的请求是全新发起后端无法知道“这是刚才那个会话的续播”只能从头开始生成。我们曾记录到一次典型 case用户提问“帮我分析这只股票的K线形态”首字“我”出来后地铁进隧道导致连接中断重连后后端返回的是全新回答“您好我是您的AI助手”用户看到的就是“我您好我是您的AI助手”。缺陷二无背压机制前端被“灌醉”EventSource 接收事件是“全盘照收”不管前端是否渲染完成。当后端 token 生成飞快比如本地 Ollama 跑 Qwen2-0.5BTPS 达到120前端来不及逐字渲染onmessage回调堆积主线程卡死光标闪烁变慢甚至触发浏览器“页面无响应”警告。我们用 Performance API 监测发现当连续收到超过15个 data: 事件/秒onmessage平均执行时间从8ms飙升至42ms。缺陷三错误边界模糊“error”事件是万金油eventsource.onerror会捕获所有异常DNS失败、SSL证书过期、后端502、甚至跨域被拒。它不告诉你具体原因也不提供 retry 选项。我们线上日志里有近40%的 SSE error 是NetworkError但前端无法区分是用户关了WiFi还是后端服务崩了只能统一展示“网络异常请重试”用户体验断层。所以我们必须绕过 EventSource用更底层、更可控的方式接管流。2.2 真正的起点用 fetch ReadableStream 构建可控流水线现代浏览器Chrome 99、Firefox 100、Safari 16.4已全面支持fetch()返回的Response.body作为ReadableStream。这才是大模型流式输出的“黄金组合”。它让我们能精确控制每个环节// ✅ 正确姿势fetch ReadableStream TextDecoder async function streamChat(input) { const controller new AbortController(); const timeoutId setTimeout(() controller.abort(), 30000); // 30秒总超时 try { const response await fetch(/api/chat, { method: POST, headers: { Content-Type: application/json }, body: JSON.stringify({ input, session_id: getCurrentSessionId() }), signal: controller.signal // 绑定 abort 信号 }); if (!response.ok) throw new Error(HTTP ${response.status}); // 获取可读流 const reader response.body.getReader(); const decoder new TextDecoder(utf-8); let buffer ; // 三级流水线启动拉read→ 缓存buffer→ 吐render while (true) { const { done, value } await reader.read(); if (done) break; // Step 1: 拉 —— 读取原始字节 buffer decoder.decode(value, { stream: true }); // Step 2: 缓存 —— 按行分割data: 格式或按 token 分割JSONL const lines buffer.split(\n); buffer lines.pop() || ; // 最后一行可能不完整留到下次 // Step 3: 吐 —— 逐行解析并触发渲染 for (const line of lines) { if (line.trim() ) continue; if (line.startsWith(data: )) { const jsonStr line.slice(6).trim(); if (jsonStr [DONE]) break; try { const chunk JSON.parse(jsonStr); renderToken(chunk.delta?.content || ); } catch (e) { console.warn(Invalid SSE line:, line); } } } } } finally { clearTimeout(timeoutId); } }这段代码看似比EventSource多十几行但它带来了质的飞跃可控中断AbortController让我们可以随时取消请求且后端能感知到Ollama、vLLM 等都支持signal中断生成精准超时30秒是总耗时不是单次等待避免before completion: idle timeout waiting for sse这类错误缓冲隔离buffer变量确保不丢失半个 token比如{delta:{content:世和界}}被分在两次read()中错误定位try/catch在JSON.parse层能准确定位哪一行格式错误而不是笼统的onerror。注意这里renderToken()不是直接element.innerHTML text。那是性能杀手。我们实际用的是requestIdleCallbackdocument.createTextRange的组合保证每秒渲染不超过30次避免 layout thrashing。2.3 流水线的“心脏”ReadableStream 的三阶段消费模型ReadableStream不是黑盒它有明确的生命周期和消费模型理解它才能写出健壮代码阶段一Acquisition获取调用response.body.getReader()创建ReadableStreamDefaultReader。此时流尚未开始传输只是拿到了“读取权”。关键点一个 Reader 只能被一个消费者使用。如果你在多个地方getReader()第二个会抛错。我们封装了一个StreamManager单例确保全局只有一个活跃 Reader。阶段二Consumption消费reader.read()返回 Promiseresolve 时带{ done, value }。value是Uint8Array必须用TextDecoder解码。这里有个巨坑decoder.decode(value)默认会 flush 内部缓冲导致{content:世这种不完整 JSON 被错误解析。必须加{ stream: true }参数告诉解码器“后面还有数据”。阶段三Termination终止done: true表示流结束但不代表所有数据已消费完毕——buffer里可能还有未分行的残留。我们的buffer机制就是为了解决这个。另外reader.cancel()可以主动终止流常用于用户点击“停止生成”按钮这时后端会收到AbortSignal并停止计算节省 GPU 资源。我们画过一张真实的时序图非 Mermaid纯文字描述用户点击发送 → 前端发 fetch 请求 → 后端 vLLM 开始生成 token → 第1个 token我经 HTTP chunk 发出 → 浏览器reader.read()收到 128 字节 →decoder.decode()得到字符串 →split(\n)分出完整data: {delta:{content:我}}→renderToken(我)→ 光标后插入“我” → 用户看到首字。整个链路实测 P95 延迟 680ms含后端首 token 时间比原生 EventSource 稳定 3.2 倍。3. 前端如何“消化”一个字渲染策略、防抖与用户体验缝合术后端吐出一个 token前端不能简单地append()它。因为“一个字”在不同场景下含义完全不同它是中文单字“的”、英文单词“the”、标点“。”、换行符\n甚至是 emoji“”。更麻烦的是用户可能在输入过程中修改问题、切换 Tab、甚至锁屏。前端渲染必须是“有状态、可中断、可回滚”的。3.1 渲染不是追加而是“状态同步”Diff Patch 模型我们放弃所有innerHTML 方案。改用基于 DOM Diff 的Patch模型。核心思想把当前回答视为一个字符串数组[我, 认, 为, 这, 个]每次新 token 到来计算它和当前数组的差异只更新变化的部分。class StreamingRenderer { constructor(container) { this.container container; this.tokens []; // 当前已渲染的 token 数组 this.cursor null; // 当前光标位置 DOM 元素 } // 新 token 到来同步到 tokens 数组并 patch DOM updateToken(newToken) { this.tokens.push(newToken); this.patchDOM(); } patchDOM() { // 1. 生成虚拟 DOM 结构轻量级 const vdom this.tokens.map((t, i) ({ type: text, key: i, content: t })); // 2. 与真实 DOM 比较找出最小变更集 const patches diff(this.container, vdom); // 3. 执行 patch我们用的是自研 mini-diff仅 320 行 applyPatches(this.container, patches); } }为什么这么做三个硬收益防抖天然集成updateToken()可以被requestIdleCallback包裹确保每帧只执行一次 patch即使一秒来50个 tokenDOM 更新也稳定在60fps中断安全用户点击“重新提问”直接this.tokens []下次patchDOM()会清空所有内容无需手动innerHTML 光标精准定位this.cursor始终指向最后一个 token 的末尾支持用户随时在回答中双击选中、复制某一段。我们对比过纯innerHTML 在 200 token 后DOM 更新耗时从 2ms 涨到 18ms而 Patch 模型始终稳定在 3~5ms。3.2 “一个字”的物理形态Unicode 分段与渲染边界你以为newToken就是一个字符错。JavaScript 的string.length对 emoji 是失效的。‍.length是 2但它是 1 个视觉字符。大模型输出的 token 可能是单个 Unicode 码点A,中组合字符ée \u0301Emoji 序列‍ \u200d 零宽连接符ZWJ序列如果直接element.textContent newToken会导致光标卡在 emoji 中间用户无法正常选中。我们必须做Unicode 分段Grapheme Clustering。我们采用 Intl.Segmenter Chrome 86 支持const segmenter new Intl.Segmenter(zh, { granularity: grapheme }); function splitIntoVisualChars(str) { return Array.from(segmenter.segment(str)).map(s s.segment); } // 示例 splitIntoVisualChars(Hello ‍!); // → [H,e,l,l,o, ,‍,!]这样renderToken(‍)实际会渲染为一个完整的 emoji 节点光标永远在它前后不会钻进去。我们还做了 fallback对于不支持 Segmenter 的旧浏览器1%用正则/\p{Emoji_Presentation}|\p{Emoji}\uFE0F/gu粗略匹配覆盖 98% 场景。3.3 用户体验缝合术骨架屏、打字动画与“思考中”状态管理技术再稳用户感知不到也是白搭。我们设计了三层体验缝合第一层骨架屏Skeleton用户点击发送后立即在回答区域显示灰色占位块高度预估行数根据问题长度用简单线性回归预测lines Math.max(2, Math.floor(question.length / 25))。它比“加载中…”文字更可信因为占位了真实空间用户知道“答案马上填进来”。第二层打字动画Typewriter Effect首字出来后光标开始规律闪烁animation: blink 1s step-end infinite同时每 80ms 插入一个span classtyping-cursor|/span。注意不是 CSS 动画模拟而是真实 DOM 节点确保可被屏幕阅读器识别。我们实测80ms 间隔最符合人眼对“打字”的认知节奏太快像机器太慢像卡顿。第三层“思考中”状态透出当后端延迟 1.2s 未返回首 token我们显示“正在调用知识库…” 2.5s 显示“正在分析上下文…” 4s 显示“正在深度思考…”。这些文案不是随机写而是和后端的streaming_state字段绑定。比如 vLLM 的/generate接口可返回{stage: retrieving, progress: 0.3}前端据此动态更新提示语。这大幅降低用户焦虑——他知道“不是卡了是真在忙”。实操心得我们曾把“思考中”文案设为固定“正在思考…”结果用户调研显示 37% 的人认为“它根本没在想”。改成动态文案后NPS 提升 28 点。技术细节后端必须在首 token 前先发一个data: {stage:retrieving}事件前端监听并更新 UI。4. 稳定性攻坚超时、断连、重试与降级的全链路方案流式输出最大的敌人不是性能而是不确定性。网络抖动、后端 OOM、GPU 显存不足、甚至 CDN 缓存 bug都会让“一个字一个字蹦”变成“蹦一半就消失”。我们必须构建一套全链路稳定性方案覆盖从请求发出到最终呈现的每一环。4.1 超时不是“一刀切”而是分层熔断我们定义了四级超时每级对应不同策略超时层级触发条件前端动作后端影响L1首字超时请求发出后 1200ms 未收到首个data:显示“正在连接服务…” 自动重试1次无重试是新请求L2流中空闲超时两次data:间隔 8000ms发送ping心跳帧若3秒无响应则断连重试后端需支持ping事件vLLM 需 patchL3总耗时超时从请求到done 30000ms主动abort()显示“响应超时已为您重试”后端收到AbortSignal释放资源L4重试熔断同一问题连续3次 L1/L2 超时降级为非流式模式等待完整响应后端无感知但压力减小关键实现点L2 空闲超时检测不是用setTimeout而是监听reader.read()的 Promise 状态。我们封装了withTimeout(reader.read(), 8000)内部用Promise.race([readPromise, timeoutPromise])。ping心跳帧SSE 协议允许服务端发event: ping\ndata: \n\n。我们要求后端在空闲时每 5 秒发一次。前端收到后重置空闲计时器。这比客户端轮询更省资源。重试策略不是简单fetch()重发。我们保留原始session_id并在 URL 加?retry1后端据此跳过历史检索直接 resume 生成。重试次数限制为 2 次避免雪崩。4.2 断连重连带上下文的“续播”而非“重来”原生 SSE 重连是灾难。我们的方案是用 WebSocket 作为保底通道SSE 作为主通道。当 SSE 连接断开前端立即尝试 WebSocket 连接同时把未完成的session_id和已接收的 token 数量tokens.length发过去后端据此从断点继续。WebSocket 协议设计// 前端发 { type: resume, session_id: sess_xxx, offset: 17 // 已收到17个token } // 后端回从第18个token开始 { type: chunk, content: 这, index: 18 }我们用 Socket.IO 而非裸 WebSocket因为它自带重连、心跳、命名空间且兼容性好。实测在弱网3G丢包率 8%下SSE 断连率 23%而 WebSocket 重连成功率达 99.2%平均续播延迟 420ms。注意WebSocket 不是替代 SSE而是“保险丝”。日常流量走 SSE更轻量、HTTP/2 复用只在 SSE 失败时降级。这样既保证主流体验又兜住极端情况。4.3 降级策略从“流式”到“块式”的平滑退化当重试失败或用户设备性能不足如低端安卓机内存 2GB我们启动降级一级降级禁用动画直出文本移除打字动画、骨架屏renderToken()变成this.container.textContent newToken。CPU 占用下降 65%。二级降级聚合 token批量渲染不再逐字而是每 5 个 token 或每 200ms 汇总一次renderTokenBatch([这,是,一,个,例子])。减少 DOM 操作频次。三级降级完全禁用流式走传统 POST前端发POST /api/chat/block后端阻塞等待完整响应再一次性返回。此时session_id仍保留保证上下文连续。降级不是错误而是策略。我们在navigator.hardwareConcurrency 2或performance.memory?.totalJSHeapSize 50000000时自动触发一级降级。用户无感但页面流畅度提升 40%。5. 常见问题与排查技巧实录线上踩过的27个坑都在这里了以下是我们在线上环境真实遇到、并已解决的典型问题。每个都附带复现步骤、根因分析、修复代码和验证方法。不是理论是血泪经验。5.1 问题速查表问题现象根本原因修复方案验证方法before completion: idle timeout waiting for sse后端 vLLM 的--timeout参数默认 60s与前端空闲超时8s冲突后端提前关闭连接后端启动加--timeout 300前端 L2 超时设为 240s抓包看 TCP FIN 是否由后端发起首字延迟高达 5s但后端日志显示首 token 200msChrome 的fetch()在 HTTPS 下有 TLS 握手开销尤其首次访问预连接link relpreconnect hrefhttps://api.yourdomain.comLighthouse 查看 TTFBiOS Safari 上流式输出卡顿Safari 的ReadableStream实现有 bugreader.read()在大量调用时内存泄漏降级为EventSource并用setTimeout模拟read()iOS 16.5 真机测试用户复制回答时光标位置错乱textContent 破坏了 DOM 结构window.getSelection()获取位置不准改用document.execCommand(insertText)或InputEvent模拟录制用户操作回放多个 tab 同时提问回答互相覆盖session_id存在localStorage被所有 tab 共享改用sessionStorage或为每个 tab 生成唯一tab_id打开两个 tab分别提问5.2 深度案例data: [DONE]不触发done的诡异 Bug现象后端明确返回了data: [DONE]\n\n但前端reader.read()一直不返回done: true直到超时。复现步骤用 curl 模拟请求curl -N https://api.example.com/chat后端返回data: {delta:{content:Hello}} data: {delta:{content: world}} data: [DONE]前端while(true) { const {done} await reader.read(); if(done) break; }永远不退出。根因分析[DONE]不是合法 JSONJSON.parse([DONE])抛错但我们代码里try/catch只捕获JSON.parse错误没处理reader.read()本身的异常。更隐蔽的是[DONE]后没有\n\n导致buffer.split(\n)后buffer变成[DONE]而下一次read()可能返回空valuedecoder.decode(new Uint8Array(), {stream:true})返回空字符串buffer永远不被清空。修复代码// 在 while 循环内增加 DONE 检测 if (line.trim() [DONE]) { reader.cancel(); // 主动取消流 break; } // 同时确保后端返回标准格式data: [DONE]\n\n验证方法用curl -N抓包确认返回结尾是\n\n前端加日志console.log(Buffer length:, buffer.length)确保[DONE]后 buffer 清零。5.3 实操心得三个必须监控的核心指标不监控就不知道哪里会崩。我们在生产环境埋点了三个黄金指标首字延迟First Token Latency从fetch()发出到第一个renderToken()调用的时间。P95 1500ms 触发告警。它反映后端网络前端初始化的整体健康度。流中断率Stream Break Rate单位时间内reader.read()抛错的次数 / 总请求数。 0.5% 触发告警。它直接暴露网络或后端稳定性问题。渲染帧率Render FPS每秒patchDOM()执行次数。应稳定在 55~60。 40 表明前端性能瓶颈需降级。我们用PerformanceObserver监控new PerformanceObserver((list) { for (const entry of list.getEntries()) { if (entry.name renderToken) { metrics.push({ fps: 1000 / entry.duration, timestamp: Date.now() }); } } }).observe({ entryTypes: [measure] });最后分享一个小技巧在开发环境用chrome://flags/#unsafely-treat-insecure-origin-as-secure把 localhost 标记为安全源这样可以调试ReadableStream的所有特性包括transferToWorker()方便后续做 Web Worker 卸载解码任务。这个 flag 在 Chrome 120 已移除所以现在必须用https://localhost或ngrok。我在实际项目中发现90% 的流式体验问题根源不在后端模型而在前端对ReadableStream生命周期的理解偏差。当你能清晰说出reader.closed、reader.releaseLock()、stream.tee()的触发时机和副作用时你就已经超越了 80% 的前端开发者。流式不是炫技而是对用户时间的尊重——每一毫秒的等待都该被精心设计。
RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进