彻底搞定大模型流式输出:从二进制碎块到“嘚嘚嘚”打字机效果,让底层逻辑飞起来

彻底搞定大模型流式输出:从二进制碎块到"嘚嘚嘚"打字机效果,让底层逻辑飞起来


你有没有遇到过这种场景:

用户点击「发送」,页面死气沉沉地转圈圈 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 到文字的全过程(最硬核的部分)

我们用最直白的方式还原浏览器收到数据的真实过程:

graph TD A[TCP 二进制流] --> B(ArrayBuffer Chunk) B --> C{TextDecoder 解码} C --> D[UTF-8 字符串] D --> E[按\n拆分成多行] E --> F[过滤 data: 开头] F --> G[JSON.parse] G --> H[取出 delta.content] H --> I[追加到 Vue ref] I --> J[页面实时更新]
关键 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,完全随机。

可能出现的三种惨状:

  1. 一个完整的 data: 行被切成两半

    chunk1: data: {"choices":[{"delta":{"content":"你

    chunk2: 好啊"}}}]

  2. 一个 chunk 塞了 8 条完整行 + 半条残缺行

  3. 最后一个 chunk 只有 data: DONE

这就是为什么 大部分 的人写流式会寄------他们天真地以为一次 read() 就等于一条完整的 JSON。

5、 处理数据

再看到这张图

流式响应中,每一行理论上以 data: 开头,后面跟着一个完整的 JSON 对象。但实际情况经常会出现:

  1. 多个 data: 粘在一起(网络分块边界刚好切在中间)
  2. 最后一行 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 内容块
我们需要进行"过滤 + 拆行"操作:

  1. 因为可能一次 chunk 包含多个 data: 行
  2. 也可能一个 data: 行被拆成两个 chunk
    所以必须:buffer += 新chunk → 按 \n 拆成数组 → 把最后一行(可能不完整)重新塞回 buffer
    然后将每行完整的 data: 去掉前缀,得到真正的 JSON 字符串
    如果这行 JSON 不完整 → JSON.parse 会报错 → 被 catch 抓住 → 自动留到 buffer 等下次拼接


最后成功解析 → 取出 choices0.delta.content → 追加到 Vue 的 ref → 页面实时刷新 → 流式输出达成!


流式输出的整条命脉就一句话:

"网络不负责给你整行 JSON,它只管扔二进制垃圾给你,你得自己捡垃圾、拼成完整的 JSON 才能吃。"


彩蛋

stream.value = falsetrue 切换对比,你会立刻感受到「从石器时代到现代文明」的体验差。

现在,你已经完全掌握了大模型流式输出的底层原理与最佳实践。

相关推荐
再吃一根胡萝卜5 小时前
08 · 前端:Vue3 + SSE 流式与执行链路可视化
面试
再吃一根胡萝卜5 小时前
03 · 后端:FastAPI 与分层架构
面试
mldong6 小时前
你的 Vue3 项目也能有钉钉同款审批流设计器:npm 装包,10 分钟画出第一条审批流
前端·vue.js
2分钟速写快排7 小时前
什么是 RAG?如何用 RAG 实现一个用户记忆?
前端·后端·ai编程
passerby60618 小时前
如何自己造一个时间处理库
前端·javascript·github
走到天涯海角9 小时前
react里面的长列表渲染优化
前端·react.js·前端框架
小羊没烦恼!9 小时前
Hello Web API系列教程——Web API与国际化
java·服务器·前端·javascript·php
北岛贰9 小时前
迷茫焦虑期,我做了一个带支付带官网的 AI 聊天虚拟恋人 App
前端·人工智能·后端
Interview Aid1129 小时前
Walmart Global Tech SDE 三轮面经|基础、并发、压力面
面试·职场和发展
mayaairi11 小时前
Vue2 组件通讯(三):全局事件总线、PubSub、插槽与组件实例属性
前端·javascript·vue.js