Spring AI 聊天记忆落库:重启后,怎样接上上一轮对话

第一轮告诉 AI:"项目代号是松果,用 Java 开发。"第二轮再问:"刚才的项目代号是什么?"

如果服务刚重启过,这个问题正好能查出一条容易漏接的链路:数据库里虽然有聊天记录,这次请求却可能只发出了第二句话。

先看一个实际跑出来的区别。同一个文件数据库,同样的问题,只改变 ChatClient 是否接入记忆 Advisor:

text 复制代码
不接 Advisor:模型入口只有 1 条消息

接入 Advisor 后:
USER:项目代号是松果,用 Java 开发。
ASSISTANT:已收到。
USER:刚才的项目代号是什么?

下面把这条链路接完整。代码用 Spring AI 2.0.1、H2 2.3.232、JDK 21,前后启动两个独立进程。模型端使用一个记录输入的 ChatModel,固定回复"已收到。",不用 API Key,也不会产生模型费用。

这样能直接检查历史消息有没有进入本轮请求。真实模型拿到上下文后会怎样回答,是接入具体模型后要继续检查的另一件事。

让存储、窗口和请求接在一起

这里需要三个组件:JdbcChatMemoryRepository 把消息保存在数据库;MessageWindowChatMemory 决定留下哪些消息;MessageChatMemoryAdvisor 在调用前取出历史,和当前问题一起交给模型。

图里画的是一次请求发往模型前的过程。调用返回后,Advisor 还会把助手回复加入记忆。

最容易漏的是最后一段接线。只创建 Repository,或只向自己的聊天记录表执行 INSERT,不会让另一个裸 ChatClient 自动带上历史。它必须使用接好 Advisor 的那个实例。

java 复制代码
var client = ChatClient.builder(model)
    .defaultAdvisors(MessageChatMemoryAdvisor.builder(memory).build())
    .build();

client.prompt().user(question)
    .advisors(a -> a.param(ChatMemory.CONVERSATION_ID, conversationId))
    .call().content();

上面这段接入代码放进已有项目时,model 换成项目使用的 ChatModel 即可。Repository、memory 和 client 应作为可复用组件管理,不必每轮重新创建。

重启后,要找回同一个会话

示例给两个会话分别分配 user-1:chat-1user-2:chat-1,各写入一问一答。第一个进程退出后,第二个进程仍访问同一个 H2 文件,并使用原来的会话 ID。

这两项缺一不可。使用 jdbc:h2:mem:,进程退出后数据不会按这个示例保留;每次请求重新生成会话 ID,读取到的也会是一个新会话。

正式接口应从已登录用户和有权访问的会话中确定 ID。前端传来的会话编号需要校验归属,不能只靠"拼了用户 ID"就认为权限已经解决。Spring AI 的会话标识说明也把每个会话的消息范围交给这个 ID 来区分。

本文验证的是两个不同 ID 的消息互不混用,没有实现登录系统。

完整代码:两个文件就能运行

目录按下面放置:

text 复制代码
chat-memory-lab/
  pom.xml
  src/main/java/demo/MemoryLab.java

pom.xml

xml 复制代码
<project xmlns="http://maven.apache.org/POM/4.0.0"
         xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
         xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 https://maven.apache.org/xsd/maven-4.0.0.xsd">
  <modelVersion>4.0.0</modelVersion>
  <groupId>demo</groupId><artifactId>chat-memory-lab</artifactId><version>1.0</version>
  <properties>
    <maven.compiler.release>21</maven.compiler.release>
    <project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
  </properties>
  <dependencies>
    <dependency>
      <groupId>org.springframework.ai</groupId><artifactId>spring-ai-client-chat</artifactId><version>2.0.1</version>
    </dependency>
    <dependency>
      <groupId>org.springframework.ai</groupId><artifactId>spring-ai-model-chat-memory-repository-jdbc</artifactId><version>2.0.1</version>
    </dependency>
    <dependency>
      <groupId>com.h2database</groupId><artifactId>h2</artifactId><version>2.3.232</version>
    </dependency>
    <dependency>
      <groupId>org.slf4j</groupId><artifactId>slf4j-simple</artifactId><version>2.0.17</version>
    </dependency>
  </dependencies>
  <build><plugins>
    <plugin>
      <groupId>org.apache.maven.plugins</groupId><artifactId>maven-compiler-plugin</artifactId><version>3.14.0</version>
    </plugin>
    <plugin>
      <groupId>org.codehaus.mojo</groupId><artifactId>exec-maven-plugin</artifactId><version>3.5.0</version>
      <configuration><mainClass>demo.MemoryLab</mainClass></configuration>
    </plugin>
  </plugins></build>
</project>

src/main/java/demo/MemoryLab.java

java 复制代码
package demo;

import java.util.List;
import org.springframework.ai.chat.client.ChatClient;
import org.springframework.ai.chat.client.advisor.MessageChatMemoryAdvisor;
import org.springframework.ai.chat.memory.ChatMemory;
import org.springframework.ai.chat.memory.MessageWindowChatMemory;
import org.springframework.ai.chat.memory.repository.jdbc.JdbcChatMemoryRepository;
import org.springframework.ai.chat.messages.AssistantMessage;
import org.springframework.ai.chat.messages.Message;
import org.springframework.ai.chat.messages.MessageType;
import org.springframework.ai.chat.model.ChatModel;
import org.springframework.ai.chat.model.ChatResponse;
import org.springframework.ai.chat.model.Generation;
import org.springframework.ai.chat.prompt.Prompt;
import org.springframework.core.io.ClassPathResource;
import org.springframework.jdbc.datasource.DriverManagerDataSource;
import org.springframework.jdbc.datasource.init.ResourceDatabasePopulator;

public class MemoryLab {
    // 演示两个服务端分配的会话 ID,不代表已经实现了登录鉴权。
    static final String A = "user-1:chat-1";
    static final String B = "user-2:chat-1";

    public static void main(String[] args) {
        if (args.length != 2 || !List.of("seed", "resume").contains(args[0])) {
            throw new IllegalArgumentException("用法:seed|resume 数据库路径");
        }
        var ds = new DriverManagerDataSource(
            "jdbc:h2:file:" + args[1], "sa", "");
        // 使用当前依赖自带的 H2 建表脚本,重复启动不会删除已有消息。
        new ResourceDatabasePopulator(new ClassPathResource(
            "org/springframework/ai/chat/memory/repository/jdbc/schema-h2.sql"))
            .execute(ds);
        var repository = JdbcChatMemoryRepository.builder().dataSource(ds).build();
        var memory = MessageWindowChatMemory.builder()
            .chatMemoryRepository(repository).maxMessages(4).build();
        var model = new RecordingModel();
        var client = ChatClient.builder(model)
            .defaultAdvisors(MessageChatMemoryAdvisor.builder(memory).build())
            .build();

        System.out.println("进程 PID:" + ProcessHandle.current().pid());
        if (args[0].equals("seed")) {
            // 仅清理本演示的两个会话,便于重复运行。
            memory.clear(A);
            memory.clear(B);
            ask(client, A, "项目代号是松果,用 Java 开发。");
            ask(client, B, "项目代号是灯塔,用 Python 开发。");
            require(memory.get(A).size() == 2, "A 的首轮消息未落库");
            require(memory.get(B).size() == 2, "B 的首轮消息未落库");
            System.out.println("首轮已保存:A=2 条,B=2 条");
            return;
        }

        require(memory.get(A).size() == 2, "请先运行 seed,再运行 resume");
        // 反例:数据库里有消息,但新建的裸 ChatClient 没有接入记忆。
        ChatClient.builder(model).build().prompt()
            .user("刚才的项目代号是什么?").call().content();
        require(model.seen.size() == 1, "裸调用应只有本轮输入");
        System.out.println("不接 Advisor:模型入口只有 1 条消息");

        ask(client, A, "刚才的项目代号是什么?");
        require(texts(model.seen).equals(List.of(
            "项目代号是松果,用 Java 开发。", "已收到。", "刚才的项目代号是什么?")),
            "重启后的消息内容或顺序不正确");
        require(model.seen.stream().map(Message::getMessageType).toList()
            .equals(List.of(MessageType.USER, MessageType.ASSISTANT, MessageType.USER)),
            "消息角色顺序不正确");
        System.out.println("接入 Advisor 后的模型输入:");
        model.seen.forEach(m -> System.out.println(m.getMessageType() + ":" + m.getText()));

        // 此时库里已有两轮共 4 条;下一次调用会再拼上当前问题。
        ask(client, A, "接下来先写接口还是页面?");
        require(model.seen.size() == 5, "应为 4 条历史加 1 条新问题");
        require(memory.get(A).size() == 4, "保存窗口应回到 4 条");
        require(texts(memory.get(A)).stream().noneMatch(s -> s.contains("松果")),
            "最早一轮应被移出窗口");
        System.out.println("第三轮:模型入口 5 条,保存窗口 4 条");

        ask(client, B, "我的项目代号是什么?");
        require(model.seen.size() == 3, "B 应只带自己的首轮历史");
        require(model.seen.getFirst().getText().contains("灯塔"), "B 历史缺失");
        require(texts(model.seen).stream().noneMatch(s -> s.contains("松果")),
            "不同会话发生混用");
        System.out.println("B 会话:3 条输入,只包含 B 的历史");
    }

    static void ask(ChatClient client, String id, String text) {
        client.prompt().user(text)
            .advisors(a -> a.param(ChatMemory.CONVERSATION_ID, id))
            .call().content();
    }

    static List<String> texts(List<Message> messages) {
        return messages.stream().map(Message::getText).toList();
    }

    static void require(boolean condition, String message) {
        if (!condition) throw new IllegalStateException(message);
    }

    static class RecordingModel implements ChatModel {
        List<Message> seen = List.of();

        @Override
        public ChatResponse call(Prompt prompt) {
            // 记录框架实际送到模型边界的消息;固定回复不模拟模型理解能力。
            seen = List.copyOf(prompt.getInstructions());
            return new ChatResponse(List.of(new Generation(new AssistantMessage("已收到。"))));
        }
    }
}

在项目目录运行第一条命令,等它结束后再运行第二条。两次 Maven 命令分别启动自己的 JVM,使用同一条数据库路径:

bash 复制代码
mvn -q compile exec:java "-Dexec.args=seed ./target/chat-db"
mvn -q exec:java "-Dexec.args=resume ./target/chat-db"

seed 会清空演示用的两个会话再写入首轮消息。需要重跑时,仍按 seed、resume 的顺序执行;只反复执行 resume,库中的状态已经不是首轮结束时的状态。

本次运行输出中的两个进程 PID 不同,其余关键结果如下:

text 复制代码
首轮已保存:A=2 条,B=2 条
不接 Advisor:模型入口只有 1 条消息
接入 Advisor 后的模型输入:
USER:项目代号是松果,用 Java 开发。
ASSISTANT:已收到。
USER:刚才的项目代号是什么?
第三轮:模型入口 5 条,保存窗口 4 条
B 会话:3 条输入,只包含 B 的历史

程序检查了消息内容和角色顺序,也检查了 B 的请求没有混进 A 的项目代号。任何一项不符合预期,都会抛出异常,不会只打印一句"验证成功"。

窗口设为 4,为什么还能发出 5 条?

这个数字值得单独看一眼。

第一轮结束,库里有一问一答,共 2 条。重启后问第二轮,模型收到"2 条历史 + 1 条新问题";第二轮回复保存后,窗口达到 4 条。

第三轮开始时,Advisor 先读取这 4 条,再拼上当前问题。因此,这一次记录到的模型输入是 5 条。

随后记忆窗口继续更新,第三轮结束后,数据库里留下最近两轮的 4 条消息。最早包含"松果"的那一轮已经被移出。代码也检查了这个变化。

在本文使用的 2.0.1 中,可以直接对照 MessageChatMemoryAdvisor 的调用前处理:先构造包含历史的请求,再把新用户消息加入记忆。maxMessages 约束的是保存的消息窗口,不能拿它当作每次模型请求的 token 上限。

还有一个直接影响产品设计的结果:窗口里的旧消息会被移除。这张记忆表不能兼任"所有聊天记录的永久档案"。如果聊天页面需要翻看完整历史,应另外保存完整记录,再明确哪些记录参与模型上下文。

接回真实模型时,先保留这个检查习惯:看最终发出去的消息列表,再看回答是否正确。列表里压根没有上一轮,调提示词解决不了这段缺失;列表已经完整,才继续查截断、角色、模型能力和回答约束。

本例只覆盖串行的文本对话,没有工具调用。同一会话并发请求也没有在这里处理;要支持多端同时发送,需要另外设计会话级串行化或冲突控制,不能把这次重启实验当成并发保证。

相关推荐
devpotato1 小时前
HashMap 扩容机制:从源码细节到工程实践
java
yueping21 小时前
如何用idea打开jar包
java
IT_Octopus1 小时前
从一个应用开发者的角度,搞懂大数据查询的完整链路
java·大数据·数据库
许彰午1 小时前
51-BpmnDesigner集成
java·低代码·架构
蜗牛互联网1 小时前
Claude放宽生命科学限制:代价是验证、分级和30天留存
java·人工智能·后端
泡海椒2 小时前
评分系统最佳实践:JQuick-Java实现权重、阈值动态配置评分
java·人工智能·python
三克的油2 小时前
java-学习1
java·开发语言·学习
mldong2 小时前
弃用 ZCode,转 DeepSeek Harness:我用 GitHub Actions 自建 Windows 打包的全实录
java·架构
Wang's Blog3 小时前
Java 接入Redis: Redis下载与源码编译安装
java·服务器·redis