从 chunk 到对话:手把手教你用 Vue 3 实现不丢字的 LLM 流式输出

114 阅读6分钟

你是不是也遇到过这种情况?

向 AI 提问后,明明开启了流式输出,结果:

  • 回复卡顿半天才蹦出几个字?
  • 中间突然跳过一段内容?
  • 甚至直接报错 Unexpected end of JSON input

别慌——不是 API 的锅,而是你漏掉了缓冲区(Buffer) 这个关键环节。

🌊 为何流式输出离不开“缓冲区”?

当你向 AI(比如 DeepSeek 或 ChatGPT)提问并启用流式输出时,服务器不会等待整段回答写完再一次性发送给你,而是采用边生成边发送的方式。这种机制称为 流式响应(Streaming Response) ,通常使用 Server-Sent Events (SSE) 协议来实现。SSE 消息格式如下:

data: {"delta": {"content": "你"}}
data: {"delta": {"content": "好"}}
data: {"delta": {"content": "!"}}

每条 data: 开头的消息代表一个独立的“消息单元”,以 \n\n\n 结尾表示一条完整消息。


⚠️ 网络传输的不可控性

尽管服务器按行生成数据,但实际的网络传输并不保证按行分割。底层协议如 HTTP/1.1 的 Chunked Transfer EncodingHTTP/2 的流式帧机制 会将整个响应体切割成多个大小不一的二进制块(chunks) 发送。这意味着一个完整的 SSE 行可能会被拆分到多个 chunks 中:

  • 第一个 chunk 可能包含:"data: {"delta": {"cont"
  • 第二个 chunk 包含:"ent": "你好"}}\n"

显然,第一个 chunk 并不是一个完整的 JSON 对象或 SSE 行,直接解析会导致错误。


❌ 直接处理未完成数据的风险

如果收到第一个 chunk 后立即尝试解析:

JSON.parse('{"delta": {"cont') // ❌ 报错!语法错误

这会导致程序崩溃。而如果选择丢弃不完整的数据,那么这些信息就会永久丢失,影响用户体验。


✅ 缓冲区的作用:拼凑完整消息

为了解决上述问题,引入了缓冲区(Buffer) 。缓冲区作为一个临时存储区域,用于收集和重组来自不同 chunks 的数据,直到形成一条完整的 SSE 行。具体步骤如下:

  1. 接收并追加:每次收到一个新的 chunk,先将其解码并追加到缓冲区末尾。
  2. 检查完整性:查看缓冲区内是否包含一条或多条完整的 SSE 行(例如以 \n\n 结尾)。
  3. 处理完整消息:一旦发现完整行,立即将其提取出来进行解析,并更新用户界面。
  4. 保留剩余部分:对于未能形成完整行的部分,继续保留在缓冲区中,等待后续数据补充。
<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <title>HTML5 Buffer</title>
</head>
<body>
    <h1>Buffer</h1>
    <div id="output"></div>
    <script>
        //JS 二进制、数组缓存
        // html5 编码对象
        const encoder = new TextEncoder();  //创建一个默认使用 UTF-8 编码的编码器。
        console.log(encoder);
        const myBuffer = encoder.encode("你好 HTML5");      //用 TextEncoder 将字符串 "你好 Buffer" 编码为 UTF-8 格式的 Uint8Array
        console.log(myBuffer);

        // 数组缓存12字节
        // 创建一个缓冲区
        const buffer = new ArrayBuffer(12);     //原始的二进制数据缓冲区(ArrayBuffer),大小为 12 字节。
        // 创建一个视图(View)来操作这个缓冲区
        const view = new Uint8Array(buffer);    //创建一个 Uint8Array 视图(view),用来以 无符号 8 位整数(即字节) 的方式读写 buffer

        for(let i = 0; i < myBuffer.length;i++) {
            // console.log(myBuffer[i]);
            view[i] = myBuffer[i];
        }

        const decoder = new TextDecoder();
        const originalText = decoder.decode(buffer);
        console.log(originalText)
        const outputDiv = document.getElementById('output');
        outputDiv.innerHTML = `
            完整数据:[${view}]<br>
            第一个字节:${view[0]}<br>
            缓冲区的字节长度:${buffer.byteLength}<br>
            原来的文本: ${originalText}
        `
    </script>
</body>
</html>

image.png

这样,既避免了解析失败,也防止了任何数据丢失。


🔧 浏览器中的“字节 ↔ 文本”桥梁:TextEncoder 与 TextDecoder

虽然我们操作的是字符串,但网络传输的是字节。现代浏览器提供了原生 API 来打通这两个世界:

  • TextEncoder:将 JavaScript 字符串编码为 UTF-8 格式的 Uint8Array
  • TextDecoder:将接收到的 Uint8Array 解码回可读字符串。

例如,字符串 "你好 HTML5"TextEncoder 编码后变为 Uint8Array,通过 TextDecoder 再还原为原始文本。


🌐 实现流式对话的关键步骤

在 Vue 或其他前端框架中实现 LLM 流式交互,典型流程如下:

  1. 使用 fetch() 发起请求,获取 response.body(即 ReadableStream<Uint8Array>)。
  2. 创建 TextDecoder 实例。
  3. 监听流的 read() 事件,逐块读取 Uint8Array
  4. 使用 decoder.decode(chunk, { stream: true }) 将其转换为字符串。
  5. 将解码后的文本追加到行缓冲区
  6. 循环检查缓冲区是否存在完整的 SSE 行,若有,则解析并更新 UI。

🚨 关键提醒:不要直接对每个 chunk 做 JSON.parse()!必须先通过缓冲区重组完整消息。


当 AI “逐字”回应你时,背后是一套严谨的数据拼装机制: 二进制流 → TextDecoder → 缓冲区 → 行解析 → UI 更新

正是 TextDecoder 与缓冲策略的协同工作,让碎片化的网络传输最终呈现出流畅自然的对话体验。理解这一链路是构建健壮流式前端的第一步。

动手打造 AI 的“逐字输出”体验:基于 Vue 3 实现流式对话界面

现在,我们将从零开始搭建一个基于 Vue 3 + DeepSeek API 的流式对话应用。整个过程分为五步:定义响应式状态、封装请求逻辑、处理非流式回退、实现核心流式解析,以及构建交互 UI。


1️⃣ 响应式状态:让数据驱动视图

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

const question = ref('讲一个喜羊羊和灰太狼的故事,200字')
const stream = ref(true)   // 启用流式输出
const content = ref('')    // 实时展示模型回复
</script>

为什么用 ref ?

得益于 ref 的响应式机制,content 的每一次微小变更(哪怕只是一个字符)都会被 Vue 精准捕获并驱动 DOM 更新——这正是流式逐字渲染得以实现的关键。


2️⃣ 核心请求函数:askLLM

const askLLM = async () => {
  if (!question.value.trim()) 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 (!response.ok) throw new Error('API 请求失败')
}

关键实践

  • 使用 .env 文件管理敏感密钥(如 VITE_DEEPSEEK_API_KEY);
  • 在发起请求前将回复内容设为“思考中...”,可立即向用户提供反馈,有效提升交互的感知响应速度。
  • 通过 stream 字段控制是否启用流式模式。

3️⃣ 非流式模式:简单但体验滞后

if (!stream.value) {
  const data = await response.json()
  content.value = data.choices[0].message.content
}

传统一次性返回看似省事,实则让用户陷入无反馈的等待黑洞;流式输出则把 AI 的推理过程可视化,让交互更有节奏、更有人味。


4️⃣ 流式模式:真正的魔法所在 ✨

if (stream.value) {
  content.value = ''
  const reader = response.body?.getReader()
  const decoder = new TextDecoder('utf-8')
  let buffer = '' // 用于拼接跨 chunk 的不完整行

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

      // 将新 chunk 解码并拼接到缓冲区
      buffer += decoder.decode(value, { stream: true })

      // 按行分割,只处理以 "data: " 开头的有效消息
      const lines = buffer.split('\n').filter(line => line.startsWith('data: '))
      let remaining = ''

      for (const line of lines) {
        const payload = line.slice(6) // 去掉 "data: "

        if (payload === '[DONE]') {
          reader.releaseLock()
          return
        }

        try {
          const parsed = JSON.parse(payload)
          const delta = parsed.choices?.[0]?.delta?.content
          if (delta) content.value += delta
        } catch (err) {
          // 解析失败?说明这条 data 行被截断了
          // 把它留到下一轮和后续 chunk 拼接
          remaining = line
          break // 后续行可能也不完整,暂停处理
        }
      }

      // 更新缓冲区:保留未处理完的部分
      buffer = remaining
    }
  } finally {
    reader?.releaseLock()
  }
}

核心机制解析

  • TextDecoder({ stream: true }) :正确处理 UTF-8 多字节字符的跨 chunk 拆分;
  • 行级缓冲(buffer :应对网络分包导致的 SSE 行断裂;
  • [DONE] 终止信号:DeepSeek 流结束的标志;
  • 错误隔离:仅跳过当前不完整行,不影响已解析内容;
  • releaseLock() :确保流读取完成后释放资源。

💡 为什么不能直接 JSON.parse 每个 chunk?
因为一个 data: {...} 可能横跨多个二进制块。只有通过缓冲+按行重组,才能安全解析。


5️⃣ 模板与交互:让界面“活”起来

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

    <div class="output">
      <label>
        <input type="checkbox" v-model="stream" />
        启用流式输出
      </label>
      <div class="response">{{ content }}</div>
    </div>
  </div>
</template>
  • v-model 实现双向绑定,无需手动监听 input 事件;
  • 切换 stream 开关可对比两种模式的体验差异。

完整代码

<script setup>

import { ref } from 'vue'

const question = ref('讲一个喜洋洋和灰太狼的故事,不低于200字')
const stream = ref(true)
const content = ref("") // 单向绑定  主要的

// 调用LLM
const askLLM = async () => { 
  // question 可以省.value  getter
  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()
  console.log(value,doneReading);
  done = doneReading;
  // chunk 内容快 包含多行data:有多少行不知道
  // data:{} 能不能传完也不知道
  const chunkValue = buffer + decoder.decode(value);
  console.log(chunkValue);
  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){
      // JSON.parse 解析失败 手动加上
      buffer += `data : ${incoming}`
    }
  }
 }

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

真正的价值不在于“能流”,而在于“为何流”:流式输出通过模拟思考节奏,消解了机器的冰冷感,将 AI 从工具升格为对话者。这份魔法,如今已在你手中。


💬 最后的话

每一个缓缓浮现的字符,都是 ReadableStreamTextDecoder 在幕后默契配合的结果,而缓冲策略则默默守护着数据的完整。这不仅是工程实现,更是对“等待”这一人类体验的尊重——技术的意义,终归落在人心之上
现在,你不仅能复现它,更能诠释它的价值。