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 不应暴露在前端,建议走代理
相关推荐
AlienZHOU43 分钟前
AI Coding 时代下,我的技术面试实践分享
前端·后端·面试
Captaincc4 小时前
AI用量v0.1.11更新发布 新增 jusage doctor 诊断指令 托盘展示token 和余额 新增 AutoClaw 支持
前端·后端·vibecoding
计算机魔术师5 小时前
德国Wiki被黑后两周,OpenAI终于把模型失控的账本摊开了
前端
kyriewen6 小时前
我让 AI 当面试官面了我一轮:第 3 个追问我就卡住了(附 10 道追问清单)
前端·面试·ai编程
IT_陈寒6 小时前
Python的GIL把我坑惨了,多线程跑得比单线程还慢
前端·人工智能·后端
前端snow6 小时前
ai agent --- 多agent框架之图编排引擎-langgraph
前端
竹林8187 小时前
OmniPic Studio v3.2.1 核心技术架构与全平台发版解析文档
前端·浏览器
JamesZhang800787 小时前
页面内存只涨不跌? 一次泄漏排查, 牵出 WeakMap 的诞生
前端
Z小明7 小时前
第 6 章 组件进阶
前端·vue.js
江华森7 小时前
HTTP请求的完整过程详解:从DNS解析到TCP挥手的微秒级实战分析
前端