📊 流式输出实现总结

18 阅读3分钟

📊 流式输出实现总结

通过 Server-Sent Events (SSE)  协议实现了 AI 回复的流式输出,整个流程如下:

1️⃣ 发起流式请求 (ChatInput.vue - fetchMessage 函数)

typescript
const response = await fetch("/api/chat/stream", {
  method: "POST",
  headers: {
    "Content-Type": "application/json",
    Authorization: `Bearer ${token}`,
  },
  body: JSON.stringify({
    message,
    session_id: sessionId,
  }),
});
  • 使用原生 fetch API 而不是 Axios(因为需要读取流)
  • 向后端 /api/chat/stream 接口发送 POST 请求
  • 携带用户消息和会话 ID

2️⃣ 获取可读流

typescript
const reader = response.body?.getReader();
const decoder = new TextDecoder();
  • 通过 response.body.getReader() 获取一个可读流阅读器
  • 创建 TextDecoder 用于将二进制数据解码为文本

3️⃣ 初始化 AI 消息占位符

typescript
messageStore.addAIMessage("");  // 添加一个空的 AI 消息
aiMessageIndex = messageStore.message.length - 1;  // 记录该消息的索引
  • 在消息列表中先添加一个空的 AI 消息对象
  • 保存其索引位置,后续用于实时更新内容

4️⃣ 循环读取流数据

typescript
while (true) {
  const { done, value } = await reader.read();
  if (done) break;  // 流结束时退出循环
  
  const text = decoder.decode(value, { stream: true });
  buffer += text;
  
  // 按换行符分割处理
  const lines = buffer.split("\n"); //分割成数组
  buffer = lines.pop() || "";  // 保留不完整的最后一行,pop()删除数组最后一个元素,并且返回他
  
  for (let line of lines) {
    if (line.startsWith("data: ")) {
      const jsonStr = line.slice(6); //字符串方法,从字符串索引6开始切割到末尾
      const data = JSON.parse(jsonStr); //jsonStr是{"content": "..."}这样的结构
      if (data.content) {
        fullContent += data.content;
        messageStore.updateAIMessageContent(aiMessageIndex, fullContent);// 更新状态仓库数组
      }
    }
  }
}

核心逻辑:

  • 使用 while(true) 循环持续读取数据块
  • 每次读取后检查 done 标志,判断是否完成
  • 将二进制数据解码为文本并追加到缓冲区
  • 按换行符 \n 分割,处理完整的 SSE 数据行
  • 保留最后一行(可能是不完整的数据片段)到下一次循环

5️⃣ 解析 SSE 格式数据

后端返回的数据格式遵循 SSE (Server-Sent Events)  规范:

data: {"content": "你"}
data: {"content": "好"}
data: {"content": ","}
data: {"content": "我"}
...

前端解析步骤:

  1. 检测行是否以 data: 开头
  2. 提取 data: 后面的 JSON 字符串
  3. 解析 JSON 获取 content 字段
  4. 累加到 fullContent 变量中

6️⃣ 实时更新 UI (message.ts - updateAIMessageContent)

typescript
function updateAIMessageContent(index: number, content: string) {
  const msgs = [...message.value];  // 创建新数组(不可变更新)
  if (msgs[index]) {
    msgs[index] = { ...msgs[index], content };  // 创建新对象
    message.value = msgs;
    triggerRef(message);  // 手动触发响应式更新
  }
}

关键点:

  • 采用不可变数据更新策略:创建新数组和新对象
  • 使用 triggerRef 确保 shallowRef 的响应式更新被触发
  • Vue 检测到变化后自动重新渲染组件

7️⃣ 视图渲染 (MessageItem.vue)

vue
<div v-else class="m-6 px-4 py-6">
  <div class="max-w-4xl mx-auto">
    <AIMarkdown :content="content" />
  </div>
</div>
  • AI 消息通过 AIMarkdown 组件渲染
  • 支持 Markdown 格式的实时显示
  • 每次 content 更新时,组件自动重新渲染

8️⃣ 处理残留数据

typescript
if (buffer.trim().startsWith("data: ")) {
  try {
    const data = JSON.parse(buffer.slice(6));
    if (data.content) {
      fullContent += data.content;
      messageStore.updateAIMessageContent(aiMessageIndex, fullContent);
    }
  } catch (e) {
    console.debug("解析最后一行失败");
  }
}
  • 循环结束后,检查缓冲区是否还有未处理的数据
  • 防止最后一行数据丢失

9️⃣ 释放资源

typescript
finally {
  reader.releaseLock();  // 释放阅读器锁
}

🎯 技术要点总结

技术点说明
SSE 协议服务器推送事件,单向流式数据传输
ReadableStreamWeb API,用于读取流式响应
TextDecoder将二进制数据解码为 UTF-8 文本
缓冲区管理处理不完整的数据片段,确保正确解析
不可变更新创建新数组/对象而非修改原数据,确保响应式追踪
triggerRef手动触发 shallowRef 的响应式更新
Vue 响应式数据变化自动触发视图重新渲染

🔄 完整流程图

用户发送消息
    ↓
调用 fetchMessage()
    ↓
发起 POST 请求到 /api/chat/stream
    ↓
获取 ReadableStream Reader
    ↓
添加空 AI 消息占位符
    ↓
┌─→ 循环读取数据块 ──────────────┐
│   ↓                            │
│   解码二进制数据为文本          │
│   ↓                            │
│   按 \n 分割行                  │
│   ↓                            │
│   解析 "data: {...}" 格式      │
│   ↓                            │
│   累加 content 到 fullContent  │
│   ↓                            │
│   调用 updateAIMessageContent  │
│   ↓                            │
│   触发 Vue 响应式更新          │
│   ↓                            │
│   UI 自动重新渲染               │
│   ↓                            │
└── 直到 done === true ←─────────┘
    ↓
释放 Reader 锁
    ↓
完成