聊天记录表里能查到旧消息,模型这一轮却看不到它们。查数据库连接之前,先看组装上下文时到底取了哪些消息。
用 Spring AI 2.1.0-RC1 的 Session API 写入 6 条消息,再把活跃视图缩到最近一轮,会得到三个不同的数字:日志 7 条、原始消息 6 条、活跃消息 4 条。关闭 JVM 后用另一个 JVM 重开文件数据库,三个数字都保留下来。
多出来的一条是压缩边界:它记录以后从日志的哪里开始取事件,早先的消息仍在原处。上下文缩小了,日志反而增加了一条,两者并不矛盾。Session 概念与活跃视图
本文核验于 2026-11-01,使用的 2.1.0-RC1 是预览版,当时官方列出的稳定版为 2.0.2。实验直接调用 SessionService 和 JDBC 仓库,没有测试 ChatClient advisor,也没有测试任何模型的回答质量。Spring AI Session Memory
六条消息,为什么留下七条事件
日志记录发生过的事:用户问题、助手回复、工具调用、工具结果和压缩边界。活跃视图再从中选择当前要用的内容,原始材料和本次上下文因此可以各自保留合适的范围。

图注:左侧保留两轮对话及新增的压缩边界,右侧只投影最近一轮的四条消息。旧轮次离开活跃视图后仍在日志中;这张图解释本次实验,不代表所有压缩策略都会产生摘要。
本次采用不生成摘要的窗口策略,最近一轮包含四条消息:用户提问、助手发起工具调用、工具返回、助手给出回答。只保留最后两条,会让工具结果缺少对应调用。实验里请求 lastN(2),实际取回了四条,因为读取窗口回退到了这一轮的起点。
lastN(2) 的 2 因而不是结果条数的硬上限。读取窗口向前补齐它落入的轮次,压缩策略也按完整轮次保留近期内容;工具交互越多,一轮里的消息就可能越多。EventFilter 的窗口规则
写入一次,再用另一个 JVM 打开
环境为 Windows 11、OpenJDK 21.0.2、Maven 3.9.12,数据库使用文件形式的 H2 2.3.232。复现时使用新目录,每次创建自己的数据库文件。
新建空目录,把以下内容存为 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>example.csdn</groupId>
<artifactId>session-memory-fixture</artifactId>
<version>1.0.0</version>
<dependencies>
<dependency>
<groupId>org.springframework.ai</groupId>
<artifactId>spring-ai-session-jdbc</artifactId>
<version>2.1.0-RC1</version>
</dependency>
<dependency>
<groupId>com.h2database</groupId>
<artifactId>h2</artifactId>
<version>2.3.232</version>
</dependency>
</dependencies>
</project>
再保存完整的 SessionExperiment.java:
java
import java.util.List;
import org.h2.jdbcx.JdbcDataSource;
import org.springframework.ai.chat.messages.AssistantMessage;
import org.springframework.ai.chat.messages.Message;
import org.springframework.ai.chat.messages.ToolResponseMessage;
import org.springframework.ai.chat.messages.UserMessage;
import org.springframework.ai.session.CreateSessionRequest;
import org.springframework.ai.session.DefaultSessionService;
import org.springframework.ai.session.EventFilter;
import org.springframework.ai.session.InMemorySessionRepository;
import org.springframework.ai.session.SessionEvent;
import org.springframework.ai.session.SessionService;
import org.springframework.ai.session.compaction.WindowCompactionStrategy;
import org.springframework.ai.session.jdbc.JdbcSessionRepository;
import org.springframework.core.io.ClassPathResource;
import org.springframework.jdbc.datasource.init.ResourceDatabasePopulator;
public class SessionExperiment {
static void check(boolean condition, String name) {
if (!condition) throw new AssertionError(name);
}
static void append(SessionService service, String session, String id, Message message) {
service.appendEvent(SessionEvent.builder().sessionId(session).id(id).message(message).build());
}
static List<String> ids(List<SessionEvent> events) {
return events.stream().map(SessionEvent::getId).toList();
}
static SessionService memory() {
return DefaultSessionService.builder()
.sessionRepository(InMemorySessionRepository.builder().build()).build();
}
public static void main(String[] args) {
if (args.length != 2) throw new IllegalArgumentException("write|read database-file");
var ds = new JdbcDataSource();
ds.setURL("jdbc:h2:file:" + args[1].replace('\\', '/'));
ds.setUser("sa");
if (args[0].equals("write")) {
new ResourceDatabasePopulator(new ClassPathResource(
"org/springframework/ai/session/jdbc/schema-h2.sql")).execute(ds);
}
var service = DefaultSessionService.builder()
.sessionRepository(JdbcSessionRepository.builder().dataSource(ds).build()).build();
var expectedActive = List.of("a-u2", "a-call2", "a-result2", "a-answer2");
if (args[0].equals("write")) {
service.create(CreateSessionRequest.builder().id("alice-chat").build());
service.create(CreateSessionRequest.builder().id("bob-chat").build());
var first = SessionEvent.builder().sessionId("alice-chat").id("a-u1")
.message(new UserMessage("First question")).build();
service.appendEvent(first);
service.appendEvent(first); // Same event id: retry, not another message.
append(service, "alice-chat", "a-answer1", new AssistantMessage("First answer"));
append(service, "alice-chat", "a-u2", new UserMessage("Check local status"));
append(service, "alice-chat", "a-call2", AssistantMessage.builder().content("")
.toolCalls(List.of(new AssistantMessage.ToolCall("c-2", "function", "status", "{}"))).build());
append(service, "alice-chat", "a-result2", ToolResponseMessage.builder().responses(
List.of(new ToolResponseMessage.ToolResponse("c-2", "status", "ready"))).build());
append(service, "alice-chat", "a-answer2", new AssistantMessage("Status is ready"));
append(service, "bob-chat", "b-u1", new UserMessage("Bob-only fixture"));
append(service, "bob-chat", "b-answer1", new AssistantMessage("Bob answer"));
check(service.getEvents("alice-chat").size() == 6, "idempotent append");
var window = service.getEvents("alice-chat", EventFilter.lastN(2));
check(ids(window).equals(expectedActive), "window retains whole turn");
System.out.println("BEFORE log=6 lastN(2)=" + window.size() + " ids=" + ids(window));
var result = service.compact("alice-chat", request -> true,
WindowCompactionStrategy.builder().lastTurns(1).build());
check(result.coveredEvents().size() == 2, "first turn covered");
check(service.getEvents("alice-chat").size() == 7, "boundary appended");
check(ids(service.getEvents("alice-chat", EventFilter.active())).equals(expectedActive), "active view");
System.out.println("AFTER log=7 messageLog=" + service.getMessages("alice-chat").size()
+ " active=" + service.getActiveMessages("alice-chat").size() + " covered=2");
var ephemeral = memory();
ephemeral.create(CreateSessionRequest.builder().id("memory-chat").build());
System.out.println("MEMORY writeProcess sessionExists=" + (ephemeral.findById("memory-chat") != null));
System.out.println("PASS WRITE: framework JDBC/H2 fixture; no model request");
} else if (args[0].equals("read")) {
check(service.getEvents("alice-chat").size() == 7, "persisted log");
check(ids(service.getEvents("alice-chat", EventFilter.active())).equals(expectedActive), "persisted view");
var active = service.getActiveMessages("alice-chat");
var call = ((AssistantMessage) active.get(1)).getToolCalls().getFirst();
var response = ((ToolResponseMessage) active.get(2)).getResponses().getFirst();
check(call.id().equals("c-2") && response.id().equals("c-2"), "tool pair round trip");
check(service.getMessages("bob-chat").size() == 2, "second session unaffected");
check(service.getMessages("alice-chat").stream().noneMatch(m -> m.getText().contains("Bob")), "no cross-session content");
check(memory().findById("memory-chat") == null, "new in-memory repository is empty");
System.out.println("REOPEN aliceLog=7 aliceActive=4 bobMessages=2 crossSessionLeak=false toolPairRestored=true");
System.out.println("MEMORY readProcess sessionExists=false");
System.out.println("PASS READ: second JVM recovered database state");
} else throw new IllegalArgumentException("write|read required");
}
}
程序有两个入口:write 建表、写入并压缩;read 只重新打开同一数据库并校验。它们要用两条 java 命令运行,才能验证进程结束后的状态,而不只是重新创建一个服务对象。
PowerShell 执行:
powershell
mvn -B -ntp dependency:build-classpath '-Dmdep.outputFile=classpath.txt'
$fixtureClasspath = (Get-Content -LiteralPath classpath.txt -Raw).Trim()
New-Item -ItemType Directory -Force classes | Out-Null
javac -encoding UTF-8 -cp $fixtureClasspath -d classes SessionExperiment.java
$fixtureRunDir = Join-Path $PWD ('runs/' + [Guid]::NewGuid().ToString('N'))
New-Item -ItemType Directory -Force $fixtureRunDir | Out-Null
$fixtureDatabase = Join-Path $fixtureRunDir 'sessions'
java -cp ("classes;" + $fixtureClasspath) SessionExperiment write $fixtureDatabase
java -cp ("classes;" + $fixtureClasspath) SessionExperiment read $fixtureDatabase
每次使用新的目录,避免复跑时与旧会话 ID 冲突。write 模式的建表脚本来自依赖包中的 H2 schema;没有启动 Spring Boot,因此需要显式初始化。官方 JDBC 仓库与 schema 入口
本次执行的关键输出如下,省略日志实现提示及本机绝对路径:
text
BEFORE log=6 lastN(2)=4 ids=[a-u2, a-call2, a-result2, a-answer2]
AFTER log=7 messageLog=6 active=4 covered=2
MEMORY writeProcess sessionExists=true
PASS WRITE: framework JDBC/H2 fixture; no model request
REOPEN aliceLog=7 aliceActive=4 bobMessages=2 crossSessionLeak=false toolPairRestored=true
MEMORY readProcess sessionExists=false
PASS READ: second JVM recovered database state
确认两个进程都正常退出并打印各自的 PASS。第二个 JVM 还读回工具调用与工具响应,检查两者 ID 都是 c-2,通过后才输出 toolPairRestored=true。

图注:对照写入进程与重开进程。文件数据库保留了 7 条事件和 4 条活跃消息;新建的内存仓库找不到上个进程创建的会话。Bob 的两条消息单独校验,表示按会话 ID 读取的选择范围没有混入 Alice。
旧消息没进上下文,还是存储没有恢复
如果页面能查到旧记录,模型却看不到,先比较完整日志和活跃消息。这个实验中 getMessages 返回 6 条,getActiveMessages 返回 4 条,这是窗口策略的预期结果。如果把完整日志再次直接拼进 prompt,就会把刚刚从上下文移出的内容重新塞回去。
重启后连会话都找不到,则先检查仓库类型和数据库地址。本次重开的是同一个 H2 文件;多实例并发、数据库故障恢复,以及 PostgreSQL、MySQL 等其他仓库的行为,还要在实际部署方式下验证。
另一个反例是把所有用户都传到同一个固定 session ID。存储完全正常,读到的却是混合历史。这时更换压缩算法没有帮助。应用应该从已验证的用户身份确定其可访问的会话,再将该会话 ID 交给 Session API。一个随机 ID 或可猜不出的字符串,都不能替代访问权限检查。
缩小上下文以后,原始记录还在,存储成本和访问权限也仍然需要管理。日志保留多久、谁能查询、过期如何删除,都应有独立规则。若要真正遗忘某段敏感内容,还要同时处理原始存储、备份和派生摘要。
排查"记忆丢了"时,先并排打印完整消息 ID 和活跃消息 ID。消息存在却未进入活跃视图,就沿过滤与压缩规则往下查;两边都找不到,再查存储和会话选择。