用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
→ 模型需要的近期上下文
二、表结构设计
会话表:
CREATE TABLE ai_conversation (
id VARCHAR(64) PRIMARY KEY,
tenant_id VARCHAR(64) NOT NULL,
user_id VARCHAR(64) NOT NULL,
title VARCHAR(200),
status VARCHAR(20) NOT NULL,
created_at TIMESTAMP NOT NULL,
updated_at TIMESTAMP NOT NULL
);
CREATE INDEX idx_conversation_owner
ON ai_conversation(tenant_id, user_id, updated_at);
消息表:
CREATE TABLE ai_chat_message (
id VARCHAR(64) PRIMARY KEY,
conversation_id VARCHAR(64) NOT NULL,
tenant_id VARCHAR(64) NOT NULL,
user_id VARCHAR(64) NOT NULL,
role VARCHAR(20) NOT NULL,
content TEXT NOT NULL,
status VARCHAR(20) NOT NULL,
request_id VARCHAR(64) NOT NULL,
created_at TIMESTAMP NOT NULL
);
CREATE INDEX idx_message_conversation
ON ai_chat_message(
tenant_id,
user_id,
conversation_id,
created_at
);
三、为什么消息表要保存status
流式生成可能出现:
GENERATING
COMPLETED
CANCELLED
FAILED
如果只在完成后写入,用户取消或异常时无法审计发生了什么。
建议状态:
public enum MessageStatus {
RECEIVED,
GENERATING,
COMPLETED,
CANCELLED,
FAILED
}
四、会话领域对象
public record Conversation(
String id,
String tenantId,
String userId,
String title,
ConversationStatus status,
Instant createdAt,
Instant updatedAt
) {
}
消息对象:
public record ChatMessageRecord(
String id,
String conversationId,
String tenantId,
String userId,
String role,
String content,
MessageStatus status,
String requestId,
Instant createdAt
) {
}
五、创建会话
@Service
public class ConversationService {
private final ConversationRepository repository;
public Conversation create(
String tenantId,
String userId
) {
String id = "conv_" + UUID.randomUUID();
Conversation conversation = new Conversation(
id,
tenantId,
userId,
null,
ConversationStatus.ACTIVE,
Instant.now(),
Instant.now()
);
repository.save(conversation);
return conversation;
}
}
不要让客户端自己指定tenantId和userId,应从认证上下文获取。
六、所有权校验
public Conversation requireOwned(
String conversationId,
String tenantId,
String userId
) {
return repository.findOwned(
conversationId,
tenantId,
userId
).orElseThrow(() ->
new AccessDeniedException("无权访问该会话")
);
}
Repository SQL必须包含三个字段,而不是查出后再“顺便看看”。
七、配置Spring AI Memory
@Bean
ChatMemory chatMemory(
ChatMemoryRepository repository
) {
return MessageWindowChatMemory.builder()
.chatMemoryRepository(repository)
.maxMessages(30)
.build();
}
@Bean("conversationChatClient")
ChatClient conversationChatClient(
ChatClient.Builder builder,
ChatMemory chatMemory
) {
return builder
.defaultSystem("""
你是企业AI助手。
不得泄露其他用户或租户的信息。
""")
.defaultAdvisors(
MessageChatMemoryAdvisor.builder(
chatMemory
).build()
)
.build();
}
八、统一聊天服务
@Service
public class EnterpriseChatService {
private final ChatClient chatClient;
private final ConversationService conversationService;
private final ChatHistoryRepository historyRepository;
public EnterpriseChatService(
@Qualifier("conversationChatClient")
ChatClient chatClient,
ConversationService conversationService,
ChatHistoryRepository historyRepository
) {
this.chatClient = chatClient;
this.conversationService = conversationService;
this.historyRepository = historyRepository;
}
@Transactional
public ChatResult chat(
UserContext user,
String conversationId,
String message
) {
conversationService.requireOwned(
conversationId,
user.tenantId(),
user.userId()
);
String requestId = UUID.randomUUID().toString();
historyRepository.saveUserMessage(
user,
conversationId,
requestId,
message
);
try {
String answer = chatClient.prompt()
.advisors(spec -> spec.param(
ChatMemory.CONVERSATION_ID,
conversationId
))
.user(message)
.call()
.content();
historyRepository.saveAssistantMessage(
user,
conversationId,
requestId,
answer,
MessageStatus.COMPLETED
);
return new ChatResult(requestId, answer);
}
catch (RuntimeException exception) {
historyRepository.saveFailure(
user,
conversationId,
requestId,
exception.getClass().getSimpleName()
);
throw exception;
}
}
}
九、为什么不能把模型调用放在长数据库事务中
模型请求可能持续数秒甚至更久。
如果整个流程使用一个长事务:
- 数据库连接被长期占用;
- 行锁持续;
- 超时后回滚用户消息;
- 失败恢复困难。
更推荐分阶段:
事务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 *
FROM ai_chat_message
WHERE tenant_id = :tenantId
AND user_id = :userId
AND conversation_id = :conversationId
AND created_at < :cursor
ORDER BY created_at DESC
LIMIT :pageSize;
优先使用游标分页,避免长会话使用大OFFSET。
十五、生产环境还需要补齐
- 数据加密;
- 敏感信息脱敏;
- 消息保留期;
- 用户导出;
- 合规删除;
- 多实例缓存;
- 流式状态;
- 归档;
- 自动摘要;
- 长期记忆;
- 评测与反馈。
总结
可审计Chat Memory服务的核心不是“把消息存进数据库”,而是明确双层职责:
Chat History
→ 完整、可展示、可审计
Chat Memory
→ 精简、可淘汰、服务模型上下文
再通过稳定conversationId、所有权校验、消息状态和删除链路,构建生产可用的多轮对话基础设施。
延伸阅读
如果你正在关注企业级 AI 应用、Spring AI、RAG、Agent 与 MCP 工程化落地,欢迎访问 智元界:
https://www.zyentor.com/
智元界将持续分享可运行的技术实战、架构设计、问题排查与企业应用案例。