当前位置: 首页 > news >正文

会话管理:创建、切换、删除对话历史

从一个“对话历史散落一地”的混乱项目,到一套清晰的会话管理架构

做智能体前端,如果你只做一个单次对话的 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> ); }

四、切换会话时的状态恢复与滚动位置

当用户点击侧边栏的某个会话时,我们需要:

  1. 从后端加载该会话的消息列表
  2. 更新消息组件中的 messages
  3. 将滚动位置定位到底部(或用户上次离开的位置)
  4. 同时更新输入框上下文(如果有草稿,也需要恢复,这里先略)

滚动位置的恢复稍微有点麻烦。一个简单的方法是把每条消息的 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);

五、删除会话的边界情况处理

删除会话时,有几种情况要考虑:

  1. 删除当前活跃会话:需要自动切换到其他会话(比如最近的一个),或者如果没有其他会话,则创建一个新会话。
  2. 删除非活跃会话:只需从列表中移除,不影响当前聊天。
  3. 删除最后一个会话:自动新建一个空会话,防止用户无会话可用。

在我们的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),}));};

七、整体架构图:前端会话模块

下面这张图展示了前端会话管理模块的整体架构:

八、总结与最佳实践

经过多轮迭代,我总结了几条会话管理的黄金原则:

  1. 状态分层:会话列表和消息内容分开存储,不要一股脑全塞进同一个 store。
  2. 持久化最小集:只把currentConversationId存到 localStorage,消息内容每次都从后端加载,避免 localStorage 爆炸。
  3. 乐观更新:删除会话时先更新 UI 再调后端,后端的失败再回滚,体验更好。
  4. 自动标题:不要让用户看到一排“新对话”,AI 生成标题能让会话列表清晰十倍。
  5. 滚动位置恢复:这是容易被忽略但用户体验提升明显的细节。
  6. 防重复加载:切换同一个会话时不应该重复请求后端。

如果你现在正要从零开始做智能体前端,建议先把会话管理的基础架子搭好,再去打磨消息气泡和流式输出。因为会话管理决定了用户能否持续使用你的产品,而流式输出只是决定了单次的体验。

http://www.jsqmd.com/news/863091/

相关文章:

  • 3步轻松实现炉石佣兵战记自动化:告别重复劳动的游戏助手
  • Unity背包系统实战:JSON配置+对象池+像素级UI优化
  • 书面沟通的5C原则
  • 基于平行素数对等腰梯形网格拓扑的完备性证明哥德巴赫猜想1+1
  • Unity背包系统实战:数据建模、UI性能与网络同步三位一体设计
  • 基于CentOS7.9部署的LAMP(2)——安装部署WordPress及Discuz
  • 思迈特SmartBI白泽V5正式发布 企业级Agent BI加速规模化落地
  • 使用 IndexedDB 在客户端存储对话记录
  • EC2 M3 Ultra Mac 实例实战:28 核 256GB 跑 12 路并行 Simulator 测试
  • GitHub中文界面插件架构解析与实战指南
  • 哥德巴赫猜想1+1基于平行素数对等腰梯形网格拓扑与素数渐近密度的大偶数满填充完备性证明
  • Appium环境搭建与元素定位实战:四层依赖与三层定位解析
  • AzurLaneAutoScript:基于图像识别与状态机的游戏自动化架构解析
  • iOS 27 语音控制获 AI 升级:自然语言操控 iPhone,Siri 革新终于有眉目
  • 2026年|面对AI检测,如何快速降低论文AIGC痕迹? - 降AI实验室
  • MCP 协议实战:用 50 行代码给本地大模型接上“工具手“,让 Ollama 也能干 Agent 的活
  • “爱能克服远距离......”
  • 桐乡汽车贴膜哪家好?口碑专业靠谱贴膜门店推荐(2026 本地实用指南) - GrowthUME
  • 3步解锁百度网盘全速下载:告别限速困扰的实用指南
  • GitHub中文界面本地化解决方案:技术架构与部署指南
  • 2026年赤峰市育婴师企业推荐排行-育婴师企业口碑排行-育婴师机构口碑排行 - 品牌推广大师
  • Wireshark深度追踪HTTP敏感数据实战方法论
  • 思科:速修复满分 Secure Workload 未授权 API 访问漏洞
  • 告别臃肿!G-Helper:华硕笔记本用户的终极轻量级控制神器
  • 2026行业内靠谱的屏幕贴合机设备厂家口碑排行 - 品牌排行榜
  • Unity UGUI Text性能优化:打字、阴影、渐变的底层原理与实战方案
  • Unity背包系统从零手戳:数据层逻辑层表现层分离实践
  • UE5 BaseInstallBundle.ini深度解析:安装包构建的元数据契约
  • Appium环境搭建实战手册:解决JDK、Android SDK与Node.js兼容性问题
  • 2026年诸暨市汽车贴膜门店合规资质深度测评:4家正规授权店实测对比,新国标下资质核验避坑指南与选型推荐 - GrowthUME