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

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


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

用户点击「发送」,页面死气沉沉地转圈圈 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 等下次拼接


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


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

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


彩蛋

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

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

相关推荐
CPU NULL8 小时前
Vue 3 前端调试与开发指南
前端·javascript·vue.js
2401_860494708 小时前
React Native鸿蒙跨平台开发:error SyntaxError:Unterminated string constant.解决bug错误
javascript·react native·react.js·ecmascript·bug
幼儿园技术家9 小时前
多方案统一认证体系对比
前端
kingmax542120089 小时前
高中数学试讲稿:《对数与指数之间的相互转化》
面试·教师资格
十一.3669 小时前
83-84 包装类,字符串的方法
前端·javascript·vue.js
over6979 小时前
深入解析:基于 Vue 3 与 DeepSeek API 构建流式大模型聊天应用的完整实现
前端·javascript·面试
用户4099322502129 小时前
Vue3计算属性如何通过缓存特性优化表单验证与数据过滤?
前端·ai编程·trae
接着奏乐接着舞9 小时前
react useMeno useCallback
前端·javascript·react.js
码农阿豪10 小时前
Vue项目构建中ESLint的“换行符战争”:从报错到优雅解决
前端·javascript·vue.js