😵做AI对话时,我被流式输出坑到崩溃
之前开发AI聊天页面,想实现文字逐字弹出的打字机效果,踩满一堆致命问题:
- 网络分片导致JSON半截,页面直接报错白屏;
- 中文多字节截断,渲染一堆乱码方块;
- 分不清
delta.content和完整message.content,拿不到输出文字; - 不处理
[DONE]结束标记,循环卡死页面。
试过EventSource,但它只支持GET请求,没法携带鉴权Header和长上下文body,完全不适合大模型接口。
最后用原生fetch + ReadableStream手撸流式解析,兼容POST、自带缓冲区兜底,几十行代码稳定跑通DeepSeek、OpenAI全系接口。
读完这篇你能收获:
- 彻底搞懂LLM流式输出底层原理:二进制流、SSE协议、ReadableStream
- 完整可复制Vue3
<script setup>实战页面,支持切换流式/一次性返回 - 生产环境必避5大高频坑,断包、乱码、解析失败全覆盖
- 缓冲区buffer核心逻辑拆解,看懂为什么必须缓存半截数据
📌先搞懂:大模型流式输出底层是什么?
1. 普通接口 vs 流式接口
- 普通请求:模型完整生成全部文字后,一次性返回完整JSON,用户只能干等,体验极差。
- 流式stream:请求参数设置
stream: true,模型每生成1个Token就立刻推送一段二进制数据,前端边收边渲染,实现打字机效果。
2. 后端返回数据流格式
后端返回二进制Uint8Array字节流,遵循SSE规范:
- 每条消息以
data:开头,\n换行符分隔; - 单次网络包可能返回1行/多行/半行数据,分片边界完全随机;
- 流结束会单独推送一行
data: [DONE]标记终止; - 单条消息内部是JSON,包含增量文本
choices[0].delta.content。
3. 前端核心处理链路
二进制Uint8Array → TextDecoder转文本 → buffer拼接残缺分片 → 按\n分割完整行 → JSON解析 → 增量追加到页面响应式变量。
💻完整实战:Vue3 + DeepSeek流式对话页面
1. 环境准备
Vite Vue3项目,根目录新建.env.local存放密钥,避免硬编码泄露
VITE_DEEPSEEK_API_KEY=你的DeepSeek密钥
2. 完整可运行 App.vue 代码
<script setup>
// vue3 composition API,逻辑内聚,热更新局部刷新
import { ref } from 'vue';
// 页面响应式状态
const question = ref('讲一个中国龙的故事');
const content = ref('');
const stream = ref(true); // 开关:流式输出/一次性返回
// 核心请求函数
const update = async () => {
if (!question.value.trim()) return;
content.value = '思考中...';
// 大模型流式接口地址
const endpoint = 'https://api.deepseek.com/chat/completions';
const headers = {
'Content-Type': 'application/json',
Authorization: `Bearer ${import.meta.env.VITE_DEEPSEEK_API_KEY}`
};
const response = await fetch(endpoint, {
method: 'POST',
headers,
body: JSON.stringify({
model: 'deepseek-v4-flash',
messages: [
{ role: 'user', content: question.value }
],
stream: stream.value // 开启流式开关
})
});
// 流式模式:逐Token打字机效果
if (stream.value) {
content.value = '';
// 获取二进制流读取器,水管取水逻辑
const reader = response.body?.getReader();
// 二进制转UTF8文本解码器,stream:true解决中文截断乱码
const decoder = new TextDecoder('utf-8', { stream: true });
let done = false;
// buffer缓存上一轮未解析完整的半截JSON行,解决断包报错
let buffer = '';
while (!done) {
// 读取一小块二进制数据,无数据时await阻塞等待
const { value, done: doneReading } = await reader?.read();
done = doneReading;
if (!value) continue;
// 拼接上一轮残留buffer + 本轮新解码文本
const chunkValue = buffer + decoder.decode(value);
buffer = ''; // 已拼接完成,清空缓冲区
// 按换行分割,只保留以data:开头的有效SSE行
const lines = chunkValue.split('\n').filter(line => line.startsWith('data: '));
for (const line of lines) {
// 切掉前缀 data: 6个字符
const incoming = line.slice(6);
// 流结束标记,终止循环
if (incoming === '[DONE]') {
done = true;
break;
}
try {
// 正常完整JSON直接解析
const data = JSON.parse(incoming);
const deltaText = data.choices[0].delta.content;
if (deltaText) {
// 增量追加,Vue细粒度响应式局部更新
content.value += deltaText;
}
} catch (err) {
// JSON不完整,存入buffer,下一轮读取拼接后再解析
buffer = `data: ${incoming}`;
}
}
}
} else {
// 非流式:等待全部生成完毕一次性渲染
const data = await response.json();
content.value = data.choices[0].message.content;
}
};
</script>
<template>
<div class="container">
<div class="input-bar">
<label>提问:</label>
<input class="input" v-model="question" placeholder="输入你的问题" />
<button @click="update">发送提问</button>
</div>
<div class="stream-switch">
<label>开启流式输出(打字机效果)</label>
<input type="checkbox" v-model="stream" />
</div>
<div class="output-box">
<h4>AI回答:</h4>
<div class="answer-text">{{ content }}</div>
</div>
</div>
</template>
<style scoped>
.container {
display: flex;
flex-direction: column;
gap: 12px;
padding: 20px;
height: 100vh;
font-size: 0.9rem;
}
.input-bar {
display: flex;
align-items: center;
gap: 8px;
}
.input {
width: 320px;
padding: 6px 8px;
}
.stream-switch {
display: flex;
align-items: center;
gap: 6px;
}
.output-box {
margin-top: 10px;
width: 100%;
}
.answer-text {
min-height: 300px;
padding: 12px;
border: 1px solid #eee;
border-radius: 6px;
white-space: pre-wrap;
}
button {
padding: 6px 14px;
cursor: pointer;
}
</style>
核心代码关键点拆解
stream: true请求参数 大模型接口强制开启流式返回,关闭则一次性返回完整回答。ReadableStream + getReader()浏览器原生流式API,像水管一样分段读取二进制数据,不用等全部响应下载完成。TextDecoder('utf-8', { stream: true })解决中文多字节截断乱码,解码器会缓存跨分片的残缺字符,下次拼接完整解码。buffer缓冲区变量 网络分片可能把一行JSON拦腰切断,解析直接报错;残缺行存入buffer,下一轮读取拼接后再解析。delta.content增量文本 流式专用增量字段,不要误用一次性返回的message.content,会拿不到文字。[DONE]结束标记 必须提前判断,否则JSON.parse('[DONE]')直接抛出语法错误,页面卡死。
⚠️流式开发5个致命坑&完整避坑方案
坑1:网络分片截断JSON,页面频繁报错
现象:控制台持续SyntaxError: Unexpected end of JSON input
原因:一次网络包只传输半行data: {},直接解析半截JSON。
✅ 方案:维护buffer缓冲区,捕获JSON解析异常时存入残缺字符串,下一轮拼接完整再处理。
坑2:中文渲染出现乱码方块 �
现象:中文偶尔变成问号/方块乱码,英文正常。
原因:UTF-8中文占3字节,分片刚好切在字符中间,解码器无缓存直接解码。
✅ 方案:new TextDecoder('utf-8', { stream: true }),开启流式解码缓存。
坑3:EventSource无法携带POST请求与鉴权Header
现象:想用EventSource简化代码,但是接口需要传body、token鉴权。
✅ 方案:放弃EventSource,使用fetch + ReadableStream,原生支持POST、自定义请求头。
坑4:混淆delta.content与message.content,拿不到输出文字
现象:流式模式下页面空白,无任何文字输出。
原因:一次性返回用message.content,流式增量只能取choices[0].delta.content。
✅ 方案:两种分支分开处理,流式逻辑只读取delta增量文本。
坑5:不处理[DONE]标记,循环无限阻塞
现象:AI输出完成后页面卡死,无法再次发送提问。
原因:流结束会单独推送data: [DONE],直接丢给JSON.parse会报错,循环无法退出。
✅ 方案:切片后优先判断incoming === '[DONE]',直接标记done终止循环。
🧩拓展:流式输出能落地哪些场景
推荐使用流式输出
- AI对话、知识库问答、RAG智能检索页面;
- 长文本生成:文案、小说、方案实时预览;
- 实时日志、服务端进度推送、文件流式上传下载。
不推荐使用流式输出
- 短文本一次性查询,字符极少,没必要增加解析复杂度;
- 低版本老旧浏览器(不支持ReadableStream,需降级一次性返回);
- 后台纯接口同步处理,不需要前端实时渲染反馈。
📝全文总结
- LLM流式输出本质是HTTP分块二进制流,通过
ReadableStream实现边收边渲染,大幅提升用户等待体验; - 生产级流式解析三要素:
TextDecoder流式解码、buffer残缺分片缓存、[DONE]结束标记判断; - Vue3用Composition API维护响应式文本,每次增量追加只局部更新DOM,性能无损耗;
- 避开断包、乱码、字段混淆、循环卡死四大核心坑,代码可直接对接OpenAI/DeepSeek/通义千问等主流模型接口;
- 流式与一次性返回双分支兼容,一套页面支持两种交互模式,适配不同业务需求。