ChatMemory 要被弃用了?Spring AI 2.1 用事件溯源重做 Agent 记忆

0 阅读19分钟

ChatMemory 要被弃用了?Spring AI 2.1 用事件溯源重做 Agent 记忆

你要是现在去搜"Spring AI 多轮对话记忆怎么实现",翻出来的教程十有八九还在写这几样东西:ChatMemory、MessageWindowChatMemory、MessageChatMemoryAdvisor,然后配一个 InMemoryChatMemoryRepository 或者 Redis 的仓库,告诉你这样 Agent 就有记忆了。

我不怪这些教程,它们在 2.0 时代确实是标准答案。但你要是把依赖升到 2.1,打开 IDE 会发现一个有点吓人的现象:上面那几个类,名字全被划上了删除线。官方把它们整套标记成 @Deprecated,并且写得很明白——for removal in 3.0.0,3.0 版本直接删掉。

替代者是一个叫 Session 的新抽象。它不是给旧接口换了个名字,而是把"记忆"这件事的存储模型整个换了:从一个会被原地修改、超长就丢消息的列表,变成一条只追加、不可变、还能随时重放的事件日志。

这篇我想讲清楚四件事:旧的 ChatMemory 到底差在哪、新的 Session 是怎么设计的、代码上怎么落地和迁移,以及一个官方博客自己都"翻车"了的地方——它当初重点宣传的多 Agent 分支隔离,在正式进 2.1 之前被维护者自己删掉了。

一、先看旧的 ChatMemory,问题到底出在哪

先把旧方案的模型摆出来。ChatMemory 的思路非常直白:每个会话 id 对应一个消息列表,模型每说一轮,就往列表尾部加一条;等列表长度超过窗口容量,就把最老的消息踢出去。

conversation-id-1: [msg1, msg2, msg3, ..., msgN]
                       └── 超了就从这里开始驱逐

短对话里这完全没问题。问题出在对话变长、而且中间夹着工具调用的时候。

截断不管"轮次",会把一次完整交互腰斩

你得先记住一个单位:一轮(turn)。用户说一句话,后面跟着助手的思考、工具调用、工具返回结果、助手基于结果给出的最终回答——这一整段才是一次完整的交互,它从一条用户消息开始,到下一条用户消息之前结束。

而 ChatMemory 是按"消息条数"淘汰的,它根本不看你这条消息处在一轮里的什么位置。于是可能出现这种情况:

USER      "帮我查下广州的天气"
ASSISTANT  (调用 getWeather 工具)
─────────── 截断线正好切在这里 ───────────
TOOL      {"temp": 28, "city": "广州"}
ASSISTANT "广州现在 28 度"

工具调用被留下了、工具结果被踢掉了,或者反过来。模型下一轮拿到的上下文里,就会出现一个没有对应调用的工具结果,或者一个永远等不到结果的工具调用。这种结构破损的输入喂给模型,它的行为基本是不可预测的——轻则答非所问,重则开始鬼打墙。

更难受的是第二点:被踢出去的消息是真的没了。你不知道具体丢了哪几条,也没法事后找回来。记忆系统自己对"遗忘"这件事没有任何记录。

公允地说一句,旧方案后来补了这个坑

我不想把 ChatMemory 批判得一无是处,那样不客观。在 2.1 的文档里,MessageWindowChatMemory 其实已经改成按整轮淘汰了——切点会对齐到轮次边界,不会再把工具调用和结果切开。

所以如果你对 Session 的理解停留在"它就是不会腰斩工具链",那是低估了这次改动。腰斩问题旧方案的后期版本已经解决了,Session 真正的差异,是换掉了存储模型本身。

在 ChatMemory 里,记忆是一个可变的、会被原地删除的列表;而在 Session 里,记忆是一条不可变的、只追加的事件日志,你看到的"上下文"只是这条日志在某个时刻的一个投影。这是两种世界观的差别,下面展开讲。

二、换脑:把记忆从"可变列表"改成"只追加的事件日志"

Session 背后的思路有个正经名字,叫事件溯源(Event Sourcing)。这个词在后端领域并不新鲜,核心就一句话:不要只存"当前状态",而是把"发生过的事"一条条记下来,当前状态需要的时候从这些事件里重新推导。

银行账户是最经典的例子:你不会直接在数据库里存一个"余额"字段然后每次覆盖它,而是记下每一笔收入和支出,余额就是这些流水加总的结果。这样任何时候你都能重放、审计、回到历史某个点。

Spring AI 把同一套思想搬到了对话记忆上。它用四个概念把这件事讲清楚。

session-memory-model.png

1. Session:只存元数据的不可变值对象

Session 代表一次连续对话的身份,但它不装对话内容,只装元数据:会话 id、创建时间、过期时间,以及一个任意的元数据 map。对话的事件日志单独存在仓库里,用到的时候再去取。

有个设计细节值得注意:Session 没有 owner 字段。那怎么知道这个会话属于谁?答案是放进元数据里,键是 SessionAttributes.PRINCIPAL_NAME。这个键所有存储都会建索引,所以想列出某个用户的全部会话,按元数据查就行:

Session session = sessionService.create(CreateSessionRequest.builder()
        .id("session-abc")                          // 不填则随机 UUID
        .timeToLive(Duration.ofDays(7))             // 不填用默认值(默认 60 天)
        .metadata(SessionAttributes.PRINCIPAL_NAME, "alice")
        .metadata(SessionAttributes.AGENT_NAME, "support")
        .build());

2. SessionEvent:日志的最小单位,追加后永不修改

真正的内容载体是 SessionEvent。它包了一条 Spring AI 的 Message,然后补上 Message 本身故意不带的几样东西:事件 id、所属会话 id、时间戳、框架标记。

事件类型 EventType 是开放的:内置 MESSAGE 和 COMPACTION 两种,你也可以把自己业务里发生的事(比如"任务进度""人工审批结果")记进同一条日志。不过要注意,只有 MESSAGE 类型的事件参与提示词组装和压缩,自定义事件只是安静地躺在日志里。

SessionEvent progress = SessionEvent.builder()
        .sessionId(sessionId)
        .type(EventType.of("MY_APP.PROGRESS"))
        .payload("step", "research")
        .build();

3. Turn:一切操作的原子单位

前面提过的轮次,在 Session 里是一等公民。一个 turn = 一条用户消息 + 到下一条用户消息之前的所有事件。后面你会看到,无论是压缩还是读取,所有操作都在 turn 这个粒度上进行,这就从机制上保证了不会出现"半轮"的上下文。

4. Active View:发给模型的,是日志的一个投影

这是整个设计里最关键的一环。日志是只追加、不会变的,那模型每次看到的"当前上下文"是什么?

答案是活动视图(Active View),它不是一个被存起来的东西,而是读取时用 getActiveMessages() 现算出来的投影。一次压缩会往日志里追加一个 COMPACTION 边界事件,由这个边界点名"视图里现在包含哪些事件",边界之后新追加的事件也自动进入视图。

于是一个事件的一生可以分成三个状态:

session-event-lifecycle.png

  • 在视图(In view):新事件刚追加,EventFilter.active() 能读到,会发给模型;
  • 已覆盖(Covered):追加了一个不再点名它的压缩边界,它离开活动视图,但仍在日志里,用 EventFilter.all() 还能读到;
  • 消失(Gone):只有整个会话被删除、或者过期清理时,才会真正消失。

看明白这个三态,你就懂了 Session 最值钱的地方:压缩永远不删除任何东西。 旧方案里"被踢出窗口就找不回"的问题,在这个模型里压根不存在——原始日志一直都在,活动视图只是它的一个读取窗口。

三、上下文压缩:什么时候压、压什么、怎么保住要点

这一节具体讲压缩。它由两个可以自由组合的抽象驱动,分工很清楚:

  • **触发器(Trigger)**回答"什么时候该压了";
  • **策略(Strategy)**回答"压的时候让哪些东西离开视图"。

触发器:按轮次、按 token,或者组合

new TurnCountTrigger(20);                          // 超过 20 轮触发
TokenCountTrigger.builder().threshold(4000).build(); // 估算 token 到 4000 触发
CompositeCompactionTrigger.anyOf(triggerA, triggerB); // 任一满足就触发

这里有个容易踩的小坑:TurnCountTrigger(n) 是"超过 n 轮"才触发,恰好等于 n 轮时不触发。另外,trigger 和 strategy 必须成对配置,只配一个,构建时直接抛 IllegalArgumentException——这是框架在帮你兜底,防止你配了个永远不会生效的压缩。

窗口策略:保留最近的一段

WindowCompactionStrategy 是最常用的策略,它保留最近的对话、覆盖更早的部分,留多少可以按三种口径指定:

WindowCompactionStrategy.builder().lastEvents(20).build();    // 最近 20 个事件
WindowCompactionStrategy.builder().lastTurns(10).build();     // 最近 10 轮
WindowCompactionStrategy.builder().tokenBudget(4000).build(); // 4000 token 内放得下的最新事件

不管用哪种口径,它都给你两条硬保证:

  1. 保留窗口一定从一轮的开头开始。 切点如果落在轮次中间,会向前挪到下一个轮次开头,绝不会留下工具结果却丢了开启这轮的用户消息。
  2. 最新一轮永远保留。 哪怕这一轮本身就超长(比如一个很长的工具循环),也不会被压缩覆盖,保证正在进行的对话永远完整。

系统消息则会被钉(pin)在视图最前面,永远不被摘要、不占窗口名额,每轮都稳定地出现在上下文开头。

滚动摘要:用一个小模型,给被压掉的内容留个"备忘"

纯窗口策略有个代价:被覆盖的内容虽然还在日志里,但对模型的活动视图而言等于"消失"了。如果这些内容里有需要记住的要点怎么办?

可以给策略配一个 ChatModelSummarizer。它会把被覆盖的轮次浓缩成一个摘要轮次——形式是一条"影子用户消息"加一条助手摘要,这样模型看到的始终是用户/助手交替的完整结构。

WindowCompactionStrategy strategy = WindowCompactionStrategy.builder()
        .lastTurns(10)
        .summarizer(ChatModelSummarizer.builder(chatModel)
                .overlapSize(2)                              // 把活动窗口的 2 个事件喂进摘要,保证连贯
                .shadowPrompt("请总结我们到目前为止的对话。")
                .chatOptions(ChatOptions.builder().model("small-model").build()) // 用便宜的小模型做摘要
                .onSummarizationFailure(req -> { /* 报警、打点 */ })
                .build())
        .build();

它叫"滚动摘要"是有原因的:前一份摘要会作为上下文喂给下一次摘要,所以每一份摘要都建立在前一份之上,像滚雪球一样累积,而不是每次从零开始。用小模型做这件事,成本几乎可以忽略,但能显著降低长对话里关键信息丢失的概率。

四、为什么它敢上生产:幂等、乐观锁、可插拔存储

讲到这,你可能会有个疑问:日志只追加、还要支持多实例并发写,万一两个请求同时往一个会话里写、或者压缩和新消息撞在一起,会不会乱?

Session 靠三个工程细节把这些问题压住了。

第一,追加是幂等的。 对同一个已经提交过的事件 id 重试追加,是一个空操作:不报错、不重复、也不推进日志版本。网络抖动导致的重试不会写出重复数据。

第二,并发写入靠乐观锁,而不是悲观锁。 每个会话维护一个事件版本号,批量追加的 API 长这样:

boolean ok = sessionService.appendEvents(sessionId, events, expectedVersion);

它的语义是"只有日志当前版本还等于我读到的 expectedVersion,才把这批事件写进去",本质就是一次比较并交换(CAS)。如果期间有别的写入者,这次调用返回 false,你重新读一遍、重建事件再写就行。压缩的边界和摘要就是用这个方式落盘的,撞车了会有限次重试,不会把摘要白算一遍。

第三,顺序按插入顺序,而不是时钟。 JDBC 仓库里维护一个单调递增的 seq 列,事件先后以它为准。这避开了分布式环境里多台机器时钟不一致、回拨之类的麻烦。

存储本身也是可插拔的:

  • InMemorySessionRepository 给测试和本地开发用,重启就没;
  • JdbcSessionRepository 是生产选项,支持 PostgreSQL、MySQL、MariaDB、H2,两张表(会话表 + 只追加的事件表);
  • 你想接别的存储(比如自家的 KV),实现 SPI 后用 spring-ai-test 里的契约测试验证一遍,行为和官方实现对齐即可。

这里顺手记三个实战里很容易忽略的点,都是不报错、但行为不对的那种坑:

  • deleteExpiredSessions 不会自己运行,得你在调度器里定时调(比如每小时一次);
  • findByMetadata 只对建了索引的键有效。principalName 始终有索引,其他键要通过 spring.ai.session.repository.jdbc.indexed-attributes 显式声明。否则会出现"内存仓库查得到、JDBC 仓库返回空列表"的诡异差异,测试好好的、上线就失灵;
  • SystemMessage 默认拒绝落盘,需要的话得在构建 DefaultSessionService 时显式打开。

五、ChatClient 落地:每次必传会话 id,Advisor 自己无状态

前面讲了不少底层概念,实际接进 ChatClient 反而非常简单,一个 SessionAdvisors 就把脏活全包了:

@Bean
ChatClient chatClient(ChatModel chatModel, SessionService sessionService) {
    return ChatClient.builder(chatModel)
            .defaultAdvisors(SessionAdvisors.builder(sessionService)
                    .compaction(new TurnCountTrigger(20),
                            WindowCompactionStrategy.builder().lastTurns(10).build())
                    .build())
            .build();
}

String answer = chatClient.prompt()
        .user("你好")
        .advisors(SessionAdvisors.session("session-abc"))   // 每次调用都要带会话 id
        .call()
        .content();

每次请求,Advisor 自动做这几件事:加载活动视图、把历史前置到提示词、把这一轮新产生的用户消息和助手回复追加进日志、触发器命中就执行压缩。应用代码里完全不用手写这些。

有两个设计决策我觉得特别值得讲。

第一,刻意没有"默认会话",每次调用必须显式传 id。 为什么?因为如果给一个全局默认会话,不同用户的历史会被悄无声息地写进同一条日志,串号是迟早的事。框架宁愿让你多传一个参数,也不替你冒这个险。会话不存在也没关系,首次使用会自动创建。

第二,Advisor 自己是无状态的。 它只在单次请求里干活:读视图、调模型、追加事件,请求结束手里不留任何东西。这意味着一个会话的第一轮可以在 A 实例跑,第二轮可以在 B 实例跑,随便负载均衡、随便扩缩容。

这其实和我前面两篇讲的东西是同一条主线:MCP 2.0 把状态从协议里删掉、DeepSeek Super-agent 的评估器无状态,到这里 Spring AI 的记忆运行时也无状态——状态全部下沉到外部存储,计算节点保持无状态,这是当下这套 Agent 基础设施能水平扩展的共同前提。

六、一个"翻车点":被删掉的多 Agent 分支隔离

好,现在讲这篇里我最想让你知道的一个"破案"细节,因为它直接关系到你照着官方博客写代码会不会踩空。

还记得 4 月那篇官方博客《Spring AI Agentic Patterns Part 7》吗?它用了不小的篇幅讲一个叫 branch(分支隔离) 的多 Agent 特性:让所有子 Agent 共享同一个会话,给每个事件打一个点分路径的标签(比如 orch、orch.researcher、orch.writer),读取时按分支过滤,保证 researcher 看不到 writer 的内容。

听起来很美好。但真相是:这套 branch API 在正式进入 Spring AI 2.1 之前,被维护者自己删掉了。

时间线很清楚:

版本时间变化
社区 0.2.02026-04博客描述的版本,有 branch,但 Advisor 只在读侧过滤、写侧不打标签,实际隔离不生效
社区 0.9.02026-09-27整套 branch API 标记为待移除
社区 0.10.02026-09-28直接删除
官方 2.1.0-RC12026-10源码里没有 branch,建表脚本里也没有这一列

为什么删?因为它和压缩合不来,也没服务好真实场景

维护者在决策记录里给的理由挺实在的。首先他们意识到,"多 Agent 协作"其实是两种不同的模式,branch 夹在中间,哪边都没服务好:

  • 接力(handoff):几个 Agent 轮流和用户对话,每个都该看到完整对话,这种共享一个会话、根本不需要过滤;
  • 委派(delegation):父 Agent 把任务交给子 Agent、只拿回一个结果,这种最合适的是每个子 Agent 一份独立历史,只有任务进去、只有结果出来。

更要命的是 branch 和压缩机制天然冲突:压缩的单位是根轮次,预算却把所有分支加一起算;递归摘要写的是根级事件、对所有 Agent 可见,结果就是一个子 Agent 的工作内容会通过摘要"泄露"给兄弟 Agent;当活动窗口里没有根级用户消息时,甚至会触发把整个窗口归档的数据丢失 bug。

于是结论很干脆:删掉 branch,文档直接推荐"一个子 Agent 一个会话"。

现在的正确姿势:把子 Agent 暴露成工具

在 2.1 里做委派式多 Agent,标准做法是把子 Agent 包装成一个工具。工具被调用时,从父会话 id 派生出一个稳定的子会话 id,子 Agent 用自己的 ChatClient(独立的系统提示词、独立的压缩参数)干活,只把最终答案返回给父会话。

public class SubAgentTools {

    private final SessionService sessions;
    private final ChatClient researcher;

    public SubAgentTools(SessionService sessions, ChatClient researcher) {
        this.sessions = sessions;
        this.researcher = researcher;
    }

    @Tool(description = "把研究任务委派给 researcher,返回它的结论")
    public String research(@ToolParam(description = "研究任务") String task,
                          ToolContext toolContext) {
        String parentId = (String) toolContext.getContext()
                .get(MemoryAdvisor.CONVERSATION_ID);

        String childId = parentId + ":researcher";   // 稳定 id,子 Agent 记得本会话里之前的任务
        sessions.findOrCreate(CreateSessionRequest.builder()
                .id(childId)
                .metadata("parentSessionId", parentId)
                .build());

        // 只有任务进去,只有最终结果回来
        return researcher.prompt()
                .user(task)
                .advisors(SessionAdvisors.session(childId))
                .call()
                .content();
    }
}

session-multi-agent.png

这样跑出来的结果是:researcher 只看得到自己的任务和自己的历史,既看不到编排者的对话,也看不到兄弟 Agent 在干嘛;编排者也看不到子 Agent 内部的工具调用,只拿到最终答案。

这个"翻车点"给我的提醒是:看技术博客一定带上版本号。 这套东西在社区孵化期是 0.x 版本,维护者明确保留了 1.0 之前做破坏性变更的权利,博客里的截图和代码很可能已经过时。照着写之前,先确认你用的版本里这个 API 还在不在。

七、顺带一提:工具确认也是"无状态"的

2.1 还有一个和 Session 同期上线的特性,叫工具调用确认(Human-in-the-Loop),我觉得它和 Session 的设计哲学是一套的,值得一起说。

场景很具体:订机票、删文件、发邮件、转账这类高危操作,你不希望模型一调用就执行,得有个人先点头。只要在工具上标一个注解:

class BookingTools {
    @Tool(description = "订机票", requiresConfirmation = true)
    String bookFlight(String flightNumber) {
        return bookingService.book(flightNumber);
    }
}

模型调用这个工具时,工具循环会在执行前暂停,这次回复里的所有调用都不会运行,而是把"等待决策的调用列表"作为一个普通响应返回给应用。应用把它展示给真人,收集到允许或拒绝的决策后,再带着结果发起下一次请求。

最妙的是那句设计原则:暂停是一个普通响应,恢复是一个普通请求。 等待期间框架不保存任何挂起线程、不存任何会话状态,所以应用重启也不怕,只要对话(也就是你的 Session 日志)还在。确认策略还能按参数来,比如只拦截金额超过 1000 的转账。

你看,这和 Session 的"运行时无状态"、MCP 的无状态,又是同一个思路。

八、划清边界:Session 是短期记忆,不是记忆的全部

最后得把一个边界划清楚,免得你以为上了 Session 就万事大吉。

Session 管的是短期记忆:一次会话内、给模型看什么、怎么压缩。它不负责"跨会话记住一个用户的长期偏好和事实"。后者在 Spring AI 的体系里是 AutoMemoryTools(以及做记忆整理的 AutoDreamService)的活——把值得长期保留的事实提炼出来,独立于具体会话存好。

官方原话说得很直白:一个完整的 Agent 记忆栈,短期和长期两者都需要,谁也替代不了谁。这部分我在前面第八篇《给 Agent 塞满 128K 上下文,它还是会失忆?》里详细聊过记忆分层和上下文工程的通用原理,这里就不重复了,这篇聚焦的是框架里这层抽象具体怎么演进、怎么落地。

写在最后:怎么迁移,以及别急着上头

如果你是从社区版或者 ChatMemory 迁过来,有几个高频的 API 改名先记一下,能少踩点坑:

旧写法2.1 里的对应
CreateSessionRequest.builder().userId(...)没有 userId,归属放元数据 PRINCIPAL_NAME
SessionMemoryAdvisor + compactionTrigger/StrategySessionAdvisors.builder().compaction(trigger, strategy)
SlidingWindowCompactionStrategyWindowCompactionStrategy.lastEvents(...)
先 findById 再 create 并捕获异常直接 findOrCreate,并发安全
手动 param 传会话 keySessionAdvisors.session(id)

最后泼三句冷水,免得你看完热血上头就把生产环境改了:

  1. 现在最新稳定版还是 2.0.2,Session 相关的构件只在 2.1.0-RC1 里,官方文档页首也写着"仍在开发中、尚不稳定",GA 之前个别 API 还可能微调;
  2. Cassandra、Neo4j、MongoDB、Redis 这些存储的 Session 对应实现还没出,官方计划在 3.0.0 之前补齐,VectorStoreChatMemoryAdvisor 也暂时保留;
  3. 我的建议是:新项目、新模块可以直接按 Session 设计,老项目在 3.0 之前按迁移指南慢慢换,没必要在生产上追一个 RC 版本。

但方向是确定的。把"记忆"从一个会被原地修改的列表,重构成一条只追加、可重放、可投影、还能安全压缩的事件日志,这不只是 Spring AI 一家的选择,而是整个 Agent 基础设施走向工程化、可运维的一个缩影。理解了事件溯源这套底层逻辑,换哪个框架你都能看得懂它在干什么。