ChatGPT 凭什么记住你上句说的?Spring AI 多轮对话记忆,3 步搞定

0 阅读8分钟

ChatGPT 凭什么记住你上句说的?Spring AI 多轮对话记忆,3 步搞定

这是 Spring AI 系列第 2 篇。还没看过第 1 篇《Java 后端 1 小时跑通第一个 AI 应用》的,点这里


上篇我跑通了 Spring AI + 智谱 GLM 的 hello world,满怀信心地做了个 AI 接口。

但马上发现一个诡异的现象:我跟 AI 说"我叫张三",下一句问它"我叫什么",它居然回——

抱歉,您还没告诉我您的名字。

我:??我上一秒才说的啊。

如果你也在调 AI 接口,大概率遇到过这个"失忆"问题。

这不是 bug,是 LLM 的本质——大模型是无状态的,每次请求对它来说都是初次见面

那 ChatGPT、智谱清言怎么做到多轮对话的?答案就藏在 Spring AI 的 ChatMemory 里。

这篇就用 50 行代码,让你的 AI 接口也能"记住上下文"。


一、为什么 LLM 会"失忆"?

理解这点很重要,不然后面看代码会懵。

LLM 的本质是无状态:你调用一次 /v1/chat/completions,服务器处理完返回结果,这次连接就结束了。下次再调用,模型完全不知道你刚才说了什么——它的"记忆"只存在于这一次请求的 prompt 里。

那 ChatGPT 怎么做到多轮对话的?

答案:把历史消息一起发给它。

第 1 轮:
  你:我叫张三
  AI:你好,张三

第 2 轮(实际发送给模型的):
  你:我叫张三
  AI:你好,张三
  你:我叫什么?
  AI:(模型看到上面两行,所以能回答)张三

就这么简单。所谓"多轮对话",本质就是把历史聊天记录塞进 prompt 里再发给模型。

那这件事谁来做?——Spring AI 的 ChatMemory 就是干这个的。


二、Spring AI 的 ChatMemory 是什么?

一句话:ChatMemory 就是 Spring 帮你管理的"聊天记录仓库"

它的工作流是这样的:

用户发消息
   ↓
ChatMemory 根据 sessionId 取出历史消息
   ↓
历史消息 + 新消息 一起发给模型
   ↓
模型回复
   ↓
新消息 + 回复 存回 ChatMemory

这里有几个关键概念:

概念作用
ChatMemory聊天记录仓库,负责存取
sessionId会话 ID,区分不同用户的对话(就像浏览器的 cookie)
MessageChatMemoryAdvisorAdvisor 是 Spring AI 的"拦截器",自动注入历史消息
MessageWindowChatMemory滑窗实现,只保留最近 N 条消息(避免 token 爆炸)

理解了这 4 个概念,代码就是顺水推舟的事。


三、50 行代码实现多轮对话

我的项目结构(基于上一篇的 hello-ai):

hello-ai/
└── src/main/java/com/fuqiang/helloai/
    ├── HelloAiApplication.java
    ├── HelloAIController.java      ← 改这里
    └── ChatMemoryConfig.java       ← 新增

好消息:不用加新依赖。 MessageWindowChatMemoryMessageChatMemoryAdvisor 已经包含在 spring-ai-starter-model-openai 里了。

Step 1:配置 ChatMemory Bean

新建 ChatMemoryConfig.java:

@Configuration
public class ChatMemoryConfig {

    @Bean
    public ChatMemory chatMemory() {
        return MessageWindowChatMemory.builder()
                .maxMessages(20)   // 滑窗:只保留最近 20 条消息
                .build();
    }
}

maxMessages(20) 是关键:意思是"每个会话只保留最近 20 条消息"。

为什么要滑窗?——token 是要钱的。如果你跟 AI 聊了 1000 句,全塞进 prompt 一次请求就爆 token 了。滑窗机制保证只发最近 N 条,既保留了"近期记忆",又控制了成本。

Step 2:启用 Advisor + 加多轮对话接口

修改 HelloAIController.java,加一个带记忆的 ChatClient/ai/chat 接口:

@RestController
@RequestMapping("/ai")
public class HelloAIController {

    private final ChatClient chatClient;
    private final ChatClient memoryChatClient;

    public HelloAIController(ChatClient.Builder builder, ChatMemory chatMemory) {
        // 无记忆版(保留原 hello 接口)
        this.chatClient = builder
                .defaultSystem("你是一个友好的助手,回答简洁清晰。")
                .build();

        // 带记忆版
        this.memoryChatClient = builder
                .defaultSystem("你是一个友好的助手,会结合上下文进行多轮对话。")
                .defaultAdvisors(MessageChatMemoryAdvisor.builder(chatMemory).build())
                .build();
    }

    @GetMapping("/hello")
    public String hello(@RequestParam String message) {
        return chatClient.prompt().user(message).call().content();
    }

    @GetMapping("/ping")
    public String ping() {
        return "pong";
    }

    // ✨ 新接口:带记忆的多轮对话
    @GetMapping("/chat")
    public String chat(@RequestParam String sessionId,
                      @RequestParam String message) {
        return memoryChatClient.prompt()
                .user(message)
                .advisors(a -> a.param(ChatMemory.CONVERSATION_ID, sessionId))
                .call()
                .content();
    }
}

两个 ChatClient 共存的设计:

  • chatClient:无记忆版,保留 /ai/hello,用于一次性问答(翻译、补全)
  • memoryChatClient:有记忆版,用于多轮对话场景

核心就这一行:

.advisors(a -> a.param(ChatMemory.CONVERSATION_ID, sessionId))

每次调用时,把 sessionId 作为会话标识传给 Advisor,Advisor 就会自动从 ChatMemory 里取出该会话的历史消息塞进 prompt。


四、效果演示(4 个测试)

启动应用,跑 4 个测试。

测试 1:同一 sessionId,记忆生效 ✅

http://localhost:8080/ai/chat?sessionId=user001&message=我叫张三,今年28岁

返回:你好,张三!28 岁正是好年纪...

http://localhost:8080/ai/chat?sessionId=user001&message=我叫什么名字?

返回:您叫张三。

062901.png 062902.png

第一次告诉 AI"我叫张三",第二次只问"我叫什么",AI 居然答对了——它记住了。

测试 2:换 sessionId,完全失忆 ✅

http://localhost:8080/ai/chat?sessionId=user002&message=我叫什么名字?

返回:抱歉,您还没有告诉我您的名字。

062903.png

同样的问题,user002 得到的回答完全不同。这就是 sessionId 的作用:不同用户、不同设备、不同会话,各自独立。

测试 3:原接口不受影响 ✅

http://localhost:8080/ai/hello?message=你好

返回:你好!有什么可以帮你的?

062904.png

无记忆版完好无损,新旧接口共存。

测试 4:控制台日志,看清"记忆"的真相 🔍

光看接口返回还不够直观。我在 /ai/chat 里临时加了几行日志,把每次请求前 ChatMemory 里已存的历史消息打印出来:

List<Message> history = chatMemory.get(sessionId);
log.info("===== 会话 [{}] 现有历史 {} 条 =====", sessionId, history.size());
history.forEach(m -> log.info("  [{}] {}", m.getMessageType(), m.getText()));

第一次请求时,历史还是空的;第二次请求时,控制台输出:

===== 会话 [user001] 现有历史 2 条 =====
  [USER] 我叫张三,今年28[ASSISTANT] 你好,张三!很高兴认识你。28岁,正是人生的黄金时期...
===== 本次新消息: 我叫什么名字? =====

这就是"记忆"的真相:第二次请求时,ChatMemory 里已经存了上一轮的 USER 消息和 ASSISTANT 回复。Advisor 会自动把这 2 条历史 + 本次新消息,一起塞进发给模型的 prompt。

模型看到的不是孤立的"我叫什么名字?",而是完整的上下文——所以它才能答出"张三"。

062905.png


五、踩坑提醒(本文最值钱的部分)

整个过程看起来顺,但我踩了 2 个坑,提前给你避雷。

🕳️ 坑 1:sessionId 用错,以为多轮对话"失效"

我最开始的测试是没传 sessionId:

http://localhost:8080/ai/chat?message=我叫张三
http://localhost:8080/ai/chat?message=我叫什么?

结果第二句 AI 还是失忆。

排查后才发现:不传 sessionId,Advisor 会用默认值,而默认值对所有请求是同一个,理论上应该能记住——但如果你像我一样,中间改过代码重启过应用,内存里的 ChatMemory 就被清空了。

教训:测试时显式传 sessionId,别依赖默认值。

🕳️ 坑 2:重启应用,记忆全丢了

某次测试:我跟 AI 聊了 5 句,记得好好的。重启应用后,再问"我叫什么",AI 又失忆了。

根因:我们用的是 MessageWindowChatMemory,数据存在 JVM 内存里。应用一重启,内存清空,记忆全丢。

这是内存版的根本局限,生产环境不能这么用。解决方案:

方案实现适用场景
JDBC 持久化实现 ChatMemoryRepository 接口,存数据库单机生产环境
Redis 持久化用 Spring AI 的 Redis 实现多实例、高并发

这就是下一篇要写的内容(文末有预告)。


六、进阶:maxMessages 怎么选?

maxMessages(20) 不是拍脑袋定的,有几个考虑:

token 消耗记忆范围适用场景
10短期对话客服 FAQ、简单问答
20中等大多数场景(推荐)
50长期上下文长文档讨论、深度咨询
100+很高接近完整历史不推荐,token 爆炸

经验法则:

  • 一条消息平均 50-200 token
  • glm-4.7 单次请求上限约 8K-128K token
  • 20 条 ≈ 2000-4000 token,既够用又不浪费

真正的多轮对话产品,不会用固定窗口。会用"摘要 + 滑窗"组合:超过窗口时,把老消息压缩成摘要再丢弃。Spring AI 1.0 还没原生支持,需要自己实现 ChatMemory 接口。这是值得单独写一篇的话题。


七、我学到了什么

  1. LLM 无状态是本质,不是 bug——理解这点,所有"AI 应用"的设计逻辑就通了
  2. 所谓"记忆"= 把历史塞进 prompt——这个心智模型比记住 API 重要 100 倍
  3. Advisor 是 Spring AI 的精髓——类似 Spring AOP,把横切逻辑(记忆、日志、风控)和业务代码解耦
  4. sessionId 是开发者的责任——AI 框架不会自动知道"这是同一个用户"
  5. 内存版只能开发用,生产必须持久化——别让用户聊到一半发现 AI "失忆"了

最大的收获:多轮对话的"魔法",拆开看就是个简单的存储 + 拼接逻辑。AI 应用没有想象中神秘,工程问题占 90%。


八、下一篇预告

这篇用了 MessageWindowChatMemory,数据在内存里。重启就丢,多实例不能共享

下一篇我会写:

《Spring AI ChatMemory 持久化:用 MySQL/Redis 存对话记录》

包括:

  • 实现 ChatMemoryRepository 接口
  • MySQL 表设计 + JDBC 配置
  • Redis 方案对比
  • 生产环境的 3 个最佳实践

这是把 demo 推向生产必须迈过的一步。


写在最后

我是一名8年 Java 后端,正在转型 AI 应用开发。Spring AI 系列会持续更新,从 hello world 到 RAG 到 Agent,一路踩坑一路写。

如果你也在转型 AI,关注我,一起走。有问题评论区聊,我会逐条回复。

如果这篇文章帮到了你,点个赞就是对我最大的鼓励 ❤️