Vue 3 实现 LLM 流式输出:从零搭建一个简易 Chat 应用

🌟 引言:为什么需要流式输出?

在调用大语言模型(如 DeepSeek、OpenAI 等)时,如果等待完整响应再一次性渲染,用户会经历漫长的"白屏"等待。而流式输出(Streaming) 能让模型生成的内容逐字/逐词返回,极大提升交互体验------就像你在和真人聊天一样。

本文将手把手带你用 Vue 3 + fetch + Server-Sent Events (SSE) 实现一个支持流式输出的简易 Chat 应用,并深入解析关键细节。


🛠️ 一、项目初始化

我们使用 Vite 快速搭建 Vue 3 项目:

kotlin 复制代码
bash
编辑
npm init vite@latest llm-stream-demo -- --template vue
cd llm-stream-demo
npm install

选择:

  • 框架:Vue
  • 变体:JavaScript

项目结构如下:

css 复制代码
text
编辑
src/
├── App.vue       # 根组件
├── main.js
└── ...

💡 Vite 优势:启动快、热更新快、原生支持 ES 模块,非常适合现代前端开发。


🧩 二、核心逻辑:如何处理流式响应?

1. API 接口规范(以 DeepSeek 为例)

DeepSeek 的 /chat/completions 接口支持 stream: true 参数,返回格式为 SSE(Server-Sent Events)

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

每行以 data: 开头,最后以 [DONE] 结束。

2. 使用 fetch + ReadableStream 解析流

关键在于正确读取 response.body 这个 HTML5 ReadableStream。下面我们将重点剖析其中最核心的一行代码。


🔍 三、前置基础:response.body?.getReader() 全面解析

在进入 reader.read() 之前,必须先理解这行代码:

ini 复制代码
js
编辑
const reader = response.body?.getReader();

它是整个流式读取流程的起点 。下面我们从 核心语法拆解 → 逐部分深度解析 → 完整示例 → 注意事项 → 适用场景 五个维度讲透。

一、核心语法拆解

语法片段 作用说明
response fetch() 返回的 Response 对象(HTTP 响应封装)
response.body Responsebody 属性,类型为 ReadableStream(可读字节流)
?. 可选链操作符,防止 response.bodynull/undefined 时报错
getReader() ReadableStream 的方法,返回 ReadableStreamDefaultReader(流读取器)
const reader 声明常量存储读取器实例,用于手动读取流中的数据块

二、逐部分深度解析

1. response:Fetch API 的响应对象

response 是调用 fetch(url) 后返回的 Promise 解析结果:

ini 复制代码
js
编辑
fetch('https://example.com/large-data')
  .then(response => {
    // 这里的 response 就是 Response 对象
    const reader = response.body?.getReader();
  });

它包含 HTTP 响应的元信息(状态码、头信息)和响应体(body)。

2. response.body:可读字节流(ReadableStream

  • 核心特性 :代表流式数据,而非一次性加载的完整内容,适用于大文件、实时日志、视频流等场景。

  • response.json() / .text() 的区别

    • json() / text() 会将整个响应体加载到内存 → 适合小数据;
    • body 支持逐块读取 → 避免内存溢出(如处理 1GB 文件)。
  • 可能的值

    • 正常响应:返回 ReadableStream 实例;
    • 无响应体(如 204 No Content)或跨域错误:返回 null

3. ?.:可选链操作符

  • 作用 :若 response.bodynullundefined,则 response.body?.getReader() 直接返回 undefined,不会执行后续方法。

  • 避免错误 :防止抛出 Cannot read properties of null (reading 'getReader')

  • 等价 ES5 写法

    ini 复制代码
    js
    编辑
    const reader = response.body ? response.body.getReader() : undefined;

4. getReader():获取流读取器

调用后返回一个 ReadableStreamDefaultReader 实例(简称 reader),其核心方法包括:

方法 作用
reader.read() 异步读取下一个数据块,返回 Promise<{ done: boolean, value: Uint8Array }>
reader.releaseLock() 释放读取器对流的独占锁,允许其他读取器使用该流

⚠️ 注意 :调用 getReader() 后,流被"锁定",只能由该读取器读取,直到调用 releaseLock()


三、完整使用示例(逐块读取流)

javascript 复制代码
js
编辑
async function readStreamData(url) {
  const response = await fetch(url);
  
  // 获取读取器(安全处理 body 为 null 的情况)
  const reader = response.body?.getReader();
  if (!reader) {
    console.log('响应无数据流');
    return;
  }

  const chunks = []; // 存储所有数据块

  try {
    while (true) {
      const { done, value } = await reader.read();
      if (done) break; // ✅ 流读取完毕
      chunks.push(value); // value 是 Uint8Array
    }
    
    // 合并所有块为完整 Uint8Array
    const fullLength = chunks.reduce((acc, chunk) => acc + chunk.length, 0);
    const fullData = new Uint8Array(fullLength);
    let offset = 0;
    for (const chunk of chunks) {
      fullData.set(chunk, offset);
      offset += chunk.length;
    }
    
    // 转为字符串(根据实际编码调整)
    const text = new TextDecoder().decode(fullData);
    console.log('完整数据:', text);
  } catch (err) {
    console.error('读取流失败:', err);
  } finally {
    reader.releaseLock(); // ✅ 释放锁,避免内存泄漏
  }
}

// 调用
readStreamData('https://example.com/large-file.txt');

四、关键注意事项

  • 独占锁getReader() 会锁定流,其他读取器无法使用,必须调用 releaseLock()
  • 数据类型valueUint8Array,需用 TextDecoder 转字符串,或按需解析为 JSON/二进制;
  • 错误处理 :网络中断等异常会 reject read() 的 Promise,需 try/catch
  • 兼容性:浏览器全支持(IE 除外),Node.js 16+ 支持(18+ 更稳定);
  • 取消读取 :可调用 reader.cancel() 主动终止流并释放资源。

五、适用场景

  • 大文件下载/上传(避免内存爆炸);
  • 实时数据推送(如 SSE、日志流);
  • 边下载边解析(JSON/CSV 分块处理);
  • 音视频流的前端处理。

总结
const reader = response.body?.getReader(); 是安全获取流读取器的关键一步,为处理海量或实时数据奠定基础。


🔥 四、深度解析:reader.read()done 状态机制

在流式响应处理中,以下这行代码是整个机制的引擎

bash 复制代码
js
编辑
const { value, done: doneReading } = await reader?.read();

它看似简单,却融合了解构赋值、异步流读取、可选链等多个关键概念。下面我们从 逐行拆解 + 核心概念 + 实际场景 + 易错点 四个维度彻底讲透。

✅ 上下文回顾

典型流式读取循环如下:

ini 复制代码
js
编辑
const reader = response.body.getReader();
const decoder = new TextDecoder();
let done = false;

while (!done) {
  const { value, done: doneReading } = await reader?.read(); // ← 核心行
  done = doneReading;
  if (value) {
    const chunk = decoder.decode(value);
    content.value += chunk;
  }
}

📖 逐行拆解核心代码

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

部分 作用 详细说明
reader?.read() 异步读取流的「下一个数据块」 - reader 是通过 response.body.getReader() 创建的流读取器; - ?. 是可选链,防止 readerundefined 时报错(比如请求失败); - read() 每次只读一块,不会自动继续,需手动循环调用。
await 等待异步读取完成 read() 返回 Promise<{ value, done }>, 必须 await 才能拿到实际数据。
{ value, done: doneReading } 解构并重命名 - value: 当前块的二进制数据(Uint8Array),流结束时为 undefined; - done: 布尔值,true 表示流已结束; - 重命名为 doneReading 是为了避免与外层 let done 变量冲突(否则循环无法终止!)。

done = doneReading;

  • 外层 let done = false 是控制 while 循环的全局状态
  • 每次读取后,将当前块的 done 状态同步给全局变量;
  • 一旦某次返回 done: true,循环终止,流读取完成。

🔑 补充深度:done 状态的本质与使用逻辑

你代码中的 done = doneReading,其实是在对 read() 返回的 done 字段做别名赋值 。这个 doneReadableStream 读取完成的核心标识,理解它对正确实现流式逻辑至关重要。

一、done(即 doneReading)的核心含义

done 状态说明 value 对应值
false 流未读取完成,本次 read() 成功获取到了一段数据块 Uint8Array(非空字节数组)
true 流已完全读取完毕,没有更多数据 undefined

✅ 简单说:

  • done: true → "流读完了,没数据了"
  • done: false → "还有数据,这次拿到了一小段"

二、done: true 的触发时机

该状态由流的底层机制自动决定,不是前端手动设置的,常见触发场景包括:

  • 正常完成:服务器发送完全部响应体,且前端已读取完所有数据块;
  • 主动终止 :调用 reader.cancel() 取消读取,后续 read() 返回 done: true
  • 响应无体 :HTTP 状态码为 204 No Content1xx 信息响应,response.body 为空;
  • 流被关闭:服务器或客户端主动断开连接(如 TCP 正常关闭)。

三、done 状态的典型使用逻辑

标准流读取循环通常这样写:

javascript 复制代码
js
编辑
while (true) {
  const { done: doneReading, value } = await reader.read();
  
  // ✅ 关键判断:流是否读完?
  if (doneReading) {
    console.log('流读取完成');
    break; // 终止循环
  }
  
  // 处理当前数据块
  console.log('收到数据:', decoder.decode(value));
}

你代码中的 done = doneReading 正是为了实现这一逻辑------将局部 done 状态同步到循环控制变量,从而安全退出。

四、关键注意点

  • done: true 是最终状态 :一旦出现,后续所有 read() 调用都会返回 { done: true, value: undefined },无需再读;
  • done: true ≠ 错误 :它是正常结束信号 ,错误会通过 Promise reject 抛出(需 try/catch 捕获);
  • 必须终止循环 :若忽略 done: true,会导致无限循环(虽然不报错,但浪费 CPU)。

五、对比理解:错误 vs 正常结束

场景 done 状态 处理方式
流正常读取完毕 true 退出循环,合并数据
读取到有效数据块 false 处理 value,继续循环
网络中断 / 流损坏 ---(无返回) read() Promise 被 reject,需 catch 捕获

总结
done = doneReading 中的 doneReading 就是 read() 返回的原始 done 字段,它是判断流是否终结的唯一可靠依据true 表示"读完了",false 表示"还有数据"。这是流式编程的基石。


🌐 核心概念:为什么流要这样读?

  • 普通响应 :服务器拼好全部内容 → 一次性返回 → 前端 response.json() 拿到完整数据。
  • 流式响应 :服务器边生成边返回(如大模型 token-by-token 输出)→ 数据分块传输 → 前端必须逐块读取

ReadableStream 的设计正是为了:

  • 避免一次性加载大文本(节省内存);
  • 支持实时渲染(提升用户体验);
  • 通过 done 明确告知"是否还有下一块"。

🎯 实际场景举例

假设你问:"1+1 等于几?",流式读取过程如下:

读取次数 read() 返回值 doneReading 全局 done 操作
第1次 { value: Uint8Array("1+1"), done: false } false false 解码为 "1+1",拼接到页面
第2次 { value: Uint8Array("等于2"), done: false } false false 拼接为 "1+1等于2"
第3次 { value: undefined, done: true } true true 循环结束

⚠️ 易错点与注意事项

  1. 必须用 while 循环
    read() 是单次操作,只调一次只能拿到第一块!
  2. value 是二进制
    直接使用会看到 Uint8Array,必须用 TextDecoder.decode() 转字符串。
  3. 可选链 ?. 很重要
    response.body 为空(如 404 错误),readerundefined,不加 ?. 会直接报错。
  4. 避免变量名冲突
    如果写成 const { value, done } = ...,会覆盖外层 done,导致循环永不退出!
  5. 流只能读一次
    调用 getReader() 后,流被"锁定",读完即销毁,无法重复读取。

💬 简化版"人话"逻辑

arduino 复制代码
plaintext
编辑
准备一个读取器
流是否结束 = false

只要没结束,就一直读:
  等待读取下一块
  拿到数据块 和 "是否最后一块"
  更新"流是否结束"状态
  如果有数据,转成文字,拼到页面上

✅ 记住两个关键点:

  1. reader.read() 每次读一块,返回 { value, done }
  2. done = doneReading 是让循环知道"什么时候该停"。

🧠 五、关于 buffer 的真相:它真的多余吗?

在很多简化示例(包括本文前面的代码)中,你会看到类似这样的写法:

ini 复制代码
js
编辑
let buffer = '';
const chunk = buffer + decoder.decode(value, { stream: true });
buffer = '';
content.value += chunk;

乍一看,buffer 全程为空,拼接和清空操作"走了流程但没产生实际效果"------在这个特定场景下,确实如此 。但这并不意味着 buffer 是多余的,而是你的示例处于"理想情况":每次 decoder.decode() 都恰好返回完整的业务字符串,没有残段

我们分两层说清楚:

1. 在你的简化示例中:buffer 确实 "没用"

回顾逻辑:

ini 复制代码
js
编辑
let buffer = '';
const decoder = new TextDecoder('utf-8');

// 第一次:buffer 为空
const chunkValue1 = buffer + decoder.decode(Buffer.from('你好世'), { stream: true });
buffer = ''; // 清空 → 仍为空

// 第二次:buffer 仍为空
const chunkValue2 = buffer + decoder.decode(Buffer.from('界'), { stream: true });
buffer = ''; // 清空 → 无意义

这里 buffer 既没有被赋值为非空值,也没有为拼接提供任何有效数据,纯粹是"标准写法的惯性"------就像给空杯子擦桌子,动作做了,但杯子本来就干净,没产生实际价值

✅ 结论:在纯文本连续输出(如 LLM 流式回复)且无需按规则切分的场景下,buffer 可省略。


2. 但在真实业务场景中:buffer 是必不可少的

buffer 的核心价值,是处理 "业务层存在未完成的字符串残段" 的情况。例如:

  • |\n} 等分隔符解析流数据;
  • 按固定长度截取协议帧;
  • 解析 JSON 流时遇到半截对象。

🌰 举个真实例子:按 | 分隔接收数据

目标完整数据你好|世界|123
实际传输被拆成两段

  • 第一段:Buffer.from('你好|世')
  • 第二段:Buffer.from('界|123')

如果没有 buffer 缓存残段,第一次会错误地认为 是一个完整字段,导致数据错乱。

正确处理(此时 buffer 发挥关键作用)

ini 复制代码
js
编辑
let buffer = '';
const decoder = new TextDecoder('utf-8');

// 第一次接收
const chunk1 = buffer + decoder.decode(Buffer.from('你好|世'), { stream: true });
// → chunk1 = '你好|世'
const parts1 = chunk1.split('|');
const complete1 = parts1.slice(0, -1);        // ['你好'] ← 可安全使用
buffer = parts1[parts1.length - 1];           // buffer = '世' ← 缓存残段!

// 第二次接收
const chunk2 = buffer + decoder.decode(Buffer.from('界|123'), { stream: true });
// → chunk2 = '世' + '界|123' = '世界|123'
const parts2 = chunk2.split('|');
const complete2 = parts2.slice(0, -1);        // ['世界', '123']
buffer = parts2[parts2.length - 1];           // buffer = '' ← 清空

// 最终完整字段:['你好', '世界', '123']

关键点buffer 缓存了第一次拆分后无法构成完整业务单元的残段 ,第二次拼接后才形成 世界------避免数据断裂


总结:buffer 的设计哲学

场景 buffer 作用 是否必要
简化 LLM 流式输出(连续文本) 无实质性作用,仅"标准写法惯性" ❌ 可省略
按分隔符/长度/协议解析流数据 缓存业务层残段,保证数据完整性 ✅ 必不可少

💡 最佳实践建议

即使当前场景不需要 buffer保留其声明和清空逻辑是一种"鲁棒性设计"------为未来可能的协议变更或数据格式扩展预留空间。属于"提前留好扩展接口"的工程思维。


🖼️ 六、完整组件实现(App.vue)

xml 复制代码
vue
编辑
<script setup>
import { ref } from 'vue'

const question = ref('讲一个喜洋洋和灰太狼的故事,20字')
const stream = ref(true)
const content = ref('')

const askLLM = async () => {
  if (!question.value) return
  
  content.value = stream.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 (!response.ok) {
    content.value = '请求失败,请检查 API Key 或网络'
    return
  }

  if (stream.value) {
    content.value = ''
    const reader = response.body?.getReader() // 获取流读取器(可能为 undefined)
    const decoder = new TextDecoder()         // 用于二进制 → 字符串
    let done = false                          // 全局流结束标记

    while (!done) {
      // 🔥【核心行】逐块读取流数据
      // - reader?.read():安全调用 read(),防止 reader 为 undefined
      // - await:等待异步读取完成
      // - 解构并重命名 done → doneReading,避免与外层变量冲突
      const { value, done: doneReading } = await reader?.read()
      
      // 更新全局流状态:决定 while 循环是否继续
      // doneReading 即 read() 返回的 done 字段,true 表示流已读完
      done = doneReading
      
      if (value) {
        // 将二进制块解码为字符串,并拼接到响应内容
        const chunk = decoder.decode(value)
        content.value += chunk
      }
    }
  } else {
    const data = await response.json()
    content.value = data.choices?.[0]?.message?.content || '无内容'
  }
}
</script>

<template>
  <div class="container">
    <div>
      <label>输入:</label>
      <input v-model="question" placeholder="请输入问题" />
      <button @click="askLLM">提交</button>
    </div>
    <div class="output">
      <label>
        <input type="checkbox" v-model="stream" /> 启用流式输出
      </label>
      <div class="content">{{ content }}</div>
    </div>
  </div>
</template>

<style scoped>
.container {
  padding: 20px;
  max-width: 600px;
  margin: 0 auto;
  font-family: -apple-system, BlinkMacSystemFont, sans-serif;
}
input, button {
  padding: 6px 10px;
  margin: 4px;
  border: 1px solid #ccc;
  border-radius: 4px;
}
.output {
  margin-top: 20px;
}
.content {
  margin-top: 10px;
  padding: 12px;
  background: #f9f9f9;
  border-radius: 6px;
  min-height: 100px;
  white-space: pre-wrap;
}
</style>

环境变量安全提示
VITE_DEEPSEEK_API_KEY 需在 .env.local 中定义(不要提交到 Git):

ini 复制代码
text
编辑
VITE_DEEPSEEK_API_KEY=your_api_key_here

📌 七、注意事项与最佳实践

  1. 错误处理必须完善

    • 检查 response.ok
    • 捕获 fetch 网络错误
    • 处理 JSON 解析异常
  2. 避免内存泄漏

    • 在组件卸载时取消未完成的流(可结合 AbortController
    • 虽然 Vue 组件销毁后 reader 会被 GC,但显式调用 reader.releaseLock() 更规范
  3. 用户体验优化

    • 显示"思考中..."加载态
    • 自动滚动到底部(可配合 nextTick + scrollIntoView
  4. 安全性

    • API Key 绝不能硬编码在前端
    • 生产环境应通过后端代理转发请求(避免暴露密钥)

🧠 八、拓展思考

如果不用 DeepSeek,换成 OpenAI 呢?

接口几乎一致!只需改:

  • endpointhttps://api.openai.com/v1/chat/completions
  • modelgpt-3.5-turbogpt-4
  • Header 中的 Bearer Token

✅ 说明:主流 LLM 的流式接口设计高度统一,迁移成本极低。

能否支持 Markdown 渲染?

可以!在 content.value += delta 后,用 marked.jsvue-markdown-plus 实时渲染:

javascript 复制代码
js
编辑
import { marked } from 'marked'
// ...
const html = marked(content.value)
// 绑定到 v-html(注意 XSS 风险!)

⚠️ 警告v-html 有 XSS 风险,务必对内容进行过滤或使用可信来源。


✅ 总结要点

要点 说明
response.body?.getReader() 安全获取流读取器,是流式处理的起点
reader.read() 每次读取一个数据块,返回 { value, done }
done 状态 true 表示流读取完成,是循环终止的唯一依据
数据类型 valueUint8Array,需用 TextDecoder 转字符串
buffer 的作用 在连续文本场景中可省略;在协议解析场景中必不可少,用于缓存业务残段
错误 vs 结束 done: true 是正常结束,错误需 catch 捕获
响应式更新 Vue 3 的 ref 自动触发 DOM 更新
安全第一 API Key 不应暴露在前端,建议走代理
相关推荐
AAA阿giao1 小时前
从零开始:用 Vue 3 + Vite 打造一个支持流式输出的 AI 聊天界面
前端·javascript·vue.js
开源之眼1 小时前
github star都很多的 React Native 和 React 有什么区别?一文教你快速分清
前端
听风说图1 小时前
AI编程助手为何总是"健忘"?
前端
踩着两条虫1 小时前
开源一个架构,为什么能让VTJ.PRO在低代码赛道“炸裂”?
前端·低代码
悦来客栈的老板1 小时前
AST反混淆实战|reese84_jsvmp反编译前的优化处理
java·前端·javascript·数据库·算法
爱看书的小沐1 小时前
【小沐学WebGIS】基于Cesium.JS绘制雷达波束/几何体/传感器Sensor(Cesium / vue / react )
javascript·vue.js·react.js·雷达·cesium·传感器·波束
踢球的打工仔1 小时前
前端css(2)
前端·css
半瓶榴莲奶^_^1 小时前
后端Web实战(登录认证)--会话技术,统一拦截技术
前端
zlpzlpzyd1 小时前
vue.js是干什么的?各个版本有什么区别?
前端·javascript·vue.js