前言
做AI聊天页面你一定遇过两种糟心体验:
- 关闭流式:点击提交黑屏等待3-10秒,一次性弹出全文,用户等待感极强
- 手写SSE流式:网络分包截断JSON,疯狂报
JSON.parse解析失败,文字丢失乱码
网上很多示例只给极简demo,没有处理分片容错,上线必崩。 本文基于Vite+Vue3+原生Fetch完整实现DeepSeek对话,同时支持流式打字机/一次性返回双模式,自带buffer分片容错逻辑,看完你能学到:
- SSE流式输出底层二进制流传输原理
- ReadableStream、TextDecoder浏览器原生API完整用法
- buffer缓冲区解决TCP分包截断JSON的核心方案
- 流式/非流式接口两套分支代码完整实现
- 开发高频踩坑清单+修复方案,直接规避线上bug
- 可直接复制运行的完整单文件组件
一、先搞懂:什么是LLM流式SSE输出
1.1 传统一次性请求(stream=false)
后端等AI完整生成全部文本,组装成完整JSON一次性返回。
前端调用response.json()直接解析,优点代码简单,缺点等待时间长,交互割裂。
1.2 SSE流式请求(stream=true)
大模型每生成一段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等第三方SDK,Vite Vue3项目开箱即用。
2.2 环境变量配置(关键,防止密钥硬编码泄露)
项目根目录新建.env文件,填入DeepSeek密钥:
VITE_DEEPSEEK_API_KEY=sk-xxxxxxxxxxxxxxxxxxxx
Vite通过import.meta.env.VITE_XXX读取环境变量,打包后不会明文暴露密钥。
三、完整可运行代码 App.vue
<script 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 class="container">
<!-- 提问输入区域 -->
<div>
<label>输入:</label>
<input class="input" v-model="question" />
<button @click="update">提交</button>
</div>
<!-- 流式开关 + AI回答展示区 -->
<div class="output">
<div>
<label>Streaming流式输出</label>
<input type="checkbox" v-model="stream" />
</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 = "";
const reader = response.body?.getReader();
const decoder = new TextDecoder();
let done = false;
let buffer = '';
reader:流专属读取器,串行读取二进制数据,保证顺序不乱decoder:全局解码器,循环内复用,避免中文跨分片乱码done:外层while循环开关,控制数据流是否全部接收完毕buffer:全文最关键容错变量,专门存储被TCP分包截断的半条data:报文
4.2 while循环:持续拉取二进制分片
while (!done) {
const { value, done: doneReading } = await reader?.read();
done = doneReading;
const chunkValue = buffer + decoder.decode(value);
buffer = "";
const lines = chunkValue.split('\n').filter((line) => line.startsWith('data: '))
}
reader.read():异步阻塞读取,有新分片立刻返回,无数据持续等待chunkValue = buffer + 新文本:核心容错操作,把上一轮残缺片段和本次新数据拼接,保证报文完整split('\n'):SSE协议每条数据换行分隔,切割后过滤无效空行、心跳包,只保留data:有效数据
4.3 for循环:解析单条SSE报文
for (const line of lines) {
const incoming = line.slice(6);
if (incoming === '[DONE]') {
done = true;
break;
}
try {
const data = JSON.parse(incoming);
const delta = data.choices[0].delta.content;
if (data && delta) content.value += delta;
} catch (err) {
buffer = `data: ${incoming}`;
}
}
line.slice(6):剔除data:固定前缀,提取纯JSON字符串[DONE]:服务端流结束标志,终止所有循环delta.content:流式接口专属增量字段,每次仅返回本次生成的少量文字,Vue响应式追加实现逐字打字效果catch容错逻辑:JSON解析报错代表当前行是残缺报文,存入buffer,下一轮循环拼接新分片后再解析,杜绝文字丢失
4.4 非流式分支简单说明
else {
const data = await response.json();
content.value = data.choices[0].message.content;
}
关闭流式时,后端等待AI全部生成完毕,一次性返回完整JSON,使用message.content完整文本,无需处理二进制流、分片、buffer,代码极简,但用户等待体验差。
五、高频开发踩坑清单(必看,上线避坑)
坑1:TCP分包截断JSON,疯狂报parse错误
现象:控制台频繁抛出JSON语法错误,AI回答文字残缺、丢失
原因:网络传输会把一条data: JSON切成两块,单块无法完整解析
解决方案:代码中buffer缓冲区,拼接残缺片段后再解析
坑2:中文跨分片解码出现乱码
现象:部分中文显示问号、乱码字符
优化方案:decoder.decode(value, { stream: true }),解码器自动缓存跨分片字节,完整解析中文
坑3:忘记清空buffer,重复叠加文本
现象:AI回答重复、内容翻倍
修复:拼接chunkValue后立刻执行buffer = ""清空缓存
坑4:混淆流式/非流式字段 delta / message
现象:关闭流式返回undefined,开启流式无文字输出 区分:
- stream=true →
data.choices[0].delta.content - stream=false →
data.choices[0].message.content
坑5:连续点击提交,多请求文字叠加错乱
优化补充:增加loading锁,请求期间禁用提交按钮,防止并发请求
坑6:API Key硬编码写在代码内
风险:前端打包后源码泄露密钥,产生高额扣费
规范:统一放入.env环境变量,通过import.meta.env读取
六、流式与非流式方案对比
| 对比维度 | stream=true 流式SSE | stream=false 一次性返回 |
|---|---|---|
| 传输方式 | 二进制分片持续推送 | 完整JSON单次返回 |
| 解析逻辑 | ReadableStream+buffer容错 | 直接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流式输出,原生Fetch+ReadableStream无需第三方SDK即可实现;
buffer缓冲区是流式解析的灵魂,专门解决TCP分包截断JSON的线上致命bug;- DeepSeek接口区分流式/非流式两套返回结构,
delta与message字段切勿混用; - 生产环境优先使用流式输出提升用户体验,同时做好分片容错、异常捕获、密钥安全管理。