一聚教程网:一个值得你收藏的教程网站

最新下载

热门教程

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

时间:2026-07-17 10:49:09 编辑:袖梨 来源:一聚教程网

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

目录

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

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

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

流式输出完全指南:让 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>
  <!-- 模板:动态 HTML,支持数据绑定 -->
</template><script setup>
  // 逻辑:数据和行为
</script><style>
  /* 样式:作用于当前组件 */
</style>

数据绑定与响应式

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

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

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

html

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

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

javascript

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

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

流式输出的约定:stream: true

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

javascript

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

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

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

javascript

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

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

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

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

javascript

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

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

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

javascript

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

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

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

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

javascript

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

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

javascript

 复制代码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] 表示结束。

解析流程:

javascript

 复制代码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 的运作方式

javascript

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

完整流式读取流程

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

javascript

 复制代码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 有什么区别?  为什么需要两者都检查?

热门栏目