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

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

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

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

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

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

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

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

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

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

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

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

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

八、总结与最佳实践

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

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

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

相关推荐
fthux1 天前
RenoPit 能为普通业主做什么?看懂图纸、审查合同,提前发现装修坑
javascript·人工智能·ai·开源·github·chrome扩展·open source·edge扩展·firefox扩展
火山引擎开发者社区1 天前
火山引擎混合云 veStack 智算平台 Day0 适配 Kimi K3
人工智能
RSABLOCKCHAIN1 天前
AI Agents in LangGraph-2
人工智能·python
cd_949217211 天前
哪些AI工具适合生成游戏开发用的3D道具模型?从道具草稿到引擎测试的选择方法
人工智能·3d
FII工业富联科技服务1 天前
工业设备运维如何Agentic化?拆解Factory Brain的设备运维架构
大数据·运维·人工智能·架构·制造
WA内核拾荒者1 天前
WhatsApp 账号异常检测的自动化告警系统设计
数据库·python·自动化
糖果店的幽灵1 天前
人已经用 WorkBuddy 找工作拿了面试,你还在一份份手工改简历
人工智能·面试·职场和发展·langgraph
石小石Orz1 天前
我发现了开发者AI产品营收的新方向
前端·虚拟现实
老马识途2.01 天前
关于跨域问题的总结
java·前端
qq29531 天前
2026 财搭子决策模拟器:多智能体投研架构能力拆解
大数据·人工智能·架构