普通视图

发现新文章,点击刷新页面。
昨天 — 2025年12月7日首页

深入浅出AI流式输出:从原理到Vue实战实现

作者 栀秋666
2025年12月6日 22:48

深入浅出AI流式输出:从原理到Vue实战实现


在当前大模型(LLM)广泛应用的背景下,用户对“响应速度”的感知越来越敏感。当我们向AI提问时,传统“等待完整结果返回”的模式常常带来数秒甚至更久的空白界面——虽然实际网络请求可能只花了1秒,但用户的焦虑感却成倍放大。

流式输出(Streaming Output) 正是破解这一痛点的关键技术。它让AI像“边想边说”一样,把生成的内容逐字推送出来,极大提升了交互的流畅性与真实感。

本文将以 Vue 3 + Vite 为例,手把手带你实现一个完整的 AI 流式对话功能,并深入剖析底层原理,助你在自己的项目中快速落地。


一、为什么需要流式输出?对比两种交互模式

❌ 传统模式:全量响应 → 用户体验差

// 非流式请求
const response = await fetch('/api/llm', { ... });
const data = await response.json();
content.value = data.content; // 一次性赋值
  • ✅ 实现简单
  • ❌ 用户需等待全部内容生成完毕
  • ❌ 长文本场景下容易出现“卡死”错觉
  • ❌ 视觉反馈延迟高,降低信任感

✅ 流式模式:增量返回 → 用户体验飞跃

// 流式请求处理
const reader = response.body.getReader();
while (true) {
  const { value, done } = await reader.read();
  if (done) break;
  const text = decoder.decode(value);
  content.value += parseChunk(text); // 实时拼接
}
  • ✅ 1~2秒内即可看到首个字符
  • ✅ 文字“打字机”效果增强沉浸感
  • ✅ 客户端可边接收边渲染,资源利用率更高
  • ✅ 更符合人类对话节奏,提升产品质感

📌 关键洞察:用户并不关心“是否真的快了”,而是关心“有没有动静”。流式输出的本质是用即时反馈对抗等待焦虑


二、技术基石:HTTP 分块传输与浏览器流 API

2.1 协议层支持:Transfer-Encoding: chunked

流式输出依赖于 HTTP/1.1 的 分块传输编码(Chunked Transfer Encoding)

  • 服务器无需知道总长度,可以一边生成数据一边发送
  • 数据被分割为多个“块”(chunk),每个块独立传输
  • 响应头中包含 Transfer-Encoding: chunked
  • 最终以一个空块(0\r\n\r\n)表示结束

这是实现流式响应的基础协议机制。

2.2 前端核心 API:ReadableStream 与 TextDecoder

现代浏览器提供了强大的原生流处理能力:

API 作用
response.body 返回一个 ReadableStream<Uint8Array>
getReader() 获取流读取器,用于逐块读取
TextDecoder 将二进制数据解码为字符串(UTF-8)

这些 API 不需要额外安装库,开箱即用,非常适合轻量级集成。


三、Vue 实战:一步步构建 AI 流式对话系统

我们使用 Vue 3 + Vite 构建一个极简的 Demo,接入 DeepSeek API 实现流式问答。

3.1 初始化项目

npm create vue@latest stream-demo
cd stream-demo
npm install

选择默认配置即可,确保启用 Vue 3 和 JavaScript 支持。

3.2 创建 .env 文件(安全存储密钥)

# .env
VITE_DEEPSEEK_API_KEY=sk-xxxxxxxxxxxxxxxxxxxxxx

⚠️ 注意:不要提交 .env 到 Git!添加到 .gitignore


3.3 核心逻辑:App.vue

(1)响应式状态定义
<script setup>
import { ref } from 'vue'

// 用户输入的问题
const question = ref('讲一个喜羊羊和灰太狼的故事,20字')

// 是否启用流式输出
const stream = ref(true)

// 存储并展示模型返回内容
const content = ref('')
</script>

利用 ref 实现响应式更新,每次 content.value += delta 都会触发视图重绘。


(2)发送请求 & 处理流式响应
const askLLM = async () => {
  if (!question.value.trim()) {
    alert('请输入问题')
    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'
  }

  try {
    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) {
      throw new Error(`请求失败:${response.status}`)
    }

    // 区分流式 / 非流式
    if (stream.value) {
      await handleStreamResponse(response)
    } else {
      const data = await response.json()
      content.value = data.choices[0].message.content
    }
  } catch (error) {
    content.value = `❌ 请求失败:${error.message}`
    console.error(error)
  }
}

(3)流式处理核心函数
async function handleStreamResponse(response) {
  const reader = response.body.getReader()
  const decoder = new TextDecoder()
  let buffer = '' // 缓存不完整 JSON 片段
  let done = false

  while (!done) {
    const { value, done: readerDone } = await reader.read()
    done = readerDone

    // 解码二进制数据
    const chunk = buffer + decoder.decode(value, { stream: true })
    buffer = ''

    // 按行处理 SSE 格式数据
    const lines = chunk.split('\n').filter(line => line.startsWith('data: '))

    for (const line of lines) {
      const raw = line.slice(6).trim() // 去除 "data: "

      if (raw === '[DONE]') {
        done = true
        break
      }

      try {
        const json = JSON.parse(raw)
        const delta = json.choices[0]?.delta?.content
        if (delta) {
          content.value += delta // 实时追加
        }
      } catch (e) {
        // 可能是不完整的 JSON,缓存起来等下一帧
        buffer = 'data:' + raw
      }
    }
  }

  reader.releaseLock()
}

🔍 重点说明:

  • decoder.decode(value, { stream: true }):启用流式解码,防止多字节字符被截断
  • buffer 缓存机制:解决因 TCP 分包导致的 JSON 被拆分问题
  • slice(6) 提取有效数据,过滤 data: 前缀
  • JSON.parsetry-catch 是必须的,避免解析失败中断整个流程

3.4 模板结构:简洁直观的 UI

<template>
  <div class="container">
    <!-- 输入区 -->
    <div class="input-group">
      <label>问题:</label>
      <input v-model="question" placeholder="请输入你想问的问题..." />
      <button @click="askLLM">发送</button>
    </div>

    <!-- 控制开关 -->
    <div class="control">
      <label>
        <input type="checkbox" v-model="stream" />
        启用流式输出
      </label>
    </div>

    <!-- 输出区 -->
    <div class="output-box">
      <h3>AI 回答:</h3>
      <p>{{ content }}</p>
    </div>
  </div>
</template>

3.5 样式美化(可选)

<style scoped>
.container {
  max-width: 800px;
  margin: 40px auto;
  padding: 20px;
  font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
}

.input-group {
  display: flex;
  gap: 10px;
  align-items: center;
  margin-bottom: 20px;
}

input[type="text"] {
  flex: 1;
  padding: 10px;
  border: 1px solid #ddd;
  border-radius: 6px;
  font-size: 14px;
}

button {
  padding: 10px 20px;
  background: #007aff;
  color: white;
  border: none;
  border-radius: 6px;
  cursor: pointer;
}

button:hover {
  background: #005edc;
}

.control {
  margin: 16px 0;
  font-size: 14px;
}

.output-box {
  border: 1px solid #eee;
  padding: 20px;
  border-radius: 8px;
  min-height: 200px;
  background-color: #f9f9fb;
  white-space: pre-wrap;
  word-wrap: break-word;
}

.output-box p {
  line-height: 1.6;
  color: #333;
}
</style>

四、常见问题与优化策略

✅ Q1:为什么有时会出现乱码或解析错误?

原因:网络传输过程中,一个完整的 JSON 字符串可能被分成两个 Uint8Array 发送,导致单次 decode 得到的是半截字符串。

解决方案

  • 使用 buffer 缓存未完成的数据
  • 在下次读取时拼接后重新尝试解析
  • 设置 stream: true 参数给 TextDecoder.decode()

✅ Q2:如何防止频繁 DOM 更新影响性能?

虽然 Vue 的响应式系统很高效,但高频字符串拼接仍可能导致重排。

优化建议

// 使用 requestAnimationFrame 限制渲染频率
let pending = false
function scheduleUpdate(delta) {
  if (!pending) {
    requestAnimationFrame(() => {
      content.value += delta
      pending = false
    })
  }
}

适用于超高速输出场景(如代码生成)。


✅ Q3:如何支持取消请求?

引入 AbortController 即可:

const controller = new AbortController()

// 在 fetch 中传入 signal
const response = await fetch(url, {
  signal: controller.signal,
  // ...
})

// 提供取消按钮
const cancelRequest = () => controller.abort()

五、应用场景拓展

流式输出不仅限于 LLM 对话,还可用于:

场景 应用示例
🤖 聊天机器人 实时对话、客服系统
📄 文档生成 报告、合同、文案自动生成
💾 文件上传下载 显示进度条
📊 日志监控 实时日志流展示
🧮 数据分析 大批量计算结果逐步呈现

只要涉及“长时间任务 + 渐进式结果”,都可以考虑使用流式思想优化体验。


六、总结:掌握流式输出,让你的 AI 应用脱颖而出

流式输出不是炫技,而是一种以人为本的设计思维。它把“等待”变成了“参与”,让用户感受到系统的“思考过程”,从而建立更强的信任感。

通过本文的学习,你应该已经掌握了:

✅ 如何发起带 stream=true 的 LLM 请求
✅ 如何使用 ReadableStream 处理分块数据
✅ 如何用 TextDecoder 安全解析二进制流
✅ 如何在 Vue 中实现实时渲染
✅ 如何应对流式中的边界情况(如 JSON 截断)

❤️ 写在最后

随着 AIGC 的普及,前端工程师的角色正在从“页面搭建者”转向“智能交互设计师”。掌握流式输出这类核心技术,不仅能做出更好用的产品,也能在未来的技术浪潮中占据先机。

动手是最好的学习方式。 打开编辑器,运行一遍这个 Demo,亲自感受那种“文字跃然屏上”的丝滑体验吧!

❌
❌