* Spring AI 历史会话管理与优化实践
在 AI 对话产品中历史会话是用户体验的重要一环。用户需要随时查看过往的聊天记录、修改标题、清理无用会话。本文将分享一个聊天系统后台如何实现历史会话的查询、分组、删除和标题编辑并重点介绍异步更新标题的设计巧思与按时间段分组的实现方案。一、功能需求概览我们要为聊天系统增加“历史对话”模块核心需求包括显示历史会话并按时间分组当天、最近 30 天、最近 1 年、1 年以上。异步设置会话标题用户发起新会话时标题为空第一次提问的内容自动成为标题同时更新最后活跃时间。删除会话物理删除数据库记录并同步清理 Redis 中缓存的对话记忆。允许用户编辑标题方便快速识别会话内容。二、核心设计2.1 异步更新标题与更新时间问题新建会话时标题字段为空因为用户还没有发消息。如果等第一次 AI 回复时再同步更新标题会增加响应延迟影响流式输出的流畅度。解决方案采用Async异步方法更新数据库。在用户第一次提问请求到达时立即异步执行标题和时间的更新主流程继续准备 AI 对话流两者互不阻塞。Async Override public void update(String sessionId, String title, Long userId) { ListChatSession list super.lambdaQuery() .eq(ChatSession::getSessionId, sessionId) .eq(ChatSession::getUserId, userId) .list(); if (CollUtil.isEmpty(list)) { return; } ChatSession chatSession list.get(0); // 标题为空时才写入避免覆盖用户手动修改的标题 if (StrUtil.isEmpty(chatSession.getTitle()) !StrUtil.isEmpty(title)) { chatSession.setTitle(StrUtil.sub(title, 0, 100)); } // 每次都更新最后活跃时间 chatSession.setUpdateTime(LocalDateTimeUtil.now()); super.updateById(chatSession); }调用时机在chat()方法中收到用户提问后立即触发。// 异步更新会话信息标题 更新时间 this.chatSessionService.update(sessionId, question, userId);这样既保证了标题的自动生成又完全不打扰 AI 流式输出的首字响应时间。2.2 历史会话查询与分组算法2.2.1 定义VO对象package com.tianji.aigc.vo; import lombok.AllArgsConstructor; import lombok.Builder; import lombok.Data; import lombok.NoArgsConstructor; import java.time.LocalDateTime; Data Builder NoArgsConstructor AllArgsConstructor public class ChatSessionVO { /** * 会话id */ private String sessionId; /** * 会话标题 */ private String title; private LocalDateTime updateTime; }2.2.2 Controller/** * 查询历史会话列表 */ GetMapping(/history) public MapString, ListChatSessionVO queryHistorySession() { return this.chatSessionService.queryHistorySession(); }2.2.3 Service/** * 查询历史会话列表 */ MapString, ListChatSessionVO queryHistorySession();2.2.4. ServiceImplOverride public MapString, ListChatSessionVO queryHistorySession() { var userId UserContext.getUser(); // 查询历史会话限制返回条数 var list super.lambdaQuery() .eq(ChatSession::getUserId, UserContext.getUser()) .isNotNull(ChatSession::getTitle) .orderByDesc(ChatSession::getUpdateTime) .last(LIMIT 30) .list(); if (CollUtil.isEmpty(list)) { log.info(No chat sessions found for user: {}, userId); return Map.of(); } // 转换为 ChatSessionVO 列表 var chatSessionVOS CollStreamUtil.toList(list, chatSession - ChatSessionVO.builder() .sessionId(chatSession.getSessionId()) .title(chatSession.getTitle()) .updateTime(chatSession.getUpdateTime()) .build() ); final var TODAY 当天; final var LAST_30_DAYS 最近30天; final var LAST_YEAR 最近1年; final var MORE_THAN_YEAR 1年以上; // 当前时间 var now LocalDateTime.now().toLocalDate(); // 按照更新时间分组 return CollStreamUtil.groupByKey(chatSessionVOS, vo - { // 计算两个日期之间的天数差 long between Math.abs(ChronoUnit.DAYS.between(vo.getUpdateTime().toLocalDate(), now)); if (between 0) { return TODAY; } else if (between 30) { return LAST_30_DAYS; } else if (between 365) { return LAST_YEAR; } else { return MORE_THAN_YEAR; } }); }2.2.5 响应结构{ code: 200, msg: OK, data: { 1年以上: [ { sessionId: 03b6491d3a1949c98cf0f8c37aa623fc, title: 水水水水谁谁谁水水水水谁谁谁水水水水水水水水, updateTime: 2023-02-26 15:45:31 } ], 最近1年: [ { sessionId: 53349594acff4a0fb92f71541491dc1b, title: 帮我推荐课程, updateTime: 2025-01-18 21:33:55 }, { sessionId: 695fdea704254c089da454133a1c17a8, title: 你是谁, updateTime: 2025-01-18 21:33:37 } ], 最近30天: [ { sessionId: e380350f97174313898c214afb37d6d8, title: 22222, updateTime: 2025-02-25 13:44:44 } ], 当天: [ { sessionId: fa046bdb4ffe48fba4915e490e1e0b0e, title: xxxxxx, updateTime: 2025-02-26 15:44:01 } ] }, requestId: bc8d535241104da7802e5d27f229d219 }2.3 删除历史会话同步清理 Redis删除操作需要同时移除 MySQL 记录和 Redis 中的对话记忆。记忆的 key 基于sessionId生成的conversationId项目中根据实际情况通过ChatMemory接口清除。2.3.1 Controller/** * 删除历史会话列表 */ DeleteMapping(/history) public void deleteHistorySession(RequestParam(sessionId) String sessionId) { this.chatSessionService.deleteHistorySession(sessionId); }2.3.2 Service/** * 删除历史会话 * * param sessionId 会话id */ void deleteHistorySession(String sessionId);2.3.3 ServiceImplOverride public void deleteHistorySession(String sessionId) { //删除数据库的数据 var queryWrapper Wrappers.ChatSessionlambdaQuery() .eq(ChatSession::getSessionId, sessionId) .eq(ChatSession::getUserId, UserContext.getUser()); super.remove(queryWrapper); //删除redis中的数据 var conversationId ChatService.getConversationId(sessionId); this.chatMemory.clear(conversationId); }2.4 标题编辑接口用户点击编辑图标后可以修改标题。后端只做简单的更新同时截断标题长度防止字段溢出Override public void updateTitle(String sessionId, String title) { super.lambdaUpdate() .set(ChatSession::getTitle, StrUtil.sub(title, 0, 100)) .eq(ChatSession::getSessionId, sessionId) .eq(ChatSession::getUserId, UserContext.getUser()) .update(); }三、总结本文实现了一个轻量但完整的历史会话管理模块其中有几个设计点值得复用异步更新非关键数据如标题、最后活跃时间避免阻塞主对话流程。时间分组的朴素算法使用ChronoUnit.DAYS.between直接比较日期简洁可靠。资源同步清理数据库和缓存Redis联动删除维持系统清洁。防御性编程标题为空判断、长度截取、用户权限校验保障数据安全。测试效果简览新建会话数据库中title为空用户发送第一条消息后标题被异步写入。历史列表按时间段分组展示数据准确空分组会被前端隐藏。删除会话可直接删除某条历史记录页面刷新后消失同时 Redis 中对话上下文被清理。修改标题点选编辑并保存后标题立即更新无需刷新页面。