📊 流式输出实现总结
通过 Server-Sent Events (SSE) 协议实现了 AI 回复的流式输出,整个流程如下:
1️⃣ 发起流式请求 (ChatInput.vue - fetchMessage 函数)
php
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,
}),
});
- 使用原生
fetchAPI 而不是 Axios(因为需要读取流) - 向后端
/api/chat/stream接口发送 POST 请求 - 携带用户消息和会话 ID
2️⃣ 获取可读流
ini
typescript
const reader = response.body?.getReader();
const decoder = new TextDecoder();
- 通过
response.body.getReader()获取一个可读流阅读器 - 创建
TextDecoder用于将二进制数据解码为文本
3️⃣ 初始化 AI 消息占位符
ini
typescript
messageStore.addAIMessage(""); // 添加一个空的 AI 消息
aiMessageIndex = messageStore.message.length - 1; // 记录该消息的索引
- 在消息列表中先添加一个空的 AI 消息对象
- 保存其索引位置,后续用于实时更新内容
4️⃣ 循环读取流数据
arduino
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) 规范:
css
data: {"content": "你"}
data: {"content": "好"}
data: {"content": ","}
data: {"content": "我"}
...
前端解析步骤:
- 检测行是否以
data:开头 - 提取
data:后面的 JSON 字符串 - 解析 JSON 获取 content 字段
- 累加到
fullContent变量中
6️⃣ 实时更新 UI (message.ts - updateAIMessageContent)
scss
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)
ini
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️⃣ 处理残留数据
kotlin
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️⃣ 释放资源
csharp
typescript
finally {
reader.releaseLock(); // 释放阅读器锁
}
🎯 技术要点总结
| 技术点 | 说明 |
|---|---|
| SSE 协议 | 服务器推送事件,单向流式数据传输 |
| ReadableStream | Web API,用于读取流式响应 |
| TextDecoder | 将二进制数据解码为 UTF-8 文本 |
| 缓冲区管理 | 处理不完整的数据片段,确保正确解析 |
| 不可变更新 | 创建新数组/对象而非修改原数据,确保响应式追踪 |
| triggerRef | 手动触发 shallowRef 的响应式更新 |
| Vue 响应式 | 数据变化自动触发视图重新渲染 |
🔄 完整流程图
bash
用户发送消息
↓
调用 fetchMessage()
↓
发起 POST 请求到 /api/chat/stream
↓
获取 ReadableStream Reader
↓
添加空 AI 消息占位符
↓
┌─→ 循环读取数据块 ──────────────┐
│ ↓ │
│ 解码二进制数据为文本 │
│ ↓ │
│ 按 \n 分割行 │
│ ↓ │
│ 解析 "data: {...}" 格式 │
│ ↓ │
│ 累加 content 到 fullContent │
│ ↓ │
│ 调用 updateAIMessageContent │
│ ↓ │
│ 触发 Vue 响应式更新 │
│ ↓ │
│ UI 自动重新渲染 │
│ ↓ │
└── 直到 done === true ←─────────┘
↓
释放 Reader 锁
↓
完成