会话管理:创建、切换、删除对话历史
从一个“对话历史散落一地”的混乱项目,到一套清晰的会话管理架构
做智能体前端,如果你只做一个单次对话的 Demo,你永远不会意识到会话管理有多重要。直到有一天,你的用户开始同时跟 AI 聊三个不同的话题——工作周报、代码调试、旅行规划——然后他问你:“我怎么回到昨天那个关于数据库优化的对话?它去哪了?”
这个时候你就会发现,没有会话管理的聊天界面,就像没有文件系统的电脑:你只能记住当前窗口的内容,关了就是丢了,切了就找不回来了。
去年我接手一个智能体项目的时候,用户投诉最多的不是 AI 回答质量,而是“我之前的对话找不到了”“不小心刷新页面,所有历史都没了”。我们花了两个月时间,把会话管理的整套机制从零搭了起来。这篇文章,我就把创建会话、切换会话、删除会话以及背后的一整套架构,彻底讲透。
一、会话管理到底在管理什么?
先给“会话”下个定义。在一个智能体应用中,一次会话(Session / Conversation)就是用户与 AI 之间从开始到结束的一段连续交互过程。它包含:
- 唯一的会话 ID
- 会话标题(通常由第一条消息或 AI 自动生成)
- 消息列表(用户消息和 AI 回复)
- 创建时间、更新时间
- 元数据(模型参数、用户偏好等)
会话管理的核心功能就三件事:创建、切换、删除。听起来简单,但实现起来要考虑的东西其实不少:
- 前端状态怎么存(Zustand / Redux / Context)
- 后端怎么持久化(数据库 + API)
- 会话列表怎么展示(侧边栏、时间分组)
- 切换会话时怎么恢复消息历史和滚动位置
- 删除会话时怎么同步更新 UI 和后端
下面是会话管理的整体架构图:
下面我们逐个拆解。
二、数据结构设计:前端 Store + 后端 API
2.1 前端状态管理(Zustand)
我推荐用 Zustand 管理会话状态。它比 Redux 简单太多,TypeScript 支持也很好。
// stores/conversationStore.tsimport{create}from'zustand';import{persist}from'zustand/middleware';exportinterfaceConversation{id:string;title:string;createdAt:number;updatedAt:number;messageCount:number;lastMessage?:string;}exportinterfaceMessage{id:string;role:'user'|'assistant';content:string;timestamp:number;}interfaceConversationState{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;}exportconstuseConversationStore=create<ConversationState>()(persist((set,get)=>({conversations:[],currentConversationId:null,messages:[],isLoading:false,fetchConversations:async()=>{set({isLoading:true});constresponse=awaitfetch('/api/conversations');constdata=awaitresponse.json();set({conversations:data,isLoading:false});},createConversation:async()=>{constresponse=awaitfetch('/api/conversations',{method:'POST'});constnewConv=awaitresponse.json();set((state)=>({conversations:[newConv,...state.conversations],currentConversationId:newConv.id,messages:[],}));returnnewConv.id;},switchConversation:async(id:string)=>{set({isLoading:true});constresponse=awaitfetch(`/api/conversations/${id}/messages`);constmessages=awaitresponse.json();set({currentConversationId:id,messages,isLoading:false,});},deleteConversation:async(id:string)=>{awaitfetch(`/api/conversations/${id}`,{method:'DELETE'});set((state)=>{constnewConversations=state.conversations.filter(c=>c.id!==id);letnewCurrentId=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){awaitget().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)=>{constlastIndex=state.messages.length-1;if(lastIndex<0)returnstate;constnewMessages=[...state.messages];newMessages[lastIndex]={...newMessages[lastIndex],content};return{messages:newMessages};});},clearMessages:()=>{set({messages:[]});},}),{name:'conversation-storage',// localStorage keypartialize:(state)=>({currentConversationId:state.currentConversationId}),// 只持久化当前会话ID}));2.2 后端数据表设计(简化版)
-- 会话表CREATETABLEconversations(idVARCHAR(36)PRIMARYKEY,user_idVARCHAR(36)NOTNULL,titleVARCHAR(255),created_atTIMESTAMPDEFAULTCURRENT_TIMESTAMP,updated_atTIMESTAMPDEFAULTCURRENT_TIMESTAMPONUPDATECURRENT_TIMESTAMP,INDEXidx_user_id(user_id));-- 消息表CREATETABLEmessages(idVARCHAR(36)PRIMARYKEY,conversation_idVARCHAR(36)NOTNULL,roleENUM('user','assistant')NOTNULL,contentTEXTNOTNULL,created_atTIMESTAMPDEFAULTCURRENT_TIMESTAMP,INDEXidx_conversation_id(conversation_id),FOREIGNKEY(conversation_id)REFERENCESconversations(id)ONDELETECASCADE);三、侧边栏会话列表组件
会话列表通常放在左侧边栏。支持新建、切换、删除、时间分组。
// 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 或后端),下次切换回来时恢复。
// 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组件中使用:
const scrollContainer = useRef<HTMLDivElement>(null); useScrollRestoration(scrollContainer);五、删除会话的边界情况处理
删除会话时,有几种情况要考虑:
- 删除当前活跃会话:需要自动切换到其他会话(比如最近的一个),或者如果没有其他会话,则创建一个新会话。
- 删除非活跃会话:只需从列表中移除,不影响当前聊天。
- 删除最后一个会话:自动新建一个空会话,防止用户无会话可用。
在我们的deleteConversation实现中已经处理了这些情况。注意在 UI 上,删除操作应该弹出确认框,避免用户误删。
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 中的会话列表项,并可选调用后端更新会话表。
// 在添加第一条消息后触发if(state.messages.length===1&&state.messages[0].role==='user'){generateTitle(state.currentConversationId,state.messages[0].content);}constgenerateTitle=async(convId:string,userMessage:string)=>{constresponse=awaitfetch(`/api/conversations/${convId}/generate-title`,{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({userMessage}),});const{title}=awaitresponse.json();// 更新会话列表中的标题set((state)=>({conversations:state.conversations.map(conv=>conv.id===convId?{...conv,title}:conv),}));};七、整体架构图:前端会话模块
下面这张图展示了前端会话管理模块的整体架构:
八、总结与最佳实践
经过多轮迭代,我总结了几条会话管理的黄金原则:
- 状态分层:会话列表和消息内容分开存储,不要一股脑全塞进同一个 store。
- 持久化最小集:只把
currentConversationId存到 localStorage,消息内容每次都从后端加载,避免 localStorage 爆炸。 - 乐观更新:删除会话时先更新 UI 再调后端,后端的失败再回滚,体验更好。
- 自动标题:不要让用户看到一排“新对话”,AI 生成标题能让会话列表清晰十倍。
- 滚动位置恢复:这是容易被忽略但用户体验提升明显的细节。
- 防重复加载:切换同一个会话时不应该重复请求后端。
如果你现在正要从零开始做智能体前端,建议先把会话管理的基础架子搭好,再去打磨消息气泡和流式输出。因为会话管理决定了用户能否持续使用你的产品,而流式输出只是决定了单次的体验。
