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

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

做智能体前端,如果你只做一个单次对话的 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. 防重复加载:切换同一个会话时不应该重复请求后端。

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

相关推荐
Upsy-Daisy1 小时前
AI Agent 项目学习笔记(八):Tool Calling 工具调用机制总览
人工智能·笔记·学习
企学宝1 小时前
企学宝5月专题课程丨《OpenClaw AI 智能体实战营:从零基础部署到全场景自动化落地》
人工智能·ai·企业培训
Bigger1 小时前
mini-cc:一个轻量级 AI 编程助手的诞生
前端·ai编程·claude
涵涵(互关)1 小时前
Naive-ui树型选择器只显示根节点
前端·ui·vue
BY组态1 小时前
Ricon组态系统最佳实践:从零开始构建物联网监控平台
前端·物联网·iot·web组态·组态
BY组态2 小时前
Ricon组态系统vs传统组态软件:为什么选择新一代Web组态平台
前端·物联网·iot·web组态·组态
SoaringHeart2 小时前
Flutter进阶:OverlayEntry 插入图层管理器 NOverlayZIndexManager
前端·flutter
冬奇Lab2 小时前
让 AI Agent 更可靠:Harness Engineering 与多 Agent 系统工程实践
人工智能·llm·agent
放下华子我只抽RuiKe52 小时前
React 从入门到生产(四):自定义 Hook
前端·javascript·人工智能·深度学习·react.js·自然语言处理·前端框架