多轮对话能否跨服务重启延续,关键不只是把消息写入数据库,还要确保本轮请求会主动读取历史并交给模型。下面从一次可复现的 Spring AI 调用入手,检查 JDBC 持久化、消息窗口、Advisor 接线和会话 ID 如何共同决定上下文能否正确恢复。
第一轮告诉 AI:“项目代号是松果,用 Java 开发。”第二轮再问:“刚才的项目代号是什么?”
如果服务刚重启过,这个问题正好能查出一条容易漏接的链路:数据库里虽然有聊天记录,这次请求却可能只发出了第二句话。
先看一个实际跑出来的区别。同一个文件数据库,同样的问题,只改变 ChatClient 是否接入记忆 Advisor:
不接 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 的那个实例。
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:ch@t-1 和 user-2:ch@t-1,各写入一问一答。第一个进程退出后,第二个进程仍访问同一个 H2 文件,并使用原来的会话 ID。
这两项缺一不可。使用 jdbc:h2:mem:,进程退出后数据不会按这个示例保留;每次请求重新生成会话 ID,读取到的也会是一个新会话。
正式接口应从已登录用户和有权访问的会话中确定 ID。前端传来的会话编号需要校验归属,不能只靠“拼了用户 ID”就认为权限已经解决。Spring AI 的会话标识说明也把每个会话的消息范围交给这个 ID 来区分。
本文验证的是两个不同 ID 的消息互不混用,没有实现登录系统。
目录按下面放置:
ch@t-memory-lab/
pom.xml
src/main/java/demo/MemoryLab.java
pom.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>ch@t-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-ch@t</artifactId><version>2.0.1</version>
</dependency>
<dependency>
<groupId>org.springframework.ai</groupId><artifactId>spring-ai-model-ch@t-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:
package demo;
import java.util.List;
import [email protected];
import org.springframework.ai.ch@t.client.advisor.MessageChatMemoryAdvisor;
import [email protected];
import [email protected];
import org.springframework.ai.ch@t.memory.repository.jdbc.JdbcChatMemoryRepository;
import [email protected];
import [email protected];
import [email protected];
import [email protected];
import [email protected];
import [email protected];
import [email protected];
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:ch@t-1";
static final String B = "user-2:ch@t-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/ch@t/memory/repository/jdbc/schema-h2.sql"))
.execute(ds);
var repository = JdbcChatMemoryRepository.builder().dataSource(ds).build();
var memory = MessageWindowChatMemory.builder()
.ch@tMemoryRepository(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,使用同一条数据库路径:
mvn -q compile exec:java "-Dexec.args=seed ./target/ch@t-db"
mvn -q exec:java "-Dexec.args=resume ./target/ch@t-db"
seed 会清空演示用的两个会话再写入首轮消息。需要重跑时,仍按 seed、resume 的顺序执行;只反复执行 resume,库中的状态已经不是首轮结束时的状态。
本次运行输出中的两个进程 PID 不同,其余关键结果如下:
首轮已保存:A=2 条,B=2 条
不接 Advisor:模型入口只有 1 条消息
接入 Advisor 后的模型输入:
USER:项目代号是松果,用 Java 开发。
ASSISTANT:已收到。
USER:刚才的项目代号是什么?
第三轮:模型入口 5 条,保存窗口 4 条
B 会话:3 条输入,只包含 B 的历史
程序检查了消息内容和角色顺序,也检查了 B 的请求没有混进 A 的项目代号。任何一项不符合预期,都会抛出异常,不会只打印一句“验证成功”。
这个数字值得单独看一眼。
第一轮结束,库里有一问一答,共 2 条。重启后问第二轮,模型收到“2 条历史 + 1 条新问题”;第二轮回复保存后,窗口达到 4 条。
第三轮开始时,Advisor 先读取这 4 条,再拼上当前问题。因此,这一次记录到的模型输入是 5 条。
随后记忆窗口继续更新,第三轮结束后,数据库里留下最近两轮的 4 条消息。最早包含“松果”的那一轮已经被移出。代码也检查了这个变化。
在本文使用的 2.0.1 中,可以直接对照 MessageChatMemoryAdvisor 的调用前处理:先构造包含历史的请求,再把新用户消息加入记忆。maxMessages 约束的是保存的消息窗口,不能拿它当作每次模型请求的 token 上限。
还有一个直接影响产品设计的结果:窗口里的旧消息会被移除。这张记忆表不能兼任“所有聊天记录的永久档案”。如果聊天页面需要翻看完整历史,应另外保存完整记录,再明确哪些记录参与模型上下文。
接回真实模型时,先保留这个检查习惯:看最终发出去的消息列表,再看回答是否正确。列表里压根没有上一轮,调提示词解决不了这段缺失;列表已经完整,才继续查截断、角色、模型能力和回答约束。
本例只覆盖串行的文本对话,没有工具调用。同一会话并发请求也没有在这里处理;要支持多端同时发送,需要另外设计会话级串行化或冲突控制,不能把这次重启实验当成并发保证。