聊天记录还在,模型为什么看不到:用 Spring AI 2.1 拆开日志与上下文

聊天记录表里能查到旧消息,模型这一轮却看不到它们。查数据库连接之前,先看组装上下文时到底取了哪些消息。

用 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。消息存在却未进入活跃视图,就沿过滤与压缩规则往下查;两边都找不到,再查存储和会话选择。

相关推荐
Deng桑3 天前
SpringAI的Advisors-用拦截器的思维管Agent
agent·spring ai·advisor·横切逻辑
nice_lcj5203 天前
【从零理解 Spring AI · 03】Spring AI 在 Spring 生态里的位置
java·spring boot·大模型·spring ai·ai应用开发
Flynn Liang3 天前
AgentScope Java 深度解析:ReActAgent、HarnessAgent 与多智能体编排——JVM 上的自治 Agent 底座
java·大模型·架构设计·ai agent·spring ai·langchain4j·agentscope
Deng桑3 天前
LangChain4j写一次工具调用-SpringAI的另一副面孔
spring ai·langchain4j
Flynn Liang4 天前
Spring AI 2.0 深度解析:ChatClient、Advisor 链与 MCP——Java 工程师的 AI 落地最优解
java·人工智能·spring·大模型·架构设计·spring ai
瑞码空间6 天前
面向接口编程与开闭原则在模拟 JDBC 驱动中的应用
java·jdbc
lgcgkCQ8 天前
【AI 转型实战 1】SpringAI 入门:搭建基础大模型对话能力
java·ai·agent·rag·spring ai·mcp·langchain4j
Deng桑10 天前
SpringAI写第一个Agent-把工单循环跑起来
java·agent·工具调用·spring ai
古法老登瞎折腾11 天前
大数据智能运维实战(十一):运维 MCP 的工程化——权限、限流、审计与错误处理
java·大数据·运维·spring ai·mcp