文章摘要Spring AI内置Chat Memory适合维护模型上下文但企业系统还需要完整聊天记录、会话所有权、消息状态、审计日志和删除策略。本文使用Spring Boot、PostgreSQL与Spring AI 2.0设计一个双层存储方案ChatMemoryRepository负责模型短期上下文业务表负责完整Chat History同时实现会话创建、所有权校验、消息写入、Memory调用和清空流程。一、目标架构Controller → ConversationService → ChatHistoryRepository → Spring AI ChatClient → MessageChatMemoryAdvisor → JdbcChatMemoryRepository两套存储职责不同Chat History → 完整记录、页面展示、审计 Chat Memory → 模型需要的近期上下文二、表结构设计会话表CREATETABLEai_conversation(idVARCHAR(64)PRIMARYKEY,tenant_idVARCHAR(64)NOTNULL,user_idVARCHAR(64)NOTNULL,titleVARCHAR(200),statusVARCHAR(20)NOTNULL,created_atTIMESTAMPNOTNULL,updated_atTIMESTAMPNOTNULL);CREATEINDEXidx_conversation_ownerONai_conversation(tenant_id,user_id,updated_at);消息表CREATETABLEai_chat_message(idVARCHAR(64)PRIMARYKEY,conversation_idVARCHAR(64)NOTNULL,tenant_idVARCHAR(64)NOTNULL,user_idVARCHAR(64)NOTNULL,roleVARCHAR(20)NOTNULL,contentTEXTNOTNULL,statusVARCHAR(20)NOTNULL,request_idVARCHAR(64)NOTNULL,created_atTIMESTAMPNOTNULL);CREATEINDEXidx_message_conversationONai_chat_message(tenant_id,user_id,conversation_id,created_at);三、为什么消息表要保存status流式生成可能出现GENERATING COMPLETED CANCELLED FAILED如果只在完成后写入用户取消或异常时无法审计发生了什么。建议状态publicenumMessageStatus{RECEIVED,GENERATING,COMPLETED,CANCELLED,FAILED}四、会话领域对象publicrecordConversation(Stringid,StringtenantId,StringuserId,Stringtitle,ConversationStatusstatus,InstantcreatedAt,InstantupdatedAt){}消息对象publicrecordChatMessageRecord(Stringid,StringconversationId,StringtenantId,StringuserId,Stringrole,Stringcontent,MessageStatusstatus,StringrequestId,InstantcreatedAt){}五、创建会话ServicepublicclassConversationService{privatefinalConversationRepositoryrepository;publicConversationcreate(StringtenantId,StringuserId){Stringidconv_UUID.randomUUID();ConversationconversationnewConversation(id,tenantId,userId,null,ConversationStatus.ACTIVE,Instant.now(),Instant.now());repository.save(conversation);returnconversation;}}不要让客户端自己指定tenantId和userId应从认证上下文获取。六、所有权校验publicConversationrequireOwned(StringconversationId,StringtenantId,StringuserId){returnrepository.findOwned(conversationId,tenantId,userId).orElseThrow(()-newAccessDeniedException(无权访问该会话));}Repository SQL必须包含三个字段而不是查出后再“顺便看看”。七、配置Spring AI MemoryBeanChatMemorychatMemory(ChatMemoryRepositoryrepository){returnMessageWindowChatMemory.builder().chatMemoryRepository(repository).maxMessages(30).build();}Bean(conversationChatClient)ChatClientconversationChatClient(ChatClient.Builderbuilder,ChatMemorychatMemory){returnbuilder.defaultSystem( 你是企业AI助手。 不得泄露其他用户或租户的信息。 ).defaultAdvisors(MessageChatMemoryAdvisor.builder(chatMemory).build()).build();}八、统一聊天服务ServicepublicclassEnterpriseChatService{privatefinalChatClientchatClient;privatefinalConversationServiceconversationService;privatefinalChatHistoryRepositoryhistoryRepository;publicEnterpriseChatService(Qualifier(conversationChatClient)ChatClientchatClient,ConversationServiceconversationService,ChatHistoryRepositoryhistoryRepository){this.chatClientchatClient;this.conversationServiceconversationService;this.historyRepositoryhistoryRepository;}TransactionalpublicChatResultchat(UserContextuser,StringconversationId,Stringmessage){conversationService.requireOwned(conversationId,user.tenantId(),user.userId());StringrequestIdUUID.randomUUID().toString();historyRepository.saveUserMessage(user,conversationId,requestId,message);try{StringanswerchatClient.prompt().advisors(spec-spec.param(ChatMemory.CONVERSATION_ID,conversationId)).user(message).call().content();historyRepository.saveAssistantMessage(user,conversationId,requestId,answer,MessageStatus.COMPLETED);returnnewChatResult(requestId,answer);}catch(RuntimeExceptionexception){historyRepository.saveFailure(user,conversationId,requestId,exception.getClass().getSimpleName());throwexception;}}}九、为什么不能把模型调用放在长数据库事务中模型请求可能持续数秒甚至更久。如果整个流程使用一个长事务数据库连接被长期占用行锁持续超时后回滚用户消息失败恢复困难。更推荐分阶段事务1写用户消息 → 调用模型 → 事务2写助手结果使用明确的消息状态关联请求。十、流式消息如何保存流程创建GENERATING助手消息 → 持续把Chunk发给前端 → 可选地批量落盘 → 完成后更新为COMPLETED不要每个Token都更新数据库。可以每500毫秒 或 累计200个字符批量保存一次。用户取消时更新CANCELLED并决定是否把不完整内容加入Chat Memory。通常不建议把半段回答作为稳定上下文。十一、标题自动生成首次对话完成后可以异步生成简短标题Spring AI会话记忆排查标题任务失败不应影响主对话。还要限制标题长度并进行内容安全过滤。十二、清空与删除清空上下文chatMemory.clear(conversationId);页面记录可以继续保留。删除会话需要处理Chat History Chat Memory 摘要 向量记忆 附件 缓存建议先进入DELETING状态再异步清理派生数据最终标记DELETED。十三、审计字段至少记录request_id conversation_id tenant_id user_id model prompt_version memory_message_count input_tokens output_tokens duration_ms status error_code不要在普通日志中打印完整消息内容。十四、分页查询历史记录SELECT*FROMai_chat_messageWHEREtenant_id:tenantIdANDuser_id:userIdANDconversation_id:conversationIdANDcreated_at:cursorORDERBYcreated_atDESCLIMIT:pageSize;优先使用游标分页避免长会话使用大OFFSET。十五、生产环境还需要补齐数据加密敏感信息脱敏消息保留期用户导出合规删除多实例缓存流式状态归档自动摘要长期记忆评测与反馈。总结可审计Chat Memory服务的核心不是“把消息存进数据库”而是明确双层职责Chat History → 完整、可展示、可审计 Chat Memory → 精简、可淘汰、服务模型上下文再通过稳定conversationId、所有权校验、消息状态和删除链路构建生产可用的多轮对话基础设施。
用Spring Boot+PostgreSQL搭建可审计的Chat Memory服务
文章摘要Spring AI内置Chat Memory适合维护模型上下文但企业系统还需要完整聊天记录、会话所有权、消息状态、审计日志和删除策略。本文使用Spring Boot、PostgreSQL与Spring AI 2.0设计一个双层存储方案ChatMemoryRepository负责模型短期上下文业务表负责完整Chat History同时实现会话创建、所有权校验、消息写入、Memory调用和清空流程。一、目标架构Controller → ConversationService → ChatHistoryRepository → Spring AI ChatClient → MessageChatMemoryAdvisor → JdbcChatMemoryRepository两套存储职责不同Chat History → 完整记录、页面展示、审计 Chat Memory → 模型需要的近期上下文二、表结构设计会话表CREATETABLEai_conversation(idVARCHAR(64)PRIMARYKEY,tenant_idVARCHAR(64)NOTNULL,user_idVARCHAR(64)NOTNULL,titleVARCHAR(200),statusVARCHAR(20)NOTNULL,created_atTIMESTAMPNOTNULL,updated_atTIMESTAMPNOTNULL);CREATEINDEXidx_conversation_ownerONai_conversation(tenant_id,user_id,updated_at);消息表CREATETABLEai_chat_message(idVARCHAR(64)PRIMARYKEY,conversation_idVARCHAR(64)NOTNULL,tenant_idVARCHAR(64)NOTNULL,user_idVARCHAR(64)NOTNULL,roleVARCHAR(20)NOTNULL,contentTEXTNOTNULL,statusVARCHAR(20)NOTNULL,request_idVARCHAR(64)NOTNULL,created_atTIMESTAMPNOTNULL);CREATEINDEXidx_message_conversationONai_chat_message(tenant_id,user_id,conversation_id,created_at);三、为什么消息表要保存status流式生成可能出现GENERATING COMPLETED CANCELLED FAILED如果只在完成后写入用户取消或异常时无法审计发生了什么。建议状态publicenumMessageStatus{RECEIVED,GENERATING,COMPLETED,CANCELLED,FAILED}四、会话领域对象publicrecordConversation(Stringid,StringtenantId,StringuserId,Stringtitle,ConversationStatusstatus,InstantcreatedAt,InstantupdatedAt){}消息对象publicrecordChatMessageRecord(Stringid,StringconversationId,StringtenantId,StringuserId,Stringrole,Stringcontent,MessageStatusstatus,StringrequestId,InstantcreatedAt){}五、创建会话ServicepublicclassConversationService{privatefinalConversationRepositoryrepository;publicConversationcreate(StringtenantId,StringuserId){Stringidconv_UUID.randomUUID();ConversationconversationnewConversation(id,tenantId,userId,null,ConversationStatus.ACTIVE,Instant.now(),Instant.now());repository.save(conversation);returnconversation;}}不要让客户端自己指定tenantId和userId应从认证上下文获取。六、所有权校验publicConversationrequireOwned(StringconversationId,StringtenantId,StringuserId){returnrepository.findOwned(conversationId,tenantId,userId).orElseThrow(()-newAccessDeniedException(无权访问该会话));}Repository SQL必须包含三个字段而不是查出后再“顺便看看”。七、配置Spring AI MemoryBeanChatMemorychatMemory(ChatMemoryRepositoryrepository){returnMessageWindowChatMemory.builder().chatMemoryRepository(repository).maxMessages(30).build();}Bean(conversationChatClient)ChatClientconversationChatClient(ChatClient.Builderbuilder,ChatMemorychatMemory){returnbuilder.defaultSystem( 你是企业AI助手。 不得泄露其他用户或租户的信息。 ).defaultAdvisors(MessageChatMemoryAdvisor.builder(chatMemory).build()).build();}八、统一聊天服务ServicepublicclassEnterpriseChatService{privatefinalChatClientchatClient;privatefinalConversationServiceconversationService;privatefinalChatHistoryRepositoryhistoryRepository;publicEnterpriseChatService(Qualifier(conversationChatClient)ChatClientchatClient,ConversationServiceconversationService,ChatHistoryRepositoryhistoryRepository){this.chatClientchatClient;this.conversationServiceconversationService;this.historyRepositoryhistoryRepository;}TransactionalpublicChatResultchat(UserContextuser,StringconversationId,Stringmessage){conversationService.requireOwned(conversationId,user.tenantId(),user.userId());StringrequestIdUUID.randomUUID().toString();historyRepository.saveUserMessage(user,conversationId,requestId,message);try{StringanswerchatClient.prompt().advisors(spec-spec.param(ChatMemory.CONVERSATION_ID,conversationId)).user(message).call().content();historyRepository.saveAssistantMessage(user,conversationId,requestId,answer,MessageStatus.COMPLETED);returnnewChatResult(requestId,answer);}catch(RuntimeExceptionexception){historyRepository.saveFailure(user,conversationId,requestId,exception.getClass().getSimpleName());throwexception;}}}九、为什么不能把模型调用放在长数据库事务中模型请求可能持续数秒甚至更久。如果整个流程使用一个长事务数据库连接被长期占用行锁持续超时后回滚用户消息失败恢复困难。更推荐分阶段事务1写用户消息 → 调用模型 → 事务2写助手结果使用明确的消息状态关联请求。十、流式消息如何保存流程创建GENERATING助手消息 → 持续把Chunk发给前端 → 可选地批量落盘 → 完成后更新为COMPLETED不要每个Token都更新数据库。可以每500毫秒 或 累计200个字符批量保存一次。用户取消时更新CANCELLED并决定是否把不完整内容加入Chat Memory。通常不建议把半段回答作为稳定上下文。十一、标题自动生成首次对话完成后可以异步生成简短标题Spring AI会话记忆排查标题任务失败不应影响主对话。还要限制标题长度并进行内容安全过滤。十二、清空与删除清空上下文chatMemory.clear(conversationId);页面记录可以继续保留。删除会话需要处理Chat History Chat Memory 摘要 向量记忆 附件 缓存建议先进入DELETING状态再异步清理派生数据最终标记DELETED。十三、审计字段至少记录request_id conversation_id tenant_id user_id model prompt_version memory_message_count input_tokens output_tokens duration_ms status error_code不要在普通日志中打印完整消息内容。十四、分页查询历史记录SELECT*FROMai_chat_messageWHEREtenant_id:tenantIdANDuser_id:userIdANDconversation_id:conversationIdANDcreated_at:cursorORDERBYcreated_atDESCLIMIT:pageSize;优先使用游标分页避免长会话使用大OFFSET。十五、生产环境还需要补齐数据加密敏感信息脱敏消息保留期用户导出合规删除多实例缓存流式状态归档自动摘要长期记忆评测与反馈。总结可审计Chat Memory服务的核心不是“把消息存进数据库”而是明确双层职责Chat History → 完整、可展示、可审计 Chat Memory → 精简、可淘汰、服务模型上下文再通过稳定conversationId、所有权校验、消息状态和删除链路构建生产可用的多轮对话基础设施。