用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/

智元界将持续分享可运行的技术实战、架构设计、问题排查与企业应用案例。