从一个"对话历史散落一地"的混乱项目,到一套清晰的会话管理架构
做智能体前端,如果你只做一个单次对话的 Demo,你永远不会意识到会话管理有多重要。直到有一天,你的用户开始同时跟 AI 聊三个不同的话题------工作周报、代码调试、旅行规划------然后他问你:"我怎么回到昨天那个关于数据库优化的对话?它去哪了?"
这个时候你就会发现,没有会话管理的聊天界面,就像没有文件系统的电脑:你只能记住当前窗口的内容,关了就是丢了,切了就找不回来了。
去年我接手一个智能体项目的时候,用户投诉最多的不是 AI 回答质量,而是"我之前的对话找不到了""不小心刷新页面,所有历史都没了"。我们花了两个月时间,把会话管理的整套机制从零搭了起来。这篇文章,我就把创建会话、切换会话、删除会话以及背后的一整套架构,彻底讲透。
一、会话管理到底在管理什么?
先给"会话"下个定义。在一个智能体应用中,一次会话(Session / Conversation)就是用户与 AI 之间从开始到结束的一段连续交互过程。它包含:
- 唯一的会话 ID
- 会话标题(通常由第一条消息或 AI 自动生成)
- 消息列表(用户消息和 AI 回复)
- 创建时间、更新时间
- 元数据(模型参数、用户偏好等)
会话管理的核心功能就三件事:创建、切换、删除。听起来简单,但实现起来要考虑的东西其实不少:
- 前端状态怎么存(Zustand / Redux / Context)
- 后端怎么持久化(数据库 + API)
- 会话列表怎么展示(侧边栏、时间分组)
- 切换会话时怎么恢复消息历史和滚动位置
- 删除会话时怎么同步更新 UI 和后端
下面是会话管理的整体架构图:

下面我们逐个拆解。
二、数据结构设计:前端 Store + 后端 API
2.1 前端状态管理(Zustand)
我推荐用 Zustand 管理会话状态。它比 Redux 简单太多,TypeScript 支持也很好。
typescript
// stores/conversationStore.ts
import { create } from 'zustand';
import { persist } from 'zustand/middleware';
export interface Conversation {
id: string;
title: string;
createdAt: number;
updatedAt: number;
messageCount: number;
lastMessage?: string;
}
export interface Message {
id: string;
role: 'user' | 'assistant';
content: string;
timestamp: number;
}
interface ConversationState {
conversations: Conversation[];
currentConversationId: string | null;
messages: Message[];
isLoading: boolean;
// 会话列表操作
fetchConversations: () => Promise<void>;
createConversation: () => Promise<string>;
switchConversation: (id: string) => Promise<void>;
deleteConversation: (id: string) => Promise<void>;
// 消息操作
addMessage: (message: Message) => void;
updateLastMessage: (content: string) => void;
clearMessages: () => void;
}
export const useConversationStore = create<ConversationState>()(
persist(
(set, get) => ({
conversations: [],
currentConversationId: null,
messages: [],
isLoading: false,
fetchConversations: async () => {
set({ isLoading: true });
const response = await fetch('/api/conversations');
const data = await response.json();
set({ conversations: data, isLoading: false });
},
createConversation: async () => {
const response = await fetch('/api/conversations', { method: 'POST' });
const newConv = await response.json();
set((state) => ({
conversations: [newConv, ...state.conversations],
currentConversationId: newConv.id,
messages: [],
}));
return newConv.id;
},
switchConversation: async (id: string) => {
set({ isLoading: true });
const response = await fetch(`/api/conversations/${id}/messages`);
const messages = await response.json();
set({
currentConversationId: id,
messages,
isLoading: false,
});
},
deleteConversation: async (id: string) => {
await fetch(`/api/conversations/${id}`, { method: 'DELETE' });
set((state) => {
const newConversations = state.conversations.filter(c => c.id !== id);
let newCurrentId = state.currentConversationId;
if (state.currentConversationId === id) {
newCurrentId = newConversations[0]?.id || null;
}
return {
conversations: newConversations,
currentConversationId: newCurrentId,
messages: newCurrentId ? state.messages : [],
};
});
// 如果切换到新会话,需要加载其消息
if (get().currentConversationId && get().currentConversationId !== id) {
await get().switchConversation(get().currentConversationId!);
}
},
addMessage: (message) => {
set((state) => ({
messages: [...state.messages, message],
}));
// 同时更新会话列表中的最后一条消息和更新时间
set((state) => ({
conversations: state.conversations.map(conv =>
conv.id === state.currentConversationId
? { ...conv, lastMessage: message.content.slice(0, 50), updatedAt: Date.now() }
: conv
),
}));
},
updateLastMessage: (content) => {
set((state) => {
const lastIndex = state.messages.length - 1;
if (lastIndex < 0) return state;
const newMessages = [...state.messages];
newMessages[lastIndex] = { ...newMessages[lastIndex], content };
return { messages: newMessages };
});
},
clearMessages: () => {
set({ messages: [] });
},
}),
{
name: 'conversation-storage', // localStorage key
partialize: (state) => ({ currentConversationId: state.currentConversationId }), // 只持久化当前会话ID
}
)
);
2.2 后端数据表设计(简化版)
sql
-- 会话表
CREATE TABLE conversations (
id VARCHAR(36) PRIMARY KEY,
user_id VARCHAR(36) NOT NULL,
title VARCHAR(255),
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
INDEX idx_user_id (user_id)
);
-- 消息表
CREATE TABLE messages (
id VARCHAR(36) PRIMARY KEY,
conversation_id VARCHAR(36) NOT NULL,
role ENUM('user', 'assistant') NOT NULL,
content TEXT NOT NULL,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
INDEX idx_conversation_id (conversation_id),
FOREIGN KEY (conversation_id) REFERENCES conversations(id) ON DELETE CASCADE
);
三、侧边栏会话列表组件
会话列表通常放在左侧边栏。支持新建、切换、删除、时间分组。
tsx
// components/Sidebar.tsx
import { useEffect } from 'react';
import { useConversationStore } from '@/stores/conversationStore';
import { Plus, Trash2, MessageSquare } from 'lucide-react';
import { formatDistanceToNow } from 'date-fns';
import { zhCN } from 'date-fns/locale';
function groupConversationsByDate(conversations: Conversation[]) {
const now = Date.now();
const today = [];
const yesterday = [];
const lastWeek = [];
const older = [];
for (const conv of conversations) {
const diff = now - conv.updatedAt;
if (diff < 24 * 3600 * 1000) today.push(conv);
else if (diff < 48 * 3600 * 1000) yesterday.push(conv);
else if (diff < 7 * 24 * 3600 * 1000) lastWeek.push(conv);
else older.push(conv);
}
return { today, yesterday, lastWeek, older };
}
export function Sidebar() {
const { conversations, currentConversationId, fetchConversations, createConversation, deleteConversation, switchConversation } = useConversationStore();
useEffect(() => {
fetchConversations();
}, []);
const handleNewChat = async () => {
await createConversation();
};
const grouped = groupConversationsByDate(conversations);
return (
<div className="w-64 bg-gray-100 dark:bg-gray-900 h-screen flex flex-col">
<div className="p-4">
<button
onClick={handleNewChat}
className="w-full flex items-center justify-center gap-2 bg-blue-500 text-white rounded-lg px-3 py-2 hover:bg-blue-600"
>
<Plus className="w-4 h-4" />
新建对话
</button>
</div>
<div className="flex-1 overflow-y-auto px-2 space-y-4">
{Object.entries(grouped).map(([key, group]) => {
if (group.length === 0) return null;
const groupNames = { today: '今天', yesterday: '昨天', lastWeek: '本周', older: '更早' };
return (
<div key={key}>
<div className="text-xs text-gray-500 mb-1 px-2">{groupNames[key as keyof typeof groupNames]}</div>
{group.map(conv => (
<div
key={conv.id}
className={`group flex items-center justify-between rounded-lg p-2 cursor-pointer hover:bg-gray-200 dark:hover:bg-gray-800 ${
currentConversationId === conv.id ? 'bg-gray-200 dark:bg-gray-800' : ''
}`}
onClick={() => switchConversation(conv.id)}
>
<div className="flex-1 truncate">
<div className="text-sm truncate">{conv.title || '新对话'}</div>
<div className="text-xs text-gray-500">{conv.lastMessage?.slice(0, 30) || '暂无消息'}</div>
</div>
<button
onClick={(e) => {
e.stopPropagation();
deleteConversation(conv.id);
}}
className="opacity-0 group-hover:opacity-100 p-1 hover:bg-gray-300 rounded"
>
<Trash2 className="w-4 h-4 text-gray-500" />
</button>
</div>
))}
</div>
);
})}
</div>
</div>
);
}
四、切换会话时的状态恢复与滚动位置
当用户点击侧边栏的某个会话时,我们需要:
- 从后端加载该会话的消息列表
- 更新消息组件中的 messages
- 将滚动位置定位到底部(或用户上次离开的位置)
- 同时更新输入框上下文(如果有草稿,也需要恢复,这里先略)
滚动位置的恢复稍微有点麻烦。一个简单的方法是把每条消息的 DOM 元素 ID 设成 message-{id},然后在切换完成时用 scrollIntoView 滚动到最后一条。但更好的体验是记住每个会话的滚动位置(存在 localStorage 或后端),下次切换回来时恢复。
tsx
// hooks/useScrollRestoration.ts
import { useEffect, useRef } from 'react';
import { useConversationStore } from '@/stores/conversationStore';
export function useScrollRestoration(containerRef: React.RefObject<HTMLDivElement>) {
const { currentConversationId, messages } = useConversationStore();
const scrollPositions = useRef<Map<string, number>>(new Map());
// 离开会话时记录滚动位置
useEffect(() => {
if (!containerRef.current) return;
const saveScroll = () => {
if (currentConversationId) {
scrollPositions.current.set(currentConversationId, containerRef.current!.scrollTop);
}
};
const container = containerRef.current;
container.addEventListener('scroll', saveScroll);
return () => container.removeEventListener('scroll', saveScroll);
}, [currentConversationId]);
// 切换会话后恢复滚动位置
useEffect(() => {
if (!containerRef.current) return;
const saved = scrollPositions.current.get(currentConversationId!);
if (saved !== undefined) {
containerRef.current.scrollTop = saved;
} else {
// 新会话则滚动到底部
containerRef.current.scrollTop = containerRef.current.scrollHeight;
}
}, [currentConversationId, messages]);
}
在 ChatInterface 组件中使用:
tsx
const scrollContainer = useRef<HTMLDivElement>(null);
useScrollRestoration(scrollContainer);
五、删除会话的边界情况处理
删除会话时,有几种情况要考虑:
- 删除当前活跃会话:需要自动切换到其他会话(比如最近的一个),或者如果没有其他会话,则创建一个新会话。
- 删除非活跃会话:只需从列表中移除,不影响当前聊天。
- 删除最后一个会话:自动新建一个空会话,防止用户无会话可用。
在我们的 deleteConversation 实现中已经处理了这些情况。注意在 UI 上,删除操作应该弹出确认框,避免用户误删。
tsx
const handleDelete = async (id: string) => {
if (window.confirm('删除后无法恢复,确定要删除这个对话吗?')) {
await deleteConversation(id);
}
};
六、会话标题的自动生成
用户新建会话时,标题默认为"新对话"。更好的体验是:当用户发送第一条消息后,AI 自动生成一个简洁的标题(比如从用户的第一条消息中提取关键词,或者让 LLM 生成)。
实现方式:
- 前端在用户发送第一条消息后,调用一个单独的 API
/api/conversations/${id}/generate-title。 - 后端使用 LLM(例如
gpt-4o-mini)根据第一条消息生成 20 字以内的标题。 - 前端收到新标题后,更新 Zustand store 中的会话列表项,并可选调用后端更新会话表。
typescript
// 在添加第一条消息后触发
if (state.messages.length === 1 && state.messages[0].role === 'user') {
generateTitle(state.currentConversationId, state.messages[0].content);
}
const generateTitle = async (convId: string, userMessage: string) => {
const response = await fetch(`/api/conversations/${convId}/generate-title`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ userMessage }),
});
const { title } = await response.json();
// 更新会话列表中的标题
set((state) => ({
conversations: state.conversations.map(conv =>
conv.id === convId ? { ...conv, title } : conv
),
}));
};
七、整体架构图:前端会话模块
下面这张图展示了前端会话管理模块的整体架构:

八、总结与最佳实践
经过多轮迭代,我总结了几条会话管理的黄金原则:
- 状态分层:会话列表和消息内容分开存储,不要一股脑全塞进同一个 store。
- 持久化最小集 :只把
currentConversationId存到 localStorage,消息内容每次都从后端加载,避免 localStorage 爆炸。 - 乐观更新:删除会话时先更新 UI 再调后端,后端的失败再回滚,体验更好。
- 自动标题:不要让用户看到一排"新对话",AI 生成标题能让会话列表清晰十倍。
- 滚动位置恢复:这是容易被忽略但用户体验提升明显的细节。
- 防重复加载:切换同一个会话时不应该重复请求后端。
如果你现在正要从零开始做智能体前端,建议先把会话管理的基础架子搭好,再去打磨消息气泡和流式输出。因为会话管理决定了用户能否持续使用你的产品,而流式输出只是决定了单次的体验。