从一个“对话历史散落一地”的混乱项目到一套清晰的会话管理架构做智能体前端如果你只做一个单次对话的 Demo你永远不会意识到会话管理有多重要。直到有一天你的用户开始同时跟 AI 聊三个不同的话题——工作周报、代码调试、旅行规划——然后他问你“我怎么回到昨天那个关于数据库优化的对话它去哪了”这个时候你就会发现没有会话管理的聊天界面就像没有文件系统的电脑你只能记住当前窗口的内容关了就是丢了切了就找不回来了。去年我接手一个智能体项目的时候用户投诉最多的不是 AI 回答质量而是“我之前的对话找不到了”“不小心刷新页面所有历史都没了”。我们花了两个月时间把会话管理的整套机制从零搭了起来。这篇文章我就把创建会话、切换会话、删除会话以及背后的一整套架构彻底讲透。一、会话管理到底在管理什么先给“会话”下个定义。在一个智能体应用中一次会话Session / Conversation就是用户与 AI 之间从开始到结束的一段连续交互过程。它包含唯一的会话 ID会话标题通常由第一条消息或 AI 自动生成消息列表用户消息和 AI 回复创建时间、更新时间元数据模型参数、用户偏好等会话管理的核心功能就三件事创建、切换、删除。听起来简单但实现起来要考虑的东西其实不少前端状态怎么存Zustand / Redux / Context后端怎么持久化数据库 API会话列表怎么展示侧边栏、时间分组切换会话时怎么恢复消息历史和滚动位置删除会话时怎么同步更新 UI 和后端下面是会话管理的整体架构图下面我们逐个拆解。二、数据结构设计前端 Store 后端 API2.1 前端状态管理Zustand我推荐用 Zustand 管理会话状态。它比 Redux 简单太多TypeScript 支持也很好。// stores/conversationStore.tsimport{create}fromzustand;import{persist}fromzustand/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:()Promisevoid;createConversation:()Promisestring;switchConversation:(id:string)Promisevoid;deleteConversation:(id:string)Promisevoid;// 消息操作addMessage:(message:Message)void;updateLastMessage:(content:string)void;clearMessages:()void;}exportconstuseConversationStorecreateConversationState()(persist((set,get)({conversations:[],currentConversationId:null,messages:[],isLoading:false,fetchConversations:async(){set({isLoading:true});constresponseawaitfetch(/api/conversations);constdataawaitresponse.json();set({conversations:data,isLoading:false});},createConversation:async(){constresponseawaitfetch(/api/conversations,{method:POST});constnewConvawaitresponse.json();set((state)({conversations:[newConv,...state.conversations],currentConversationId:newConv.id,messages:[],}));returnnewConv.id;},switchConversation:async(id:string){set({isLoading:true});constresponseawaitfetch(/api/conversations/${id}/messages);constmessagesawaitresponse.json();set({currentConversationId:id,messages,isLoading:false,});},deleteConversation:async(id:string){awaitfetch(/api/conversations/${id},{method:DELETE});set((state){constnewConversationsstate.conversations.filter(cc.id!id);letnewCurrentIdstate.currentConversationId;if(state.currentConversationIdid){newCurrentIdnewConversations[0]?.id||null;}return{conversations:newConversations,currentConversationId:newCurrentId,messages:newCurrentId?state.messages:[],};});// 如果切换到新会话需要加载其消息if(get().currentConversationIdget().currentConversationId!id){awaitget().switchConversation(get().currentConversationId!);}},addMessage:(message){set((state)({messages:[...state.messages,message],}));// 同时更新会话列表中的最后一条消息和更新时间set((state)({conversations:state.conversations.map(convconv.idstate.currentConversationId?{...conv,lastMessage:message.content.slice(0,50),updatedAt:Date.now()}:conv),}));},updateLastMessage:(content){set((state){constlastIndexstate.messages.length-1;if(lastIndex0)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 classNamew-64 bg-gray-100 dark:bg-gray-900 h-screen flex flex-col div classNamep-4 button onClick{handleNewChat} classNamew-full flex items-center justify-center gap-2 bg-blue-500 text-white rounded-lg px-3 py-2 hover:bg-blue-600 Plus classNamew-4 h-4 / 新建对话 /button /div div classNameflex-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 classNametext-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 classNameflex-1 truncate div classNametext-sm truncate{conv.title || 新对话}/div div classNametext-xs text-gray-500{conv.lastMessage?.slice(0, 30) || 暂无消息}/div /div button onClick{(e) { e.stopPropagation(); deleteConversation(conv.id); }} classNameopacity-0 group-hover:opacity-100 p-1 hover:bg-gray-300 rounded Trash2 classNamew-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.RefObjectHTMLDivElement) { const { currentConversationId, messages } useConversationStore(); const scrollPositions useRefMapstring, 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 useRefHTMLDivElement(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.length1state.messages[0].roleuser){generateTitle(state.currentConversationId,state.messages[0].content);}constgenerateTitleasync(convId:string,userMessage:string){constresponseawaitfetch(/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(convconv.idconvId?{...conv,title}:conv),}));};七、整体架构图前端会话模块下面这张图展示了前端会话管理模块的整体架构八、总结与最佳实践经过多轮迭代我总结了几条会话管理的黄金原则状态分层会话列表和消息内容分开存储不要一股脑全塞进同一个 store。持久化最小集只把currentConversationId存到 localStorage消息内容每次都从后端加载避免 localStorage 爆炸。乐观更新删除会话时先更新 UI 再调后端后端的失败再回滚体验更好。自动标题不要让用户看到一排“新对话”AI 生成标题能让会话列表清晰十倍。滚动位置恢复这是容易被忽略但用户体验提升明显的细节。防重复加载切换同一个会话时不应该重复请求后端。如果你现在正要从零开始做智能体前端建议先把会话管理的基础架子搭好再去打磨消息气泡和流式输出。因为会话管理决定了用户能否持续使用你的产品而流式输出只是决定了单次的体验。