Spring AI Session API 深度实战:从 ChatMemory 平滑迁移到事件溯源的企业级短期记忆 📅 发布时间:2026/9/8 16:01:40 👁 浏览次数: 引言:电商客服 Agent 的"工具调用幻觉"是怎么来的八月中旬一个周一早上,电商客服 Agent 的 SLA 报告被拎到早会上:过去一个月客诉率从 0.4% 升到 3.1%,用户问得最多的原话是"我刚才明明让你查过"。翻日志一看规律很清晰:第 1~3 轮对话里,模型老老实实调queryOrdergetLogistics工具;第 4~8 轮,工具调用记录归零,回复里开始出现"您的订单已签收,物流公司中通,下午 3:20 配送"——可用户问的是上个订单的物流单号。模型在用上一轮工具返回的数据,假装"记得"这一轮。这不是 prompt 写得不够紧,是Spring AI ChatMemory 这个抽象层从 1.0 开始就没把工具调用消息当成一等公民:AssistantMessage里的toolCalls字段、ToolExecutionResultMessage这种"模型 → 框架调工具 → 结果回灌给模型"的中间消息,默认一律不存。某 SaaS 团队的复现脚本里,对话长度从 4 轮拉到 12 轮,工具调用率从 92% 跌到 31%,幻觉率从 4% 涨到 29%。更要命的是第二个问题:消息级(而非轮次级)的滑动窗口会切断工具调用。假设一个 6 条消息的完整轮次是"用户问 → 助手发工具调用 A → 工具 A 返回 → 助手发工具调用 B → 工具 B 返回 → 助手最终回答",当MessageWindowChatMemory.maxMessages(20)的窗口滑动时,可能正好把"助手发工具调用 A"挤出去,把"工具 A 返回"留下。模型下一轮看到的是一个孤立的结果,不知道是谁调了它,只能从 AssistantMessage 的文本片段里搜"已发货 2026-08-12",于是直接复述上去。Spring 团队今年 4 月 15 日发布了 Agentic Patterns 系列第七篇《Session API — Event-Sourced Short-Term Memory with Context Compaction》,宣布ChatMemory 将在 Spring AI 2.1(2026 年 11 月)正式被弃用,取而代之的就是今天要拆的Spring AI Session API。它做对了三件事:Session/SessionEvent 把工具调用中间消息变成一等公民,每条事件带 UUID、时间戳、branch label、METADATA_SYNTHETIC 框架标识Turn(轮次)作为不可切的原子单位,所有压缩策略都强制在 Turn 边界切割,模型永远不会看到孤立的工具结果Compaction 变成可组合的一等概念:触发器(trigger)决定何时压缩,策略(strategy)决定怎么压缩,4 种策略 × 2 种触发器、可 OR 组合今天这篇,我们就从源码级拆穿 Session API 的设计哲学,紧扣一个完整的多 Agent 电商客服实战项目把六个核心组件(Session、SessionEvent、SessionService、CompactionTrigger、CompactionStrategy、SessionMemoryAdvisor)落到位,附上 8 个生产踩坑清单。一、为什么必须从 ChatMemory 迁移:消息列表范式的三重致命缺陷1.1 协议层断层:工具调用中间消息被序列化丢弃Spring AI 1.x 把ChatMemory.add()默认实现成"只追加 Message 列表"。原始的ToolCall/ToolResult结构会被序列化为文本片段丢进去——等同于把医生开的药方复印件抽掉了医嘱栏。OpenAI Function Calling 协议里,模型要看tool_call_id才知道"这是上一条工具调用结果的回复";没这个 id,它就把工具当另一次普通文本交流处理。更隐蔽的是 Spring 1.x 里InMemoryChatMemory内部把AssistantMessage.getToolCalls()这条 JSON 字段直接丢掉了。后来虽然官方在升级日志里写"per-model internal tool execution has been removed from all ChatModel implementations",把锅推给了 ToolCallingAdvisor 接管工具循环,但存储层一断,上下文就缺一块,ConversationHandler 拉回历史时工具调用结构已经残缺。1.2 框架层假设:消息级淘汰的 Turn 边界破坏Spring 团队把 ChatMemory 默认实现定位成"短上下文小记忆窗口"。"省 token"是它的设计动机。"按消息数淘汰"也是直觉上最自然的实现。但凡引入工具调用,"消息级淘汰"就立刻出错:一轮完整对话 = [UserMessage, Assistant(toolCalls), ToolResult, Assistant(toolCalls), ToolResult, Assistant(text)] 六条消息,被 maxMessages=20 的窗口切到剩下 19 条?随便选个起点切,都有可能切断到Assistant(toolCalls)或ToolResult上。模型下一轮要重做这个工具调用时,看到的是"我刚调了 queryOrder 拿到了某条数据",但没有tool_call_id把它和上一轮的 question 关联。1.3 应用层误解:ChatMemory 不能装下工具轮次太多团队把它当成"把对话历史传回去就行"的任务,忽略了AssistantMessage.getToolCalls()才是模型决定"要不要再次调工具"的关键线索。OpenAI / Anthropic 的 Function Calling 协议都明确:模型要看到tool_call_id才知道"上一条工具调用结果的回复"。没这个 id,它就把工具当另一次普通文本交流处理。要修这个缺陷,治本是改ChatMemory.add()把 tool call 序列化进去,但 Spring AI 2.0 GA 后这块仍然没有完全统一,得自己写MessageWindowChatMemory子类。治本不是每个团队都能立刻做的事,迭代节奏太快、停服成本太高。Spring 团队于是干脆换范式——这一换,就是 Session API。二、Session API 的内核:从"消息列表"到"事件溯源"2.1 核心三大抽象Session:不可变的纯元数据值对象。只存sessionId、userId、expiresAt、metadata(业务可放租户、渠道等),事件日志统一在仓储层。源码简化版:public record Session( String id, // UUID String userId, // 归属用户 Instant createdAt, // 创建时间 Instant expiresAt, // TTL,可选 MapString, Object metadata // 自定义元数据 ) { }SessionEvent:包装 Spring AIMessage,补 Message 故意省略的关键信息:public record SessionEvent( String id, // 事件 UUID,保证幂等 String sessionId, // 归属 Session Instant timestamp, // 时间戳 String branch, // 分支标签(多 Agent 隔离) SetEventFlag flags, // 框架标记,如 METADATA_SYNTHETIC Message message // UserMessage / AssistantMessage / ToolResponseMessage ) { }关键设计:id字段是幂等性基石。配合IdempotentSessionEventIdGenerator,重试不会重复插入事件——它会复用模型自身的tool_call_id,没有就哈希sessionId + messageContent。branch字段用点号分隔(root.supervisor.order),实现多 Agent 分支隔离。两个并行子 Agent 写同一个 Session 但只能看到自己分支和祖先分支的事件。METADATA_SYNTHETIC标记"这是 LLM 生成的摘要",后续 Recall Storage 检索时知道是压缩产物。2.2 Turn:不可切的原子单位Turn = 一条 UserMessage + 之后所有 AssistantMessage、ToolCall、ToolResult,直到下一条 UserMessage。Turn 1: [USER "Spring AI 是什么?"] [ASSISTANT text] Turn 2: [USER "它怎么用工具?"] [ASSISTANT(tool call: queryOrder)] [TOOL result] [ASSISTANT text]所有压缩策略操作 Turn 粒度,保留窗口永远从 UserMessage 开始。模型永远不会被一个孤立的工具结果坑到。这是 Session API 存在的全部理由。2.3 持久化 SPI:SessionRepository 与 JDBC 实现public interface SessionRepository { void save(Session session); OptionalSession findById(String sessionId); ListSession findByUserId(String userId); int deleteExpiredSessions(Instant cutoff); void appendEvent(SessionEvent event); ListSessionEvent getEvents(String sessionId, EventFilter filter); // 关键:CAS 替换 boolean replaceEvents(String sessionId, ListSessionEvent expected, ListSessionEvent replacement); }replaceEvents是乐观并发控制的入口:压缩时先读现有事件列表,再算新事件列表,最后用 CAS 替换。如果在你读和写之间有别人写了新事件,CAS 失败,重试。这就是为什么 Session API 不需要锁——多线程、多 A