你是不是也遇到过这种情况?
向 AI 提问后,明明开启了流式输出,结果:
- 回复卡顿半天才蹦出几个字?
- 中间突然跳过一段内容?
- 甚至直接报错
Unexpected end of JSON input?
别慌——不是 API 的锅,而是你漏掉了缓冲区(Buffer) 这个关键环节。
🌊 为何流式输出离不开“缓冲区”?
当你向 AI(比如 DeepSeek 或 ChatGPT)提问并启用流式输出时,服务器不会等待整段回答写完再一次性发送给你,而是采用边生成边发送的方式。这种机制称为 流式响应(Streaming Response) ,通常使用 Server-Sent Events (SSE) 协议来实现。SSE 消息格式如下:
data: {"delta": {"content": "你"}}
data: {"delta": {"content": "好"}}
data: {"delta": {"content": "!"}}
每条 data: 开头的消息代表一个独立的“消息单元”,以 \n 或 \n\n 结尾表示一条完整消息。
⚠️ 网络传输的不可控性
尽管服务器按行生成数据,但实际的网络传输并不保证按行分割。底层协议如 HTTP/1.1 的 Chunked Transfer Encoding 或 HTTP/2 的流式帧机制 会将整个响应体切割成多个大小不一的二进制块(chunks) 发送。这意味着一个完整的 SSE 行可能会被拆分到多个 chunks 中:
- 第一个 chunk 可能包含:
"data: {"delta": {"cont" - 第二个 chunk 包含:
"ent": "你好"}}\n"
显然,第一个 chunk 并不是一个完整的 JSON 对象或 SSE 行,直接解析会导致错误。
❌ 直接处理未完成数据的风险
如果收到第一个 chunk 后立即尝试解析:
JSON.parse('{"delta": {"cont') // ❌ 报错!语法错误
这会导致程序崩溃。而如果选择丢弃不完整的数据,那么这些信息就会永久丢失,影响用户体验。
✅ 缓冲区的作用:拼凑完整消息
为了解决上述问题,引入了缓冲区(Buffer) 。缓冲区作为一个临时存储区域,用于收集和重组来自不同 chunks 的数据,直到形成一条完整的 SSE 行。具体步骤如下:
- 接收并追加:每次收到一个新的 chunk,先将其解码并追加到缓冲区末尾。
- 检查完整性:查看缓冲区内是否包含一条或多条完整的 SSE 行(例如以
\n\n结尾)。 - 处理完整消息:一旦发现完整行,立即将其提取出来进行解析,并更新用户界面。
- 保留剩余部分:对于未能形成完整行的部分,继续保留在缓冲区中,等待后续数据补充。
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>HTML5 Buffer</title>
</head>
<body>
<h1>Buffer</h1>
<div id="output"></div>
<script>
//JS 二进制、数组缓存
// html5 编码对象
const encoder = new TextEncoder(); //创建一个默认使用 UTF-8 编码的编码器。
console.log(encoder);
const myBuffer = encoder.encode("你好 HTML5"); //用 TextEncoder 将字符串 "你好 Buffer" 编码为 UTF-8 格式的 Uint8Array
console.log(myBuffer);
// 数组缓存12字节
// 创建一个缓冲区
const buffer = new ArrayBuffer(12); //原始的二进制数据缓冲区(ArrayBuffer),大小为 12 字节。
// 创建一个视图(View)来操作这个缓冲区
const view = new Uint8Array(buffer); //创建一个 Uint8Array 视图(view),用来以 无符号 8 位整数(即字节) 的方式读写 buffer
for(let i = 0; i < myBuffer.length;i++) {
// console.log(myBuffer[i]);
view[i] = myBuffer[i];
}
const decoder = new TextDecoder();
const originalText = decoder.decode(buffer);
console.log(originalText)
const outputDiv = document.getElementById('output');
outputDiv.innerHTML = `
完整数据:[${view}]<br>
第一个字节:${view[0]}<br>
缓冲区的字节长度:${buffer.byteLength}<br>
原来的文本: ${originalText}
`
</script>
</body>
</html>
这样,既避免了解析失败,也防止了任何数据丢失。
🔧 浏览器中的“字节 ↔ 文本”桥梁:TextEncoder 与 TextDecoder
虽然我们操作的是字符串,但网络传输的是字节。现代浏览器提供了原生 API 来打通这两个世界:
TextEncoder:将 JavaScript 字符串编码为 UTF-8 格式的Uint8Array;TextDecoder:将接收到的Uint8Array解码回可读字符串。
例如,字符串 "你好 HTML5" 经 TextEncoder 编码后变为 Uint8Array,通过 TextDecoder 再还原为原始文本。
🌐 实现流式对话的关键步骤
在 Vue 或其他前端框架中实现 LLM 流式交互,典型流程如下:
- 使用
fetch()发起请求,获取response.body(即ReadableStream<Uint8Array>)。 - 创建
TextDecoder实例。 - 监听流的
read()事件,逐块读取Uint8Array。 - 使用
decoder.decode(chunk, { stream: true })将其转换为字符串。 - 将解码后的文本追加到行缓冲区。
- 循环检查缓冲区是否存在完整的 SSE 行,若有,则解析并更新 UI。
🚨 关键提醒:不要直接对每个 chunk 做
JSON.parse()!必须先通过缓冲区重组完整消息。
当 AI “逐字”回应你时,背后是一套严谨的数据拼装机制: 二进制流 → TextDecoder → 缓冲区 → 行解析 → UI 更新。
正是
TextDecoder与缓冲策略的协同工作,让碎片化的网络传输最终呈现出流畅自然的对话体验。理解这一链路是构建健壮流式前端的第一步。
动手打造 AI 的“逐字输出”体验:基于 Vue 3 实现流式对话界面
现在,我们将从零开始搭建一个基于 Vue 3 + DeepSeek API 的流式对话应用。整个过程分为五步:定义响应式状态、封装请求逻辑、处理非流式回退、实现核心流式解析,以及构建交互 UI。
1️⃣ 响应式状态:让数据驱动视图
<script setup>
import { ref } from 'vue'
const question = ref('讲一个喜羊羊和灰太狼的故事,200字')
const stream = ref(true) // 启用流式输出
const content = ref('') // 实时展示模型回复
</script>
为什么用
ref?得益于
ref的响应式机制,content的每一次微小变更(哪怕只是一个字符)都会被 Vue 精准捕获并驱动 DOM 更新——这正是流式逐字渲染得以实现的关键。
2️⃣ 核心请求函数:askLLM
const askLLM = async () => {
if (!question.value.trim()) return
content.value = '思考中...' // 立即反馈,降低用户焦虑
const endpoint = 'https://api.deepseek.com/chat/completions'
const headers = {
Authorization: `Bearer ${import.meta.env.VITE_DEEPSEEK_API_KEY}`,
'Content-Type': 'application/json'
}
const response = await fetch(endpoint, {
method: 'POST',
headers,
body: JSON.stringify({
model: 'deepseek-chat',
stream: stream.value,
messages: [{ role: 'user', content: question.value }]
})
})
if (!response.ok) throw new Error('API 请求失败')
}
关键实践:
- 使用
.env文件管理敏感密钥(如VITE_DEEPSEEK_API_KEY);- 在发起请求前将回复内容设为“思考中...”,可立即向用户提供反馈,有效提升交互的感知响应速度。
- 通过
stream字段控制是否启用流式模式。
3️⃣ 非流式模式:简单但体验滞后
if (!stream.value) {
const data = await response.json()
content.value = data.choices[0].message.content
}
传统一次性返回看似省事,实则让用户陷入无反馈的等待黑洞;流式输出则把 AI 的推理过程可视化,让交互更有节奏、更有人味。
4️⃣ 流式模式:真正的魔法所在 ✨
if (stream.value) {
content.value = ''
const reader = response.body?.getReader()
const decoder = new TextDecoder('utf-8')
let buffer = '' // 用于拼接跨 chunk 的不完整行
try {
while (true) {
const { value, done } = await reader.read()
if (done) break
// 将新 chunk 解码并拼接到缓冲区
buffer += decoder.decode(value, { stream: true })
// 按行分割,只处理以 "data: " 开头的有效消息
const lines = buffer.split('\n').filter(line => line.startsWith('data: '))
let remaining = ''
for (const line of lines) {
const payload = line.slice(6) // 去掉 "data: "
if (payload === '[DONE]') {
reader.releaseLock()
return
}
try {
const parsed = JSON.parse(payload)
const delta = parsed.choices?.[0]?.delta?.content
if (delta) content.value += delta
} catch (err) {
// 解析失败?说明这条 data 行被截断了
// 把它留到下一轮和后续 chunk 拼接
remaining = line
break // 后续行可能也不完整,暂停处理
}
}
// 更新缓冲区:保留未处理完的部分
buffer = remaining
}
} finally {
reader?.releaseLock()
}
}
核心机制解析:
TextDecoder({ stream: true }):正确处理 UTF-8 多字节字符的跨 chunk 拆分;- 行级缓冲(
buffer) :应对网络分包导致的 SSE 行断裂;[DONE]终止信号:DeepSeek 流结束的标志;- 错误隔离:仅跳过当前不完整行,不影响已解析内容;
releaseLock():确保流读取完成后释放资源。
💡 为什么不能直接
JSON.parse每个 chunk?
因为一个data: {...}可能横跨多个二进制块。只有通过缓冲+按行重组,才能安全解析。
5️⃣ 模板与交互:让界面“活”起来
<template>
<div class="container">
<div>
<label>提问:</label>
<input v-model="question" placeholder="输入你的问题..." />
<button @click="askLLM">发送</td>
</div>
<div class="output">
<label>
<input type="checkbox" v-model="stream" />
启用流式输出
</label>
<div class="response">{{ content }}</div>
</div>
</div>
</template>
v-model实现双向绑定,无需手动监听 input 事件;- 切换
stream开关可对比两种模式的体验差异。
完整代码
<script setup>
import { ref } from 'vue'
const question = ref('讲一个喜洋洋和灰太狼的故事,不低于200字')
const stream = ref(true)
const content = ref("") // 单向绑定 主要的
// 调用LLM
const askLLM = async () => {
// question 可以省.value getter
if (!question.value) {
console.log('question 不能为空');
return
}
content.value = '思考中...';
const endpoint = 'https://api.deepseek.com/chat/completions';
const headers = {
'Authorization': `Bearer ${import.meta.env.VITE_DEEPSEEK_API_KEY}`,
'Content-Type': 'application/json'
}
const response = await fetch(endpoint, {
method: 'POST',
headers,
body: JSON.stringify({
model: 'deepseek-chat',
stream:stream.value,
messages: [
{
role: 'user',
content: question.value
}
]
})
})
if(stream.value){
content.value = "";
const reader = response.body?.getReader();
const decoder = new TextDecoder();
let done = false; //流是否结束? 没有
let buffer = '';
while(!done){
// 解构的同时,重命名
const { value, done:doneReading } = await reader?.read()
console.log(value,doneReading);
done = doneReading;
// chunk 内容快 包含多行data:有多少行不知道
// data:{} 能不能传完也不知道
const chunkValue = buffer + decoder.decode(value);
console.log(chunkValue);
buffer = '';
const lines = chunkValue.split('\n')
.filter(line => line.startsWith('data: '))
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(delta) {
content.value+=delta
}
}catch(err){
// JSON.parse 解析失败 手动加上
buffer += `data : ${incoming}`
}
}
}
}else{
const data = await response.json();
console.log(data);
content.value = data.choices[0].message.content;
}
}
</script>
<template>
<div class="container">
<div>
<label>请输入:</label>
<input class="input" v-model="question"/>
<button @click="askLLM">提交</button>
</div>
<div class="output">
<div>
<label>Streaming</label>
<input type="checkbox" v-model="stream" />
<div>{{content}}</div>
</div>
</div>
</div>
</template>
<style scoped>
* {
margin: 0;
padding: 0;
}
.container {
display: flex;
flex-direction: column;
/* 主轴、次轴 */
align-items: start;
justify-content: start;
height: 100vh;
font-size: 0.85rem;
}
.input {
width: 200px;
}
button {
padding: 0 10px;
margin-left: 6px;
}
.output {
margin-top: 10px;
min-height: 300px;
width: 100%;
text-align: left;
}
</style>
真正的价值不在于“能流”,而在于“为何流”:流式输出通过模拟思考节奏,消解了机器的冰冷感,将 AI 从工具升格为对话者。这份魔法,如今已在你手中。
💬 最后的话
每一个缓缓浮现的字符,都是 ReadableStream 与 TextDecoder 在幕后默契配合的结果,而缓冲策略则默默守护着数据的完整。这不仅是工程实现,更是对“等待”这一人类体验的尊重——技术的意义,终归落在人心之上。
现在,你不仅能复现它,更能诠释它的价值。