【AI】多窗口流式问答设计(前后端)
问题
在 AI 问答页面中,如果使用 SSE 或 WebSocket 实现大模型流式输出,同时支持多个聊天窗口(类似 ChatGPT 左侧会话列表),核心问题是:
每个聊天窗口必须拥有独立的会话上下文、独立的流式请求、独立的消息状态和独立的取消控制。
例如:
- 用户在窗口 A 提问,AI 正在流式生成回答。
- 用户切换到窗口 B,继续发起新的提问。
- 窗口 A 的回答仍然可以在后台继续生成。
- 用户再次切回窗口 A,可以看到已经生成的内容,并继续接收后续内容。
- 即使窗口 A 和窗口 B 同时生成,也不能出现消息串线、上下文混乱、状态覆盖等问题。
这里最重要的设计原则是:
将聊天会话(Conversation)、消息(Message)和流式任务(Stream Task)进行解耦,不能将流式请求绑定到当前页面或当前选中的聊天窗口。
下面从前端、后端、流式通信、状态管理和异常处理五个方面设计一套可落地的方案。
一、整体架构设计
技术栈:
- 前端:Vue 3 + Pinia
- 通信:SSE。
- 后端:FastAPI
- 消息存储:PostgreSQL
- 缓存与任务状态:Redis
- 消息队列:Kafka

整体可以理解为:前端管理多个独立会话,后端管理多个独立生成任务,二者通过唯一标识建立关联。
二、前端设计:每个聊天窗口独立管理
2.1 数据结构设计
不是只维护一个全局的 messages 数组,而应该按照 conversationId 维护消息列表。
数据结构:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25
| interface Conversation { id: string; title: string; messages: Message[]; activeTaskId?: string; status: 'idle' | 'streaming' | 'error'; updatedAt: number; }
interface Message { id: string; conversationId: string; role: 'user' | 'assistant'; content: string; status: 'pending' | 'streaming' | 'completed' | 'error'; createdAt: number; }
interface StreamTask { taskId: string; conversationId: string; assistantMessageId: string; controller: AbortController; status: 'running' | 'completed' | 'cancelled' | 'error'; }
|
核心是三个 ID:
| ID |
作用 |
| conversationId |
标识聊天会话 |
| messageId |
标识具体消息 |
| taskId |
标识一次大模型生成任务 |
三者不能混用。
例如:
1 2 3 4 5 6 7 8 9
| Conversation A ├── Message 1(用户问题) ├── Message 2(AI回答) └── Task A1
Conversation B ├── Message 3(用户问题) ├── Message 4(AI回答) └── Task B1
|
即使两个窗口同时生成,后端也能够准确识别每个任务属于哪个会话。
2.2 前端状态管理
Vue 3 + Pinia:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36
| export const useChatStore = defineStore('chat', { state: () => ({ conversations: {} as Record<string, Conversation>, activeConversationId: '', tasks: {} as Record<string, StreamTask> }),
getters: { currentConversation(state) { return state.conversations[state.activeConversationId]; } },
actions: { switchConversation(id: string) { this.activeConversationId = id; },
updateMessage( conversationId: string, messageId: string, delta: string ) { const conversation = this.conversations[conversationId]; if (!conversation) return;
const message = conversation.messages.find( item => item.id === messageId );
if (message) { message.content += delta; } } } });
|
关键点:updateMessage() 必须通过 conversationId + messageId 定位消息,而不是直接修改当前页面显示的消息。否则,当用户切换窗口时,可能会将 A 窗口的 Token 错误追加到 B 窗口。
2.3 流式请求独立管理
解决窗口切换互不影响的核心。
常见错误实现:全局只有一个控制器
1
| let controller = new AbortController();
|
如果 A 窗口发起请求,B 窗口再发起请求,后者可能覆盖前者的控制器,导致无法正确管理 A 的请求。
正确做法:
每次生成请求创建独立的 AbortController,并使用 taskId 保存。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128
| async function sendMessage( conversationId: string, content: string ) { const store = useChatStore();
const userMessageId = crypto.randomUUID(); const assistantMessageId = crypto.randomUUID(); const taskId = crypto.randomUUID();
const conversation = store.conversations[conversationId];
conversation.messages.push({ id: userMessageId, conversationId, role: 'user', content, status: 'completed', createdAt: Date.now() });
conversation.messages.push({ id: assistantMessageId, conversationId, role: 'assistant', content: '', status: 'streaming', createdAt: Date.now() });
const controller = new AbortController();
store.tasks[taskId] = { taskId, conversationId, assistantMessageId, controller, status: 'running' };
conversation.activeTaskId = taskId; conversation.status = 'streaming';
try { const response = await fetch('/api/chat/stream', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ conversationId, taskId, userMessageId, assistantMessageId, content }), signal: controller.signal });
if (!response.ok || !response.body) { throw new Error('流式请求失败'); }
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 events = buffer.split('\n\n'); buffer = events.pop() ?? '';
for (const event of events) { const dataLine = event .split('\n') .find(line => line.startsWith('data:'));
if (!dataLine) continue;
const data = JSON.parse( dataLine.slice(5).trim() );
if (data.type === 'delta') { store.updateMessage( conversationId, assistantMessageId, data.content ); }
if (data.type === 'done') { store.tasks[taskId].status = 'completed'; } } }
} catch (error: any) { if (error.name === 'AbortError') { console.log('任务已取消'); } else { console.error(error); store.tasks[taskId].status = 'error'; } } finally { const task = store.tasks[taskId];
if (task) { const current = store.conversations[conversationId];
if (current?.activeTaskId === taskId) { current.activeTaskId = undefined; current.status = 'idle'; } } } }
|
注意点:
AbortController 必须属于单次任务,不能多个窗口共享。
- SSE 回调必须根据固定的
conversationId 和 messageId 更新数据。
- 切换窗口只修改
activeConversationId,不应该取消原来的流式请求。
- 流式请求管理逻辑应该放在 Pinia Store 或独立的 ChatService 中,而不是聊天组件内部。
- 生产环境需要处理 SSE 的
id、event、data、多行数据和不完整数据块,不能简单依赖 split('\n\n')。
2.4 切换窗口时应该做什么?
假设当前有三个窗口:

切换窗口时只需要:
1 2 3
| function switchChat(conversationId: string) { chatStore.activeConversationId = conversationId; }
|
不需要:
- 重新发起大模型请求。
- 重新创建 AbortController。
- 取消之前的请求。
- 清空其他窗口的消息。
- 将其他窗口的消息复制到当前窗口。
注意:不要在 Vue 组件的 onUnmounted 或 React 组件的 cleanup 中,无条件取消流式请求。
因为窗口切换可能导致组件卸载,而请求应该继续在 Store 或 ChatService 中运行。
三、后端设计:每个会话独立维护上下文
前端隔离只是第一步,后端也必须隔离。
如果后端只维护一个全局对话历史,例如:
那么多个用户、多个窗口的上下文就可能相互污染。
应该以 conversation_id 作为会话隔离的基本单位,以 task_id 作为生成任务的管理单位。
3.1 数据库设计
三张核心表。
1. 会话表 conversation
| 字段 |
说明 |
| id |
会话 ID |
| user_id |
所属用户 |
| title |
会话标题 |
| status |
会话状态 |
| created_at |
创建时间 |
| updated_at |
更新时间 |
2. 消息表 message
| 字段 |
说明 |
| id |
消息 ID |
| conversation_id |
所属会话 |
| user_id |
用户 ID |
| role |
user / assistant |
| content |
消息内容 |
| status |
消息状态 |
| sequence |
消息顺序 |
| created_at |
创建时间 |
3. 任务表 generation_task
| 字段 |
说明 |
| id |
任务 ID |
| conversation_id |
所属会话 |
| user_id |
用户 ID |
| assistant_message_id |
AI 消息 ID |
| status |
running/completed/cancelled/error |
| cancel_requested |
是否请求取消 |
| started_at |
开始时间 |
| finished_at |
结束时间 |
数据库关系:
1 2 3 4 5 6 7 8 9 10
| User ├── Conversation A │ ├── Message 1 │ ├── Message 2 │ └── Task A1 │ └── Conversation B ├── Message 3 ├── Message 4 └── Task B1
|
需要建立以下索引:
1 2 3 4 5 6 7 8
| CREATE INDEX idx_conversation_user ON conversation(user_id);
CREATE INDEX idx_message_conversation_sequence ON message(conversation_id, sequence);
CREATE INDEX idx_task_conversation_status ON generation_task(conversation_id, status);
|
3.2 后端 API 设计
建议将会话管理和流式任务管理拆开。
| API |
方法 |
功能 |
/api/conversations |
POST |
创建会话 |
/api/conversations |
GET |
获取会话列表 |
/api/conversations/{id} |
GET |
获取会话详情 |
/api/conversations/{id}/messages |
GET |
获取历史消息 |
/api/chat/stream |
POST |
创建流式生成任务 |
/api/tasks/{id} |
GET |
获取任务状态 |
/api/tasks/{id}/cancel |
POST |
取消生成任务 |
/api/tasks/{id}/events |
GET |
重新订阅流式事件 |
其中,/api/chat/stream 接收:
1 2 3 4 5 6 7
| { "conversationId": "conv_001", "taskId": "task_001", "userMessageId": "msg_001", "assistantMessageId": "msg_002", "content": "请介绍一下RAG架构" }
|
后端不能完全信任前端传入的 ID,需要校验当前登录用户是否有权访问对应会话,并校验消息和任务 ID 的归属关系。
3.3 后端流式生成逻辑
核心逻辑:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105
| from fastapi import FastAPI from fastapi.responses import StreamingResponse from pydantic import BaseModel import asyncio import json
app = FastAPI()
class ChatRequest(BaseModel): conversationId: str taskId: str userMessageId: str assistantMessageId: str content: str
task_registry = {}
async def mock_llm_stream(prompt: str): for token in ["RAG", "是一种", "检索增强生成", "技术。"]: await asyncio.sleep(0.5) yield token
@app.post("/api/chat/stream") async def chat_stream(request: ChatRequest):
conversation_id = request.conversationId task_id = request.taskId
history = await get_history(conversation_id)
await save_user_message( conversation_id, request.userMessageId, request.content )
stop_event = asyncio.Event() task_registry[task_id] = stop_event
async def generate():
answer = ""
try: prompt = build_prompt(history, request.content)
async for token in mock_llm_stream(prompt):
if stop_event.is_set(): break
answer += token
event = { "type": "delta", "taskId": task_id, "conversationId": conversation_id, "messageId": request.assistantMessageId, "content": token }
yield f"data: {json.dumps(event, ensure_ascii=False)}\n\n"
if stop_event.is_set(): status = "cancelled" else: status = "completed"
await save_assistant_message( conversation_id, request.assistantMessageId, answer, status )
yield f"data: {json.dumps({'type': 'done', 'taskId': task_id, 'status': status})}\n\n"
except Exception as e: await mark_task_error(task_id, str(e))
event = { "type": "error", "taskId": task_id, "message": str(e) }
yield f"data: {json.dumps(event)}\n\n"
finally: task_registry.pop(task_id, None)
return StreamingResponse( generate(), media_type="text/event-stream", headers={ "Cache-Control": "no-cache", "X-Accel-Buffering": "no" } )
|
这里的 get_history、save_user_message、save_assistant_message、mark_task_error、build_prompt 是需要接入实际数据库和业务服务的函数。
四、流式任务与窗口切换的关系
这里需要区分三个不同的操作。
| 用户操作 |
前端行为 |
后端行为 |
| 切换聊天窗口 |
只切换显示内容 |
不做任何处理 |
| 关闭聊天窗口组件 |
不取消任务 |
继续生成 |
| 点击停止生成 |
取消对应 taskId |
停止对应模型任务 |
| 刷新浏览器 |
恢复会话和任务状态 |
查询任务状态、恢复事件 |
| 删除会话 |
清理当前会话状态 |
取消相关任务并删除或归档数据 |
4.1 如何实现独立取消?
不能使用一个全局的停止标记。
错误实现:
正确实现:
1 2 3 4
| task_registry = { "task_001": asyncio.Event(), "task_002": asyncio.Event() }
|
取消指定任务:
1 2 3 4 5 6 7 8 9 10 11 12
| @app.post("/api/tasks/{task_id}/cancel") async def cancel_task(task_id: str):
stop_event = task_registry.get(task_id)
if stop_event: stop_event.set()
return { "taskId": task_id, "status": "cancel_requested" }
|
实际生产环境还需要:
- 校验任务所属用户。
- 更新数据库中的任务状态。
- 通知实际执行模型推理的 Worker。
- 取消模型 SDK 的生成请求,而不只是停止向前端发送 Token。
- 处理任务已经完成、已经取消等幂等情况。
前端停止某个窗口的任务:
1 2 3 4 5 6 7 8 9 10 11 12 13 14
| function stopGeneration(taskId: string) { const store = useChatStore(); const task = store.tasks[taskId];
if (!task) return;
task.controller.abort();
fetch(`/api/tasks/${taskId}/cancel`, { method: 'POST' }); }
|
注意:abort() 只能保证客户端停止接收数据,不代表后端模型推理一定停止。因此需要同时发送取消指令。
4.2 用户切换窗口后,如何保证流式内容不丢失?
假设:
1 2 3 4 5
| 16:00:00 窗口 A 开始生成 16:00:02 窗口 A 已生成 100 Token 16:00:03 用户切换到窗口 B 16:00:05 窗口 A 已生成 200 Token 16:00:08 用户切回窗口 A
|
由于 A 的请求仍然在后台执行,Store 会持续接收 Token 并更新 A 的消息。
因此用户切回 A 时,可以直接看到最新的 200 Token 或更多内容。
但还有一个容易忽略的问题:浏览器刷新、网络断开、电脑休眠等情况可能导致 SSE 连接中断。
建议增加事件持久化与断点续传机制:
1 2 3 4 5 6 7 8 9 10 11
| LLM Token 输出 | v Stream Event | v Redis Stream / 数据库 | +------> SSE 推送给前端 | +------> 持久化最终消息
|
每个事件增加递增序号:
1 2 3 4 5 6 7 8
| { "eventId": 105, "taskId": "task_001", "conversationId": "conv_001", "messageId": "msg_002", "type": "delta", "content": "检索增强生成" }
|
前端记录最后收到的 eventId。
重连时:
1
| GET /api/tasks/task_001/events?after=105
|
后端从 105 之后继续推送事件。
如果使用原生 SSE 的 EventSource,还可以使用 Last-Event-ID;但你的场景使用 POST 提交问题时,推荐继续使用 fetch + ReadableStream,通过查询参数或请求头实现断点续传。
注意:事件重放需要去重,前端不能重复追加已经接收过的 Token。 如果事件记录已过期,还应支持通过消息快照恢复完整内容。
五、生产环境进一步优化
如果只是一个用户、一个后端实例,前面这套设计基本可以满足需求。
但如果要构建真正的企业级 AI 问答系统,我建议再考虑以下几个问题。
5.1 多实例部署时的任务管理
假设后端有三个实例:

不能再依赖 Python 的全局 task_registry,因为任务可能由服务 A 创建,却由服务 B 接收到取消请求。
推荐:
- Redis:存储任务状态、任务与 Worker 的映射。
- Redis Pub/Sub:跨实例通知取消信号。
- Redis Streams:保存可重放的流式事件。
- Kafka:在任务规模较大、需要可靠异步处理和削峰时使用。
其中 Redis Pub/Sub 不保证消息持久化,取消通知如果要求可靠,需要配合任务状态轮询、Redis Streams 或其他可靠通知机制。
5.2 同一个窗口是否允许同时生成多个回答?
建议区分两种情况:
| 场景 |
推荐策略 |
| 不同会话同时生成 |
允许 |
| 同一会话同时生成 |
默认禁止 |
| 同一会话取消旧任务后重新生成 |
允许 |
| 不同用户同时生成 |
允许,受资源配额限制 |
为什么同一个会话建议默认禁止?
因为两个问题可能同时读取相同的历史上下文,最后生成结果的顺序不确定,容易造成上下文顺序混乱。
如果确实需要支持同一会话并发生成,则必须引入消息版本、上下文快照和明确的提交顺序。
通过conversation_id + generation_lock实现同一会话的生成互斥。
也可以使用数据库乐观锁或 Redis 分布式锁,但必须设置锁超时、续租和任务完成后的释放机制,防止死锁。
5.3 前端性能优化
如果多个窗口同时生成,Token 更新频率可能非常高。
不建议每收到一个 Token 就触发整个聊天页面重新渲染。
推荐:
- 按 conversationId 分别管理消息状态。
- 按 messageId 更新对应消息。
- 对 UI 更新进行 30~60ms 的批量刷新。
- 对长消息使用虚拟列表。
- 历史消息分页加载。
- 已完成的会话不再参与流式更新。
- 对 Markdown 渲染进行增量优化,避免每个 Token 都完整解析整个文档。
特别是包含代码块、表格、数学公式的 AI 回答,Markdown 全量渲染可能成为性能瓶颈。
六、完整流程
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37
| sequenceDiagram participant U as 用户 participant F as 前端 Store participant A as 后端 API participant R as Redis participant L as LLM
U->>F: 在窗口 A 提问 F->>F: 创建独立 task_A F->>A: 提交 conversation_A A->>R: 注册 task_A A->>L: 调用模型 A
L-->>A: Token A A-->>F: SSE delta A F->>F: 更新消息 A
U->>F: 切换到窗口 B Note over F,A: task_A 继续运行
U->>F: 在窗口 B 提问 F->>F: 创建独立 task_B F->>A: 提交 conversation_B A->>R: 注册 task_B A->>L: 调用模型 B
par A 持续生成 L-->>A: Token A A-->>F: SSE delta A and B 持续生成 L-->>A: Token B A-->>F: SSE delta B end
F->>F: 分别更新 A、B 的消息 U->>F: 切回窗口 A F-->>U: 展示 A 最新回答
|