引言:电商客服 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 :不可变的纯元数据值对象。只存 sessionId、userId、expiresAt、metadata(业务可放租户、渠道等),事件日志统一在仓储层。源码简化版:
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 实现把 Session 和 SessionEvent 落到两张表:
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_content、tool_calls、tool_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 校验陷阱
SessionMemoryAdvisor 在 before() 会校验 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,几万轮客服对话下去单会话能堆出几千条事件。需要两条策略:
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); }
- 按 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 触发器可组合 :
SlidingWindowTurnWindowTokenCount三种廉价丢弃型 +RecursiveSummarizationLLM 滚动摘要型,加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 替代)