从零理解 LLM 流式输出:Vue3 + DeepSeek API 实战

文章目录


一、什么是流式输出?为什么需要它?

一次性返回的痛点

当你调用大语言模型(LLM)的 API 时,如果不开启流式输出,整个流程是这样的:

复制代码
用户发送问题 → 服务器开始推理 → 推理完成 → 一次性返回全部结果 → 前端渲染

推理过程可能非常漫长------比如"用 JavaScript 写一个贪吃蛇游戏",LLM 可能需要推理 30 秒甚至更久。用户盯着空白页面干等半分钟,这是 AI 产品绝不能出现的体验。

流式输出的解决思路

不要等所有结果都生成完再返回,而是生成一个 token 就立即返回一个 token:

复制代码
用户发送问题 → 服务器开始推理 → 生成第1个token立即发送 → 生成第2个token立即发送 → ... → 生成完毕
                ↓                      ↓                      ↓
         前端显示第一个字          前端追加第二个字        前端持续拼接

可以把它想象成两根水管 :LLM 服务器是出水口,客户端是接水口,生成的 token 像水流一样源源不断地从服务器流向客户端。最终效果就是 Chatbot 像老式打字机一样,一个字一个字地把答案"打"出来,用户感知到"AI 正在思考"而不是"卡死了"。

时间消耗在哪里?

LLM 生成回答的耗时主要来自两方面:

耗时来源 说明
Transformer 推理计算 每次生成下一个 token 都需要走一遍完整的神经网络前向传播,计算密集型操作
问题复杂度 问题越难、越长,需要生成的 token 数越多,总耗时自然越长

流式输出并不能减少总耗时,但它把"等待 30 秒 → 看到完整答案"变成"等待 0.5 秒 → 看到第一个字 → 持续看到新字",用户的感知等待时间大幅缩短。


二、客户端与服务端的约定

流式输出需要客户端和服务端配合才能实现。

以 DeepSeek API(兼容 OpenAI 协议)为例,服务端收到请求后检查 stream 字段:

  • stream: true → 采用 SSE(Server-Sent Events) 格式,逐 token 推送数据
  • stream: false → 等全部生成完毕后,一次性返回完整 JSON

客户端在发送请求时,需要在请求体中明确告知服务端:

json 复制代码
{
  "model": "deepseek-v4-flash",
  "messages": [{ "role": "user", "content": "讲一个故事" }],
  "stream": true
}

这个 stream: true 就是客户端和服务端之间的"暗号"------双方约好用流式的方式传输数据。服务端看到这个字段为 true,就会切换为 SSE 模式,每当 LLM 推理出一个新 token,就立即推送给客户端。


三、Vue 基础:.vue 组件文件(已熟悉的可跳过)

什么是组件?

现代前端框架(Vue / React)都遵循组件化思想 ------把页面上一个独立、可复用的功能块封装成一个 .vue 文件。就像乐高积木一样,页面由一个个组件拼装而成。组件化带来三个好处:

  • 封装:HTML、CSS、JS 放在一起,职责清晰
  • 复用:同一组件可在多处使用
  • 维护:改一个功能只需改对应的组件文件

一个 .vue 组件文件由三个区块组成:

复制代码
<template>   →  HTML 模板(动态的,可绑定数据)
<script>     →  JS 逻辑(数据、方法、生命周期)
<style>      →  CSS 样式

template(模板)

不同于静态 HTML,template 里可以写插值表达式 {``{ }} 来绑定数据。当数据变化时,页面对应内容自动更新------不再需要手动操作 DOM。

script(脚本)

Vue3 推荐使用 <script setup> 语法糖,在 <script> 里定义的变量和函数可以直接在 template 中使用:

html 复制代码
<script setup>
import { ref } from 'vue'

const count = ref(1);  // ref 创建一个响应式数据
count.value = 2;       // script 里要用 .value 访问
</script>

ref() 是 Vue 创建响应式数据 的核心 API。它的工作原理是:Vue 内部用 JavaScript 的 Proxy 对象拦截对 .value 的读写操作,当值发生变化时自动通知所有用到它的地方进行更新------这就是"数据驱动视图"的底层实现。

理解双向绑定 v-model

Vue 的数据流通常是单向的 (数据变了 → 视图更新)。但表单元素是个例外------用户可以在输入框里打字,数据需要从视图"流回"到数据。v-model 实现了双向数据绑定

html 复制代码
<input type="text" v-model="question" />
  • 数据 → 视图:question 的值显示在输入框中
  • 视图 → 数据:用户输入内容时,question 自动更新

v-model 的底层其实是 :value(属性绑定)+ @input(事件监听)的语法糖。


四、动手实战:Vue3 + DeepSeek 流式输出

4.1 项目结构与入口文件

src/main.js ------ 应用的启动入口:

js 复制代码
import { createApp } from 'vue'
import './style.css'
import App from './App.vue'

// 创建 Vue 应用实例并挂载到 #app 节点
createApp(App).mount('#app')

index.html ------ 浏览器加载的 HTML 页面:

html 复制代码
<!doctype html>
<html lang="en">
  <head>
    <meta charset="UTF-8" />
    <link rel="icon" type="image/svg+xml" href="/vite.svg" />
    <meta name="viewport" content="width=device-width, initial-scale=1.0" />
    <title>stream-demo</title>
  </head>
  <body>
    <!-- #app 是 Vue 的挂载点,Vue 会接管这个 div 内部的一切 -->
    <div id="app"></div>
    <!-- 入口文件:浏览器从这里开始加载 Vue 应用 -->
    <script type="module" src="/src/main.js"></script>
  </body>
</html>

整个启动链路:index.htmlmain.jscreateApp(App).mount('#app') → Vue 接管 #app 下所有渲染。

4.2 API Key 配置

在 Vite 项目中,环境变量通过 .env.local 文件配置:

复制代码
VITE_DEEPSEEK_API_KEY=sk-xxxxxxxxxxxxx

为什么用 .env.local Vite(基于 Node.js 的构建工具)启动时会自动读取 .env.local 中的变量并注入到前端代码。变量名必须以 VITE_ 开头才能被前端访问------这是 Vite 的安全机制,防止后端敏感变量意外泄露到浏览器端。

在代码中通过 import.meta.env.VITE_DEEPSEEK_API_KEY 读取。这个 API Key 会在构建时被静态替换为实际值,打入最终产物中。

4.3 完整代码实现

下面是 App.vue 的完整代码,同时支持流式和非流式两种模式:

html 复制代码
<template>
  <div class="container">
    <div class="input-area">
      <label>输入:</label>
      <input type="text" class="input" v-model="question" />
      <button @click="update">提交</button>
    </div>

    <div class="output">
      <div class="stream-toggle">
        <label>Streaming</label>
        <input type="checkbox" v-model="stream" />
        <span v-if="stream">(流式模式已开启)</span>
      </div>
      <!-- 核心:AI 回复内容在这里实时渲染 -->
      <div class="content">{{ content }}</div>
    </div>
  </div>
</template>

<script setup>
import { ref } from 'vue'

// ========== 响应式数据 ==========
const question = ref('讲一个关于中国龙的故事')
const stream = ref(false)
const content = ref('')

// ========== 核心方法:发送请求并处理响应 ==========
const update = async () => {
  if (!question.value) return
  content.value = '思考中...'

  const endpoint = 'https://api.deepseek.com/chat/completions'
  const headers = {
    'Content-Type': 'application/json',
    Authorization: `Bearer ${import.meta.env.VITE_DEEPSEEK_API_KEY}`
  }

  const response = await fetch(endpoint, {
    method: 'POST',
    headers,
    body: JSON.stringify({
      model: 'deepseek-v4-flash',
      messages: [{ role: 'user', content: question.value }],
      stream: stream.value
    })
  })

  // ========== 流式输出分支 ==========
   if(stream.value){
    content.value='';
    //大文件上传 慢慢流向 权限+形式 js 原生提供了ReadableStream
    //llm 服务器 streamAble对象 数据流?
    //stream 对象 水流 服务器端流向浏览器
    //response.body 服务器端响应体 二进制流
    console.log(response.body);
    // 水管子,嘬一口 返回 读取器对象
    // await 等token 流来为止
    const reader=response.body?.getReader();
    console.log(reader);
    // 二进制编解码
    let done = false;// 开关变量  data:[DONE]
    let buffer ='';// 截断准备了 上一次JSON.parse() 失败的
    // 不完整的json completion
    const decoder = new TextDecoder();// 二进制流服务的
    while(!done){
      // 嘬一口,嘬到了resolve,没嘬到,继续等
      // data: [Done]
      console.log(await reader?.read());
      const {done:doneReading,value}=await reader?.read();// reader对象 兼容性,老浏览器不一定支持
      done = doneReading;
      // 除了把本轮的value 要处理之外,之前会有的东西一起处理
      const chunkValue = buffer+decoder.decode(value);
      // chunk 一小块 JSON 格式
      // delta 偏移量 一小块一小块 的增量
      // 解析 json 字符串 chices[0].delta.content
      // json 有断开的可能 动态 buffer 不一定有值
      // 如果有,上一个chunk 最后一行,不完整的json
      // console.log(chunkValue);
      buffer='';// 上一次的已经拼到这一次来了,buffer 任务完成了
      // json 字符串 多行数据
      // 一次发送一行,也可能发送多行 llm 计算速度和任务量
      // data: {开始,又有数据来了}
      // // data: {开始,又有数据来了}
      const lines = chunkValue.split('\n')
      // 严谨性 \n 不止一个  也可能有多个
        .filter((line) => line.startsWith('data: '));

        for(const line of lines){
          // data: {"id":"chatcmpl-123","choices":[{"delta":{"content":"你好"}}]}
          const incoming = line.slice(6);// 切掉申明头
          if(incoming ==='[DONE]'){ // 流完成
            // 两种情况,一种是在next Token 就设置了done:true
            // 一种是单独发送一条data: [DONE] 文本流
            done = true;
            break;
          }
          // incoming content json 字符串
          try{
            const data = JSON.parse(incoming);
            const delta = data.choices[0].delta.content;
            if(data && delta){
              content.value += delta;
            }
          }catch(e){
            // data: 一定要加  没有} 结束
            buffer=`data: ${incoming}`;
          }
        }
    }
  }else{
    const data=await response.json();
    //只需要修改数据状态,响应式
    content.value=data.choices[0].message.content;
  }
};
</script>

<style>
.container {
  display: flex;
  flex-direction: column;
  align-items: start;
  height: 100vh;
  font-size: 0.85rem;
  padding: 20px;
}

.input-area {
  display: flex;
  align-items: center;
  gap: 6px;
}

.input {
  width: 260px;
  padding: 4px 8px;
}

button {
  padding: 4px 16px;
  cursor: pointer;
}

.output {
  margin-top: 16px;
  width: 100%;
  max-width: 700px;
}

.stream-toggle {
  margin-bottom: 8px;
}

.content {
  min-height: 200px;
  padding: 12px;
  background: #f5f5f5;
  border-radius: 6px;
  white-space: pre-wrap;
  word-break: break-word;
  line-height: 1.7;
}
</style>

4.4 流式输出核心逻辑拆解

上面代码中 if (stream.value) 分支是整个流式输出的核心,逐步拆解。

第一步:获取读取器

js 复制代码
const reader = response.body?.getReader()

response.body 是 Fetch API 返回的 ReadableStream 对象。getReader() 获取一个读取器,通过它可以逐块(chunk) 读取二进制数据,而不是等所有数据到达后再处理。?. 是可选链操作符------如果 response.bodynullreader 就是 undefined,不会报错(理论上 body 不会为 null,但防御性编程是好习惯)。

第二步:创建解码器

js 复制代码
const decoder = new TextDecoder()

网络传输的原始数据是二进制(Uint8Array)TextDecoder 负责将二进制解码为 UTF-8 字符串。

第三步:缓冲区处理------最容易出错的环节

js 复制代码
const chunkValue = buffer+decoder.decode(value);
......

try{
      const data = JSON.parse(incoming);
      const delta = data.choices[0].delta.content;
      if(data && delta){
        content.value += delta;
      }
    }catch(e){
        // data: 一定要加  没有} 结束
        buffer=`data: ${incoming}`;
    }
}

网络传输是分 chunk 的,一个 chunk 可能在任意字节处被截断:

复制代码
// Chunk 1 到达:  "data: {"choi"
// Chunk 2 到达:  "ces":[{"delta":{"content":"你好"}}]}\n\n"

如果直接对每个 chunk 单独解析 JSON,必然失败。解决方案:

  1. buffer 累积所有已到达但尚未处理完的文本
  2. 如果数据都能完整解析,立即处理
  3. 不完整片段留在 buffer,等下一个 chunk 到来时拼接

decoder.decode(value, { stream: true }) 中的 { stream: true } 参数告诉解码器:"这是一个流,后面还有数据"。当遇到不完整的 UTF-8 多字节字符时(比如中文字符"你"的 3 个字节被切成 2 字节 + 1 字节分布在两个 chunk),解码器会保留内部状态,不会用替换字符 填充------这就保证了中文不会出现乱码。

第四步:解析 SSE 数据格式

DeepSeek API 的流式响应使用 SSE(Server-Sent Events) 协议:

复制代码
data: {"id":"chatcmpl-xxx","choices":[{"delta":{"content":"你"}}],...}

data: {"id":"chatcmpl-xxx","choices":[{"delta":{"content":"好"}}],...}

data: [DONE]
  • 每条数据以 data: 开头(注意后面有一个空格)
  • 数据之间用空行(\n\n)隔开
  • 服务端发送 data: [DONE] 表示流结束

提取增量内容并更新页面

js 复制代码
const data = JSON.parse(incoming);
const delta = parsed.choices?.[0]?.delta?.content
if (data && delta) content.value += delta

每收到一个 token 就拼接到 content 上。由于 contentref() 创建的响应式数据,Vue 会自动把变化同步到页面------用户看到的就是逐字输出的打字机效果。

关键区分 :以上代码使用了可选链 ?. 访问嵌套属性,这是一种安全的写法------如果某层属性不存在,表达式短路返回 undefined 而不是直接报错,代码更健壮。


流式 vs 非流式,取数路径不同:

这是最容易写错的地方,务必注意:

模式 内容所在路径 含义
流式(stream: true) choices[0].delta.content delta 是增量,每次只包含新生成的一个或几个 token
非流式(stream: false) choices[0].message.content message 是完整消息体,包含全部生成的文本

流式模式下如果写成了 message.content,拿到的始终是 undefined,页面不会有任何输出。


五、项目文件清单

复制代码
stream-demo/
├── index.html              # HTML 入口,包含 #app 挂载点
├── vite.config.js          # Vite 配置,引入 @vitejs/plugin-vue
├── .env.local              # 环境变量,存放 API Key(不提交到 Git)
├── package.json            # 项目依赖
└── src/
    ├── main.js             # 应用入口:创建 Vue 实例并挂载
    ├── style.css           # 全局样式
    └── App.vue             # 根组件:包含全部核心逻辑

启动命令:

bash 复制代码
npm install
npm run dev
# 浏览器访问 http://localhost:5173

六、全文总结

本文从一个真实的用户体验痛点出发------LLM 生成太慢,用户等得不耐烦 ------引出了流式输出这一关键技术方案。核心思路是:不再等所有结果生成完再返回,而是生成一个 token 就推送一个 token,让用户感知到持续的反馈。

在技术实现上,我们完成了四件事:

  1. 理解了 SSE 协议 :服务端以 data: {json}\n\n 的格式逐条推送,以 data: [DONE] 结束
  2. 掌握了 ReadableStream API :通过 response.body.getReader() 逐块读取二进制数据
  3. 处理了流式解码边界问题TextDecoder{ stream: true } + 缓冲区 buffer 解决跨 chunk 截断
  4. 用 Vue3 响应式系统实现了页面自动更新,不需要手动操作 DOM

这篇文章的完整代码是一个最小可运行的流式 AI Chatbot 原型,可以在此基础上扩展:美化 UI、添加对话历史、支持多轮对话、增加停止生成按钮等。


七、核心知识点复盘

知识点 一句话总结
流式 vs 非流式 流式:stream: true,逐 token 返回,取 delta.content;非流式:一次性返回,取 message.content
SSE 协议 data: 开头,\n\n 分隔每条数据,[DONE] 表示结束
ReadableStream response.body.getReader() 获取读取器,.read() 返回 { value, done }
TextDecoder 二进制 → 字符串,{ stream: true } 避免多字节字符(中文)截断乱码
缓冲区 buffer 累积跨 chunk 的不完整行,按 \n 分割,pop() 取最后不完整行留在缓冲区
ref() 响应式 数据变化自动更新页面,script 中通过 .value 读写
v-model 双向数据绑定,底层是 :value + @input 的语法糖
Vite 环境变量 变量名必须以 VITE_ 开头,通过 import.meta.env.VITE_XXX 读取

八、常见问题 / 避坑指南

Q1:为什么流式输出时页面会卡顿或抖动?

每个 token 都触发一次 Vue 响应式更新。如果 LLM 吐 token 的速度很快(每秒 50+ 个),频繁的 DOM 更新会造成性能压力。可以用 requestAnimationFrame 做批量更新:

js 复制代码
let pending = ''
let rafId = null

// 收到 delta 时不直接更新 content.value
pending += delta
if (!rafId) {
  rafId = requestAnimationFrame(() => {
    content.value += pending
    pending = ''
    rafId = null
  })
}

这样约每 16ms(一帧)才更新一次 DOM,大幅减少渲染压力。

Q2:网络断了或者用户关闭页面,流式输出会怎样?

会卡死。reader.read() 在服务端异常断连时可能永远不会返回 done: true。生产环境需要:

js 复制代码
const AC = new AbortController()
const timeout = setTimeout(() => AC.abort(), 30000) // 30 秒超时

const response = await fetch(endpoint, {
  // ...
  signal: AC.signal  // 关联 AbortController
})

// 正常结束后清除超时
clearTimeout(timeout)

Q3:delta.contentundefined,页面不出来东西怎么办?

确认三件事:

  • 请求体中的 stream 字段是否为 true
  • SSE 数据解析路径是否正确:流式是 choices[0].delta.content,不是 choices[0].message.content
  • 浏览器 Network 面板中查看响应体,确认服务端确实返回了 SSE 格式的数据

Q4:decoder.decode(value, { stream: true }){ stream: true } 到底有什么用?

中文字符在 UTF-8 中占 3 个字节。如果网络 chunk 刚好把一个中文字符的 3 个字节切成两部分(前 2 个字节在上一个 chunk,后 1 个字节在下一个 chunk),不加 { stream: true } 时,解码器会把前 2 个字节替换成 (替换字符 U+FFFD),导致文字变成乱码。加上这个参数后,解码器会保留内部状态,等下一个 chunk 的字节到了再一起解码出正确的字符。

Q5:.env.local.env 有什么区别?提交到 Git 应该怎么做?

文件 用途 是否提交 Git
.env 所有环境共享的配置(如端口号) ✅ 可以提交
.env.local 本地覆盖,存放敏感信息(API Key) ❌ 绝对不能提交
.env.development 仅开发环境加载 ✅ 可以提交
.env.production 仅生产环境加载 ✅ 可以提交

Vite 加载优先级(后面的覆盖前面的):.env.env.local.env.[mode].local.env.[mode]


本文配套完整代码可在 stream-demo 项目中找到,npm install && npm run dev 即可运行体验。

相关推荐
Jackson__3 小时前
问到你想清楚,这个 skill 专治没想明白就写代码!
前端·github·ai编程
এ慕ོ冬℘゜3 小时前
前端实战:Textarea 实时字数计数输入器(带字数限制+UI优化完整源码)
前端·ui
皓悦编程记3 小时前
【YOLO26系列】基于YOLO26的面部表情检测系统【python源码+Pyqt5界面/WEB+数据集+训练代码】
人工智能·yolo·目标检测·开源·yolo26
远铂3 小时前
BuildAdmin AI内容营销一体化解决方案:整合SEO与GEO优化,解锁AI时代全域流量增长
人工智能·geo·buildadmin
黑马水牛3 小时前
PC+RK3588连板在线调试模型推理
人工智能·经验分享·深度学习
tntxia3 小时前
迁移学习
人工智能
Eloudy3 小时前
基于 RDMA-CM(librdmacm)的聊天程序
网络·人工智能
WUYOUGYLU3 小时前
大模型:人工智能时代的关键引擎
人工智能
2601_950760793 小时前
BCMA:急性髓系白血病免疫治疗的新型可行靶点
人工智能·算法·蛋白
vickycheung34 小时前
启扬I.MX8MPlus开发板如何在NPU上适配Movenet 模型
人工智能·arm 嵌入式开发