流式输出完全指南:让 AI 对话像打字机一样流畅

流式输出完全指南:让 AI 对话像打字机一样流畅

摘要 :为什么 ChatGPT 能逐字显示回答,而其他 AI 要等半天才蹦出结果?流式输出(Streaming)是 LLM 产品体验的关键技术。本文从 Vue 3 前端项目出发,拆解 ReadableStreamgetReader()TextDecoder、SSE 数据格式和 Buffer 缓冲机制,带你掌握 AI 对话"逐字显示"的完整实现。

📑 目录

  • 流式输出是什么?AI 对话的"打字机"效果
  • Vue 3 基础:组件化与数据绑定
  • 流式输出的约定:stream: true
  • ReadableStream:数据流的"水管"模型
  • reader.read():数据驱动的循环节拍器
  • TextDecoder:二进制转文本的解码器
  • SSE 格式与 DONE 结束标志
  • Buffer 缓冲:解决数据块被切割的问题
  • 完整流式读取流程
  • 一点总结
  • 互动讨论

流式输出是什么?AI 对话的"打字机"效果

使用 ChatGPT 或 DeepSeek 时,你会发现回答是逐字逐句 显示出来的,像打字机一样。这就是流式输出(Streaming)

对比维度 非流式(stream: false 流式(stream: true
返回方式 LLM 生成完整结果后,一次性返回 LLM 生成一点,就传输一点
用户体验 等待完整响应,然后突然显示 逐字显示,体验流畅
网络传输 单次大块传输 多次小块传输(分块)
前端处理 response.json() 直接解析 response.body 流式读取 + 解码

同步处理时,耗时主要来自 Transformer 推理和问题复杂度。如果让用户一直等待,体验会很差。流式输出的优化思路很简单:不用一次性给出,而是逐个 token 推理生成,实时展示

就像在 LLM 和客户端之间接了一根"水管",生成的 token 像水流一样不断流向客户端。前端工程师需要做的,就是在这根"水管"上安装读取器,把水接住并展示出来。

Vue 3 基础:组件化与数据绑定

在开始流式输出之前,先理解 Vue 3 的基础概念。Vue 3 是前端第二流行的框架(React 是第一),它们都具备三个核心思想:组件化、数据绑定、响应式

组件化:页面的最小工作单元

传统开发以 HTML 标签为最小单位,但标签太小、太多,难以维护。Vue 把 .vue 文件作为组件------一个组件封装了 HTML、CSS、JS 三部分,形成一个可复用、好维护的业务单元。

.vue 文件的三部分

html

xml 复制代码
<template>
  <!-- 模板:动态 HTML,支持数据绑定 -->
</template>

<script setup>
  // 逻辑:数据和行为
</script>

<style>
  /* 样式:作用于当前组件 */
</style>

数据绑定与响应式

Vue 的核心是数据驱动------你只需要修改数据,页面自动更新。三种数据绑定语法:

语法 用途 数据流向
{{ }} 内容区显示数据 单向:JS → 页面
: / v-bind 标签属性绑定 单向:JS → 属性
v-model 表单双向绑定 双向:JS ↔ 页面

v-model 是 Vue 中最常用的指令之一。它让表单输入和 JS 数据保持同步------用户在输入框中打字,数据自动更新;数据被修改,输入框自动刷新。这就是"数据驱动"的核心:我们只需要关注数据,界面会自动跟随

html

ini 复制代码
<input v-model="question" />

<script setup> 中,使用 ref 声明响应式数据:

javascript

csharp 复制代码
import { ref } from 'vue'
const question = ref('讲一个关于龙的故事')
const content = ref('')
const stream = ref(true)

ref 把普通值包装成响应式对象,修改时用 .value,模板中会自动解包。

流式输出的约定:stream: true

调用 LLM API 时,客户端和服务端通过一个约定来开启流式输出:

javascript

php 复制代码
const response = await fetch(endpoint, {
  method: 'POST',
  headers,
  body: JSON.stringify({
    model: 'deepseek-v4-flash',
    messages: [{ role: 'user', content: question.value }],
    stream: stream.value  // ← 约定字段:true 开启流式,false 关闭
  })
});
  • 客户端约定 :发送 stream: true,表示期望接收流式响应
  • 服务端约定 :接受 stream: true,token 一生成就输出,而不是等全部生成完

stream: true 时,fetch 返回的 response.body 是一个 ReadableStream 对象,而不是完整的 JSON。

ReadableStream:数据流的"水管"模型

stream: true 时,服务器不一次性传完所有数据,而是通过 response.body 这个可读流(ReadableStream) 逐块发送。

javascript

vbscript 复制代码
console.log(response.body);
// 输出:ReadableStream { locked: false, ... }

ReadableStream 就像一个从服务器通向浏览器的水管 ,数据以分块(chunk) 的形式不断流过来。要读取这些数据,需要在水管上安装一个"水龙头":

javascript

ini 复制代码
const reader = response.body?.getReader();
console.log(reader);
// 输出:ReadableStreamDefaultReader { ... }

getReader() 创建一个 ReadableStreamDefaultReader 对象,它提供了 read() 方法来逐块读取数据。

reader.read():数据驱动的循环节拍器

reader.read() 是流式读取的核心 API:

javascript

csharp 复制代码
const { value, done } = await reader.read();

关键认知reader.read() 返回一个 Promise,而 await 让循环的每一次迭代都由"数据块的到达"来驱动,而不是由 CPU 时钟驱动。

场景 行为
数据还没到 await reader.read() 暂停循环,让出主线程,浏览器可以继续响应用户操作、渲染页面
数据块到达 浏览器底层网络进程唤醒 JS 引擎,await 恢复,循环继续执行
数据处理慢 ReadableStream 内部队列积压数据,触发背压机制,自动降低网络传输速度

一句话总结:await 让"数据到达"成为循环的"节拍器"。

javascript

ini 复制代码
let done = false;
while (!done) {
  const { value, done: doneReading } = await reader.read();
  done = doneReading;
  // 处理 value...
}

value 是一个 Uint8Array ------ 二进制数据的数组,每个元素是 0~255 之间的整数。

TextDecoder:二进制转文本的解码器

网络传输的数据是二进制(Uint8Array),需要转换为字符串才能使用。

javascript

ini 复制代码
const decoder = new TextDecoder();
const chunk = decoder.decode(value, { stream: true });

TextDecoder 的原理可以用一个简单的示例说明:

javascript

ini 复制代码
const encoder = new TextEncoder();
const bytes = encoder.encode("你好");
console.log(bytes); // Uint8Array(6)

const decoder = new TextDecoder();
const str = decoder.decode(bytes);
console.log(str); // "你好"

关键参数 { stream: true } :表示当前块可能是不完整的,解码器会保留状态,跨块处理多字节字符(如中文 UTF-8 字符被切成两半时)。如果省略这个参数,跨块的中文字符可能解码失败,出现乱码。

SSE 格式与 DONE 结束标志

LLM 的流式响应使用 SSE(Server-Sent Events) 格式:

text

css 复制代码
data: {"choices":[{"delta":{"content":"你"}}]}

data: {"choices":[{"delta":{"content":"好"}}]}

data: [DONE]

每一行以 data: 开头,后面跟着 JSON 字符串。delta.content 字段包含本次新增的 token 内容。最后以 data: [DONE] 表示结束。

解析流程:

javascript

ini 复制代码
const payload = line.slice(6);  // 去掉 "data: " 前缀
if (payload === '[DONE]') {
  done = true;
  break;
}
const json = JSON.parse(payload);
const delta = json.choices[0].delta?.content || '';
if (delta) {
  content.value += delta;  // 逐字拼接到页面绑定的变量
}

Buffer 缓冲:解决数据块被切割的问题

为什么需要 buffer?

网络数据块(chunk)是任意切割的,可能把一个完整的 SSE 消息切成两半:

text

swift 复制代码
块1: "data: {"choices":[{"delta":{"content":"你"}}]}\n\ndata: {"choices":[{"delta":{"content":"好"
块2: ""}}]}\n\ndata: [DONE]\n\n"

如果直接按 \n 切分当前块,块1的最后一行 data: {"choices":[{"delta":{"content":"好" 是不完整的,无法直接解析。

buffer 的运作方式

javascript

ini 复制代码
let buffer = '';

while (!done) {
  const { value, done: doneReading } = await reader.read();
  done = doneReading;

  if (value) {
    const chunk = decoder.decode(value, { stream: true });
    buffer += chunk;                    // 拼接到缓冲区
    const lines = buffer.split('\n');   // 按行切分
    buffer = lines.pop() || '';         // 最后一行可能不完整,暂存到下一次

    for (const line of lines) {
      // 处理完整行
      if (line.startsWith('data: ')) {
        // 解析...
      }
    }
  }
}
步骤 作用
buffer += chunk 把新数据拼到缓冲区
split('\n') 切分成完整行 + 最后一行
lines.pop() 把最后一行(可能不完整)存回 buffer,等下一个 chunk 再拼
for 循环 处理已经完整的所有行

buffer 同时解决了两个问题: ① 暂存跨块数据;② 确保消息不会被切割截断。

完整流式读取流程

将以上所有概念整合,得到完整的流式读取流程:

javascript

ini 复制代码
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;

    if (value) {
      // 1. 二进制 → 文本
      const chunk = decoder.decode(value, { stream: true });
      buffer += chunk;

      // 2. 按行切分,处理完整行
      const lines = buffer.split('\n');
      buffer = lines.pop() || '';

      for (const line of lines) {
        if (line.startsWith('data: ')) {
          const payload = line.slice(6);
          // 3. 检测结束标志
          if (payload === '[DONE]') {
            done = true;
            break;
          }
          // 4. 解析 JSON,提取 delta
          try {
            const json = JSON.parse(payload);
            const delta = json.choices[0].delta?.content || '';
            if (delta) {
              content.value += delta;  // 5. 逐字拼接到页面
            }
          } catch (e) {
            // 忽略解析错误
          }
        }
      }
    }
  }
}

两个层面的"结束"

结束来源 表现 处理方式
SSE 协议结束 收到 data: [DONE] 主动跳出循环(业务层面的结束)
流自身关闭 reader.read() 返回 done: true 被动退出循环(网络层面的结束)

推荐做法: 优先检测 [DONE] 结束循环,done: true 作为兜底保护。

一点总结

流式输出 = fetch + ReadableStream + reader.read() + TextDecoder + buffer 拼接。

概念 一句话解释
stream: true 告诉 LLM"请像水龙头一样,边生成边流出"
response.body ReadableStream,服务器到浏览器的"水管"
getReader() 在水管上安装"水龙头",打开读取通道
reader.read() 打开一次水龙头,取出一块数据
await reader.read() 数据驱动的节拍器------有数据才继续,没数据就等待
TextDecoder 二进制(Uint8Array)→ 文本(字符串)的解码器
buffer 跨块数据拼接器,保证消息不被切割截断
data: [DONE] 业务层面的结束标志
done: true 网络层面的结束(兜底保护)

互动讨论

  1. 为什么 TextDecoder 解码时需要用 { stream: true } 参数? 如果省略会怎样?
  2. bufferlines.pop() 把最后一行存回 buffer,如果最后一行是完整的,pop() 会返回什么?
  3. 如果 LLM 返回的数据块非常大,split('\n') 会不会导致内存问题?
  4. reader.read()await 在等待数据时,浏览器主线程会被阻塞吗? 为什么?
  5. data: [DONE]done: true 有什么区别? 为什么需要两者都检查?

📌 一点心得:流式输出是 AI 产品体验的"第一印象"。用户可能不懂技术,但"逐字显示"和"干等 10 秒"的体验差异是巨大的。作为前端工程师,掌握流式输出不仅是技术能力的体现,更是对用户体验的深刻理解。

相关推荐
এ慕ོ冬℘゜2 小时前
原生 JS 实现自定义单选日历组件(无 UI 库、纯手写、禁用过去日期)
开发语言·javascript·ui
mONESY3 小时前
Skill 技能与 MCP 协议的底层逻辑与协同真相
javascript
南川琼语4 小时前
HTTP——AJAX
前端·javascript·ajax
不简说4 小时前
JS 代码技巧 vol.2 — 20 个数组/对象/字符串/正则的实用技巧
前端·javascript·面试
小小编程路5 小时前
C++ 块作用域详解:从基础到实战
前端·javascript·vue.js
虚惊一场5 小时前
MarkView:纯前端 Markdown 实时预览工具的架构设计
前端·javascript·vue.js
冰暮流星5 小时前
javascript之对象的继承
javascript
海上彼尚5 小时前
Nodejs也能写Agent - 17.LangGraph篇 - Checkpointer 与 Memory
前端·javascript·人工智能·langchain·node.js
早期的虫儿有鸟吃6 小时前
Vue3
前端·javascript·vue.js