大模型流式输出前端实战:ReadableStream+SSE打字机效果全踩坑指南

发布时间:2026/7/23 17:43:04

大模型流式输出前端实战:ReadableStream+SSE打字机效果全踩坑指南 文章目录一、先唠明白为啥普通接口会让用户干等1.1 开stream前后接口返回天差地别二、ReadableStream浏览器里的数据自来水管2.1 Uint8Array看不懂的数字字节不能直接渲染三、分清两个容易搞混的东西ReadableStream ≠ SSE3.1 缓冲区buffer处理粘包断包的核心3.2 封装SSE事件解析工具函数3.3 完整流式读取循环逻辑四、Vue3页面实时渲染实现打字机效果五、为什么不用原生EventSource非要用Fetch六、SSE vs WebSocket大模型场景怎么选七、后端侧必须配套的能力八、全文总结一条链路记牢P.S. 无意间发现了一个巨牛的人工智能教程非常通俗易懂对AI感兴趣的朋友强烈推荐去看看传送门https://blog.csdn.net/HHX_01一、先唠明白为啥普通接口会让用户干等咱们平时写的普通HTTP接口逻辑特别死板后端必须等全部内容生成完毕打包完整JSON才丢给前端。之前做AI文案工具踩过超大坑写一篇千字演讲稿模型推理要七八秒。用户界面一片空白后台日志明明在跑产品直接跑过来问我你这页面是不是卡死了更离谱的是有用户以为网站崩了反复刷新直接把接口并发干爆运维找我聊了半小时。流式输出直接把这套逻辑推翻模型每生成一小段文字后端立刻打包发过来前端实时追加展示从根源解决等待焦虑。完整链路一句话概括用户提问 → 模型产出Token → 服务端推送事件 → 浏览器解析数据流 → 页面实时更新。1.1 开stream前后接口返回天差地别没开流式的接口返回一整块完整JSON前端一句response.json()直接解析完事简单到不用动脑子。{choices:[{message:{content:完整回答文字}}]}开启stream之后响应不再是完整对象而是源源不断的碎片化数据流长这样data: {choices:[{delta:{content:你}}]} data: {choices:[{delta:{content:好}}]} data: [DONE]每一段里的delta.content就是新增文字前端不能一次性解析得不停拼接增量内容。二、ReadableStream浏览器里的数据自来水管用fetch请求流式接口后response.body本质就是ReadableStream相当于一根连接后端的水管。刚接触的时候我以为response.body是直接存文字的变量打印出来一看全是ReadableStream对象当场懵了查了半天才明白这只是管道拿不到数据必须装个水龙头。这个水龙头就是getReader()不装水龙头管道里的水一滴都接不出来。基础读取代码长这样constresponseawaitfetch(/api/stream,{method:POST,headers:{Content-Type:application/json},body:JSON.stringify({prompt:question.value})});if(!response.body)thrownewError(浏览器不支持流式);constreaderresponse.body.getReader();循环调用reader.read()会返回两个关键值value本次读到的二进制字节数据done布尔值代表数据流是否彻底结束如果后端暂时没发新数据read()会自动等待不用自己写定时器轮询浏览器底层帮我们处理等待逻辑。2.1 Uint8Array看不懂的数字字节不能直接渲染read拿到的value是Uint8Array一堆0-255的纯数字是网络传输的原始字节直接丢页面上全是乱码数字。我第一次没做解码直接把Uint8Array打印到页面满屏都是228、189这种数字测试同事以为我写bug把接口加密了。中文UTF-8字符最少占3个字节网络分片刚好把一个汉字拆成两半不解码直接拼接文字直接乱码成问号。解决办法就是TextDecoder搭配stream:true开启流式解码constdecodernewTextDecoder(utf-8);// 分片解码保留不完整字节consttextdecoder.decode(value,{stream:true});// 流结束后刷新剩余字节bufferdecoder.decode();stream: true是核心解码器会缓存没拼完的字节等下一批数据过来再合并解码完美解决汉字被分片截断的乱码问题。三、分清两个容易搞混的东西ReadableStream ≠ SSE很多新人会把这俩概念混为一谈其实职责完全分开一点不沾边。ReadableStream浏览器读取HTTP响应二进制流的底层API负责接收字节SSE服务端定义的文本事件传输格式规定数据怎么打包、怎么分割。SSE标准格式靠空行分割独立事件每条数据以data:开头大模型接口统一用[DONE]标记传输结束。踩过一个致命误区觉得一次read拿到的数据就是一条完整SSE事件直接JSON.parse结果频繁报语法错误。网络传输不会管你的业务边界一次读取可能拿到半条事件、多条完整事件、或者前一段尾后一段头直接解析百分百报错。3.1 缓冲区buffer处理粘包断包的核心正确处理逻辑必须维护一个字符串缓冲区新解码文本追加到buffer末尾按SSE标准空行分割出完整事件分割后数组最后一段不完整内容重新存回buffer等待下一轮数据拼接关键代码就一行buffer events.pop() ?? ;丢掉这行所有跨分片的文字直接丢失。3.2 封装SSE事件解析工具函数写一个通用解析函数专门提取data内容、识别结束标记、取出增量文字deltafunctionparseSSEEvent(eventText,onDelta){constpayloadeventText.split(/\r?\n/).filter(lineline.startsWith(data:)).map(lineline.slice(5).trimStart()).join(\n);if(!payload)returnfalse;if(payload[DONE])returntrue;constdataJSON.parse(payload);constdeltadata.choices?.[0]?.delta?.content;deltaonDelta(delta);returnfalse;}函数返回true代表数据流传输完毕外层循环直接终止读取。3.3 完整流式读取循环逻辑把流读取、字节解码、SSE解析整合到一个异步函数一次性处理全流程asyncfunctionreadSSEStream(response,onDelta){if(!response.body)thrownewError(无可用流);constreaderresponse.body.getReader();constdecodernewTextDecoder(utf-8);letbuffer;letfinishedfalse;while(!finished){const{value,done}awaitreader.read();if(done){bufferdecoder.decode();break;}bufferdecoder.decode(value,{stream:true});consteventsbuffer.split(/\r?\n\r?\n/);bufferevents.pop()??;for(consteventTextofevents){finishedparseSSEEvent(eventText,onDelta);if(finished)break;}}if(!finishedbuffer.trim())parseSSEEvent(buffer,onDelta);}四、Vue3页面实时渲染实现打字机效果依靠Vue响应式变量每次拿到delta增量直接拼接页面自动更新不用手动操作DOM。之前有人跟我说打字机效果用setInterval模拟就行我试了一次直接放弃。后端要等全文生成完一次性返回弱网下空白十多秒用户流失直接涨纯前端模拟完全是自欺欺人。完整Vue代码示例import { ref } from vue; const question ref(写一篇英语作文); const content ref(); const loading ref(false); async function submit() { if (!question.value || loading.value) return; content.value ; loading.value true; try { const res await fetch(/api/stream, { method: POST, headers: { Content-Type: application/json }, body: JSON.stringify({ prompt: question.value }) }); if (!res.ok) throw new Error(请求异常${res.status}); await readSSEStream(res, delta { content.value delta; }); } catch (err) { content.value err.message; } finally { loading.value false; } } {{ loading ? 生成中... : 提交 }} {{ content }} .output { margin-top: 16px; white-space: pre-wrap; } plaintext两个细节注意点CSS添加white-space: pre-wrap保留模型返回的换行、空格格式只用文本插值渲染拒绝v-html防止AI输出内容携带恶意HTML脚本。五、为什么不用原生EventSource非要用FetchEventSource确实原生支持SSE但它有几个硬伤完全不适合AI聊天场景。刚入行的时候图省事直接用EventSource上线才发现它只能发GET请求不能传POST请求体。提问内容、鉴权参数全塞URL里参数一多直接超长报错还没法自定义复杂请求头改接口改了一整晚。AI对话场景刚需POST传参、自定义请求头、主动中断请求、自定义错误捕获FetchReadableStream全部完美支持灵活度拉满。六、SSE vs WebSocket大模型场景怎么选对比维度SSEWebSocket通信方向服务端单向推送客户端、服务端双向互发底层协议基于HTTP长连接独立WebSocket协议数据类型仅文本数据文本、二进制都支持AI文本生成适配度完美适配逻辑简单能实现但代码冗余复杂大模型生成逻辑是前端发一次提问后端持续回文字单向传输足够绝大多数场景SSE性价比更高。只有需要持续双向实时交互的产品才考虑WebSocket。七、后端侧必须配套的能力前端流式能跑通后端BFF层缺一不可绝对不能让前端直接调用大模型原始API。见过有人直接把模型API Key写在VITE环境变量里打包后前端源码明文泄露当天就被爬虫薅走接口产生高额账单。后端标准流程接收前端提问参数服务端存储密钥代理请求大模型接口开启流式传输设置标准SSE响应头持续转发分片数据流给浏览器必备响应头配置Content-Type: text/event-stream; charsetutf-8Cache-Control: no-cacheConnection: keep-alive八、全文总结一条链路记牢AI打字机效果不是前端动画造假是一套完整的字节传输链路LLM生成Token → 后端封装SSE事件 → HTTP分块流 → ReadableStream接收二进制 → Uint8Array字节 → TextDecoder流式解码 → buffer缓存拆分完整事件 → JSON解析delta增量 → Vue响应式实时渲染三个核心边界避坑要点字节边界流式TextDecoder处理中文分片杜绝乱码事件边界buffer缓存不完整SSE片段防止解析报错安全边界密钥存后端BFF前端不暴露任何大模型鉴权信息。搞懂这三层边界不管是Vue、React还是原生JS对接大模型流式接口都不会再踩重复的坑。P.S. 无意间发现了一个巨牛的人工智能教程非常通俗易懂对AI感兴趣的朋友强烈推荐去看看传送门https://blog.csdn.net/HHX_01

相关新闻