彻底搞定大模型流式输出:从二进制碎块到"嘚嘚嘚"打字机效果,让底层逻辑飞起来
你有没有遇到过这种场景:
用户点击「发送」,页面死气沉沉地转圈圈 5 秒,然后「啪」一下整段 500 字答案全部吐出来。
用户体验 = 灾难。
而真正丝滑的 ChatGPT、Claude、DeepSeek Web 版是怎么做的?
答案就是:流式输出(Streaming)。
今天我们就用最硬核的方式,把流式输出的底层原理、字节流处理、SSE 协议、Vue3 响应式结合、常见坑与终极优化全部讲透
前三段,我们来了解一下流式输出所涉及到的知识点,到第四段我们直接让流式输出底层逻辑直接飞起来!
一、为什么流式输出能让用户「爽到飞起」?
普通请求(stream: false):
text
用户点击 → 前端等待 → LLM 思考 8 秒 → 完整返回 500 字 → 前端一次性渲染
感知延迟 = 8 秒 + 网络
流式请求(stream: true):
text
用户点击 → LLM 每生成 1~3 个 token 立刻返回 → 前端实时追加显示
感知延迟 ≈ 300~800ms(第一个 token 到达的时间)
这就是为什么 ChatGPT 打字像真人一样「一字一字冒出来」
结论:流式不是「锦上添花」,而是现代 AI 聊天界面「雪中送炭」的标配。
二、流式输出的真实数据长什么样?
DeepSeek、OpenAI、通用的 Server-Sent Events(SSE)格式:

关键点:
- 每行以
data:开头 - 每一行都是一个完整的 JSON(除了最后一行
[DONE]) delta.content就是本次新增的文字片段- 网络传输的是 二进制 Chunk,前端需要自己拼接、解码、解析
这也是为什么很多人写流式会出错------没处理好残缺的 JSON 行。
三、底层:从二进制 Buffer 到文字的全过程(最硬核的部分)
我们用最直白的方式还原浏览器收到数据的真实过程:
关键 API 一览(现代浏览器原生支持)
| API | 作用 | 备注 |
|---|---|---|
fetch() + stream: true |
开启流式请求 | 必须设置 |
response.body.getReader() |
获取二进制流读取器 | 返回 ReadableStreamDefaultReader |
reader.read() |
每次读取一个 chunk(Uint8Array) | 返回 { value, done } |
new TextDecoder() |
把 Uint8Array → 字符串 | 支持 UTF-8,默认就是 |
new TextEncoder() |
字符串 → Uint8Array(编码时用) | 发请求时用不到,但面试常考 |
经典 Demo:手动玩转 Buffer
html
<script>
const encoder = new TextEncoder();
const buf = encoder.encode("你好 HTML5"); // Uint8Array(12)
const buffer = new ArrayBuffer(12);
const view = new Uint8Array(buffer);
view.set(buf); // 复制进去
const decoder = new TextDecoder();
console.log(decoder.decode(buffer)); // "你好 HTML5"
</script>
这个例子说明:所有网络传输底层都是字节,中文一个字 = 3 字节,所以「你好」占 6 字节。
四、 流式输出终极解析
先来上一段完整代码,方便后面打飞他

vue
<script setup>
import { ref } from 'vue'
const question = ref('讲一个喜洋洋和灰太狼的故事,20字')
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='';//把上次的清空
//html5 流式响应对象
//响应体的读对象
const reader = response.body?.getReader();
//流出来的是二进制流 buffer
const decoder = new TextDecoder();
let done = false;//流是否结束 没有结束
let buffer = '';
while(!done){//只要没有完成就一直去拼接buffer
//解构的同时 重命名
const {value,done:doneReaing}=await reader?.read()
console.log(value,doneReaing);
done = doneReaing;
//chunk 内容块 包含多行data 有多少行不确定
//data:{} 能不能传完也不知道
const chunkValue = buffer +decoder.decode(value,{ stream: true });//decode完之后就是文本字符串
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{
//llm 流式生成 tokens 长度不定的
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>
1、网络传的永远只有 0 和 1
"电脑上传输的都是二进制的方式,网络底层永远只有 0 和 1"
无论你是发"你好"两个字,还是发 4K 视频,本质上都是下面这玩意儿在网线里飞:
01001000 01100101 01101100 01101100 01101111
浏览器收到后,先把它们塞进一个叫 ArrayBuffer 的盒子,再给你一个 Uint8Array 的"视图"去操作它。
看一段简单代码:
js
const myBuffer = encoder.encode("你好 HTML5"); // → Uint8Array(12)
const buffer = new ArrayBuffer(12);
const view = new Uint8Array(buffer);
view.set(myBuffer);
结论:
流式输出从出生那一刻起,就是一堆碎掉的二进制垃圾。
你要的"丝滑打字"?对不起,先自己捡垃圾。
2、两大神器:水龙头 + 翻译官
js
| 角色 | API | 作用 | 对应你文件里的代码 |
|------------|----------------------------|----------------------------------|------------------------------------------------|
| 水龙头 | response.body.getReader() | 把网络流变成可控的"水管" | const reader = response.body?.getReader() |
| 翻译官 | new TextDecoder() | 把二进制水翻译成人类能看的汉字 | const decoder = new TextDecoder() |
缺一不可。
没有水龙头 → 拿不到数据
没有翻译官 → 拿到一堆数字垃圾
3、读数据时的"解构+重命名"黑魔法
js
const { value, done: doneReading } = await reader.read()
done = doneReading
为什么不直接写 const { value, done }?
因为外层 while 循环要靠一个叫 done 的变量控制死活:
```js
let done = false;
while (!done) {
const { value, done: doneReading } = await reader.read();
done = doneReading; // 这一步才真正结束循环
}
这就是代码里"解构重命名的终极原因------避免变量名冲突,保持逻辑清晰。
4、最重要的一环:chunk 为什么是"狗啃过的"?
chunk(内容块)是浏览器网络层每次 read() 吐给你的二进制包,常见大小 16KB~64KB,完全随机。
可能出现的三种惨状:
-
一个完整的 data: 行被切成两半
chunk1: data: {"choices":[{"delta":{"content":"你
chunk2: 好啊"}}}]
-
一个 chunk 塞了 8 条完整行 + 半条残缺行
-
最后一个 chunk 只有 data: [DONE]
这就是为什么 大部分 的人写流式会寄------他们天真地以为一次 read() 就等于一条完整的 JSON。
5、 处理数据
再看到这张图

流式响应中,每一行理论上以 data: 开头,后面跟着一个完整的 JSON 对象。但实际情况经常会出现:
- 多个 data: 粘在一起(网络分块边界刚好切在中间)
- 最后一行 JSON 不完整(只收到一半就被截断)
所以我们先拆分再解析
js
const lines = chunkValue.split('\n').filter(line=>line.startsWith('data: '))
先把多个粘连的data给分成单个的
js
const incoming =line.slice(6)
再把data: 前缀给削掉,这样就得到了我们需要的JSON对象
最后在进行解析
js
const data = JSON.parse(incoming);
如果JOSN是不完整的,parse解析就会报错,这就是我们为什么要用buffer拼接
6、buffer:流式输出的灵魂(垃圾桶理论)
js
let buffer = '' // 残缺 JSON 临时停车场
这是整套方案的灵魂------buffer 蓄水池机制。
因为网络可能把一行 JSON 切成两半、三半、甚至十半,我们必须准备一个"垃圾桶"先存着:
JavaScript
ini
let buffer = ''; // 全局的残缺字符串蓄水池
每次读到新 chunk,都要先拼到 buffer 里:
JavaScript
ini
let chunkText = buffer + decoder.decode(value, { stream: true });
// 注意这里的 { stream: true }!告诉 decoder "我可能还没完"
buffer = ''; // 先清空,准备重新装垃圾
7. delta.content 追加,ref 一动页面舞
Vue3 的 ref 是响应式的,只要改 .value,页面就自动更新:
JavaScript
css
content.value += delta;
这就是你看到文字一个一个蹦出来的根本原因。
不需要 setTimeout,不需要 requestAnimationFrame,Vue 自己搞定。
五、总结
流式输出底层逻辑汇总:
电脑上传输的都是二进制的方式
网络底层永远只有 0 和 1
想要拿到我们看得懂的文本,首先需要两个工具:getReader() 和 TextDecoder()
一个取"水龙头",一个把"二进制水"翻译成汉字
一个负责拿到二进制流 Buffer,一个负责把拿到的这个二进制流解码成我们看得懂的字符串value 就是 Uint8Array(专业叫法就是 Buffer)
reader读取的时候默认为{value, done},为了不影响外层while循环,解构的时候选择重命名这就是为什么写 done: doneReading 的终极原因
接下来进入流式输出的最重要一环:因为 token 是按序生成、随时发送的,网络每次也只能打包固定大小的数据,
所以我们实际拿到的二进制 chunk 可能是残缺的,也可能是多条完整的混在一起
这个时候需要我们手动进行字符串拼接,使用一个空字符串 buffer 做"蓄水池"buffer 就是"残缺 JSON 临时停车场"
得到的二进制流解码后叫做 chunk 内容块
我们需要进行"过滤 + 拆行"操作:
- 因为可能一次 chunk 包含多个 data: 行
- 也可能一个 data: 行被拆成两个 chunk
所以必须:buffer += 新chunk → 按 \n 拆成数组 → 把最后一行(可能不完整)重新塞回 buffer
然后将每行完整的 data: 去掉前缀,得到真正的 JSON 字符串
如果这行 JSON 不完整 → JSON.parse 会报错 → 被 catch 抓住 → 自动留到 buffer 等下次拼接!
最后成功解析 → 取出 choices[0].delta.content → 追加到 Vue 的 ref → 页面实时刷新 → 流式输出达成!
流式输出的整条命脉就一句话:
"网络不负责给你整行 JSON,它只管扔二进制垃圾给你,你得自己捡垃圾、拼成完整的 JSON 才能吃。"
彩蛋
把 stream.value = false 和 true 切换对比,你会立刻感受到「从石器时代到现代文明」的体验差。
现在,你已经完全掌握了大模型流式输出的底层原理与最佳实践。
