大模型流式输出前端实战:ReadableStream+SSE打字机效果全踩坑指南
文章目录
- 一、先唠明白:为啥普通接口会让用户干等?
- 1.1 开stream前后,接口返回天差地别
- 二、ReadableStream:浏览器里的数据自来水管
- 2.1 Uint8Array:看不懂的数字字节,不能直接渲染
- 三、分清两个容易搞混的东西:ReadableStream ≠ SSE
- 3.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(),不装水龙头,管道里的水一滴都接不出来。
基础读取代码长这样:
constresponse=awaitfetch('/api/stream',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({prompt:question.value})});if(!response.body)thrownewError('浏览器不支持流式');constreader=response.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开启流式解码:
constdecoder=newTextDecoder('utf-8');// 分片解码,保留不完整字节consttext=decoder.decode(value,{stream:true});// 流结束后刷新剩余字节buffer+=decoder.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内容、识别结束标记、取出增量文字delta:
functionparseSSEEvent(eventText,onDelta){constpayload=eventText.split(/\r?\n/).filter(line=>line.startsWith('data:')).map(line=>line.slice(5).trimStart()).join('\n');if(!payload)returnfalse;if(payload==='[DONE]')returntrue;constdata=JSON.parse(payload);constdelta=data.choices?.[0]?.delta?.content;delta&&onDelta(delta);returnfalse;}函数返回true代表数据流传输完毕,外层循环直接终止读取。
3.3 完整流式读取循环逻辑
把流读取、字节解码、SSE解析整合到一个异步函数,一次性处理全流程:
asyncfunctionreadSSEStream(response,onDelta){if(!response.body)thrownewError('无可用流');constreader=response.body.getReader();constdecoder=newTextDecoder('utf-8');letbuffer='';letfinished=false;while(!finished){const{value,done}=awaitreader.read();if(done){buffer+=decoder.decode();break;}buffer+=decoder.decode(value,{stream:true});constevents=buffer.split(/\r?\n\r?\n/);buffer=events.pop()??'';for(consteventTextofevents){finished=parseSSEEvent(eventText,onDelta);if(finished)break;}}if(!finished&&buffer.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,非要用Fetch?
EventSource确实原生支持SSE,但它有几个硬伤,完全不适合AI聊天场景。
刚入行的时候图省事直接用EventSource,上线才发现,它只能发GET请求,不能传POST请求体。
提问内容、鉴权参数全塞URL里,参数一多直接超长报错,还没法自定义复杂请求头,改接口改了一整晚。
AI对话场景刚需:POST传参、自定义请求头、主动中断请求、自定义错误捕获,Fetch+ReadableStream全部完美支持,灵活度拉满。
六、SSE vs WebSocket,大模型场景怎么选?
| 对比维度 | SSE | WebSocket |
|---|---|---|
| 通信方向 | 服务端单向推送 | 客户端、服务端双向互发 |
| 底层协议 | 基于HTTP长连接 | 独立WebSocket协议 |
| 数据类型 | 仅文本数据 | 文本、二进制都支持 |
| AI文本生成适配度 | 完美适配,逻辑简单 | 能实现,但代码冗余复杂 |
大模型生成逻辑是:前端发一次提问,后端持续回文字,单向传输足够,绝大多数场景SSE性价比更高。只有需要持续双向实时交互的产品,才考虑WebSocket。
七、后端侧必须配套的能力
前端流式能跑通,后端BFF层缺一不可,绝对不能让前端直接调用大模型原始API。
见过有人直接把模型API Key写在VITE环境变量里,打包后前端源码明文泄露,当天就被爬虫薅走接口,产生高额账单。
后端标准流程:
- 接收前端提问参数
- 服务端存储密钥,代理请求大模型接口
- 开启流式传输,设置标准SSE响应头
- 持续转发分片数据流给浏览器
必备响应头配置:
Content-Type: text/event-stream; charset=utf-8
Cache-Control: no-cache
Connection: 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
