Spring AI企业级应用实战(4):Chat Memory、会话隔离、持久化与上下文压缩

文章摘要

前几篇已经完成Spring AI统一调用层和流式输出。本篇继续实现企业级多轮对话:使用MessageChatMemoryAdvisor管理近期消息,要求每次请求显式提供conversationId,通过PostgreSQL保存完整Chat History与持久化Memory,校验租户和用户所有权,并在上下文接近预算时生成结构化摘要。最终形成一个支持多实例部署、流式调用、审计和安全删除的会话系统。

一、本篇目标

完成以下能力:

创建会话
稳定conversationId
多租户所有权校验
Chat Memory持久化
完整Chat History
同步与流式调用
窗口控制
上下文摘要
清空与删除
可观测性

最终链路:

HTTP请求
→ 身份认证
→ 会话所有权校验
→ 写入Chat History
→ MessageChatMemoryAdvisor
→ ChatClient
→ 模型
→ 保存结果
→ 更新指标

二、项目结构

src/main/java/com/zyentor/ai
├── config
│   └── ChatMemoryConfig.java
├── conversation
│   ├── Conversation.java
│   ├── ConversationRepository.java
│   ├── ConversationService.java
│   └── ConversationController.java
├── history
│   ├── ChatMessageRecord.java
│   ├── ChatHistoryRepository.java
│   └── JdbcChatHistoryRepository.java
├── memory
│   ├── MemoryCompactionService.java
│   └── ConversationSummary.java
├── service
│   └── EnterpriseConversationAiService.java
└── security
    └── UserContext.java

三、依赖

            org.springframework.ai
            spring-ai-bom
            2.0.0
            pom
            import






        org.springframework.ai
        spring-ai-starter-model-deepseek



        org.springframework.ai
        spring-ai-starter-model-chat-memory-repository-jdbc



        org.springframework.boot
        spring-boot-starter-webflux



        org.springframework.boot
        spring-boot-starter-jdbc



        org.postgresql
        postgresql
        runtime

具体Starter名称应以当前Spring AI 2.0.x文档和依赖清单为准。

四、配置数据源和模型

spring:
  datasource:
    url: jdbc:postgresql://localhost:5432/spring_ai_demo
    username: ai_user
    password: ${DB_PASSWORD}

  ai:
    model:
      chat: deepseek

    deepseek:
      api-key: ${DEEPSEEK_API_KEY}
      chat:
        model: deepseek-v4-flash
        temperature: 0.2
        max-tokens: 2048

生产环境不要把密钥写进仓库。

五、配置Chat Memory

@Configuration
public class ChatMemoryConfig {

    @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();
    }
}

maxMessages=30只是示例,生产应通过真实Token分布评测。

六、为什么每次请求必须传conversationId

Spring AI 2.0内置Memory Advisor要求显式提供:

ChatMemory.CONVERSATION_ID

调用:

chatClient.prompt()
        .advisors(spec -> spec.param(
                ChatMemory.CONVERSATION_ID,
                conversationId
        ))
        .user(message)
        .call()
        .content();

不要使用默认会话,也不要把conversationId写死在Advisor Bean中。

七、会话表

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,
    memory_version BIGINT NOT NULL DEFAULT 1,
    created_at TIMESTAMP NOT NULL,
    updated_at TIMESTAMP NOT NULL
);

memory_version可以用于:

  • 缓存失效;
  • 摘要版本;
  • 调试;
  • 会话重置。

八、完整消息表

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,
    model VARCHAR(100),
    prompt_version VARCHAR(50),
    input_tokens BIGINT,
    output_tokens BIGINT,
    created_at TIMESTAMP NOT NULL
);

Chat Memory表只服务模型上下文;该表保存完整历史。

九、会话创建

@Service
public class ConversationService {

    private final ConversationRepository repository;

    public Conversation create(UserContext user) {
        Instant now = Instant.now();

        Conversation conversation = new Conversation(
                "conv_" + UUID.randomUUID(),
                user.tenantId(),
                user.userId(),
                null,
                ConversationStatus.ACTIVE,
                1L,
                now,
                now
        );

        repository.save(conversation);
        return conversation;
    }
}

tenantId和userId来自认证上下文。

十、所有权校验

public Conversation requireOwned(
        UserContext user,
        String conversationId
) {
    return repository.findOwned(
            conversationId,
            user.tenantId(),
            user.userId()
    ).orElseThrow(() ->
            new AccessDeniedException("无权访问该会话")
    );
}

不能只验证会话存在。

十一、同步对话服务

@Service
public class EnterpriseConversationAiService {

    private final ChatClient chatClient;
    private final ConversationService conversationService;
    private final ChatHistoryRepository historyRepository;

    public String chat(
            UserContext user,
            String conversationId,
            String message
    ) {
        conversationService.requireOwned(
                user,
                conversationId
        );

        String requestId =
                UUID.randomUUID().toString();

        historyRepository.saveUserMessage(
                user,
                conversationId,
                requestId,
                message
        );

        try {
            String answer = chatClient.prompt()
                    .advisors(spec -> spec
                            .param(
                                    ChatMemory.CONVERSATION_ID,
                                    conversationId
                            )
                            .param(
                                    "tenantId",
                                    user.tenantId()
                            )
                    )
                    .user(message)
                    .call()
                    .content();

            historyRepository.saveAssistantMessage(
                    user,
                    conversationId,
                    requestId,
                    answer,
                    MessageStatus.COMPLETED
            );

            return answer;
        }
        catch (RuntimeException exception) {
            historyRepository.markFailed(
                    conversationId,
                    requestId,
                    exception.getClass()
                            .getSimpleName()
            );
            throw exception;
        }
    }
}

十二、避免长数据库事务

模型调用可能持续数秒。

不要:

开启数据库事务
→ 写用户消息
→ 等模型十几秒
→ 写助手消息
→ 提交

推荐:

事务1:写用户消息
→ 模型调用
→ 事务2:写助手消息或失败状态

通过request_id关联两段记录。

十三、流式调用

public Flux> stream(
        UserContext user,
        String conversationId,
        String message
) {
    conversationService.requireOwned(
            user,
            conversationId
    );

    String requestId =
            UUID.randomUUID().toString();

    historyRepository.saveUserMessage(
            user,
            conversationId,
            requestId,
            message
    );

    StringBuilder buffer = new StringBuilder();

    return chatClient.prompt()
            .advisors(spec -> spec.param(
                    ChatMemory.CONVERSATION_ID,
                    conversationId
            ))
            .user(message)
            .stream()
            .content()
            .doOnNext(buffer::append)
            .map(chunk -> ServerSentEvent.builder(
                    new ChatStreamEvent(
                            requestId,
                            "delta",
                            chunk
                    )
            ).build())
            .doOnComplete(() ->
                    historyRepository
                            .saveAssistantMessage(
                                    user,
                                    conversationId,
                                    requestId,
                                    buffer.toString(),
                                    MessageStatus.COMPLETED
                            )
            )
            .doOnCancel(() ->
                    historyRepository.markCancelled(
                            conversationId,
                            requestId,
                            buffer.toString()
                    )
            );
}

需要继续验证Provider在取消订阅后是否真正终止上游生成。

十四、半段回答是否进入Memory

用户取消后可能只生成:

根据你的问题,主要有三个方面,第一……

不建议把这段内容当作完整Assistant消息进入稳定Memory。

可选策略:

取消时不写Memory
完整Chat History保存CANCELLED消息
下一轮提示用户上一轮已中断

十五、为什么需要上下文压缩

长期会话会不断消耗Token。

只扩大Message Window会导致:

  • 成本上升;
  • 延迟增加;
  • 重要信息被噪声淹没;
  • 超出上下文窗口。

推荐上下文:

结构化摘要
+最近若干轮消息
+当前RAG证据

十六、摘要表

CREATE TABLE ai_conversation_summary (
    id VARCHAR(64) PRIMARY KEY,
    conversation_id VARCHAR(64) NOT NULL,
    tenant_id VARCHAR(64) NOT NULL,
    summary_version BIGINT NOT NULL,
    covered_until_message_id VARCHAR(64) NOT NULL,
    content JSONB NOT NULL,
    created_at TIMESTAMP NOT NULL
);

结构化内容:

{
  "goal": "构建企业RAG方案",
  "facts": [
    "客户使用PostgreSQL",
    "数据不能离开私有云"
  ],
  "decisions": [
    "向量存储选择pgvector"
  ],
  "openQuestions": [
    "峰值并发尚未确认"
  ]
}

十七、什么时候触发压缩

可以使用:

消息数量阈值
Token预算阈值
会话持续时间
工具事件数量

推荐以Token预算为主:

预计上下文Token
> 可用历史预算的80%
→ 触发压缩

十八、压缩流程

1. 读取上次摘要
2. 读取摘要之后的新消息
3. 生成新结构化摘要
4. 校验Schema
5. 保存summary_version
6. 更新covered_until_message_id
7. 后续Prompt加载摘要和近期消息

原始消息不删除,用于审计和重新生成摘要。

十九、摘要不能替代业务事实

模型摘要可能错误。

以下信息必须从业务系统实时获取:

  • 订单状态;
  • 当前权限;
  • 余额;
    -审批结果;
  • 产品库存;
  • 合同版本。

摘要只能作为对话背景,不能作为强一致事实来源。

二十、会话清空与删除

清空模型上下文

chatMemory.clear(conversationId);

同时增加memory_version,使缓存失效。

删除会话

流程:

标记DELETING
→ 删除Memory
→ 删除摘要和向量记忆
→ 处理附件
→ 按策略删除或匿名化History
→ 标记DELETED

二十一、监控指标

chat_conversation_created_count
chat_memory_message_count
chat_memory_load_duration
chat_memory_clear_count
conversation_cross_owner_denied_count
summary_generation_count
summary_validation_failed_count
stream_cancelled_count
history_memory_write_mismatch_count

二十二、测试场景

同一会话连续多轮
同一用户两个窗口
同租户两个用户
两个不同租户
应用重启
请求进入不同实例
流式取消
模型超时
Memory清空
摘要触发
非法conversationId访问

最重要的测试是多租户隔离。

二十三、本篇完整架构

认证用户
→ Conversation所有权
→ 完整History写入
→ 显式conversationId
→ MessageChatMemoryAdvisor
→ 持久化Memory Repository
→ ChatClient
→ 模型
→ 保存状态
→ Token预算触发摘要

总结

生产级Spring AI多轮对话不能只注册一个Memory Advisor。

完整方案必须同时解决:

稳定会话ID
+租户与用户隔离
+持久化Memory
+独立Chat History
+流式状态
+上下文压缩
+审计与删除

下一篇将继续实现:

Spring AI企业级应用实战(5):Tool Calling、权限校验、幂等与人工确认。

延伸阅读

如果你正在关注企业级 AI 应用、Spring AI、RAG、Agent 与 MCP 工程化落地,欢迎访问 智元界

https://www.zyentor.com/

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