Spring AI Session API 深度实战:从 ChatMemory 平滑迁移到事件溯源的企业级短期记忆

引言:电商客服 Agent 的"工具调用幻觉"是怎么来的

八月中旬一个周一早上,电商客服 Agent 的 SLA 报告被拎到早会上:过去一个月客诉率从 0.4% 升到 3.1%,用户问得最多的原话是"我刚才明明让你查过"。翻日志一看规律很清晰:第 1~3 轮对话里,模型老老实实调 queryOrder getLogistics 工具;第 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 :不可变的纯元数据值对象。只存 sessionIduserIdexpiresAtmetadata(业务可放租户、渠道等),事件日志统一在仓储层。源码简化版:

java 复制代码
public record Session(
        String id,                    // UUID
        String userId,                // 归属用户
        Instant createdAt,            // 创建时间
        Instant expiresAt,            // TTL,可选
        Map<String, Object> metadata  // 自定义元数据
) { }

SessionEvent :包装 Spring AI Message,补 Message 故意省略的关键信息:

java 复制代码
public record SessionEvent(
        String id,                    // 事件 UUID,保证幂等
        String sessionId,             // 归属 Session
        Instant timestamp,            // 时间戳
        String branch,                // 分支标签(多 Agent 隔离)
        Set<EventFlag> 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 实现

java 复制代码
public interface SessionRepository {
    void save(Session session);
    Optional<Session> findById(String sessionId);
    List<Session> findByUserId(String userId);
    int deleteExpiredSessions(Instant cutoff);

    void appendEvent(SessionEvent event);
    List<SessionEvent> getEvents(String sessionId, EventFilter filter);

    // 关键:CAS 替换
    boolean replaceEvents(String sessionId, List<SessionEvent> expected, List<SessionEvent> replacement);
}

replaceEvents乐观并发控制 的入口:压缩时先读现有事件列表,再算新事件列表,最后用 CAS 替换。如果在你读和写之间有别人写了新事件,CAS 失败,重试。这就是为什么 Session API 不需要锁------多线程、多 Agent 并发访问同一个 Session 都不会丢消息。

JDBC 实现把 SessionSessionEvent 落到两张表:

sql 复制代码
CREATE TABLE ai_session (
    id VARCHAR(64) PRIMARY KEY,
    user_id VARCHAR(64) NOT NULL,
    created_at TIMESTAMP NOT NULL,
    expires_at TIMESTAMP,
    metadata TEXT,
    version BIGINT NOT NULL DEFAULT 0   -- 乐观锁版本号
);

CREATE TABLE ai_session_event (
    id VARCHAR(64) PRIMARY KEY,
    session_id VARCHAR(64) NOT NULL,
    timestamp TIMESTAMP NOT NULL,
    branch VARCHAR(512),
    flags VARCHAR(256),
    message_type VARCHAR(32) NOT NULL,
    message_content TEXT NOT NULL,
    tool_calls TEXT,                     -- JSON 数组,保留完整结构
    tool_call_id VARCHAR(128),           -- OpenAI/Anthropic 协议 id
    FOREIGN KEY (session_id) REFERENCES ai_session(id)
);
CREATE INDEX idx_event_session_branch_ts ON ai_session_event(session_id, branch, timestamp);

message_contenttool_callstool_call_id 三列联合保证 工具调用中间消息完整落库------这正是 ChatMemory 做不到的。

三、Compaction:可组合的两层抽象

3.1 触发器:什么时候压缩

java 复制代码
public interface CompactionTrigger {
    boolean shouldCompact(CompactionRequest request);  // (session, events, eventCount, turnCount)
}

// 两个内置触发器
new TurnCountTrigger(20);                                 // 超过 20 轮触发
TokenCountTrigger.builder().threshold(4000).build();      // 估算 token 超 4000 触发
// OR 组合:任一条件满足就触发
CompositeCompactionTrigger.anyOf(
        new TurnCountTrigger(20),
        TokenCountTrigger.builder().threshold(4000).build()
);

3.2 策略:怎么压缩

四条内置策略,所有都强制 Turn 边界切割:

策略 LLM 调用 适用场景
SlidingWindowCompactionStrategy 成本敏感的最近 N 条上下文
TurnWindowCompactionStrategy 保留最近 N 个完整轮次
TokenCountCompactionStrategy 硬性 token 预算
RecursiveSummarizationCompactionStrategy 长对话需保留上下文,滚动摘要

前三个是廉价丢弃型------快、零成本、够用就够。RecursiveSummarization 是最贵的:每次压缩时把"准备淘汰的内容 + 上次摘要"一起丢给 LLM 做递归摘要,结果作为 METADATA_SYNTHETIC 事件入库,构建滚动压缩历史。它不再每次重算,所以 token 成本受控。

java 复制代码
RecursiveSummarizationCompactionStrategy compactionStrategy =
        RecursiveSummarizationCompactionStrategy.builder(chatClient)
                .maxEventsToKeep(10)                // 保留最近 10 条真实事件
                .overlapSize(2)                    // 摘要 prompt 喂 2 条活跃窗口做衔接
                .summaryPromptTemplate("...""")    // 自定义摘要 prompt
                .build();

触犯护栏 :触发器和策略必须成对设置------只设其一直接抛 IllegalArgumentException。要么都设,要么都不设(关闭压缩)。

3.3 CompactionUtils:Turn 边界对齐

CompactionUtils.snapToTurnBoundary(events, targetSize) 是 Turn 安全的关键实现。如果天真地取最后 N 条事件,刚好切到 Assistant(tool call) 上------算法会向前推进到最近的 UserMessage,丢弃中间的非 Turn 起始消息,永远保证保留窗口的第一条是 UserMessage。

四、SessionMemoryAdvisor:ChatClient 的全自动记忆插件

java 复制代码
public interface AdvisedRequest { /* ChatClient 请求 */ }
public interface AdvisedResponse { /* ChatClient 响应 */ }

public class SessionMemoryAdvisor implements CallAdvisor {
    public AdvisedResponse adviseCall(AdvisedRequest request, CallAdvisorChain chain) {
        // before:① 解 SESSION_ID、② 校验 sessionOwner==userId、③ 加载历史、④ prepend to prompt
        AdvisedRequest expanded = expandWithHistory(request);
        AdvisedResponse response = chain.nextAroundCall(expanded);
        // after:① MessageFilter 过滤、② 追加事件、③ 触发 trigger→strategy.compact()
        return appendAndCompact(response, expanded.getContext());
    }
}

完整生命周期:

复制代码
HTTP 请求 → ChatClient.prompt(userXxx)
            ↓
SessionMemoryAdvisor.before()
  ├─ SESSION_ID_CONTEXT_KEY 必须存在,否则 IllegalStateException
  ├─ SESSION_OWNER_CONTEXT_KEY 校验,防止跨用户访问
  ├─ SessionService.getEvents(sessionId, filter)
  ├─ SystemMessage 重排到前面(System 永远在前)
  └─ 把历史 + 新 user 一起送进 LLM
            ↓
        [LLM 调用,ToolCallingAdvisor 可能递归若干轮]
            ↓
SessionMemoryAdvisor.after()
  ├─ MessageFilter.default→skipEmptyMessages()(Bedrock 等会回空 end_turn 帧)
  ├─ sessionService.appendMessage / appendEvent
  └─ trigger.shouldCompact() → true 则 strategy.compact() 在内存同步完成
            ↓
HTTP 响应返回

SessionMemoryAdvisor 默认 order = Ordered.HIGHEST_PRECEDENCE + 1000,高于 ToolAdvisor(order=300)这意味着 before() 先跑、after() 最后跑------工具结果完全收齐后才写 Session,压缩和写入没有竞态。

五、完整企业级实战:ShopMind 多 Agent 电商客服系统

我们要搭建一个电商客服系统,包含三个并行子 Agent(订单 Agent、物流 Agent、退款 Agent),通过 Spring AI Session API 的 branch label 共享同一份主客服 Session:

复制代码
用户消息 → 主客服 Agent(root 分支)
          ├─ 委派到订单 Agent(branch=root.order,工具链路长)
          ├─ 委派到物流 Agent(branch=root.logistics,多工具链)
          └─ 委派到退款 Agent(branch=root.refund)

每个子 Agent 自己的工具调用轮次都包含 tool_call_id,Turn 原子化保证不丢。

5.1 Maven 依赖

xml 复制代码
<dependencyManagement>
    <dependencies>
        <dependency>
            <groupId>org.springaicommunity</groupId>
            <artifactId>spring-ai-session-bom</artifactId>
            <version>0.5.0</version>
            <type>pom</type>
            <scope>import</scope>
        </dependency>
    </dependencies>
</dependencyManagement>

<dependencies>
    <dependency>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter-web</artifactId>
    </dependency>
    <dependency>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter-jdbc</artifactId>
    </dependency>
    <dependency>
        <groupId>org.springaicommunity</groupId>
        <artifactId>spring-ai-session-management</artifactId>
    </dependency>
    <dependency>
        <groupId>org.springaicommunity</groupId>
        <artifactId>spring-ai-starter-session-jdbc</artifactId>
    </dependency>
    <dependency>
        <groupId>org.springframework.ai</groupId>
        <artifactId>spring-ai-starter-model-openai</artifactId>
    </dependency>
</dependencies>

5.2 Session + Compaction 配置

java 复制代码
@Configuration
public class SessionConfig {

    /** 主客服 Trigger:25 轮 或 4000 token 都触发压缩 */
    @Bean
    public CompactionTrigger shopMindTrigger() {
        return CompositeCompactionTrigger.anyOf(
                new TurnCountTrigger(25),
                TokenCountTrigger.builder().threshold(4000).build()
        );
    }

    /** 主客服 Strategy:保留最近 8 个事件,递归摘要保全局上下文 */
    @Bean
    public CompactionStrategy shopMindStrategy(ChatClient.Builder builder) {
        return RecursiveSummarizationCompactionStrategy.builder(builder.build())
                .maxEventsToKeep(8)
                .overlapSize(2)
                .summaryPromptTemplate("""
                        你是电商客服对话压缩助手。
                        上一段摘要:{{previousSummary}}
                        待压缩事件:{{events}}
                        请输出 200 字以内中文摘要,重点保留:用户意图、未解决的订单号、
                        异常工具调用、未确认的退款/物流承诺。
                        摘要:
                        """)
                .build();
    }

    @Bean
    public SessionMemoryAdvisor sessionMemoryAdvisor(
            SessionService sessionService,
            CompactionTrigger shopMindTrigger,
            CompactionStrategy shopMindStrategy) {
        return SessionMemoryAdvisor.builder(sessionService)
                .defaultUserId("anonymous")
                .compactionTrigger(shopMindTrigger)
                .compactionStrategy(shopMindStrategy)
                .build();
    }
}

5.3 主客服 ChatClient

java 复制代码
@Service
public class ShopMindCustomerService {

    private final ChatClient rootClient;

    public ShopMindCustomerService(ChatModel chatModel,
                                   SessionMemoryAdvisor sessionMemoryAdvisor) {
        this.rootClient = ChatClient.builder(chatModel)
                .defaultSystem("""
                        你是 ShopMind 电商客服主管 Agent。
                        根据用户问题委派给子 Agent:订单查询、物流查询、退款处理。
                        回答必须基于子 Agent 的真实结果,禁止编造订单/物流状态。
                        """)
                .defaultAdvisors(sessionMemoryAdvisor)
                .build();
    }

    public String chat(String sessionId, String userId, String message) {
        return rootClient.prompt()
                .user(message)
                .advisors(a -> a
                        .param(SessionMemoryAdvisor.SESSION_ID_CONTEXT_KEY, sessionId)
                        .param(SessionMemoryAdvisor.SESSION_OWNER_CONTEXT_KEY, userId))
                .call()
                .content();
    }
}

5.4 三个子 Agent:分支隔离 + 长工具调用链

java 复制代码
@Component
public class OrderAgent {

    private final ChatClient branchClient;
    private final OrderTools orderTools;
    private final LogisticsTools logisticsTools;

    public OrderAgent(ChatModel chatModel,
                      SessionService sessionService,
                      OrderTools orderTools,
                      LogisticsTools logisticsTools) {
        this.orderTools = orderTools;
        this.logisticsTools = logisticsTools;
        // 子 Agent 同样的 SessionMemoryAdvisor 配置,但通过 branch label 隔离
        SessionMemoryAdvisor branchAdvisor = SessionMemoryAdvisor.builder(sessionService)
                .compactionTrigger(new TurnCountTrigger(15))
                .compactionStrategy(SlidingWindowCompactionStrategy.builder().maxEvents(6).build())
                .build();
        this.branchClient = ChatClient.builder(chatModel)
                .defaultSystem("""
                        你是订单专员。通过 query_order / refund_order / apply_coupon 工具
                        完成订单状态查询、退款发起、优惠券核销。
                        """)
                .defaultAdvisors(branchAdvisor)
                .defaultTools(orderTools, logisticsTools)
                .build();
    }

    public String executeInBranch(String rootSessionId, String request) {
        String branch = rootSessionId + ".order";
        return branchClient.prompt()
                .user(request)
                .advisors(a -> a
                        .param(SessionMemoryAdvisor.SESSION_ID_CONTEXT_KEY, branch)
                        .param(SessionMemoryAdvisor.BRANCH_PARENT_CONTEXT_KEY, rootSessionId))
                .call()
                .content();
    }
}

OrderTools 是工具类,含 queryOrder refundOrder applyCoupon 三个 @Tool 方法,每个方法签名清晰:

java 复制代码
@Component
public class OrderTools {

    @Tool(description = "按订单号查订单详情(含 7 天内状态变更记录)")
    public OrderDetail queryOrder(
            @ToolParam(description = "订单号,形如 SN202609120001") String orderNo) {
        // 真实查 DB
        return orderRepository.findDetail(orderNo);
    }

    @Tool(description = "发起退款申请,必须基于已发货 7 天内的订单")
    public RefundResult refundOrder(
            @ToolParam(description = "订单号") String orderNo,
            @ToolParam(description = "退款原因,限 100 字") String reason) {
        return refundService.apply(orderNo, reason);
    }

    @Tool(description = "按用户 ID 查最近 10 单(含商品明细)")
    public List<OrderSummary> recentOrders(
            @ToolParam(description = "用户 ID") String userId) {
        return orderRepository.findTop10ByUserIdOrderByCreatedAtDesc(userId);
    }
}

5.5 委派:主 Agent 调子 Agent

java 复制代码
@RestController
@RequestMapping("/api/shopmind")
public class ShopMindController {

    private final ShopMindCustomerService rootService;
    private final OrderAgent orderAgent;
    private final LogisticsAgent logisticsAgent;
    private final RefundAgent refundAgent;

    public ShopMindController(ShopMindCustomerService rootService,
                              OrderAgent orderAgent,
                              LogisticsAgent logisticsAgent,
                              RefundAgent refundAgent) {
        this.rootService = rootService;
        this.orderAgent = orderAgent;
        this.logisticsAgent = logisticsAgent;
        this.refundAgent = refundAgent;
    }

    @PostMapping("/chat")
    public ResponseEntity<ChatReply> chat(@RequestBody ChatRequest req) {
        String sessionId = req.sessionId();
        String userId = req.userId();

        // 简化示例:先用主 Agent 判意图,再委派
        // 真实生产可用 A2A 协议或 LangChain4j RoutingChain
        String rootAnswer = rootService.chat(sessionId, userId, req.message());

        if (needsLogistics(req.message()) && logicsNotCovered(rootAnswer)) {
            String logistics = logisticsAgent.executeInBranch(sessionId, req.message());
            rootAnswer = rootService.chat(sessionId, userId,
                    "[Logistics 补充] " + logistics);
        }
        if (needsRefund(req.message()) && !refundProcessed(rootAnswer)) {
            String refund = refundAgent.executeInBranch(sessionId, req.message());
            rootAnswer = rootService.chat(sessionId, userId,
                    "[Refund 补充] " + refund);
        }

        return ResponseEntity.ok(new ChatReply(rootAnswer, sessionId));
    }
}

5.6 Recall Storage:找回被压缩的历史

SessionEventTools 提供 conversation_search 工具,Agent 可以关键词搜索完整历史,即使被摘要掉:

java 复制代码
@Component
public class HistorySearchTools {

    private final SessionService sessionService;

    public HistorySearchTools(SessionService sessionService) {
        this.sessionService = sessionService;
    }

    @Tool(description = "在当前会话的全部历史事件中按关键词搜索(即使已被压缩)")
    public List<HistoryHit> conversationSearch(
            @ToolParam(description = "关键词,如订单号 / 用户名") String keyword,
            @ToolParam(description = "返回条数,默认 5") int limit,
            Context ctx) {
        String sessionId = (String) ctx.getContext().get(SessionMemoryAdvisor.SESSION_ID_CONTEXT_KEY);
        EventFilter filter = EventFilter.builder()
                .keyword(keyword)                      // 全文搜索
                .lastN(1000)                            // 不只搜活跃窗口
                .build();
        List<SessionEvent> hits = sessionService.getEvents(sessionId, filter);
        return hits.stream().limit(limit).map(this::toHit).toList();
    }
}

被压缩后的"三个月前客户问过 SN202605030007 的物流"也能重新被搜索回来。EventFilter 还支持 MessageFilter.skipEmptyMessages()includeSynthetic(false)branches(Set.of("root.order")) 等组合。

5.7 关键运行日志

启动后第一次跑对话,你会看到 SessionMemoryAdvisor 完整 before-after 生命周期:

复制代码
SessionMemoryAdvisor.before():
  - resolveSessionId: rootSessionId=session-001
  - checkOwnership: userId=alice, session.owner=alice ✓
  - loadHistory: events=2 (Turn 1: [USER, ASSISTANT])
  - merged messages: [SYSTEM, USER, ASSISTANT(摘要), USER_new]

LLM call... tool_calls=[queryOrder(SN001)] → ToolResult → LLM final answer

SessionMemoryAdvisor.after():
  - appendResponse: AssistantMessage(tool_calls=[OrderDetail])
  - appendToolResult: SessionEvent(id=..., tool_call_id=call_xyz, branch=root)
  - turnCount now 2, tokenCount 1850, trigger fires: NO
  - Session mutated: eventCount=4, turnCount=2
HTTP 200 OK

第 25 轮后,trigger 触发,RecursiveSummarizationCompactionStrategy 把前 17 轮 + 上次摘要喂给 LLM 做新摘要,标记为 METADATA_SYNTHETIC 存为新事件。

六、生产踩坑清单(八个工程陷阱)

6.1 SESSION_ID_CONTEXT_KEY 缺失导致 IllegalStateException

SessionMemoryAdvisor 不允许漏传 session id------这是有意为之:共用 fallback session id 会让不同用户的历史"静默合并"。生产中务必把 sessionId 作为必填参数从上游传过来,不要自己生成随机值(每次请求一个新 session 等于不持久化)。

java 复制代码
// ❌ 错误:每次随机
String sessionId = UUID.randomUUID().toString();
// ✅ 正确:从 token / 业务标识派生
String sessionId = "shopmind:" + userId + ":" + ticketId;

6.2 SESSION_OWNER_CONTEXT_KEY 校验陷阱

SessionMemoryAdvisorbefore() 会校验 SESSION_OWNER_CONTEXT_KEY == session.owner,不匹配抛 IllegalStateException。多租户场景必须把租户 ID 塞进这个上下文,不要仅靠 session id 前缀判断(攻击者能伪造 ID)。

6.3 MessageFilter.skipEmptyMessages() 漏配导致死循环

Bedrock Converse API 在工具调用后会回一个空的 end_turn 帧。Spring AI 默认 MessageFilter.skipEmptyMessages() 会跳过这种空 AssistantMessage。漏配会把空 AssistantMessage 永久写入 Session,下一轮模型看到"什么都没说"的助手消息 + 用户消息,结构错乱。

如果你自定义了 MessageFilter,务必继承它的 skipEmpty:

java 复制代码
MessageFilter.builder()
        .skipEmptyMessages(true)
        .retainToolCallMessages(true)   // 默认就是 true,不要关
        .build();

6.4 RecursiveSummarization 高并发下 CAS 无限重试

replaceEvents 是乐观 CAS,并发触发压缩时失败的线程会无限重试。生产中必须加重试上限 + 退避:

java 复制代码
RetryTemplate.builder()
        .maxAttempts(3)
        .exponentialBackoff(50, 2.0, 200)
        .retryOn(OptimisticLockingFailureException.class)
        .build()
        .execute(ctx -> sessionService.compact(sessionId, trigger, strategy));

否则在高 QPS 客服场景,CAS 失败率到 1% 时整个 Session 服务可能陷入重试风暴。

6.5 Branch label 拼写错导致事件丢失

Branch label 用点号分隔(root.supervisor.order)。如果手抖写成 root.Order(大小写不同),EventFilter.branches(Set.of("root.order")) 找不到那条事件,数据直接"消失"看不到。生产强制约定 :branch label 用全小写 + 下划线,写一个 BranchLabels 常量工具类:

java 复制代码
public final class BranchLabels {
    public static final String ORDER = "root.order";
    public static final String LOGISTICS = "root.logistics";
    public static final String REFUND = "root.refund";

    private BranchLabels() {}

    public static String forSession(String sessionId, String agent) {
        return sessionId + "." + agent.toLowerCase(Locale.ROOT);
    }
}

6.6 JDBC 仓库 event 表膨胀

AI_SESSION_EVENT 是 append-only,几万轮客服对话下去单会话能堆出几千条事件。需要两条策略:

  1. expiresAt + @Scheduled(fixedRate = 3_600_000) 定期清理

java @Scheduled(fixedRate = 3_600_000) void sweepExpiredSessions() { int removed = sessionService.deleteExpiredSessions(Instant.now()); log.info("Swept {} expired sessions", removed); }

  1. 按 metadata 归档

java @Tool(description = "将会话归档到冷存储") public void archiveSession(String sessionId) { Session s = sessionService.findById(sessionId).orElseThrow(); List<SessionEvent> events = sessionService.getEvents(sessionId, EventFilter.all()); s3.putObject("sessions/" + sessionId + ".jsonl", toJsonl(events)); sessionService.delete(sessionId); }

6.7 InMemorySessionRepository 多实例数据分裂

默认 InMemorySessionRepository 用 ConcurrentHashMap,一重启就丢、跨实例不共享。生产必须切 JDBC

yaml 复制代码
spring:
  ai:
    session:
      repository:
        jdbc:
          initialize-schema: always      # dev 用;prod 用 Flyway 单独管
          platform: postgresql

否则上线两个 Pod,用户 A 第一次请求到 Pod1、第二次到 Pod2,Pod2 完全没记忆。

6.8 IdempotentSessionEventIdGenerator 哈希冲突

IdempotentSessionEventIdGenerator 默认按 (sessionId, content) 哈希生成 id。同一会话中用户连发两条一模一样的消息("你好"),两条 UserMessage 会被去重为一条!生产一定传一个 per-request run id 收紧范围

java 复制代码
IdempotentSessionEventIdGenerator idGen = new IdempotentSessionEventIdGenerator(
        "sessionId",     // 必须的 context key
        "runId"          // 每请求一个 UUID,注入到 advisor context
);

七、迁移路径:从 ChatMemory 平滑过渡

Spring AI 2.1(2026 年 11 月)发布后,旧 ChatMemory 代码不会立即爆雷,但会收到 @Deprecated 警告。下面是分阶段迁移路径:

7.1 阶段 1:并行双写(2026 Q3-Q4)

保留 MessageWindowChatMemory,新增 SessionMemoryAdvisor两个同时工作

java 复制代码
@Bean
ChatClient chatClient(ChatModel model,
                     ChatMemory legacyMemory,         // 旧的
                     SessionService sessionService,    // 新的
                     SessionMemoryAdvisor sessionAdvisor) {
    return ChatClient.builder(model)
            .defaultAdvisors(
                    MessageChatMemoryAdvisor.builder(legacyMemory).build(),
                    sessionAdvisor
            )
            .build();
}

对比两边写入量、读出消息结构。

7.2 阶段 2:双读比对(2026 Q4-2027 Q1)

切 1% 流量到 Session API,跑 A/B 测试:客诉率、工具调用率、模型幻觉率三项指标对比 ChatMemory 基线。

7.3 阶段 3:流量切换 + 移除 ChatMemory(2027 Q1+)

确认指标无回归(甚至幻觉率下降 80%+)后,切 100% 流量到 Session API,移除 MessageChatMemoryAdvisor Bean,删除 MessageWindowChatMemory 引用。残留的 ChatMemory 接口实现可保留为兼容老会话读取 API,但不再写入。

7.4 ChatMemory → Session 等价配置速查

ChatMemory 旧用法 Session API 新写法
MessageWindowChatMemory.builder().maxMessages(20).build() SessionMemoryAdvisor.builder(svc).compactionTrigger(new TurnCountTrigger(20)).compactionStrategy(SlidingWindowCompactionStrategy.builder().maxEvents(20).build()).build()
JdbcChatMemory + JdbcChatMemoryRepository spring-ai-starter-session-jdbc 自动装配 JdbcSessionRepository
InMemoryChatMemory (deprecated) InMemorySessionRepository.builder().build()
ChatMemoryRepository.findByConversationId(id) SessionService.getMessages(sessionId)
new ChatMemory.add(id, msg) sessionService.appendMessage(sessionId, msg)

八、结语

Spring AI Session API 不是 ChatMemory 的"小升级",是把对话记忆从"消息级日志"重构成"事件溯源的事件日志"的一次范式切换

  • 三元抽象Session(会话元数据) + SessionEvent(带 UUID/时间戳/branch 的事件包装) + SessionRepository(可换 JDBC/Redis/自实现的 SPI)。事件溯源让工具调用消息变成一等公民。
  • Turn 原子化:所有压缩策略都强制在 UserMessage 边界切割,永远不会切断工具调用链。这是 Session API 存在的全部理由。
  • 4 策略 × 2 触发器可组合SlidingWindow TurnWindow TokenCount 三种廉价丢弃型 + RecursiveSummarization LLM 滚动摘要型,加 CompositeCompactionTrigger.anyOf 灵活组合。
  • 乐观 CAS + 多 Agent Branch:乐观并发控制 + 点号分隔的 branch label,让多 Agent 协同不必锁。
  • 完整 JDBC 持久化 :append-only AI_SESSION_EVENT 表 + tool_call_id 字段,从协议层根治 ChatMemory 的工具调用中间消息丢失问题。

对企业级 AI Agent 落地,这是真正能上生产架构的版本:工具调用幻觉率腰斩、客服上下文跨实例稳定、对话可压缩可检索。Java 工程师做 AI 这条路上,又少了一个"非 Python 不可"的借口------Spring AI Session 让你用熟悉的 Spring 编程模型,就把短期记忆工程化做到了极致

迁移窗口期从今天开始,2026 年 11 月就是 ChatMemory 的 deadline。

往期精选 (按时间倒排)

  • Spring AI 2.0 + LangChain4j 1.19 可观测性企业级实战:OTel GenAI 语义约定

  • LangChain4j Advanced RAG 企业级实战:从 Query Transformer 到 Re-Rank 源码级落地

  • Java 26 Vector API 企业级 AI 推理加速实战:SIMD 原理到 LLM Embedding 落地

  • Spring AI Alibaba 1.1.2.2 Graph 多智能体企业级实战

  • Spring AI 2.0 + ChatMemory 实战(被 Session API 替代)

相关推荐
QCodingDev1 小时前
Spring AI 2.0企业级RAG实战:引用校验、无依据拒答与知识治理怎么做?
java·人工智能·spring·ai
吴声子夜歌1 小时前
ApacheCommons——commons-math3(科学计算与线性代数)(一)
java·线性代数·算法·apache
tachibana21 小时前
Embedding 有哪几种算法?
人工智能·算法·ai·大模型·llm·embedding·agent
PFFstronger1 小时前
Code Diff 测试覆盖分析工具 — 开发提测后,自动发现遗漏的测试点
人工智能·功能测试·ai
砚底藏山河1 小时前
行情工程实战 M01|存储选型 CSVSQLiteMySQL
java·python·金融·maven
名字还没想好☜1 小时前
Java 用 LinkedHashMap 三行实现 LRU 缓存:accessOrder、removeEldestEntry 与线程安全
java·后端·安全·spring·缓存
蓝速科技1 小时前
固定涉外场景台式翻译机选型与落地指南
网络·人工智能·自然语言处理·语音识别·技术分享
VIP_CQCRE2 小时前
一行配置接入 OpenAI 语音转文字:Ace Data Cloud 让音频转写更简单
ai·openai·api·语音识别·acedatacloud
谢亮_vipxieliang2 小时前
ValidX vs Google Guava Preconditions:验证 vs 断言
java·spring boot·后端·spring cloud·hibernate·guava