当前位置: 首页 > news >正文

大模型流式输出前端实战: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:处理粘包断包的核心

正确处理逻辑必须维护一个字符串缓冲区:

  1. 新解码文本追加到buffer末尾
  2. 按SSE标准空行分割出完整事件
  3. 分割后数组最后一段不完整内容,重新存回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,大模型场景怎么选?

对比维度SSEWebSocket
通信方向服务端单向推送客户端、服务端双向互发
底层协议基于HTTP长连接独立WebSocket协议
数据类型仅文本数据文本、二进制都支持
AI文本生成适配度完美适配,逻辑简单能实现,但代码冗余复杂

大模型生成逻辑是:前端发一次提问,后端持续回文字,单向传输足够,绝大多数场景SSE性价比更高。只有需要持续双向实时交互的产品,才考虑WebSocket。

七、后端侧必须配套的能力

前端流式能跑通,后端BFF层缺一不可,绝对不能让前端直接调用大模型原始API。

见过有人直接把模型API Key写在VITE环境变量里,打包后前端源码明文泄露,当天就被爬虫薅走接口,产生高额账单。

后端标准流程:

  1. 接收前端提问参数
  2. 服务端存储密钥,代理请求大模型接口
  3. 开启流式传输,设置标准SSE响应头
  4. 持续转发分片数据流给浏览器

必备响应头配置:

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

http://www.jsqmd.com/news/1249246/

相关文章:

  • 即梦去水印小程序怎么用?2026 年实测好用的方法 - 免费软件工具方法教程
  • 【限时解密】AI自动化电商中台搭建全记录(含3套可即插即用Prompt+数据看板模板):仅开放至本周五
  • 2026芜湖卫生间渗水发霉最全解答!不砸砖防水靠谱吗?根治楼下渗水方法 - 宅安选房屋修缮
  • 「领铄智能」完成千万级 A 轮追加融资,陆石投资、动平衡资本联合投资
  • Anaconda常用命令
  • 数据库------>多表连接查询
  • 麒麟信安入选“2022年度湖南高新技术企业综合创新能力100强”
  • 2026 无尘车间、净化车间、洁净车间、无尘室、洁净室选购避坑指南! - 深度智识库
  • 麒麟信安参与2021全球开源技术峰会深圳站发表主题演讲
  • openwrt软路由重置firstboot重启reboot关机poweroff
  • 徐州卫生间漏水维修推荐:这几家正规靠谱机构合集(2026年7月份实测) - 捷修防水
  • 深入解析Tiva C系列μDMA控制器:乒乓与分散聚集模式实战
  • TI TM4C129LNCZAD微控制器实战:从数据手册到嵌入式系统设计
  • 2026 年 AI 网文创作工具怎么选?8款主流产品深度实测(附避坑指南与搭配方案)
  • MSPM0低功耗子系统(LFSS)设计:RTC、IWDT与安全功能实战指南
  • OpenWrt搭建time machine软路由搭建苹果时间机器服务器
  • 2026南京包包回收权威测评:本地领先连锁机构综合实力横向对比 - 奢侈品回收评测
  • 对讲机 VOX 声控失灵、自动乱发射修复 香坊双工电子声控系统专项调校
  • 网红奢包为何贬值快?解析西安二手市场爆款包包流通短板 - 日常财经早知道
  • Riverpod3 注解方式写api 请求的provider在其他地方调用方式
  • 单网口电脑安装openwrt软路由做单臂路由
  • 【计算机毕业设计案例】基于 Django 的鲜花礼品个性化定制系统 花卉商品运维与销售数据统计系统(程序+文档+讲解+定制)
  • openwrt软路由实现ipv6上网配置
  • AI赋能知识付费:技术应用与商业变现全解析
  • esxi不识别硬盘_ESXI安装OpenWRT amp; LEDE软路由部署指南(附镜像下载)
  • 小程序商城平台哪家强?2026功能、售后与性价比对比
  • vm虚拟机安装lede旁路由_Vmware虚拟机安装LEDE实现软路由openwrt
  • OpenWrt 软路由介绍
  • xss-labs搭建及通关攻略
  • 桥梁桥墩施工质量检测数据集VOC+YOLO格式876张2类别