Spring AI 2.0 Chat Memory报conversationId不能为空?升级后的完整排查方法

文章摘要

Spring AI 2.0升级后,使用MessageChatMemoryAdvisorVectorStoreChatMemoryAdvisor的项目可能直接抛出conversationId不能为空、缺少ChatMemory.CONVERSATION_ID或历史对话突然失效。原因是新版本要求每次经过内置Memory Advisor的调用都显式提供会话ID,旧版默认会话ID和Builder上的.conversationId()方式已经不再适用。本文给出正确传参、多租户会话ID设计、流式接口、异步线程和常见误区的完整排查方法。

一、典型错误

IllegalArgumentException:
conversationId must not be null

旧代码:

ChatClient chatClient = ChatClient.builder(chatModel)
        .defaultAdvisors(
                MessageChatMemoryAdvisor.builder(chatMemory).build()
        )
        .build();

调用:

chatClient.prompt()
        .user(message)
        .call()
        .content();

这里注册了Memory Advisor,却没有给当前请求传入会话ID。

二、为什么新版本强制显式会话ID

旧版默认会话ID容易造成:

所有用户共用同一会话
测试数据污染生产
多租户消息串线
无法追踪对话归属

新版本采用快速失败:

没有conversationId
→ 立即抛出异常

这比静默串话更安全。

三、正确调用方式

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

关键是把ChatMemory.CONVERSATION_ID放进Advisor上下文,而不是拼进User Prompt。

四、旧版Builder方式为什么不能继续用

旧代码可能写:

MessageChatMemoryAdvisor.builder(chatMemory)
        .conversationId("default")
        .build();

Spring AI 2.0已经移除这种在Advisor构建时固定会话ID的方式。

原因很直接:

一个ChatClient Bean
→ 服务很多用户
→ 固定会话ID会导致串话

正确方式是:

Advisor在Bean中注册
会话ID在每次请求中传入

五、不要每次随机生成新ID

错误:

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

如果每次请求都执行,三轮对话会变成三个独立会话。

正确流程:

创建会话
→ 返回conversationId
→ 前端持续保存
→ 后续消息重复携带

请求示例:

{
  "conversationId": "conv_01JXYZ",
  "message": "继续解释第二点"
}

六、不要直接用userId当conversationId

同一用户可能同时打开:

  • 客服咨询;
  • 技术问答;
  • 售前方案;
  • 数据分析。

如果:

conversationId = userId

这些窗口会被合并。

推荐归属结构:

tenantId
+userId
+业务场景
+conversationId

对外只暴露随机ID,后端保存完整归属。

七、多租户必须校验会话所有权

不能相信客户端提交的conversationId

服务端必须确认:

会话存在
属于当前tenantId
属于当前userId
状态可用
public Conversation loadOwned(
        String tenantId,
        String userId,
        String conversationId
) {
    Conversation conversation =
            repository.findById(conversationId)
                    .orElseThrow();

    if (!conversation.tenantId().equals(tenantId)
            || !conversation.userId().equals(userId)) {
        throw new AccessDeniedException("无权访问该会话");
    }

    return conversation;
}

完成所有权校验后,才能把会话ID交给Memory Advisor。

八、流式调用也要传conversationId

Flux response = chatClient.prompt()
        .advisors(spec -> spec.param(
                ChatMemory.CONVERSATION_ID,
                conversationId
        ))
        .user(message)
        .stream()
        .content();

同步接口正常、流式接口失忆时,检查:

  • 是否使用同一个ChatClient;
  • 流式调用是否传Advisor参数;
  • 自定义Advisor是否只实现CallAdvisor
  • 取消流后是否错误保存半段消息。

九、异步线程容易丢上下文

错误:

CompletableFuture.supplyAsync(() ->
        chatClient.prompt()
                .user(message)
                .call()
                .content()
);

原请求中的tenantId、userId、conversationId和Trace ID不会自动进入新线程。

推荐显式传递:

public record AiRequestContext(
        String tenantId,
        String userId,
        String conversationId,
        String requestId
) {
}

异步任务接收完整上下文,不依赖ThreadLocal。

十、检查是否注入了正确的ChatClient

配置:

@Bean("memoryChatClient")
ChatClient memoryChatClient(
        ChatClient.Builder builder,
        ChatMemory chatMemory
) {
    return builder
            .defaultAdvisors(
                    MessageChatMemoryAdvisor.builder(chatMemory).build()
            )
            .build();
}

业务却重新执行:

this.chatClient = builder.build();

新客户端没有Memory Advisor。

应注入:

public AiService(
        @Qualifier("memoryChatClient")
        ChatClient chatClient
) {
    this.chatClient = chatClient;
}

十一、InMemory导致重启后失忆

默认内存仓库适合:

  • 本地开发;
  • 单元测试;
  • 单实例Demo。

不适合:

  • 多实例;
  • 容器重启;
  • 长期会话;
  • 生产环境。

生产应使用JDBC、Cassandra、Neo4j或自定义持久化Repository。

十二、Chat Memory不是完整聊天记录

Chat Memory只保存模型当前需要的上下文,窗口策略可能淘汰旧消息。

完整Chat History应单独保存:

chat_conversation
chat_message

推荐:

完整消息
→ Chat History数据库

模型上下文
→ Chat Memory

十三、MessageWindow为什么少了旧消息

MessageWindowChatMemory会保留最近N条消息,超过窗口后淘汰旧内容。

Spring AI 2.0会尽量按用户回合边界裁剪,避免从一次交互中间截断,但它仍然不是长期存档。

长期对话应采用:

近期窗口
+结构化摘要
+必要时的长期语义记忆

十四、工具调用出现历史重复怎么办

检查是否同时使用:

手工拼接历史
+MessageChatMemoryAdvisor

还要检查:

  • 是否注册了多个Memory Advisor;
  • ToolCallingAdvisor是否自己维护循环历史;
  • 当前Prompt是否已经包含Memory;
  • Advisor顺序是否合理。

十五、清空会话要处理哪些数据

chatMemory.clear(conversationId);

还需要按业务规则处理:

  • 完整Chat History;
  • 摘要;
  • 长期记忆;
  • 缓存;
  • 附件;
  • 向量索引。

“清空页面”与“物理删除数据”不是同一件事。

十六、推荐Controller写法

@PostMapping("/conversations/{conversationId}/messages")
public ChatResponse chat(
        @PathVariable String conversationId,
        Authentication authentication,
        @RequestBody ChatRequest request
) {
    UserContext user = currentUser(authentication);

    conversationService.assertOwner(
            user.tenantId(),
            user.userId(),
            conversationId
    );

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

    return new ChatResponse(conversationId, answer);
}

tenantId和userId应从认证上下文读取,而不是相信请求Body。

十七、排查清单

□ 每次请求传ChatMemory.CONVERSATION_ID
□ 不再使用DEFAULT_CONVERSATION_ID
□ 不再调用Builder.conversationId()
□ 同一会话使用稳定ID
□ 没有每次随机生成ID
□ 没有直接用userId合并所有窗口
□ 会话归属经过租户和用户校验
□ 同步与流式调用都传会话ID
□ 异步线程显式传上下文
□ 注入了注册Memory Advisor的ChatClient
□ 生产环境没有只使用InMemory仓库
□ Chat History与Chat Memory分开保存

总结

Spring AI 2.0要求显式提供conversationId,是一次安全性和可维护性提升。

正确方案不是寻找新的“默认会话ID”,而是建立:

稳定的会话生命周期
+租户和用户归属校验
+每次请求显式传递
+持久化Memory Repository
+独立Chat History

延伸阅读

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

https://www.zyentor.com/

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