1. 从“等待”到“流淌”:为什么我们需要流式输出?

如果你最近在捣鼓大语言模型(LLM)的应用,比如自己部署一个类似ChatGPT的聊天界面,一定会遇到一个核心体验问题: 等待感 。传统的API调用,比如一个普通的HTTP请求,是“一问一答”的模式。你发送问题,服务器端的大模型吭哧吭哧开始推理,生成全部文本,直到最后一个字写完,才一次性打包成完整的响应体,通过HTTP返回给你。这个过程,用户面对的是一个空白的输入框或一个旋转的加载图标,除了等待,别无他法。对于生成长文本的场景,这种等待可能是几十秒甚至几分钟,体验极其糟糕。

流式输出(Streaming Output)就是为了解决这个“等待”问题而生的。它的核心思想是“边生成,边发送,边渲染”。服务器不再等大模型生成完整的回答,而是模型每生成一个词元(token),服务器就立刻把这个词元发送给客户端。客户端在收到第一个数据块时,就可以立即开始渲染,让用户看到文字是逐字“流淌”出来的。这种即时反馈极大地提升了交互的流畅度和沉浸感,是当前AI应用前端体验的基石。

那么,这个“流淌”的过程在技术上是如何实现的呢?这背后是一套从前端到后端的完整技术链。在前端,我们主要和三个核心概念打交道: SSE(Server-Sent Events) Fetch API 与 ReadableStream ,以及用于处理二进制数据的 Uint8Array 。很多人可能用过类似 EventSource 的库来对接SSE,但在现代前端框架如 Vue 3 的生态下,尤其是在追求更精细控制和更好性能时,直接使用 Fetch API 来处理流式响应正成为更主流和灵活的选择。本文将从一个实践者的角度,拆解从建立连接到最终渲染的完整流程,并分享在 Vue 3 + Vite 项目中调试这些流式数据时,我踩过的坑和总结的技巧。

2. 协议层基石:理解 SSE 的本质与局限

当我们谈论大模型的流式输出时,SSE 是无法绕开的一个协议。很多人对它有个误解,认为它是一种全新的、复杂的黑科技。其实不然,SSE 本质上是一个轻量级的、基于 HTTP 的协议标准。它的设计非常简洁:在客户端和服务器之间建立一个 单向的、长久的 HTTP 连接 ,服务器可以通过这个连接,持续地向客户端推送数据。

SSE 的工作原理 可以这样理解:

  1. 客户端发起请求 :浏览器或前端应用向一个特定的服务器端点发送一个普通的 HTTP GET 请求。
  2. 服务器保持连接 :服务器收到请求后,并不立即关闭连接,而是将响应的 Content-Type 设置为 text/event-stream ,并保持 TCP 连接打开。
  3. 服务器推送事件 :每当有新的数据需要发送时,服务器就向这个连接写入遵循特定格式的文本数据块。每个数据块称为一个“事件”(Event),格式大致如下:
    event: message
    data: {"content": "这是", "finish_reason": null}
    
    data: {"content": "一段", "finish_reason": null}
    
    event: message
    data: {"content": "流式", "finish_reason": null}
    data: {"content": "文本。", "finish_reason": "stop"}
    
    注意,每个事件以两个换行符 \n\n 结束。 data: 行可以有多行,最终会拼接成一个完整的字符串。
  4. 客户端监听处理 :客户端通过 EventSource API 或类似库监听这个连接。每当收到一个完整的事件块,就会触发一个 message 事件,开发者可以在事件回调中获取并处理 data 字段的内容。

为什么大模型场景常用 SSE?

  • 简单易用 :对于标准的文本流推送,浏览器原生 EventSource 使用起来非常简单。
  • 自动重连 EventSource 内置了连接断开后的自动重连机制。
  • 文本友好 :SSE 设计上就是传输文本的,与大模型返回的 JSON 或纯文本数据天然契合。

然而,SSE 的局限性也很明显,这促使我们寻找更底层的方案:

  • 仅支持 GET 请求 EventSource 只能发起 GET 请求。而大模型的 API 调用,往往需要携带复杂的提示词(prompt)和参数,这些数据放在 GET 请求的 URL 查询字符串中非常笨重且有长度限制,更适合通过 POST 请求的 Body 发送。
  • 协议与实现耦合 EventSource 对响应格式有严格要求( text/event-stream ),并且隐藏了底层连接细节。当我们需要更精细的控制(如自定义请求头、处理非标准格式、手动管理连接生命周期)时,它就力不从心了。
  • 单向通信 :SSE 是服务器向客户端的单向通信。虽然对于单纯的输出流这没问题,但在一些需要双向交互验证的复杂场景下显得不足。

正是这些限制,让我们把目光投向了更基础、更强大的 Web API——Fetch。

3. 核心武器库:解剖 Fetch API、ReadableStream 与 Uint8Array

要突破 SSE 的限制,实现一个功能完备、可控性强的流式请求,我们需要深入下一层:使用 Fetch API 直接处理流式响应体。这是现代前端处理流数据的核心手段。

3.1 Fetch API:不仅仅是“获取”

Fetch API 提供了一个名为 fetch() 的全局方法,用于发起网络请求。它返回一个 Promise,该 Promise 在收到 HTTP 响应头后就会解析(resolve),解析的值是一个 Response 对象。关键在于,这个 Response 对象的 body 属性本身就是一个 ReadableStream

这意味着,我们不必等待整个响应体下载完毕,就可以开始读取数据。这对于大模型动辄几十KB甚至几MB的流式响应来说,是至关重要的性能优化。

一个发起流式 POST 请求的示例:

const response = await fetch('/api/chat/stream', {
  method: 'POST',
  headers: {
    'Content-Type': 'application/json',
  },
  body: JSON.stringify({
    model: 'gpt-3.5-turbo',
    messages: [{ role: 'user', content: '你好,请介绍一下你自己。' }],
    stream: true // 关键:告诉后端需要流式响应
  })
});

// 此时,连接已建立,响应头已收到,但响应体(body)还在传输中
const readableStream = response.body;
// 接下来就可以处理这个流了

3.2 ReadableStream:数据流的抽象

ReadableStream 是 Web Streams API 的一部分,它代表了一个可读的二进制数据流。你可以把它想象成一根水管,数据像水一样从服务器端流过来。我们的任务是安装一个“水龙头”(reader)来接水。

处理 ReadableStream 的基本模式是:

  1. 获取一个读取器( reader )。
  2. 循环调用 reader.read() 方法。这个方法返回一个 Promise,当流中有数据可读时,Promise 解析并返回一个对象,包含两个属性: value (本次读取到的数据块,是一个 Uint8Array ) 和 done (流是否已结束)。
  3. 处理 value ,然后继续读取,直到 done true
const reader = readableStream.getReader();
const decoder = new TextDecoder('utf-8'); // 用于将二进制数据解码成字符串
let accumulatedText = '';

try {
  while (true) {
    const { done, value } = await reader.read();
    if (done) {
      console.log('流式传输结束');
      break;
    }
    // value 是一个 Uint8Array,需要解码
    const chunk = decoder.decode(value, { stream: true });
    accumulatedText += chunk;
    // 这里可以更新UI,例如将 accumulatedText 设置到 Vue 的响应式变量中
    console.log('收到数据块:', chunk);
  }
} catch (error) {
  console.error('读取流失败:', error);
} finally {
  reader.releaseLock(); // 重要:释放读取器锁
}

注意 decoder.decode(value, { stream: true }) 中的 { stream: true } 选项。这告诉解码器,当前解码的数据可能是一个多字节字符(如中文)的一部分,不要抛出错误,等待后续数据到来再一起解码。这是处理流式文本时的一个关键细节,否则你可能会在控制台看到乱码或解码错误。

3.3 Uint8Array:二进制数据的容器

为什么 reader.read() 返回的 value Uint8Array ,而不是直接的字符串?因为网络传输的本质是二进制字节流。 Uint8Array 是一个表示 8 位无符号整数数组的类型化数组,它是 JavaScript 中处理原始二进制数据的高效方式。

服务器发送的每一个数据块,在 TCP 层面都是一串字节。Fetch API 将这些字节原封不动地包装成 Uint8Array 交给我们。这样做的好处是:

  • 保真 :保留了数据的原始格式,无论是纯文本、JSON 片段还是其他二进制数据(如图片碎片),前端都可以按需处理。
  • 高效 :避免了不必要的字符串编码/解码开销,直到我们需要使用文本内容时,才用 TextDecoder 进行解码。

一个常见的坑:数据块的分割与拼接 服务器端在写入流时,并没有义务保证每次写入的数据块恰好是一个完整的逻辑单元(比如一个完整的 JSON 对象或一句话)。它可能因为网络缓冲区、服务器端框架的实现等原因,将一次逻辑上完整的数据拆分成多个 Uint8Array 发送,也可能将多次逻辑上小的数据合并成一个大的 Uint8Array 发送。

例如,后端可能发送了这样的数据:

数据块1: `{"content": "Hello`
数据块2: `, world!", "finish": false}\n\n`

如果你对每个数据块都尝试 JSON.parse() ,那么在数据块1处就会抛出错误,因为它不是一个合法的 JSON。

解决方案是使用“缓冲区”和“分隔符” 。大模型流式接口通常使用 \n\n \n 作为数据块之间的分隔符(即所谓的 “data: ...\n\n” 格式,或简化的 “{...}\n” 格式)。我们的前端逻辑需要将收到的二进制数据解码成字符串后,缓存在一个变量里,然后根据分隔符来分割出完整的逻辑数据块。

const reader = response.body.getReader();
const decoder = new TextDecoder();
let buffer = ''; // 文本缓冲区

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

  // 将二进制数据块解码并追加到缓冲区
  buffer += decoder.decode(value, { stream: true });

  // 按行分割缓冲区
  const lines = buffer.split('\n');
  // 最后一行可能是不完整的,保留在缓冲区中
  buffer = lines.pop() || '';

  for (const line of lines) {
    const trimmedLine = line.trim();
    if (!trimmedLine || trimmedLine === 'data: [DONE]') continue; // 忽略空行和结束标记
    if (trimmedLine.startsWith('data: ')) {
      const jsonStr = trimmedLine.substring(6); // 去掉 "data: " 前缀
      try {
        const data = JSON.parse(jsonStr);
        // 处理真正的数据,如 data.choices[0].delta.content
        console.log('解析后的数据:', data);
      } catch (e) {
        console.warn('解析JSON失败,可能是不完整数据:', jsonStr);
      }
    }
  }
}
// 循环结束后,记得处理缓冲区中可能残留的最后一小段数据
if (buffer.trim()) {
  console.warn('流结束,缓冲区仍有未处理数据:', buffer);
}

这段代码展示了一个健壮的流式数据解析器核心逻辑: 解码 -> 缓冲 -> 按分隔符分割 -> 逐条解析 。这是手动处理大模型流式响应时最需要理解和实现的部分。

4. 在 Vue 3 项目中构建流式对话组件

理解了底层原理,我们就可以在 Vue 3 项目中,构建一个真实的、可复用的流式对话组件。我们将使用 <script setup> 语法和 Composition API,并搭配 Vite 构建工具。

4.1 组件结构与状态设计

首先,我们设计组件的状态。一个典型的聊天界面需要:消息列表、当前用户输入、加载状态。

<!-- ChatStream.vue -->
<script setup>
import { ref, reactive } from 'vue';

// 消息列表,每个消息包含角色和内容
const messages = ref([
  { role: 'assistant', content: '你好!我是AI助手,有什么可以帮您?' }
]);
// 用户输入
const userInput = ref('');
// 是否正在流式接收中
const isLoading = ref(false);
// 当前助手消息的索引(用于追加流式内容)
const currentAssistantMessageIndex = ref(-1);
</script>

<template>
  <div class="chat-container">
    <div class="messages">
      <div v-for="(msg, index) in messages" :key="index" :class="['message', msg.role]">
        <strong>{{ msg.role === 'user' ? '你' : '助手' }}:</strong> {{ msg.content }}
      </div>
      <div v-if="isLoading" class="loading-indicator">思考中...</div>
    </div>
    <div class="input-area">
      <textarea v-model="userInput" @keydown.enter.prevent="sendMessage" :disabled="isLoading"></textarea>
      <button @click="sendMessage" :disabled="isLoading || !userInput.trim()">发送</button>
    </div>
  </div>
</template>

4.2 核心流式请求函数

接下来是核心的 sendMessage 函数。我们将使用 Fetch API 来处理流式响应。

<script setup>
// ... 其他状态定义

const sendMessage = async () => {
  if (!userInput.value.trim() || isLoading.value) return;

  const userMessage = userInput.value.trim();
  userInput.value = ''; // 清空输入框

  // 将用户消息添加到列表
  messages.value.push({ role: 'user', content: userMessage });
  // 为助手创建一个初始为空的消息占位
  messages.value.push({ role: 'assistant', content: '' });
  currentAssistantMessageIndex.value = messages.value.length - 1;
  isLoading.value = true;

  try {
    const response = await fetch('http://your-backend-api/chat/stream', {
      method: 'POST',
      headers: {
        'Content-Type': 'application/json',
        // 如果需要认证,可以在这里添加 Authorization 头
        // 'Authorization': `Bearer ${yourToken}`
      },
      body: JSON.stringify({
        model: 'gpt-3.5-turbo',
        messages: [
          ...messages.value.slice(0, -1).map(m => ({ role: m.role, content: m.content })),
          { role: 'user', content: userMessage }
        ],
        stream: true,
        temperature: 0.7,
      })
    });

    if (!response.ok || !response.body) {
      throw new Error(`网络请求失败: ${response.status}`);
    }

    await handleStreamResponse(response);
  } catch (error) {
    console.error('请求出错:', error);
    // 更新最后一条助手消息为错误信息
    messages.value[currentAssistantMessageIndex.value].content = `抱歉,出错了: ${error.message}`;
  } finally {
    isLoading.value = false;
    currentAssistantMessageIndex.value = -1;
  }
};

const handleStreamResponse = async (response) => {
  const reader = response.body.getReader();
  const decoder = new TextDecoder('utf-8');
  let buffer = '';

  try {
    while (true) {
      const { done, value } = await reader.read();
      if (done) {
        // 流结束,处理缓冲区可能残留的数据(如果有)
        processBuffer(buffer);
        break;
      }
      // 解码并追加到缓冲区
      buffer += decoder.decode(value, { stream: true });
      // 处理缓冲区中的完整行
      buffer = processBuffer(buffer);
    }
  } catch (error) {
    console.error('读取流时出错:', error);
    throw error;
  } finally {
    reader.releaseLock();
  }
};

const processBuffer = (buffer) => {
  const lines = buffer.split('\n');
  // 保留最后一行(可能不完整)作为新的缓冲区
  const newBuffer = lines.pop() || '';

  for (const line of lines) {
    const trimmedLine = line.trim();
    // 忽略空行和特殊事件行
    if (!trimmedLine || trimmedLine.startsWith('event:')) continue;
    
    if (trimmedLine === 'data: [DONE]') {
      // 流式传输结束信号
      return newBuffer;
    }

    if (trimmedLine.startsWith('data: ')) {
      const jsonStr = trimmedLine.substring(6); // 移除 "data: " 前缀
      try {
        const parsed = JSON.parse(jsonStr);
        // 假设后端返回格式类似 OpenAI API: { choices: [{ delta: { content: "..." } }] }
        const contentDelta = parsed.choices?.[0]?.delta?.content;
        if (contentDelta) {
          // 关键:更新 Vue 响应式数据,触发视图更新
          messages.value[currentAssistantMessageIndex.value].content += contentDelta;
        }
        // 也可以处理 finish_reason 等字段
        if (parsed.choices?.[0]?.finish_reason) {
          console.log('生成结束原因:', parsed.choices[0].finish_reason);
        }
      } catch (e) {
        // 忽略解析错误,可能是不完整的 JSON 片段
        console.warn('解析数据行失败,可能数据不完整:', trimmedLine);
      }
    }
  }

  return newBuffer;
};
</script>

这个实现包含了几个关键点:

  1. 错误处理 :对网络请求失败和流读取失败都进行了捕获。
  2. 缓冲区管理 processBuffer 函数负责按 \n 分割并解析数据,正确处理了数据块可能被拆分或合并的情况。
  3. 响应式更新 :通过直接修改 messages.value[currentAssistantMessageIndex.value].content 来追加内容,Vue 的响应式系统会自动检测到变化并更新 DOM,实现文字的逐字打印效果。
  4. 资源清理 :在 finally 块中调用 reader.releaseLock() ,确保读取器被正确释放。

4.3 用户体验优化:添加打字机效果与中断控制

基本的流式展示已经完成,但我们可以做得更好。

打字机效果 :直接追加内容可能太快,可以添加一个简单的动画,模拟逐字打印。我们可以使用 setTimeout requestAnimationFrame 来缓冲。

// 在 processBuffer 函数中,替换直接的内容追加
if (contentDelta) {
  // 改为调用一个打字机函数
  typewriterEffect(contentDelta);
}

const typewriterEffect = (text) => {
  let i = 0;
  const speed = 20; // 每个字符的间隔(毫秒)
  const targetIndex = currentAssistantMessageIndex.value;
  
  const type = () => {
    if (i < text.length && targetIndex === currentAssistantMessageIndex.value) {
      messages.value[targetIndex].content += text.charAt(i);
      i++;
      setTimeout(type, speed);
    }
  };
  type();
};

注意:这个简单实现有个问题,如果流式数据到达很快,会创建很多定时器。更优的方案是将所有到达的 contentDelta 先缓存在一个队列里,由一个统一的定时器消费。

中断控制 :用户可能在生成过程中希望停止。我们需要提供一个“停止”按钮,并能够中止网络请求。

<script setup>
// 新增一个 AbortController 用于中断请求
let abortController = null;

const sendMessage = async () => {
  if (!userInput.value.trim() || isLoading.value) return;
  
  // 创建新的 AbortController
  abortController = new AbortController();
  isLoading.value = true;
  // ... 其他初始化代码

  try {
    const response = await fetch('http://your-backend-api/chat/stream', {
      // ... 其他配置
      signal: abortController.signal // 传入中断信号
    });
    // ... 处理响应
  } catch (error) {
    // 如果是主动中断,错误名称为 'AbortError'
    if (error.name === 'AbortError') {
      console.log('请求被用户中断');
      messages.value[currentAssistantMessageIndex.value].content += '\n\n[已中断]';
    } else {
      // ... 其他错误处理
    }
  } finally {
    // ... 清理工作
    abortController = null;
  }
};

// 停止生成函数
const stopGenerating = () => {
  if (abortController) {
    abortController.abort(); // 触发 AbortError
  }
};
</script>

<template>
  <!-- 在按钮区域添加一个停止按钮 -->
  <div class="input-area">
    <button @click="stopGenerating" v-if="isLoading">停止</button>
    <!-- ... 其他按钮 -->
  </div>
</template>

通过 AbortController ,我们实现了对正在进行的 Fetch 请求的控制,提升了应用的交互性。

5. Vue 3 + Vite 项目中的流式调试实战与避坑指南

在开发过程中,流式相关的 bug 往往比较隐蔽,因为涉及异步数据的分块到达和解析。下面分享我在 Vue 3 + Vite 项目中调试流式应用时总结的一套方法和遇到的典型问题。

5.1 调试工具与技巧

  1. 浏览器开发者工具 - 网络面板

    • 查看请求 :确认你的 POST 请求是否成功发出, Content-Type body 是否正确。
    • 查看响应 :这是 最重要的调试步骤 。找到你的流式请求,点击它,在“响应”(Response)标签页里,你看到的不是最终结果,而是服务器持续推送过来的原始数据流。你可以在这里直观地看到数据格式是否是 data: {...}\n\n ,是否有不该出现的字符,数据块是否完整。
    • 复制响应体 :你可以将看到的原始响应数据复制出来,用于离线分析和单元测试。
  2. 浏览器开发者工具 - 控制台

    • 在你的 handleStreamResponse processBuffer 函数中大量使用 console.log 。打印出原始的 value (Uint8Array),打印出解码后的 buffer ,打印出分割后的每一行 line ,打印出尝试解析的 jsonStr 和解析结果 parsed 。通过日志流水线,你可以精准定位问题发生在哪个环节:是数据没收到?解码出错?分割不对?还是 JSON 格式不符?
  3. 构建一个模拟后端 : 在开发初期,或者后端接口不稳定时,在本地模拟一个流式接口极其有用。你可以用 Node.js 的 http 模块或者 Express 快速搭建一个。

    // mock-server.js (使用 Express)
    import express from 'express';
    const app = express();
    app.use(express.json());
    
    app.post('/api/mock-stream', (req, res) => {
      res.setHeader('Content-Type', 'text/event-stream');
      res.setHeader('Cache-Control', 'no-cache');
      res.setHeader('Connection', 'keep-alive');
    
      const message = "这是一段模拟的流式响应。";
      let index = 0;
    
      const intervalId = setInterval(() => {
        if (index < message.length) {
          const chunk = message.substring(index, index + 1); // 每次发送一个字
          const data = JSON.stringify({ choices: [{ delta: { content: chunk } }] });
          res.write(`data: ${data}\n\n`);
          index++;
        } else {
          res.write('data: [DONE]\n\n');
          clearInterval(intervalId);
          res.end();
        }
      }, 50); // 每50毫秒发送一个字
    });
    
    app.listen(3001, () => console.log('Mock server running on port 3001'));
    

    这样,你就可以完全控制返回的数据格式和速度,前端代码可以独立于真实后端进行开发和调试。

5.2 常见问题与解决方案

问题一:控制台报错 “Unexpected token < in JSON at position 0”

  • 现象 :在 JSON.parse(jsonStr) 时抛出错误,提示 JSON 不合法。
  • 排查
    1. 检查网络面板的响应体。很可能服务器返回的不是你期望的 JSON 流,而是一个 HTML 页面(比如 404 或 500 错误页)。响应体的开头是 <html> <!DOCTYPE>
    2. 检查请求 URL 和代理配置(Vite 的 server.proxy )是否正确,确保请求真正到达了后端流式接口,而不是静态文件服务器。
  • 解决 :修正 API 地址或代理配置。确保后端接口正确设置了 Content-Type: text/event-stream 并返回了正确的流式数据。

问题二:中文显示乱码或出现“�”字符

  • 现象 :接收到的文本中,中文变成了乱码或问号方块。
  • 排查
    1. 检查 TextDecoder 的编码是否设置为 'utf-8' 。这是目前 Web 标准的通用编码。
    2. 关键 :确认 decoder.decode(value, { stream: true }) 中传入了 { stream: true } 选项。没有这个选项,当 Uint8Array 恰好在一个多字节字符(如一个中文字符占3个字节)的中间被截断时,解码会失败并输出替换字符(�)。
    3. 检查服务器端编码是否也是 UTF-8。
  • 解决 :确保正确使用 TextDecoder stream: true 选项。

问题三:数据接收不完整或拼接错误

  • 现象 :UI 上显示的文字缺失、重复,或者 JSON 解析频繁失败。
  • 排查
    1. processBuffer 函数中打印每一行 trimmedLine ,观察数据格式是否严格符合 data: {...} 。有时服务器可能多发了空格、少了换行,或者使用了不同的前缀(如 "data: " "data:" )。
    2. 检查你的缓冲区逻辑。确保 lines.pop() 正确地保留了不完整的最后一行。在流结束后的 finally 块或 done true 时,检查 buffer 变量是否还有未处理的数据。
    3. 服务器可能使用了 \r\n 作为换行符。使用 buffer.split(/\r?\n/) 可以同时兼容 \n \r\n
  • 解决 :根据服务器实际返回的格式,调整数据分割和前缀剥离的逻辑。增强解析器的容错性,比如尝试匹配 trimmedLine.match(/^data:\s*(.+)/) 来提取数据部分。

问题四:Vue 响应式更新不及时或性能问题

  • 现象 :文字不是逐字出现,而是卡顿一下然后大段出现;或者界面在流式接收时变得很卡。
  • 排查
    1. 更新频率过高 :如果每个 token(可能是一个字或一个词)都触发一次 Vue 的响应式更新和 DOM 渲染,对于长文本来说频率太高了。可以尝试“节流”,累积一定数量的字符(比如10个)或固定时间间隔(比如50毫秒)再更新一次 message.content
    2. 使用了深度监视或计算属性 :如果组件中对 messages 数组使用了 watch 且设置了 deep: true ,或者有依赖它的复杂计算属性,每次内容追加都会触发昂贵的计算。
  • 解决
    • 实现一个简单的节流更新:
    let updateBuffer = '';
    let updateTimer = null;
    
    const scheduleUpdate = (delta) => {
      updateBuffer += delta;
      if (!updateTimer) {
        updateTimer = setTimeout(() => {
          messages.value[currentAssistantMessageIndex.value].content += updateBuffer;
          updateBuffer = '';
          updateTimer = null;
        }, 50); // 每50毫秒批量更新一次
      }
    };
    // 在 processBuffer 中,用 scheduleUpdate(contentDelta) 替换直接的内容追加
    
    • 审查组件中的 watch computed ,避免不必要的深度监听。

问题五:在开发热重载(HMR)时流连接异常

  • 现象 :使用 Vite 开发时,每次保存文件触发热更新后,之前的流式连接可能不会自动关闭,导致内存泄漏或错误。
  • 排查 :Vite 的 HMR 会重新执行模块代码,但不会自动清理之前组件实例中创建的 AbortController 或持有的 ReadableStream reader。
  • 解决 :在组件的 onUnmounted 生命周期钩子中执行清理操作。
    <script setup>
    import { onUnmounted } from 'vue';
    // ... 其他代码
    onUnmounted(() => {
      if (abortController) {
        abortController.abort();
      }
      // 如果有 reader,也需要考虑释放锁,但通常 reader 会在请求结束时释放。
      // 更稳妥的做法是在发送新请求前,检查并中断旧请求。
    });
    </script>
    

流式输出的实现,是一个融合了网络协议、数据流处理和前端框架响应的综合课题。从理解 SSE 的局限,到掌握 Fetch + ReadableStream 的底层操作,再到在 Vue 3 中构建出稳定、流畅的用户界面,每一步都需要对细节的精准把控。调试的过程,就是与不稳定的网络、非常规的数据分块和框架的响应式机制不断博弈的过程。希望本文拆解的原理、提供的代码范例和总结的避坑经验,能帮助你顺利地将“流淌”的 AI 智慧,优雅地呈现在你的下一个应用之中。

更多推荐