大家好我是程序员小鱼。前段时间我帮一个学弟做模拟面试。他简历上写着基于 Spring AI 实现智能客服选课系统我随口问了一句你这个系统用户上一句说我叫张三下一句问我叫什么AI 是怎么记住的他愣了一下说呃……框架自己处理的吧。我又问那如果服务器重启了聊天记录还在不在用户删了一个会话数据库里删了几张表他彻底懵了。这不是他一个人的问题。很多同学用 Spring AI 做项目ChatMemory 配上去能用就完事了但面试官多追问一层为什么这么设计就答不上来了。今天这篇文章我带你从真实的项目代码出发把 AI 会话记忆和聊天记录管理这件事从头到尾拆清楚。读完你会发现核心思想就一句话但展开讲每一层都是设计决策。一、先做一个思想实验假设你此刻要写一个有记忆的AI对话系统。不考虑任何框架你第一反应会怎么做大概率是这样搞一张数据库表四个字段 id, 会话编号, 谁说的, 说了什么 用户说一句话 → 插一条记录 用户问历史消息 → 按会话编号查出来 用户删会话 → 按会话编号全删掉恭喜你思路完全正确。但工程上真正要解决的问题从来不是怎么做而是这么做之后还会出什么问题。比如对话越来越长每次都把所有历史发给大模型token 烧得起吗三个不同的业务场景AI聊天、客服选课、小游戏聊天记录都存一张表吗有些场景需要持久化重启后记录还在有些不需要游戏嘛开心就好怎么区分用户删会话的时候有没有可能删了内容但没删目录留下一条幽灵记录带着这些问题我们看真实项目是怎么解决的。 小鱼点睛从能用到为什么这么用是普通开发者到高级开发者的分水岭。接下来的每一个设计决策都可以成为你面试时的加分答案。二、一张图看懂两个核心概念在深入代码之前先用一个生活化比喻帮你建立认知想象你有一个电话本和一个通话录音机。电话本记录我跟谁通过话张三、李四、王五……但不记录说了什么。录音机记录每次通话说了什么但不负责告诉你你跟多少人通过话。想看我跟张三聊过啥先翻电话本找到张三再找到对应的录音。图①ChatHistoryRepository电话本 ChatMemory录音机比喻这个系统中比喻对应组件职责 电话本ChatHistoryRepository管理有哪些会话会话目录️ 录音机ChatMemory管理每个会话聊了什么会话内容一句话总结前者管目录后者管内容。前端展示聊天列表调前者点进去查看聊天记录调后者。 小鱼点睛面试的时候不要一上来就背技术名词。先用这个比喻讲清楚为什么需要两个组件面试官会觉得你真正理解了而不是在背答案。三、ChatMemoryAI 的海马体3.1 它到底干了什么ChatMemory 是 Spring AI 框架提供的接口只有三个方法public interface ChatMemory { void add(String chatId, ListMessage messages); // 记下来 ListMessage get(String chatId); // 想起来 void clear(String chatId); // 忘掉 }就这三个方法撑起了 AI 的记忆能力。但 Spring AI 自带的实现是MessageWindowChatMemory——基于内存数据存在 JVM 堆里。应用一重启所有记忆归零。这对哄哄模拟器这类娱乐场景没问题。但对AI 对话机器人和智能客服来说用户今天聊的内容明天还想接着聊内存存储就不够用了。所以这个项目做了一个关键决策自己实现一个基于 MySQL 的 ChatMemory —— InSqlChatMemory。3.2 InSqlChatMemory把记忆写进数据库图②InSqlChatMemory 实现 ChatMemory 接口ChatMessageMapper 负责 SQL 执行来看核心源码三个方法三段 SQL逻辑干干净净。 小鱼点睛注意get()里的ORDER BY id ASC。这一行很不起眼但决定了历史消息的顺序。如果顺序乱了发给大模型的上下文就变成了AI先说、用户后问模型会完全懵掉。这就是细节决定成败。3.3 AI 记住你的秘密get()被调用的时机有两个。第一个你能猜到——前端请求历史记录时。第二个才是精髓——每次用户发新消息时Spring AI 的 MessageChatMemoryAdvisor 会自动调get()把历史消息加载出来拼接到 Prompt 里发给大模型。用一个具体的例子来感受你: 我叫张三 AI: 好的张三 --- 下一次对话 --- 你: 我叫什么 幕后发生的事情: 1. get(会话001) → [UserMessage(我叫张三), AssistantMessage(好的张三)] 2. 系统把这些消息拼接: [历史上下文] 用户: 我叫张三 AI: 好的张三 ─────────────── [当前问题] 用户: 我叫什么 3. 发送给大模型 4. AI 回答: 你之前说过你叫张三AI 不是真的记住了而是每次对话前系统默默把历史记录塞进了 Prompt。 小鱼点睛这是一个重要的认知升级——上下文记忆的本质是用 token 换记忆。对话越长历史消息越多Prompt 越长token 消耗越大。所以记忆是有成本的。这也是为什么后续需要上下文压缩这种进阶技术。四、ChatHistoryRepository会话的通讯录4.1 接口定义public interface ChatHistoryRepository { void save(String type, String chatId); // 登记新会话 void delete(String type, String chatId); // 注销会话 ListString getChatIds(String type); // 查列表 }注意多了一个type参数。这个参数是整个架构的点睛之笔我后面会展开讲。4.2 为什么需要两种实现这个项目支撑了三种业务场景type场景用什么存储为什么chatAI 对话机器人MySQL用户希望记录一直在service智能客服选课MySQL客服场景需要追溯game哄哄模拟器JVM 内存游戏嘛开心就好存内存还是存数据库不是一刀切而是按业务类型灵活切换。这就是工程上的合适原则。图③同一接口两种实现 — 内存HashMapvs 数据库MySQL4.3 内存实现一行 HashMap 搞定Component public class InMemoryChatHistoryRepository implements ChatHistoryRepository { // 核心数据结构 // keychat → [chat-001, chat-002, ...] // keygame → [game-xyz, ...] private final MapString, ListString chatHistory new HashMap(); Override public void save(String type, String chatId) { // computeIfAbsent: 懒初始化避免手动判空 ListString chatIds chatHistory.computeIfAbsent(type, k - new ArrayList()); if (chatIds.contains(chatId)) return; // 防重复 chatIds.add(chatId); } Override public ListString getChatIds(String type) { // getOrDefault: 查不到返回空列表避免 NPE return chatHistory.getOrDefault(type, List.of()); } Override public void delete(String type, String chatId) { ListString chatIds chatHistory.get(type); if (chatIds ! null) { chatIds.remove(chatId); if (chatIds.isEmpty()) chatHistory.remove(type); // 顺手清理 } } }几个小细节值得注意computeIfAbsent—— 一行搞定如果不存在就初始化比if put优雅得多contains去重 —— 用户可能在同一个会话发多条消息但会话 ID 只需登记一次删完后检查是否为空 —— 空列表自动回收不占内存4.4 数据库实现多一步查重Repository(inSqlChatHistoryRepository) public class InSqlChatHistoryReponsitory implements ChatHistoryRepository { Override public void save(String type, String chatId) { if (exists(type, chatId)) return; // 先查有没有有就跳过 ChatHistory entity new ChatHistory(); entity.setType(type); entity.setChatId(chatId); chatHistoryMapper.insert(entity); } private boolean exists(String type, String chatId) { ListString chatIds chatHistoryMapper.selectChatIdsByType(type); return chatIds.contains(chatId); } // ... delete 和 getChatIds 类似走 SQL }同样的防重复逻辑内存用contains()数据库走SELECT。思想一样手段不同。4.5 数据库表设计chat_history 表会话目录只关心有哪些会话不关心聊了什么idtypechat_id说明1chatchat-001AI 对话机器人2chatchat-002AI 对话机器人3servicesrv-2024智能客服选课4gamegame-xyz哄哄模拟器chat_message 表会话内容每一轮对话存两条用户说 AI答ORDER BY id ASC 还原对话顺序idconversation_idrolecontent1chat-001user你好2chat-001assistant你好有什么可以帮你的吗3chat-001user我叫张三4chat-001assistant好的张三我记住了图④chat_history会话目录与 chat_message会话内容一对多关系 小鱼点睛两张表职责泾渭分明。面试官可能会问为什么不合并成一张表答案关注点分离。合在一起当然也能用但查有哪些会话和查某会话的聊天内容是两个完全不同的业务需求分开设计更清晰索引优化也更精准。五、ChatHistoryController整个系统的调度中心这个 Controller 是我认为整个设计最值得讲的部分。也是面试中能拉开差距的地方。5.1 四个依赖两两一组RestController RequestMapping(/ai/history) public class ChatHistoryController { private final ChatMemory chatMemory; // 内存-内容 private final InSqlChatMemory inSqlChatMemory; // 数据库-内容 private final ChatHistoryRepository inMemoryChatHistoryRepo; // 内存-目录 private final ChatHistoryRepository inSqlChatHistoryRepo; // 数据库-目录 // 构造函数注入 Qualifier 精确绑定 public ChatHistoryController( ChatMemory chatMemory, InSqlChatMemory inSqlChatMemory, Qualifier(inMemoryChatHistoryRepository) ChatHistoryRepository inMemoryChatHistoryRepo, Qualifier(inSqlChatHistoryRepository) ChatHistoryRepository inSqlChatHistoryRepo) { this.chatMemory chatMemory; this.inSqlChatMemory inSqlChatMemory; this.inMemoryChatHistoryRepo inMemoryChatHistoryRepo; this.inSqlChatHistoryRepo inSqlChatHistoryRepo; } }为什么 Controller 要同时持有四个依赖而不是让前端自己选因为前端不需要知道这个会话存在内存还是数据库——它只关心数据有没有。把路由逻辑放在后端前端的调用方式完全统一。5.2 一行代码的路由魔法private boolean isDatabaseType(String type) { return Arrays.asList(chat, service).contains(type.toLowerCase()); }效果GET /ai/history/chat/chat-001 → 走 MySQL GET /ai/history/service/srv-001 → 走 MySQL GET /ai/history/game/game-xyz → 走 JVM 内存对前端来说URL 格式一模一样完全感知不到背后走的是内存还是数据库。图⑤ChatHistoryController 根据 type 自动分流对前端完全透明5.3 三个 API 的完整实现API 1获取会话列表 ——聊天列表页GetMapping(/{type}) public ListString getChatIds(PathVariable(type) String type) { if (isDatabaseType(type)) { return inSqlChatHistoryRepo.getChatIds(type); } else { return inMemoryChatHistoryRepo.getChatIds(type); } }返回示例[chat-001, chat-002, chat-003]前端拿着这个列表渲染侧边栏 聊天记录 ├── Java学习问题 ├── SpringAI 配置咨询 └── 毕业设计思路讨论API 2获取会话内容 ——历史回显GetMapping(/{type}/{chatId}) public ListMessageVO getChatHistory( PathVariable(type) String type, PathVariable(chatId) String chatId) { ListMessage messages; if (isDatabaseType(type)) { messages inSqlChatMemory.get(chatId); } else { messages chatMemory.get(chatId); } if (messages null) return List.of(); // 关键将 Spring AI 的 Message 对象转为前端友好的 VO return messages.stream() .map(MessageVO::new) .toList(); }为什么需要 MessageVOSpring AI 的Message接口使用MessageType枚举表示角色不方便 JSON 序列化。MessageVO做了一层转换public class MessageVO { private String role; // user | assistant private String content; // 消息正文 public MessageVO(Message message) { this.role switch (message.getMessageType()) { case USER - user; case ASSISTANT - assistant; default - unknown; }; this.content message.getText(); } }返回示例[ { role: user, content: 你好 }, { role: assistant, content: 你好有什么可以帮你的吗 }, { role: user, content: 我叫张三 }, { role: assistant, content: 好的张三我记住了 } ]API 3删除会话 ——既要删内容也要删目录DeleteMapping(/{type}/{chatId}) public void deleteChatHistory( PathVariable(type) String type, PathVariable(chatId) String chatId) { if (isDatabaseType(type)) { // ⚠️ 注意删除顺序先内容后目录 inSqlChatMemory.clear(chatId); // SQL: DELETE FROM chat_message WHERE conversation_id ? inSqlChatHistoryRepo.delete(type, chatId); // SQL: DELETE FROM chat_history WHERE type? AND chat_id? } else { chatMemory.clear(chatId); inMemoryChatHistoryRepo.delete(type, chatId); } } 小鱼点睛为什么删除顺序是先内容后目录这是一个经典的数据一致性问题。假设反过来❌ 错误顺序先删目录再删内容→ 删目录成功 ✓ → 删内容失败 ✗数据库挂了网络断了 → 结果目录里找不到这个会话但 chat_message 表里还留着一堆消息 → 成了幽灵数据永远清理不掉✅ 正确顺序先删内容再删目录→ 删内容成功 ✓ → 删目录失败 ✗ → 结果目录里还有这个会话但点进去是空的 → 用户最多疑惑一下不会产生垃圾数据面试官如果问到分布式系统中的数据一致性你直接拿这个例子讲比背 CAP 理论有说服力得多。六、ChatType 枚举一个容易被忽视的设计细节public enum ChatType { CHAT(chat), // AI 对话机器人 SERVICE(service), // 智能客服选课系统 GAME(game); // 哄哄模拟器 private final String value; // getValue()... }为什么不用字符串直接写chat三个原因写不错—— IDE 有自动补全不会出现caht这种拼写错误改不乱—— 要加一个pdf问答类型枚举里加一行就行所有引用处自动生效看得懂——ChatType.CHAT.getValue()比魔法字符串chat更有语义 小鱼点睛面试官问你枚举和常量有什么区别你可以说枚举不是用来替代常量而是用来把一组相关联的常量组织成一个类型让编译器帮你检查。七、MessageChatMemoryAdvisor最被低估的一行配置很多同学在项目里配了这行代码但不知道它到底干了什么Bean public ChatClient chatClient(OllamaChatModel model, Qualifier(inSqlChatMemory) ChatMemory chatMemory) { return ChatClient.builder(model) .defaultSystem(你是一个智能助手用简短友好的语气回答问题。) .defaultAdvisors( MessageChatMemoryAdvisor.builder(chatMemory).build() // ← 这一行 ) .build(); }这行配置是整个记忆系统的自动化引擎。图⑥Advisor 前置拦截get 加载历史→ 大模型处理 → 后置拦截add 保存消息它拦截了每次对话的两个关键时刻如上图所示前置拦截BEFORE—— 自动调用chatMemory.get(chatId)把该会话的所有历史消息加载出来拼接到 Prompt 中一起发给大模型这样 AI 就有了上下文。后置拦截AFTER—— 大模型返回响应后自动调用chatMemory.add(chatId, [用户消息, AI回复])把本轮对话存进数据库为下一次对话做准备。你什么都不用做框架帮你把记和忆全自动处理了。这就是为什么面试官问AI 怎么记住上下文的你不能只说ChatMemory。你要讲清楚这个 Advisor 的拦截机制——它才是让 ChatMemory 和 ChatClient 产生关联的桥梁。而且还有一个关键参数.advisors(a - a.param(ChatMemory.CONVERSATION_ID, chatId))这一行把chatId传给了 AdvisorAdvisor 再传给 ChatMemory。这样同一个 ChatMemory 实例可以同时服务多个会话互不干扰。八、CommonConfigurationBean 装配的艺术整个系统的 Bean 依赖关系是在CommonConfiguration中统一编排的。这里有一个很巧妙的Primary设计Configuration public class CommonConfiguration { // 内存 ChatMemory —— Primary 标注默认使用 Bean Primary public ChatMemory chatMemory() { return MessageWindowChatMemory.builder().build(); } // 数据库 ChatMemory —— 需要显式指定才使用 Bean public InSqlChatMemory inSqlChatMemory() { return new InSqlChatMemory(); } // 对话机器人 —— 显式注入 InSqlChatMemory数据库 Bean public ChatClient chatClient(OllamaChatModel model, Qualifier(inSqlChatMemory) ChatMemory chatMemory) { return ChatClient.builder(model) .defaultAdvisors( MessageChatMemoryAdvisor.builder(chatMemory).build() ).build(); } // 哄哄模拟器 —— 不指定自动注入 Primary内存 Bean public ChatClient gamechatClient(OpenAiChatModel model, ChatMemory chatMemory) { // chatMemory 自动拿到 MessageWindowChatMemory return ChatClient.builder(model) .defaultAdvisors( MessageChatMemoryAdvisor.builder(chatMemory).build() ).build(); } }Primary的妙用不需要持久化的 Bean如gamechatClient不写Qualifier自动拿到内存实现需要持久化的 Bean如chatClient显式写Qualifier(inSqlChatMemory)拿到数据库实现。 小鱼点睛Spring 的PrimaryQualifier组合是优雅处理多数情况用A少数情况用B的经典手段。面试被问到依赖注入的高级用法直接抛这个例子。九、一次完整的对话从生到死的 6 个瞬间现在把所有组件串起来。这是全文最值钱的一章建议收藏后反复看。图⑦一个会话的生命周期 — 从创建到删除6 步关键流程ChatMemory 与 ChatHistoryRepository 执行流程很多同学容易把 ChatMemory 和 ChatHistoryRepository 搞混。实际上ChatMemory 负责保存聊天内容(Message) ChatHistoryRepository 负责保存会话ID(chatId)两者共同实现了历史会话列表 上下文记忆能力第1步创建会话用户发送你好进入ChatController.chat()首先保存会话IDchatHistoryRepository.save( chat, chat-001 );执行INSERT INTO chat_history (type,chat_id) VALUES (chat,chat-001);此时数据库记录chat-001表示产生了一个新的会话第2步调用大模型chatClient.prompt() .user(你好) .advisors(a - a.param( ChatMemory.CONVERSATION_ID, chat-001 ) ) .stream() .content();大模型返回你好有什么可以帮你的吗第3步自动保存聊天内容此时并不是 Controller 保存消息。而是MessageChatMemoryAdvisor自动调用chatMemory.add(...)保存用户你好 AI你好有什么可以帮你的吗对应INSERT INTO chat_message(...) INSERT INTO chat_message(...)数据库中chat-001 用户你好 AI你好有什么可以帮你的吗第4步继续聊天用户输入我叫张三发送给大模型前chatMemory.get(chat-001)先读取历史记录用户你好 AI你好有什么可以帮你的吗然后拼接历史记录 用户你好 AI你好有什么可以帮你的吗 当前消息 用户我叫张三一起发送给大模型。因此 AI 才能理解上下文好的张三我记住了。这就是ChatMemory 的记忆能力第5步查看历史会话列表前端请求GET /ai/history/chat进入chatHistoryRepository.getChatIds()查询SELECT chat_id FROM chat_history返回[ chat-001, chat-002, chat-003 ]用于展示侧边栏chat-001 chat-002 chat-003第6步查看聊天记录前端请求GET /ai/history/chat/chat-001进入chatMemory.get(chat-001)查询SELECT * FROM chat_message WHERE conversation_idchat-001返回用户你好 AI你好有什么可以帮你的吗 用户我叫张三 AI好的张三我记住了。前端渲染聊天页面。第7步删除会话前端请求DELETE /ai/history/chat/chat-001首先删除聊天内容chatMemory.clear(chat-001)执行DELETE FROM chat_message WHERE conversation_idchat-001然后删除会话IDchatHistoryRepository.delete( chat, chat-001 )执行DELETE FROM chat_history WHERE chat_idchat-001至此整个会话被彻底删除。整体时序图用户 │ ▼ ChatController │ ├── save(chatId) │ │ │ ▼ │ ChatHistoryRepository │ ▼ ChatClient │ ▼ ChatMemory.get() │ ▼ 获取历史消息 │ ▼ 发送给大模型 │ ▼ AI回复 │ ▼ ChatMemory.add() │ ▼ 保存聊天内容总结ChatHistoryRepository 负责管理会话(chatId) ChatMemory 负责管理聊天内容(Message) Repository决定 有哪些会话 Memory决定 每个会话聊过什么 两者配合实现 历史记录 上下文记忆十、架构全景图图⑧5 层架构全景 — 前端 → Controller → Advisor → 接口 → 存储内存/MySQL架构全景图见上方图⑧十一、三个最值得讲给面试官听的设计决策决策 1策略模式 —— 一套接口两种实现同一个ChatMemory接口有内存版和数据库版。同一个ChatHistoryRepository接口也是。为什么这样设计因为不同的业务场景对持久化的需求不同。游戏不需要存聊天必须存。用接口抽象上层代码不用关心底层实现。面试话术我们把存储策略抽象成接口内存和数据库分别实现。切换存储方式时业务代码一行不用改。未来如果要加 Redis 版只需要新增一个实现类符合开闭原则。决策 2MessageChatMemoryAdvisor —— 透明拦截关注点分离记忆的读写和业务逻辑完全解耦。面试话术我们使用 Spring AI 的 Advisor 机制在对话流程的前后两个节点做拦截——前置加载历史上下文后置保存本轮消息。Controller 不需要感知记忆的读写时机ChatMemory 也不需要知道什么时候被调用。这是典型的 AOP 思想在 AI 框架中的落地。决策 3业务类型分流 —— 一个接口多种场景同一个ChatHistoryController根据 URL 中的type参数自动路由到不同存储。面试话术我们在 URL 设计中加入了 type 参数结合枚举类做类型安全的路由。同一个接口服务三种业务场景新增场景只需加枚举值符合开闭原则。 小鱼点睛面试官问你在这个项目里做了什么有亮点的设计就拿这三个讲。每个都能展开 5 分钟以上。十二、面试高频追问速答这里整理了面试中最容易被追问的几个问题建议收藏Q1ChatMemory 的 get() 什么时候被调用两个时机。一是用户主动查看历史记录时ChatHistoryController 调用二是每次新对话前MessageChatMemoryAdvisor 自动调用用于加载上下文。Q2为什么要分 ChatMemory 和 ChatHistoryRepository关注点分离。前者管会话内容后者管会话目录。如果合并也能用但查询有哪些会话和某会话的聊天记录是两个不同需求分开设计更清晰。Q3内存存储和数据库存储怎么切换通过 Spring 的 Primary Qualifier 机制。默认注入内存实现需要数据库的场景显式指定即可。新增一种存储方式只需新增一个实现类。Q4删除会话时为什么先删内容再删目录防止幽灵数据。如果先删目录但删内容失败消息就永远留在数据库里。反过来先删内容即使第二步失败最多是目录里有个空会话。Q5为什么不把所有聊天记录都存数据库合适原则。游戏场景不需要持久化存数据库反而增加 I/O 开销。不是所有数据都值得进数据库。Q6type 参数用字符串不怕写错吗后端用 ChatType 枚举做类型安全校验前端传的字符串会经过isDatabaseType()匹配。未来可以升级为枚举的 name() 做精确匹配。十三、总结这套系统剥开所有细节核心逻辑就一句话ChatHistoryRepository 管有哪些会话目录ChatMemory 管每个会话聊了什么内容。但面试官要听的不是这一句话。而是你能讲清楚内存和数据库为什么要共用一套接口策略模式ChatMemory 的get()为什么有两个调用时机上下文记忆原理删除操作为什么先内容后目录数据一致性为什么需要type字段业务隔离 灵活路由MessageChatMemoryAdvisor 干了什么透明拦截 AOPPrimaryQualifier解决了什么问题依赖注入策略面试的本质不是你用了什么而是你为什么这么用。希望这篇文章能帮你在面试中把这个项目的设计决策一层一层讲清楚。如果觉得有帮助欢迎点赞、在看、转发这是对小魚持续输出原创技术文章最大的支持有问题欢迎评论区留言小魚每条都会认真回复。本文代码基于 heima-springai 项目真实源码编写项目地址见仓库。
Spring AI 实战:从零实现 AI 对话的记忆与历史记录管理(附源码级解析)
大家好我是程序员小鱼。前段时间我帮一个学弟做模拟面试。他简历上写着基于 Spring AI 实现智能客服选课系统我随口问了一句你这个系统用户上一句说我叫张三下一句问我叫什么AI 是怎么记住的他愣了一下说呃……框架自己处理的吧。我又问那如果服务器重启了聊天记录还在不在用户删了一个会话数据库里删了几张表他彻底懵了。这不是他一个人的问题。很多同学用 Spring AI 做项目ChatMemory 配上去能用就完事了但面试官多追问一层为什么这么设计就答不上来了。今天这篇文章我带你从真实的项目代码出发把 AI 会话记忆和聊天记录管理这件事从头到尾拆清楚。读完你会发现核心思想就一句话但展开讲每一层都是设计决策。一、先做一个思想实验假设你此刻要写一个有记忆的AI对话系统。不考虑任何框架你第一反应会怎么做大概率是这样搞一张数据库表四个字段 id, 会话编号, 谁说的, 说了什么 用户说一句话 → 插一条记录 用户问历史消息 → 按会话编号查出来 用户删会话 → 按会话编号全删掉恭喜你思路完全正确。但工程上真正要解决的问题从来不是怎么做而是这么做之后还会出什么问题。比如对话越来越长每次都把所有历史发给大模型token 烧得起吗三个不同的业务场景AI聊天、客服选课、小游戏聊天记录都存一张表吗有些场景需要持久化重启后记录还在有些不需要游戏嘛开心就好怎么区分用户删会话的时候有没有可能删了内容但没删目录留下一条幽灵记录带着这些问题我们看真实项目是怎么解决的。 小鱼点睛从能用到为什么这么用是普通开发者到高级开发者的分水岭。接下来的每一个设计决策都可以成为你面试时的加分答案。二、一张图看懂两个核心概念在深入代码之前先用一个生活化比喻帮你建立认知想象你有一个电话本和一个通话录音机。电话本记录我跟谁通过话张三、李四、王五……但不记录说了什么。录音机记录每次通话说了什么但不负责告诉你你跟多少人通过话。想看我跟张三聊过啥先翻电话本找到张三再找到对应的录音。图①ChatHistoryRepository电话本 ChatMemory录音机比喻这个系统中比喻对应组件职责 电话本ChatHistoryRepository管理有哪些会话会话目录️ 录音机ChatMemory管理每个会话聊了什么会话内容一句话总结前者管目录后者管内容。前端展示聊天列表调前者点进去查看聊天记录调后者。 小鱼点睛面试的时候不要一上来就背技术名词。先用这个比喻讲清楚为什么需要两个组件面试官会觉得你真正理解了而不是在背答案。三、ChatMemoryAI 的海马体3.1 它到底干了什么ChatMemory 是 Spring AI 框架提供的接口只有三个方法public interface ChatMemory { void add(String chatId, ListMessage messages); // 记下来 ListMessage get(String chatId); // 想起来 void clear(String chatId); // 忘掉 }就这三个方法撑起了 AI 的记忆能力。但 Spring AI 自带的实现是MessageWindowChatMemory——基于内存数据存在 JVM 堆里。应用一重启所有记忆归零。这对哄哄模拟器这类娱乐场景没问题。但对AI 对话机器人和智能客服来说用户今天聊的内容明天还想接着聊内存存储就不够用了。所以这个项目做了一个关键决策自己实现一个基于 MySQL 的 ChatMemory —— InSqlChatMemory。3.2 InSqlChatMemory把记忆写进数据库图②InSqlChatMemory 实现 ChatMemory 接口ChatMessageMapper 负责 SQL 执行来看核心源码三个方法三段 SQL逻辑干干净净。 小鱼点睛注意get()里的ORDER BY id ASC。这一行很不起眼但决定了历史消息的顺序。如果顺序乱了发给大模型的上下文就变成了AI先说、用户后问模型会完全懵掉。这就是细节决定成败。3.3 AI 记住你的秘密get()被调用的时机有两个。第一个你能猜到——前端请求历史记录时。第二个才是精髓——每次用户发新消息时Spring AI 的 MessageChatMemoryAdvisor 会自动调get()把历史消息加载出来拼接到 Prompt 里发给大模型。用一个具体的例子来感受你: 我叫张三 AI: 好的张三 --- 下一次对话 --- 你: 我叫什么 幕后发生的事情: 1. get(会话001) → [UserMessage(我叫张三), AssistantMessage(好的张三)] 2. 系统把这些消息拼接: [历史上下文] 用户: 我叫张三 AI: 好的张三 ─────────────── [当前问题] 用户: 我叫什么 3. 发送给大模型 4. AI 回答: 你之前说过你叫张三AI 不是真的记住了而是每次对话前系统默默把历史记录塞进了 Prompt。 小鱼点睛这是一个重要的认知升级——上下文记忆的本质是用 token 换记忆。对话越长历史消息越多Prompt 越长token 消耗越大。所以记忆是有成本的。这也是为什么后续需要上下文压缩这种进阶技术。四、ChatHistoryRepository会话的通讯录4.1 接口定义public interface ChatHistoryRepository { void save(String type, String chatId); // 登记新会话 void delete(String type, String chatId); // 注销会话 ListString getChatIds(String type); // 查列表 }注意多了一个type参数。这个参数是整个架构的点睛之笔我后面会展开讲。4.2 为什么需要两种实现这个项目支撑了三种业务场景type场景用什么存储为什么chatAI 对话机器人MySQL用户希望记录一直在service智能客服选课MySQL客服场景需要追溯game哄哄模拟器JVM 内存游戏嘛开心就好存内存还是存数据库不是一刀切而是按业务类型灵活切换。这就是工程上的合适原则。图③同一接口两种实现 — 内存HashMapvs 数据库MySQL4.3 内存实现一行 HashMap 搞定Component public class InMemoryChatHistoryRepository implements ChatHistoryRepository { // 核心数据结构 // keychat → [chat-001, chat-002, ...] // keygame → [game-xyz, ...] private final MapString, ListString chatHistory new HashMap(); Override public void save(String type, String chatId) { // computeIfAbsent: 懒初始化避免手动判空 ListString chatIds chatHistory.computeIfAbsent(type, k - new ArrayList()); if (chatIds.contains(chatId)) return; // 防重复 chatIds.add(chatId); } Override public ListString getChatIds(String type) { // getOrDefault: 查不到返回空列表避免 NPE return chatHistory.getOrDefault(type, List.of()); } Override public void delete(String type, String chatId) { ListString chatIds chatHistory.get(type); if (chatIds ! null) { chatIds.remove(chatId); if (chatIds.isEmpty()) chatHistory.remove(type); // 顺手清理 } } }几个小细节值得注意computeIfAbsent—— 一行搞定如果不存在就初始化比if put优雅得多contains去重 —— 用户可能在同一个会话发多条消息但会话 ID 只需登记一次删完后检查是否为空 —— 空列表自动回收不占内存4.4 数据库实现多一步查重Repository(inSqlChatHistoryRepository) public class InSqlChatHistoryReponsitory implements ChatHistoryRepository { Override public void save(String type, String chatId) { if (exists(type, chatId)) return; // 先查有没有有就跳过 ChatHistory entity new ChatHistory(); entity.setType(type); entity.setChatId(chatId); chatHistoryMapper.insert(entity); } private boolean exists(String type, String chatId) { ListString chatIds chatHistoryMapper.selectChatIdsByType(type); return chatIds.contains(chatId); } // ... delete 和 getChatIds 类似走 SQL }同样的防重复逻辑内存用contains()数据库走SELECT。思想一样手段不同。4.5 数据库表设计chat_history 表会话目录只关心有哪些会话不关心聊了什么idtypechat_id说明1chatchat-001AI 对话机器人2chatchat-002AI 对话机器人3servicesrv-2024智能客服选课4gamegame-xyz哄哄模拟器chat_message 表会话内容每一轮对话存两条用户说 AI答ORDER BY id ASC 还原对话顺序idconversation_idrolecontent1chat-001user你好2chat-001assistant你好有什么可以帮你的吗3chat-001user我叫张三4chat-001assistant好的张三我记住了图④chat_history会话目录与 chat_message会话内容一对多关系 小鱼点睛两张表职责泾渭分明。面试官可能会问为什么不合并成一张表答案关注点分离。合在一起当然也能用但查有哪些会话和查某会话的聊天内容是两个完全不同的业务需求分开设计更清晰索引优化也更精准。五、ChatHistoryController整个系统的调度中心这个 Controller 是我认为整个设计最值得讲的部分。也是面试中能拉开差距的地方。5.1 四个依赖两两一组RestController RequestMapping(/ai/history) public class ChatHistoryController { private final ChatMemory chatMemory; // 内存-内容 private final InSqlChatMemory inSqlChatMemory; // 数据库-内容 private final ChatHistoryRepository inMemoryChatHistoryRepo; // 内存-目录 private final ChatHistoryRepository inSqlChatHistoryRepo; // 数据库-目录 // 构造函数注入 Qualifier 精确绑定 public ChatHistoryController( ChatMemory chatMemory, InSqlChatMemory inSqlChatMemory, Qualifier(inMemoryChatHistoryRepository) ChatHistoryRepository inMemoryChatHistoryRepo, Qualifier(inSqlChatHistoryRepository) ChatHistoryRepository inSqlChatHistoryRepo) { this.chatMemory chatMemory; this.inSqlChatMemory inSqlChatMemory; this.inMemoryChatHistoryRepo inMemoryChatHistoryRepo; this.inSqlChatHistoryRepo inSqlChatHistoryRepo; } }为什么 Controller 要同时持有四个依赖而不是让前端自己选因为前端不需要知道这个会话存在内存还是数据库——它只关心数据有没有。把路由逻辑放在后端前端的调用方式完全统一。5.2 一行代码的路由魔法private boolean isDatabaseType(String type) { return Arrays.asList(chat, service).contains(type.toLowerCase()); }效果GET /ai/history/chat/chat-001 → 走 MySQL GET /ai/history/service/srv-001 → 走 MySQL GET /ai/history/game/game-xyz → 走 JVM 内存对前端来说URL 格式一模一样完全感知不到背后走的是内存还是数据库。图⑤ChatHistoryController 根据 type 自动分流对前端完全透明5.3 三个 API 的完整实现API 1获取会话列表 ——聊天列表页GetMapping(/{type}) public ListString getChatIds(PathVariable(type) String type) { if (isDatabaseType(type)) { return inSqlChatHistoryRepo.getChatIds(type); } else { return inMemoryChatHistoryRepo.getChatIds(type); } }返回示例[chat-001, chat-002, chat-003]前端拿着这个列表渲染侧边栏 聊天记录 ├── Java学习问题 ├── SpringAI 配置咨询 └── 毕业设计思路讨论API 2获取会话内容 ——历史回显GetMapping(/{type}/{chatId}) public ListMessageVO getChatHistory( PathVariable(type) String type, PathVariable(chatId) String chatId) { ListMessage messages; if (isDatabaseType(type)) { messages inSqlChatMemory.get(chatId); } else { messages chatMemory.get(chatId); } if (messages null) return List.of(); // 关键将 Spring AI 的 Message 对象转为前端友好的 VO return messages.stream() .map(MessageVO::new) .toList(); }为什么需要 MessageVOSpring AI 的Message接口使用MessageType枚举表示角色不方便 JSON 序列化。MessageVO做了一层转换public class MessageVO { private String role; // user | assistant private String content; // 消息正文 public MessageVO(Message message) { this.role switch (message.getMessageType()) { case USER - user; case ASSISTANT - assistant; default - unknown; }; this.content message.getText(); } }返回示例[ { role: user, content: 你好 }, { role: assistant, content: 你好有什么可以帮你的吗 }, { role: user, content: 我叫张三 }, { role: assistant, content: 好的张三我记住了 } ]API 3删除会话 ——既要删内容也要删目录DeleteMapping(/{type}/{chatId}) public void deleteChatHistory( PathVariable(type) String type, PathVariable(chatId) String chatId) { if (isDatabaseType(type)) { // ⚠️ 注意删除顺序先内容后目录 inSqlChatMemory.clear(chatId); // SQL: DELETE FROM chat_message WHERE conversation_id ? inSqlChatHistoryRepo.delete(type, chatId); // SQL: DELETE FROM chat_history WHERE type? AND chat_id? } else { chatMemory.clear(chatId); inMemoryChatHistoryRepo.delete(type, chatId); } } 小鱼点睛为什么删除顺序是先内容后目录这是一个经典的数据一致性问题。假设反过来❌ 错误顺序先删目录再删内容→ 删目录成功 ✓ → 删内容失败 ✗数据库挂了网络断了 → 结果目录里找不到这个会话但 chat_message 表里还留着一堆消息 → 成了幽灵数据永远清理不掉✅ 正确顺序先删内容再删目录→ 删内容成功 ✓ → 删目录失败 ✗ → 结果目录里还有这个会话但点进去是空的 → 用户最多疑惑一下不会产生垃圾数据面试官如果问到分布式系统中的数据一致性你直接拿这个例子讲比背 CAP 理论有说服力得多。六、ChatType 枚举一个容易被忽视的设计细节public enum ChatType { CHAT(chat), // AI 对话机器人 SERVICE(service), // 智能客服选课系统 GAME(game); // 哄哄模拟器 private final String value; // getValue()... }为什么不用字符串直接写chat三个原因写不错—— IDE 有自动补全不会出现caht这种拼写错误改不乱—— 要加一个pdf问答类型枚举里加一行就行所有引用处自动生效看得懂——ChatType.CHAT.getValue()比魔法字符串chat更有语义 小鱼点睛面试官问你枚举和常量有什么区别你可以说枚举不是用来替代常量而是用来把一组相关联的常量组织成一个类型让编译器帮你检查。七、MessageChatMemoryAdvisor最被低估的一行配置很多同学在项目里配了这行代码但不知道它到底干了什么Bean public ChatClient chatClient(OllamaChatModel model, Qualifier(inSqlChatMemory) ChatMemory chatMemory) { return ChatClient.builder(model) .defaultSystem(你是一个智能助手用简短友好的语气回答问题。) .defaultAdvisors( MessageChatMemoryAdvisor.builder(chatMemory).build() // ← 这一行 ) .build(); }这行配置是整个记忆系统的自动化引擎。图⑥Advisor 前置拦截get 加载历史→ 大模型处理 → 后置拦截add 保存消息它拦截了每次对话的两个关键时刻如上图所示前置拦截BEFORE—— 自动调用chatMemory.get(chatId)把该会话的所有历史消息加载出来拼接到 Prompt 中一起发给大模型这样 AI 就有了上下文。后置拦截AFTER—— 大模型返回响应后自动调用chatMemory.add(chatId, [用户消息, AI回复])把本轮对话存进数据库为下一次对话做准备。你什么都不用做框架帮你把记和忆全自动处理了。这就是为什么面试官问AI 怎么记住上下文的你不能只说ChatMemory。你要讲清楚这个 Advisor 的拦截机制——它才是让 ChatMemory 和 ChatClient 产生关联的桥梁。而且还有一个关键参数.advisors(a - a.param(ChatMemory.CONVERSATION_ID, chatId))这一行把chatId传给了 AdvisorAdvisor 再传给 ChatMemory。这样同一个 ChatMemory 实例可以同时服务多个会话互不干扰。八、CommonConfigurationBean 装配的艺术整个系统的 Bean 依赖关系是在CommonConfiguration中统一编排的。这里有一个很巧妙的Primary设计Configuration public class CommonConfiguration { // 内存 ChatMemory —— Primary 标注默认使用 Bean Primary public ChatMemory chatMemory() { return MessageWindowChatMemory.builder().build(); } // 数据库 ChatMemory —— 需要显式指定才使用 Bean public InSqlChatMemory inSqlChatMemory() { return new InSqlChatMemory(); } // 对话机器人 —— 显式注入 InSqlChatMemory数据库 Bean public ChatClient chatClient(OllamaChatModel model, Qualifier(inSqlChatMemory) ChatMemory chatMemory) { return ChatClient.builder(model) .defaultAdvisors( MessageChatMemoryAdvisor.builder(chatMemory).build() ).build(); } // 哄哄模拟器 —— 不指定自动注入 Primary内存 Bean public ChatClient gamechatClient(OpenAiChatModel model, ChatMemory chatMemory) { // chatMemory 自动拿到 MessageWindowChatMemory return ChatClient.builder(model) .defaultAdvisors( MessageChatMemoryAdvisor.builder(chatMemory).build() ).build(); } }Primary的妙用不需要持久化的 Bean如gamechatClient不写Qualifier自动拿到内存实现需要持久化的 Bean如chatClient显式写Qualifier(inSqlChatMemory)拿到数据库实现。 小鱼点睛Spring 的PrimaryQualifier组合是优雅处理多数情况用A少数情况用B的经典手段。面试被问到依赖注入的高级用法直接抛这个例子。九、一次完整的对话从生到死的 6 个瞬间现在把所有组件串起来。这是全文最值钱的一章建议收藏后反复看。图⑦一个会话的生命周期 — 从创建到删除6 步关键流程ChatMemory 与 ChatHistoryRepository 执行流程很多同学容易把 ChatMemory 和 ChatHistoryRepository 搞混。实际上ChatMemory 负责保存聊天内容(Message) ChatHistoryRepository 负责保存会话ID(chatId)两者共同实现了历史会话列表 上下文记忆能力第1步创建会话用户发送你好进入ChatController.chat()首先保存会话IDchatHistoryRepository.save( chat, chat-001 );执行INSERT INTO chat_history (type,chat_id) VALUES (chat,chat-001);此时数据库记录chat-001表示产生了一个新的会话第2步调用大模型chatClient.prompt() .user(你好) .advisors(a - a.param( ChatMemory.CONVERSATION_ID, chat-001 ) ) .stream() .content();大模型返回你好有什么可以帮你的吗第3步自动保存聊天内容此时并不是 Controller 保存消息。而是MessageChatMemoryAdvisor自动调用chatMemory.add(...)保存用户你好 AI你好有什么可以帮你的吗对应INSERT INTO chat_message(...) INSERT INTO chat_message(...)数据库中chat-001 用户你好 AI你好有什么可以帮你的吗第4步继续聊天用户输入我叫张三发送给大模型前chatMemory.get(chat-001)先读取历史记录用户你好 AI你好有什么可以帮你的吗然后拼接历史记录 用户你好 AI你好有什么可以帮你的吗 当前消息 用户我叫张三一起发送给大模型。因此 AI 才能理解上下文好的张三我记住了。这就是ChatMemory 的记忆能力第5步查看历史会话列表前端请求GET /ai/history/chat进入chatHistoryRepository.getChatIds()查询SELECT chat_id FROM chat_history返回[ chat-001, chat-002, chat-003 ]用于展示侧边栏chat-001 chat-002 chat-003第6步查看聊天记录前端请求GET /ai/history/chat/chat-001进入chatMemory.get(chat-001)查询SELECT * FROM chat_message WHERE conversation_idchat-001返回用户你好 AI你好有什么可以帮你的吗 用户我叫张三 AI好的张三我记住了。前端渲染聊天页面。第7步删除会话前端请求DELETE /ai/history/chat/chat-001首先删除聊天内容chatMemory.clear(chat-001)执行DELETE FROM chat_message WHERE conversation_idchat-001然后删除会话IDchatHistoryRepository.delete( chat, chat-001 )执行DELETE FROM chat_history WHERE chat_idchat-001至此整个会话被彻底删除。整体时序图用户 │ ▼ ChatController │ ├── save(chatId) │ │ │ ▼ │ ChatHistoryRepository │ ▼ ChatClient │ ▼ ChatMemory.get() │ ▼ 获取历史消息 │ ▼ 发送给大模型 │ ▼ AI回复 │ ▼ ChatMemory.add() │ ▼ 保存聊天内容总结ChatHistoryRepository 负责管理会话(chatId) ChatMemory 负责管理聊天内容(Message) Repository决定 有哪些会话 Memory决定 每个会话聊过什么 两者配合实现 历史记录 上下文记忆十、架构全景图图⑧5 层架构全景 — 前端 → Controller → Advisor → 接口 → 存储内存/MySQL架构全景图见上方图⑧十一、三个最值得讲给面试官听的设计决策决策 1策略模式 —— 一套接口两种实现同一个ChatMemory接口有内存版和数据库版。同一个ChatHistoryRepository接口也是。为什么这样设计因为不同的业务场景对持久化的需求不同。游戏不需要存聊天必须存。用接口抽象上层代码不用关心底层实现。面试话术我们把存储策略抽象成接口内存和数据库分别实现。切换存储方式时业务代码一行不用改。未来如果要加 Redis 版只需要新增一个实现类符合开闭原则。决策 2MessageChatMemoryAdvisor —— 透明拦截关注点分离记忆的读写和业务逻辑完全解耦。面试话术我们使用 Spring AI 的 Advisor 机制在对话流程的前后两个节点做拦截——前置加载历史上下文后置保存本轮消息。Controller 不需要感知记忆的读写时机ChatMemory 也不需要知道什么时候被调用。这是典型的 AOP 思想在 AI 框架中的落地。决策 3业务类型分流 —— 一个接口多种场景同一个ChatHistoryController根据 URL 中的type参数自动路由到不同存储。面试话术我们在 URL 设计中加入了 type 参数结合枚举类做类型安全的路由。同一个接口服务三种业务场景新增场景只需加枚举值符合开闭原则。 小鱼点睛面试官问你在这个项目里做了什么有亮点的设计就拿这三个讲。每个都能展开 5 分钟以上。十二、面试高频追问速答这里整理了面试中最容易被追问的几个问题建议收藏Q1ChatMemory 的 get() 什么时候被调用两个时机。一是用户主动查看历史记录时ChatHistoryController 调用二是每次新对话前MessageChatMemoryAdvisor 自动调用用于加载上下文。Q2为什么要分 ChatMemory 和 ChatHistoryRepository关注点分离。前者管会话内容后者管会话目录。如果合并也能用但查询有哪些会话和某会话的聊天记录是两个不同需求分开设计更清晰。Q3内存存储和数据库存储怎么切换通过 Spring 的 Primary Qualifier 机制。默认注入内存实现需要数据库的场景显式指定即可。新增一种存储方式只需新增一个实现类。Q4删除会话时为什么先删内容再删目录防止幽灵数据。如果先删目录但删内容失败消息就永远留在数据库里。反过来先删内容即使第二步失败最多是目录里有个空会话。Q5为什么不把所有聊天记录都存数据库合适原则。游戏场景不需要持久化存数据库反而增加 I/O 开销。不是所有数据都值得进数据库。Q6type 参数用字符串不怕写错吗后端用 ChatType 枚举做类型安全校验前端传的字符串会经过isDatabaseType()匹配。未来可以升级为枚举的 name() 做精确匹配。十三、总结这套系统剥开所有细节核心逻辑就一句话ChatHistoryRepository 管有哪些会话目录ChatMemory 管每个会话聊了什么内容。但面试官要听的不是这一句话。而是你能讲清楚内存和数据库为什么要共用一套接口策略模式ChatMemory 的get()为什么有两个调用时机上下文记忆原理删除操作为什么先内容后目录数据一致性为什么需要type字段业务隔离 灵活路由MessageChatMemoryAdvisor 干了什么透明拦截 AOPPrimaryQualifier解决了什么问题依赖注入策略面试的本质不是你用了什么而是你为什么这么用。希望这篇文章能帮你在面试中把这个项目的设计决策一层一层讲清楚。如果觉得有帮助欢迎点赞、在看、转发这是对小魚持续输出原创技术文章最大的支持有问题欢迎评论区留言小魚每条都会认真回复。本文代码基于 heima-springai 项目真实源码编写项目地址见仓库。