“AI 回答像打字一样出现?”——用 Vue 3 实现流式输出对话界面

170 阅读7分钟

在 AI 应用爆发的今天,前端工程师不再只是“切图仔”——我们也要和 LLM(大语言模型)打交道。本文将带你用 Vue 3 + Vite 快速搭建一个支持流式响应(Streaming)的 AI 对话 Demo,并深入理解其中的核心机制:响应式数据 + Fetch 流处理。

场景说明

我们要实现的功能很简单:

  • 用户输入一个问题(如“讲一个喜羊羊和灰太狼的故事,20字”)
  • 点击“提交”,调用 DeepSeek 的 Chat API
  • 如果开启 Streaming 开关,AI 回复会逐字显示(像打字一样)
  • 如果关闭,则等待完整响应后一次性显示

这正是 ChatGPT、Claude、DeepSeek 等主流 AI 产品的交互方式。

技术栈选择

  • 框架:Vue 3(组合式 API + <script setup>

  • 构建工具:Vite(极速启动,无需配置)

  • 核心能力:

    • ref:管理响应式状态(问题、回复内容、开关)
    • fetch + response.body.getReader():处理流式响应
    • TextDecoder:将二进制流解码为文本
    • v-model:双向绑定表单与数据

注意:我们只用 ref,不引入 reactivecomputed 等复杂概念,保持极简。


初始化项目

npm init vite
vue cd ai-demo
npm install

完整代码(App.vue)

先看最终成果,把以下代码复制到你的 src/App.vue 中即可运行(记得配置 .env.local):

<script setup>
import { ref } from 'vue'
const question = ref('讲一个喜洋洋和灰太狼的故事,20字')
const stream = ref(true)
const content = ref("")

const askLLM = async () => { 
  if (!question.value) {
    console.log('question 不能为空');
    return 
  }

  content.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(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;
    const chunkValue=buffer+decoder.decode(value);
    buffer='';
    const lines=chunkValue.split('\n').filter(line=>line.startsWith('data: '));
    for(const line of lines){
    const incoming=line.slice(6);
    if(incoming==='[DONE]'){
      done=true;
      break;
    }
    try{
      const data=JSON.parse(incoming);
      const delta=data.choices[0].delta.content;
      if(delta){
        content.value+=delta;
      }
    }catch(err){
      buffer+=`data: ${incoming}`
    console.log(buffer);
    }
    }
  }else{
  const data=await response.json();
  console.log(data);
  content.value=data.choices[0].message.content;
  }
  
}
</script>

<template>
  <div class="container">
    <div>
      <label>输入:</label>
      <input class="input" v-model="question"/>
      <button @click="askLLM">提交</button>
    </div>
    <div class="output">
      <div>
        <label>Streaming</label>
        <input type="checkbox" v-model="stream" />
        <div>{{content}}</div>
      </div>
    </div>
  </div>
</template>

<style scoped>
* {
  margin: 0;
  padding: 0;
}
.container {
  display: flex;
  flex-direction: column;
  align-items: start;
  justify-content: start;
  height: 100vh;
  font-size: 0.85rem;
}
.input {
  width: 200px;
}
button {
  padding: 0 10px;
  margin-left: 6px;
}
.output {
  margin-top: 10px;
  min-height: 300px;
  width: 100%;
  text-align: left;
}
</style>

同时,在项目根目录创建 .env.local 文件:

VITE_DEEPSEEK_API_KEY=你的 DeepSeek API 密钥

代码逐行解析

第一步:定义响应式状态

const question = ref('讲一个喜洋洋和灰太狼的故事,20字')
const stream = ref(true)
const content = ref("")
  • question:用户输入的问题,默认值便于测试。
  • stream:布尔值,控制是否启用流式输出。
  • content:AI 的回复内容,初始为空。

这三个变量都是通过 ref() 创建的响应式对象。在模板中使用 {{ content }} 时,Vue 会自动追踪其变化,并在 .value 改变时更新 DOM。


第二步:表单绑定与事件触发

 <div class="container">
    <div>
      <label>输入:</label>
      <input class="input" v-model="question"/>
      <button @click="askLLM">提交</button>
    </div>
    <div class="output">
      <div>
        <label>Streaming</label>
        <input type="checkbox" v-model="stream" />
        <div>{{content}}</div>
      </div>
    </div>
  </div>
  • v-model="question" 实现输入框与 question 的双向绑定。
  • v-model="stream" 将复选框状态同步到 stream
  • @click="askLLM" 在点击按钮时调用 askLLM 函数。

这是 Vue 最核心的声明式编程思想:你只描述“数据是什么”,不关心“如何更新 DOM”


第三步:调用 LLM 接口

 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
        }
      ]
    })
  })

请求体遵循 OpenAI 兼容格式:

  • model:指定使用 deepseek-chat 模型
  • stream:决定是否开启流式响应
  • messages:对话历史,这里只传用户当前消息

Vite 会在构建时将 import.meta.env.VITE_DEEPSEEK_API_KEY 替换为 .env.local 中的值。


第四步:处理流式响应(核心逻辑)

  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;
    const chunkValue=buffer+decoder.decode(value);
    buffer='';
    const lines=chunkValue.split('\n').filter(line=>line.startsWith('data: '));
    for(const line of lines){
    const incoming=line.slice(6);
    if(incoming==='[DONE]'){
      done=true;
      break;
    }
    try{
      const data=JSON.parse(incoming);
      const delta=data.choices[0].delta.content;
      if(delta){
        content.value+=delta;
      }
    }catch(err){
      buffer+=`data: ${incoming}`
    console.log(buffer);
    }
    }
  }

🔍 逐行详细解析

1. 判断是否启用流式模式

if(stream.value){
  • 如果用户勾选了 “Streaming” 复选框(stream.value === true),则进入流式处理逻辑。

2. 清空上一次的回复内容

content.value="";//把上次的生成清空
  • content 响应式变量置为空字符串,确保新回复不会追加到旧内容后面。
  • 注释明确说明这是“把上次的生成清空”,体现良好的用户体验设计。

3. 获取可读流(ReadableStream)

const reader=response.body?.getReader();
  • response.body 是一个 ReadableStream 对象(前提是服务器支持流式响应)。
  • 调用 .getReader() 返回一个 reader 实例,用于逐步读取流中的数据块(chunks)。
  • 使用可选链 ?. 防止 response.body 为 null 时崩溃。

💡 DeepSeek 的 Chat Completions API 在 stream: true 时会返回 text/event-stream 格式的响应体,符合 SSE(Server-Sent Events)规范。


4. 创建文本解码器

const decoder=new TextDecoder();
  • 流中的每个 chunk 是 Uint8Array 类型的二进制数据
  • TextDecoder 是浏览器内置 API,用于将二进制数据解码为 UTF-8 字符串。
  • 默认使用 UTF-8 编码,无需额外配置。

5. 初始化控制变量

let done=false;//流是否结束 没有
let buffer='';
  • done:标记流是否已读取完毕,初始为 false
  • buffer:用于暂存跨 chunk 的残缺数据(例如一个 JSON 被拆到两个网络包中)。

6. 循环读取流直到结束

while(!done){
  • 只要流未结束(done === false),就持续读取数据。

7. 读取下一个数据块

const {value,done: doneReading}=await reader?.read()
  • reader.read() 返回一个 Promise,解析后得到 { value, done }
  • 使用解构赋值,并将 done 重命名为 doneReading 避免与外层 done 冲突。
  • value 是当前 chunk 的二进制数据(Uint8Array)。
  • doneReadingtrue 表示服务器已发送完所有数据。

8. 更新流结束状态

done=doneReading;
  • 将本次读取结果的 done 状态同步到循环控制变量。

9. 解码并拼接缓冲区

const chunkValue=buffer+decoder.decode(value);//文本字符串
  • decoder.decode(value) 将当前二进制 chunk 转为字符串。
  • 与上一轮残留的 buffer 拼接,形成完整的文本片段 chunkValue
  • 这一步是处理网络分帧导致的 JSON 截断问题的关键。

10. 清空缓冲区

buffer='';
  • 已将 buffer 内容合并到 chunkValue,故清空以备下一轮使用。

11. 按行分割并过滤有效数据行

const lines=chunkValue.split('\n').filter(line=>line.startsWith('data: '));
  • SSE 协议规定:每条消息以 data: 开头,行末以 \n 结束。
  • split('\n') 将整个 chunk 按换行符切分为多行。
  • filter(...) 仅保留以 "data: " 开头的行(排除空行或注释行)。

示例原始流数据:

image.png

从图上我们可以更清晰地观察到chunkValue的数据情况帮助理解,并且这也可以清晰地看出我们真正想要的数据在.choices[0].delta.content中


12. 遍历每一行有效数据

for(const line of lines){
  • 对每一条 data: ... 行进行处理。

13. 去除前缀 data:

const incoming=line.slice(6);//干掉数据标志
  • line 形如 "data: {...}"slice(6) 去掉前 6 个字符(即 "data: "),得到纯 payload。
  • 注释“干掉数据标志”生动表达了这一步的目的。

14. 检查流结束信号

if(incoming==='[DONE]'){
  done=true;
  break;
}
  • DeepSeek(及 OpenAI 兼容 API)在流结束时会发送一行 data: [DONE]
  • 此时应立即终止循环,避免后续无效解析。

15. 尝试解析 JSON 并提取内容

try{
  const data=JSON.parse(incoming);
  const delta=data.choices[0].delta.content;
  if(delta){
    content.value+=delta;
  }
}
  • JSON.parse(incoming) 将字符串转为 JS 对象。
  • data.choices[0].delta.content 中提取新增的文本片段(token)。
  • delta 存在(非 null/undefined),就追加到 content.value
  • Vue 的响应式系统会自动更新模板中的 {{content}},实现“打字机”效果。

⚠️ 注意:某些 chunk 可能只包含 {"choices": [{"delta": {}}]}(无 content),此时 delta 为 undefined,不追加。


16. 处理 JSON 解析失败(关键容错)

catch(err){
  // JSON.parse 解析失败 
  buffer+=`data: ${incoming}`
  console.log(buffer);
}
  • 为什么可能失败?
    因为网络传输是分块的,一个完整的 JSON 可能被拆到两个 chunk 中。例如:

    • Chunk 1: data: {"choices": [{"delta": {"content": "你好
    • Chunk 2: 世界"}}]}
    • 第一块单独 JSON.parse 会报错。
  • 如何解决?
    将当前无法解析的 incoming 重新加上 data: 前缀,存入 buffer,留到下一轮循环与新 chunk 合并后再试。

  • console.log(buffer) 便于调试,观察残片累积情况。

这是流式解析中最容易出错但最关键的容错机制,你的代码已正确实现。


流式处理的核心要点

步骤目的技术点
获取 reader读取流式响应response.body.getReader()
解码二进制转为可读文本TextDecoder
缓冲残片处理跨 chunk JSONbuffer 变量
按行分割提取有效消息split('\n') + filter
去前缀解析获取 token 内容slice(6) + JSON.parse
实时更新实现打字效果content.value += delta
容错回退防止解析中断try...catch + buffer 回填

总结

通过这个小项目,你掌握了:

  • Vue 3 的 refv-model 实现响应式表单
  • 使用 fetch 调用大模型 API
  • 利用 response.body.getReader() 处理流式响应

前端 × AI 的时代已经到来,而你,刚刚完成了第一个作品。