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

作者:技术拾光时间:2026-07-18 08:18:09浏览:476

流式输出通过SSE实现AI对话逐字展示,利用ReadableStream和TextDecoder解析二进制数据块,Buffer缓冲解决切割问题,保证数据完整性和低延迟,显著提升用户体验,增强交互实时性,优化交互流畅度,减少等待时间,提升满意度,实现实时交互,增强用户感受。

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

你有没有想过,为什么 ChatGPT 或 DeepSeek 在回答问题时,不是一口气把答案全扔出来,而是像真人打字一样,一个字一个字地往外蹦?这种体验背后,其实藏着一个很精妙的设计——流式输出(Streaming),也就是我们常说的 SSE(Server-Sent Events)。

说到这儿,可能有人会问:这玩意儿到底有什么用?直接用 JSON 一次性返回不好吗?好,但不够好。想象一下,你去问一个复杂的逻辑问题,大模型需要花个几秒甚至十几秒来推理。如果这段时间屏幕上一直是一个转圈圈的状态,用户大概率会关掉页面。而流式输出,就像是在回答者和聆听者之间拉了一根水管,水龙头一开,答案就源源不断地流出来,用户能第一时间看到内容,体验感瞬间拉满。

这篇文章,我们就来彻底拆解流式输出的实现细节。从 Vue 3 的基础概念,到 fetch API 的底层操作,再到 ReadableStream、TextDecoder、Buffer 缓冲这些核心组件,手把手带你走完整个流程。

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

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

流式输出,说白了就是让 AI 像打字机一样,一边想一边往外蹦字。与之相对的,是非流式输出——模型把整段话都憋完了,再一口气吐给你。

对比维度非流式(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

 复制代码<template>
  
template><script setup>
  // 逻辑:数据和行为
script><style>
  /* 样式:作用于当前组件 */
style>

数据绑定与响应式

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

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

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

html

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

在中,使用ref声明响应式数据:

ja vascript

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

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

流式输出的约定:stream: true

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

ja vascript

 复制代码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)逐块发送。

ja vascript

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

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

ja vascript

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

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

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

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

ja vascript

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

从实现机制上讲,reader.read()返回一个 Promise,而await让循环的每一次迭代都由“数据块的到达”来驱动,而不是由 CPU 时钟驱动。

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

ja vascript

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

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

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

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

ja vascript

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

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

ja vascript

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

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

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

解析流程:

ja vascript

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

 复制代码块1: "data: {"choices":[{"delta":{"content":"你"}}]}nndata: {"choices":[{"delta":{"content":"好"
块2: ""}}]}nndata: [DONE]nn"

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

buffer 的运作方式

ja vascript

 复制代码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循环处理已经完整的所有行

完整流式读取流程

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

ja vascript

 复制代码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作为兜底保护。

一点总结

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

互动讨论

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