你有没有想过,为什么 ChatGPT 能一个字一个字地蹦出来,而不是让你盯着转圈等半天? 这背后究竟是魔法还是工程?本文带你从零手撕一个流式输出 Demo,搞懂 SSE、二进制流、JSON 截断处理的底层原理。
一、Agent 时代:学会把活分给 AI
在 Agent 开发时代,AI 越来越像人,正在走向 AGI。但我们要清醒地认识到:AI 不是万能的,学会分工才是关键。
1.1 工作拆分原则
| 交给 Agent 的活 | 我们自己接管的活 |
|---|---|
| 项目工程初始化 | 核心业务逻辑 |
| 重复模板代码 | 架构决策 |
| 文档生成 | 最终审核 |
为什么不自己写 src/App.vue、index.html? 没有必要从 0 开始写一个 Vue 项目,直接到 GitHub 拉取一个模板项目,把时间花在更有价值的事情上。
二、热更新:Vite 给开发者的礼物
2.1 什么是热更新(Hot Reload)
开发阶段,我们希望修改代码后不用刷新浏览器就能看到变化,这就是热更新。
文件修改 → 局部刷新(不丢失页面状态)
2.2 为什么需要它?
如果每次修改都整页刷新,Vue/React 中那些密密麻麻的数据状态就全丢了------用户输入的内容、表单进度、弹窗状态全部归零。热更新只替换变更的模块,保留当前页面状态,开发体验直接拉满。
这个功能来自 Vite,基于 ES Module 实现,是现代前端开发的利器。
三、流式输出的本质:二进制流
3.1 Stream 是什么?
stream 返回的本质是二进制流 ,在 JavaScript 中就是 Uint8Array:
js
// 0-255 之间的无符号整数数组
Unit8Array[十进制数, ...]
3.2 编码与解码
字符串和二进制流之间的转换靠 TextEncoder / TextDecoder:
js
const encoder = new TextEncoder();
// 字符串 → Uint8Array 二进制
const bytes = encoder.encode("你好");
console.log(bytes); // Uint8Array(6) [228, 189, 160, 229, 165, 189]
const decoder = new TextDecoder();
// 二进制 → 字符串
const str = decoder.decode(bytes);
console.log(str); // 你好
原理:
你好↔ 编码数字(如 128、129)↔ 二进制,0-255 每个数字对应一个字节。
四、Server 流式输出:SSE 协议全解析
4.1 后端返回的数据流长什么样?
css
data: {"choices":[{"delta":{"content":"你"}}]}
data: {"choices":[{"delta":{"content":"好"}}]}
data: [DONE]
几个关键特征:
- 二进制文本:本质是一串文本流
\n换行符 区分每个data:数据块,一行结束data: {}是 JSON 格式文本,结构和completion类似data: [DONE]标识流结束
4.2 为什么用换行分隔?
兼顾响应速度 和传输效率:
- LLM 生成 token 时返回 JSON,格式短、解析快
- 一次性发送多少行
data:不确定,可能一行,也可能两三行(取决于 LLM 计算速度)
4.3 核心难点:JSON 截断问题
⚠️ 数据包有大小限制,当 JSON 数据超过缓冲区大小时,会被截断!
css
// 实际场景:一个完整的 JSON 可能被拆成两半
第1个 chunk: data: {"choices":[{"delta":{"content":"你
第2个 chunk: 好"}}]}
如果直接 split('\n') 然后 JSON.parse(),极有可能失败。
4.4 解决方案:buffer 缓冲区机制
js
let buffer = ''; // 存上一次 JSON.parse 失败的不完整数据
while (!done) {
const { value, done: doneReading } = await reader.read();
done = doneReading;
// 关键:把上一轮的 buffer 拼到这一轮前面
const chunkValue = buffer + decoder.decode(value);
buffer = ''; // 拼完后清空 buffer
const lines = chunkValue.split('\n')
.filter(line => line.startsWith('data:'));
for (const line of lines) {
const incoming = line.slice(6); // 去掉 "data: " 头(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 不完整,存回 buffer,下一轮接着拼
buffer = `data: ${incoming}`;
}
}
}
核心思想:解析失败不扔掉,存到 buffer 里,等下一轮数据到来时拼接后再解析。
五、完整实战:Vue3 调用 DeepSeek 流式 API
5.1 核心代码
vue
<script setup>
import { ref } from 'vue'
const question = ref('讲一个中国龙的故事');
const content = ref('');
const stream = ref(true);
const update = async () => {
if (!question.value) return;
content.value = '思考中...';
const response = await fetch('https://api.deepseek.com/chat/completions', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
Authorization: `Bearer ${import.meta.env.VITE_DEEPSEEK_API_KEY}`
},
body: JSON.stringify({
model: 'deepseek-v4-flash',
messages: [{ role: 'user', content: question.value }],
stream: stream.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();
done = doneReading;
const chunkValue = buffer + decoder.decode(value);
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) {
buffer = `data: ${incoming}`;
}
}
}
} else {
const data = await response.json();
content.value = data.choices[0].message.content;
}
}
</script>
5.2 关键概念速查
| 概念 | 含义 |
|---|---|
response.body |
服务器响应体,是一个 ReadableStream 二进制流 |
getReader() |
获取读取器,像"水管子嘬一口",有数据就 resolve,没数据就等 |
delta |
偏移量/增量,每次返回的一小块 token |
[DONE] |
流结束标识 |
5.3 两种结束方式
done: true:reader.read()返回时设置data: [DONE]:服务端单独发送一条结束文本流
六、总结
流式输出的核心就三件事:
- 二进制流 :
Uint8Array+TextEncoder/Decoder编解码 - SSE 协议 :
data:开头 +\n分行 +[DONE]结束 - buffer 机制:JSON 截断时暂存不完整数据,下一轮拼接再解析
理解了这三点,你就能手撕任何 LLM 的流式响应了。
💡 动手试试:把代码跑起来,输入一个问题,看着 AI 一个字一个字蹦出来的瞬间,你会真正理解"流式"的魅力。