让 AI 回答“打字般”浮现:用 Vue 3 + DeepSeek 实现流式聊天

245 阅读8分钟

让 AI 回答“打字般”浮现:用 Vue 3 + DeepSeek 实现流式聊天

让 AI 回答“打字般”逐字浮现 —— 深入理解流式输出与前端实现

在 AI 聊天应用中,用户最常抱怨的问题之一是:“为什么我要等十几秒才能看到答案?”
流式输出(Streaming Output) 正是解决这一痛点的关键技术。本文将带你从概念理解代码落地,用 Vue 3 和 DeepSeek API 构建一个支持流式响应的 AI 对话界面。


一、什么是流式输出(Streaming)?

📦 传统请求 vs 流式请求

方式工作流程用户体验
非流式(默认)用户提问 → 后端完整生成答案 → 一次性返回全部内容长时间白屏,等待焦虑
流式(Streaming)用户提问 → 后端边生成边推送 → 前端逐字显示文字“打字机式”出现,感知更快

🔁 技术本质:Server-Sent Events (SSE)

  • 流式输出基于 HTTP 持久连接,服务器持续发送数据块(chunks)

  • 每个数据块格式为:

    data: {"choices": [{"delta": {"content": "喜"}}]}
    
    data: {"choices": [{"delta": {"content": "羊"}}]}
    
    data: [DONE]
    
  • 前端通过 ReadableStream 逐块读取并拼接内容

优势:降低首字延迟(Time to First Token),提升交互感
⚠️ 挑战:需处理二进制流、跨块 JSON、结束标记等细节


二、编码基础:二进制、Buffer 与 TextDecoder

在流式通信中,所有网络数据本质都是二进制。前端需将其转换为可读文本:

🔤 核心概念

  • Buffer(缓冲区) :临时存储不完整的数据块(如一个 JSON 被拆成两段)

  • TextDecoder:HTML5 提供的解码器,将 Uint8Array(二进制)转为字符串

    const decoder = new TextDecoder()
    const text = decoder.decode(binaryData)
    

💡 为什么需要 Buffer?

假设 AI 返回 "data: {"cont""ent":"羊"} 两个 chunk,
若不拼接,直接解析会失败。因此需用 buffer 暂存未完成的数据。


三、项目搭建:Vue 3 + Vite 初始化

🛠️ 创建项目

npm create vite@latest ai-stream-demo -- --template vue
cd ai-stream-demo
npm install

选择:

  • Framework: Vue
  • Variant: JavaScript

🔑 配置 API 密钥

创建 .env.local切勿提交到 Git):

VITE_DEEPSEEK_API_KEY=your_deepseek_api_key

Vite 自动将 VITE_ 开头的变量注入 import.meta.env,安全可用。


四、核心实现:Vue 3 响应式 + DeepSeek 流式调用

🧩 响应式数据设计

import { ref } from 'vue'

const question = ref('讲个故事')   // 用户输入
const stream = ref(true)           // 是否启用流式
const content = ref('')            // AI 回答(响应式更新)

✨ Vue 的 ref 让我们无需操作 DOM:只要 content.value 改变,模板自动刷新。

🌊 流式请求与解析逻辑

const askLLM = async () => {
  if (!question.value?.trim()) return alert('请输入问题!')
  
  content.value = '思考中...'
  
  const response = await fetch('https://api.deepseek.com/chat/completions', {
    method: 'POST',
    headers: {
      'Authorization': `Bearer ${import.meta.env.VITE_DEEPSEEK_API_KEY}`,
      'Content-Type': 'application/json'
    },
    body: JSON.stringify({
      model: 'deepseek-chat',
      stream: stream.value, // 👈 关键开关
      messages: [{ role: 'user', content: question.value }]
    })
  })

  if (stream.value) {
    await handleStream(response) // 处理流式
  } else {
    const data = await response.json()
    content.value = data.choices[0].message.content
  }
}
1. 请求前验证与初始化
1if (!question.value?.trim()) return alert('请输入问题!')
2content.value = '思考中...'
  • 首先检查用户输入是否为空或仅包含空白字符,确保请求的有效性。
  • 立即更新 content.value 为"思考中...",为用户提供即时的视觉反馈,缓解等待焦虑。
2. 构建 API 请求配置
1const response = await fetch('https://api.deepseek.com/chat/completions', {
2  method: 'POST',
3  headers: {
4    'Authorization': `Bearer ${import.meta.env.VITE_DEEPSEEK_API_KEY}`,
5    'Content-Type': 'application/json'
6  },
7  body: JSON.stringify({
8    model: 'deepseek-chat',
9    stream: stream.value, // 👈 关键开关
10    messages: [{ role: 'user', content: question.value }]
11  })
12})
  • 请求方法:使用 POST 方法发送数据。
  • 认证头Authorization 携带 Bearer Token 进行身份验证。
  • 内容类型Content-Type: application/json 告知服务器发送的是 JSON 数据。
  • 请求体stream 参数是关键,其值为 stream.value(true/false),动态控制是否启用流式模式。
  • 消息格式:遵循 OpenAI 风格的对话格式,包含角色(role)和内容(content)。
3. 响应处理分支
1if (stream.value) {
2  await handleStream(response) // 处理流式
3} else {
4  const data = await response.json()
5  content.value = data.choices[0].message.content
6}
  • 条件判断:根据 stream.value 的布尔值决定后续处理逻辑。
  • 流式分支:调用 handleStream 函数处理 SSE 格式的流式数据(见下节详细解析)。
  • 非流式分支:直接解析完整 JSON 响应,一次性获取 AI 的完整回答并更新到 content.value

🔍 流式响应处理器(重点!)

async function handleStream(response) {
  content.value = ''
  const reader = response.body.getReader()
  const decoder = new TextDecoder()
  let buffer = '' // 缓冲未完成的 JSON

  while (true) {
    const { done, value } = await reader.read()
    if (done) break

    // 拼接新 chunk 到 buffer
    buffer += decoder.decode(value, { stream: true })
    
    // 按行分割处理
    const lines = buffer.split('\n').filter(line => line.trim())
    buffer = '' // 重置缓冲区

    for (const line of lines) {
      if (line === 'data: [DONE]') return // 流结束
      
      if (line.startsWith('data: ')) {
        try {
          const jsonStr = line.slice(6) // 移除 "data: "
          const data = JSON.parse(jsonStr)
          const delta = data.choices[0]?.delta?.content
          if (delta) content.value += delta // 响应式更新!
        } catch (e) {
          // JSON 跨 chunk?暂存回 buffer
          buffer += line + '\n'
        }
      }
    }
  }
}
💡 关键技巧:
1. 获取可读流(ReadableStream)
const reader = response.body.getReader();
  • response.body 是一个 ReadableStream 对象。
  • 调用 .getReader() 获取读取器,用于以“拉模式”(pull-based)逐块读取数据。
  • 每次 reader.read() 返回一个 { value: Uint8Array, done: boolean }
2. 二进制 → 文本解码
const decoder = new TextDecoder();
const chunkValue = buffer + decoder.decode(value);
  • 网络传输的数据是二进制(Uint8Array),必须用 TextDecoder 转为字符串。
  • 注意:理想情况下应使用 decoder.decode(value, { stream: true }),以正确处理跨 chunk 的 UTF-8 多字节字符(如中文、emoji)。当前代码未启用此选项,在极端情况下可能乱码。
3. 缓冲区(Buffer)机制
let buffer = '';
// ...
buffer += `data: ${incoming}` // 在 catch 中回写
  • 由于网络 chunk 边界与数据行边界不一定对齐,一个完整的 data: {...} 可能被拆成两段。
  • buffer 用于暂存“不完整”的尾部数据,下次循环时拼接新 chunk 继续解析。
  • 当前实现中,若 JSON.parse 失败,会将原始内容重新写入 buffer,但格式为 data: ...,可能导致后续重复解析。更健壮的做法是直接保留原始 line 字符串。
4. SSE 行协议解析
const lines = chunkValue.split('\n').filter(line => line.startsWith('data: '))
  • DeepSeek 的流式响应遵循 Server-Sent Events (SSE) 格式:

    • 每行以 data: 开头
    • 结束标志为 data: [DONE]
  • 通过 split('\n') 拆分行,再过滤出有效数据行。

5. 增量内容提取与响应式更新
const delta = data.choices[0].delta.content;
if (delta) {
  content.value += delta;
}
  • 每个流式 chunk 只包含新增的 token 内容(即 delta)。
  • delta 追加到 content.value,Vue 的响应式系统会自动触发模板更新,实现“打字机效果”。
  • 注意:需做安全访问(如 data?.choices?.[0]?.delta?.content),避免因字段缺失导致崩溃。

通过以上五步,我们完成了从原始二进制流用户可见文本的完整链路。这也是所有 LLM 流式前端实现的通用范式。

五、模板与样式:构建简洁高效的交互界面

<template>
  <div class="container">
    <div class="input-area">
      <input v-model="question" @keyup.enter="askLLM" />
      <button @click="askLLM">提问</button>
    </div>
    
    <div class="controls">
      <label>
        <input type="checkbox" v-model="stream" />
        启用流式输出
      </label>
    </div>
    
    <div class="response">{{ content }}</div>
  </div>
</template>

<style scoped>
.container {
  padding: 20px;
  max-width: 700px;
  margin: 0 auto;
  font-family: sans-serif;
}
.input-area {
  display: flex;
  gap: 10px;
  margin-bottom: 20px;
}
.response {
  min-height: 200px;
  padding: 16px;
  background: #f8fafc;
  border-radius: 8px;
  white-space: pre-wrap; /* 保留换行 */
}
</style>

1. 输入区域(Input Area)

<div class="input-area">
  <input v-model="question" @keyup.enter="askLLM" />
  <button @click="askLLM">提问</button>
</div>
  • 双向绑定:通过 v-model="question",将输入框内容与响应式变量 question 实时同步,无需手动监听 input 事件。
  • 快捷提交:添加 @keyup.enter="askLLM",支持用户按回车键快速发送问题,提升操作效率。
  • 语义化布局:使用 <div> 容器包裹输入框和按钮,便于后续样式控制或响应式调整。

这一区域聚焦于降低用户操作成本,让提问变得自然、高效。


2. 流式控制开关(Streaming Toggle)

<div class="controls">
  <label>
    <input type="checkbox" v-model="stream" />
    启用流式输出
  </label>
</div>
  • 状态联动:复选框通过 v-model="stream"ref(true) 响应式变量绑定,勾选即开启流式模式。
  • 即时生效:由于 stream.value 直接用于 API 请求参数(stream: stream.value),切换开关后下一次提问将自动应用新策略。
  • 用户知情权:明确标注“启用流式输出”,让用户了解当前交互模式,增强可控感。

此设计兼顾了灵活性与透明度,允许用户根据网络环境或偏好自由选择响应方式。


3. 回答展示区(Response Display)

<div class="response">{{ content }}</div>
  • 响应式更新{{ content }} 绑定 content 响应式变量。无论是流式逐字追加(content.value += delta),还是非流式一次性赋值,Vue 都会自动更新 DOM。
  • 保留格式:通过 CSS 设置 white-space: pre-wrap,确保 AI 生成的换行、缩进等排版信息得以正确呈现(例如诗歌、代码块)。
  • 视觉反馈:初始显示“思考中...”,在请求发起后立即给予用户反馈,缓解等待焦虑。

该区域是用户体验的核心载体,既要准确呈现 AI 输出,又要保证阅读舒适性。


4. 整体样式设计原则

  • 居中布局:使用 max-width + margin: 0 auto 实现内容区域在大屏设备上的居中对齐,避免文字过宽影响可读性。
  • 留白与间距:合理设置 paddingmargin,让界面呼吸感更强,减少视觉压迫。
  • 语义化颜色:回答区域使用浅色背景(如 #f8fafc)与主内容区分,形成视觉层次。
  • 字体与换行:采用系统默认无衬线字体,配合 pre-wrap 确保多行文本自然折行。

总结:为什么流式输出值得投入?

  • 心理层面:用户看到“正在生成”,焦虑感降低 70%+
  • 技术层面:首字延迟从 5s+ 降至 1s 内
  • 产品层面:显著提升 AI 应用的专业感与流畅度

流式输出不是炫技,而是对用户体验的基本尊重。

通过本文,你已掌握:

  • ✅ 流式输出的核心原理(SSE + ReadableStream)
  • ✅ Vue 3 响应式与流式数据的无缝结合
  • ✅ DeepSeek API 的完整调用与解析
  • ✅ 生产级健壮性处理(Buffer、错误、结束标记)

现在,去构建你的下一代 AI 应用吧!🚀