前言做AI聊天页面你一定遇过两种糟心体验关闭流式点击提交黑屏等待3-10秒一次性弹出全文用户等待感极强手写SSE流式网络分包截断JSON疯狂报JSON.parse解析失败文字丢失乱码网上很多示例只给极简demo没有处理分片容错上线必崩。本文基于ViteVue3原生Fetch完整实现DeepSeek对话同时支持流式打字机/一次性返回双模式自带buffer分片容错逻辑看完你能学到SSE流式输出底层二进制流传输原理ReadableStream、TextDecoder浏览器原生API完整用法buffer缓冲区解决TCP分包截断JSON的核心方案流式/非流式接口两套分支代码完整实现开发高频踩坑清单修复方案直接规避线上bug可直接复制运行的完整单文件组件一、先搞懂什么是LLM流式SSE输出1.1 传统一次性请求streamfalse后端等AI完整生成全部文本组装成完整JSON一次性返回。前端调用response.json()直接解析优点代码简单缺点等待时间长交互割裂。1.2 SSE流式请求streamtrue大模型每生成一段Token就封装成data: JSON格式通过二进制流实时推送到前端传输载体response.body二进制可读流Uint8Array字节数组分隔规则每条数据用换行\n分割结尾单独发送data: [DONE]标识流结束传输痛点TCP网络分包会把一条完整JSON拆成两半直接解析报错必须用buffer缓存残缺片段1.3 核心API介绍response.body.getReader()创建流读取器逐块拉取二进制数据TextDecoder()二进制Uint8Array转UTF-8字符串解决中文乱码buffer缓冲区存储上一轮未解析完成的残缺data:报文下一轮拼接完整再解析二、项目前置环境配置2.1 依赖无需额外安装本方案纯浏览器原生API不需要openai/langchain等第三方SDKVite Vue3项目开箱即用。2.2 环境变量配置关键防止密钥硬编码泄露项目根目录新建.env文件填入DeepSeek密钥VITE_DEEPSEEK_API_KEYsk-xxxxxxxxxxxxxxxxxxxxVite通过import.meta.env.VITE_XXX读取环境变量打包后不会明文暴露密钥。三、完整可运行代码 App.vuescript setup import { ref } from vue // 响应式状态 const question ref(讲一个中国龙的故事); // 用户输入提问 const content ref(); // AI输出内容 const stream ref(true); // 是否开启流式输出开关 // 核心请求函数 const update async () { // 空提问拦截避免无效请求 if (!question.value) return; content.value 思考中...; // DeepSeek对话接口地址 const endpoint https://api.deepseek.com/chat/completions; const headers { Content-Type: application/json, Authorization: Bearer ${import.meta.env.VITE_DEEPSEEK_API_KEY} }; // 发起POST请求 const response await fetch(endpoint, { method: POST, headers, body: JSON.stringify({ model: deepseek-v4-flash, messages: [ { role: user, content: question.value } ], stream: stream.value // 动态控制流式开关 }) }) // 分支1流式输出打字机效果本文核心 if (stream.value) { content.value ; // 清空思考中占位文字 // 获取二进制流读取器 const reader response.body?.getReader(); // 二进制转UTF8文本解码器 const decoder new TextDecoder(); let done false; // 流读取完成标记 let buffer ; // 残缺分片缓存解决JSON截断报错核心 // 循环持续拉取二进制分片 while (!done) { // 异步读取一块二进制数据 const { value, done: doneReading } await reader?.read(); done doneReading; // 拼接上一轮残留残缺片段 当前新解码文本 const chunkValue buffer decoder.decode(value); buffer ; // 缓存已合并清空等待下一轮残缺数据 // 按换行分割文本过滤仅保留data:开头的SSE有效行 const lines chunkValue.split(\n) .filter((line) line.startsWith(data: )) // 逐行解析每条SSE报文 for (const line of lines) { // 切掉前缀 data: 6个字符获取纯JSON/结束标识 const incoming line.slice(6); // 检测到结束标识终止全部循环 if (incoming [DONE]) { done true; break; } try { // 解析JSON字符串 const data JSON.parse(incoming); // 流式专属增量文本delta const delta data.choices[0].delta.content; // 存在增量文字则追加到页面实现打字机效果 if (data delta) { content.value delta; } } catch (err) { // JSON解析失败分片不完整存入buffer下一轮拼接 buffer data: ${incoming}; } } } } // 分支2非流式一次性返回 else { const data await response.json(); // 非流式使用message完整文本而非delta增量 content.value data.choices[0].message.content; } } /script template div classcontainer !-- 提问输入区域 -- div label输入/label input classinput v-modelquestion / button clickupdate提交/button /div !-- 流式开关 AI回答展示区 -- div classoutput div labelStreaming流式输出/label input typecheckbox v-modelstream / /div div{{ content }}/div /div /div /template style scoped .container { display: flex; flex-direction: column; align-items: flex-start; justify-content: flex-start; height: 100vh; font-size: 0.85rem; padding: 20px; } .input { width: 300px; padding: 4px 8px; } .output { margin-top: 12px; min-height: 300px; width: 100%; text-align: left; line-height: 1.6; } button { padding: 4px 12px; margin-left: 8px; cursor: pointer; } /style四、核心流式逻辑逐行深度拆解4.1 基础变量初始化if(stream.value){content.value;constreaderresponse.body?.getReader();constdecodernewTextDecoder();letdonefalse;letbuffer;reader流专属读取器串行读取二进制数据保证顺序不乱decoder全局解码器循环内复用避免中文跨分片乱码done外层while循环开关控制数据流是否全部接收完毕buffer全文最关键容错变量专门存储被TCP分包截断的半条data:报文4.2 while循环持续拉取二进制分片while(!done){const{value,done:doneReading}awaitreader?.read();donedoneReading;constchunkValuebufferdecoder.decode(value);buffer;constlineschunkValue.split(\n).filter((line)line.startsWith(data: ))}reader.read()异步阻塞读取有新分片立刻返回无数据持续等待chunkValue buffer 新文本核心容错操作把上一轮残缺片段和本次新数据拼接保证报文完整split(\n)SSE协议每条数据换行分隔切割后过滤无效空行、心跳包只保留data:有效数据4.3 for循环解析单条SSE报文for(constlineoflines){constincomingline.slice(6);if(incoming[DONE]){donetrue;break;}try{constdataJSON.parse(incoming);constdeltadata.choices[0].delta.content;if(datadelta)content.valuedelta;}catch(err){bufferdata:${incoming};}}line.slice(6)剔除data:固定前缀提取纯JSON字符串[DONE]服务端流结束标志终止所有循环delta.content流式接口专属增量字段每次仅返回本次生成的少量文字Vue响应式追加实现逐字打字效果catch容错逻辑JSON解析报错代表当前行是残缺报文存入buffer下一轮循环拼接新分片后再解析杜绝文字丢失4.4 非流式分支简单说明else{constdataawaitresponse.json();content.valuedata.choices[0].message.content;}关闭流式时后端等待AI全部生成完毕一次性返回完整JSON使用message.content完整文本无需处理二进制流、分片、buffer代码极简但用户等待体验差。五、高频开发踩坑清单必看上线避坑坑1TCP分包截断JSON疯狂报parse错误现象控制台频繁抛出JSON语法错误AI回答文字残缺、丢失原因网络传输会把一条data: JSON切成两块单块无法完整解析解决方案代码中buffer缓冲区拼接残缺片段后再解析坑2中文跨分片解码出现乱码现象部分中文显示问号、乱码字符优化方案decoder.decode(value, { stream: true })解码器自动缓存跨分片字节完整解析中文坑3忘记清空buffer重复叠加文本现象AI回答重复、内容翻倍修复拼接chunkValue后立刻执行buffer 清空缓存坑4混淆流式/非流式字段 delta / message现象关闭流式返回undefined开启流式无文字输出区分streamtrue →data.choices[0].delta.contentstreamfalse →data.choices[0].message.content坑5连续点击提交多请求文字叠加错乱优化补充增加loading锁请求期间禁用提交按钮防止并发请求坑6API Key硬编码写在代码内风险前端打包后源码泄露密钥产生高额扣费规范统一放入.env环境变量通过import.meta.env读取六、流式与非流式方案对比对比维度streamtrue 流式SSEstreamfalse 一次性返回传输方式二进制分片持续推送完整JSON单次返回解析逻辑ReadableStreambuffer容错直接response.json()输出字段delta.content增量小段message.content全文用户体验边生成边展示低等待感知等待全部生成后一次性渲染代码复杂度高需处理分片、异常截断极低两行代码完成适用场景正式AI对话产品内部简单工具、本地Demo七、项目扩展优化方向增加加载锁新增loading响应式变量请求中禁用按钮防止重复点击异常捕获外层增加try/catch处理网络失败、401密钥错误、接口限流Markdown渲染流式输出纯文本流结束后引入marked渲染富文本多轮对话扩展messages数组存储历史聊天上下文实现连续对话中断请求使用AbortController支持中途停止AI生成换行样式兼容CSS增加white-space: pre-wrap保留AI返回换行格式八、总结AI产品丝滑打字机交互核心依靠SSE流式输出原生FetchReadableStream无需第三方SDK即可实现buffer缓冲区是流式解析的灵魂专门解决TCP分包截断JSON的线上致命bugDeepSeek接口区分流式/非流式两套返回结构delta与message字段切勿混用生产环境优先使用流式输出提升用户体验同时做好分片容错、异常捕获、密钥安全管理。