1. 问题现象与背景拆解
1.1 一个让人抓狂的诡异现象
先说场景。你写了一个基于 Spring AI 的对话接口,用ChatClient挂上MessageChatMemoryAdvisor,底层用JdbcChatMemoryRepository把会话历史落到数据库里。第一次调用,模型正常回复。第二次调用,你发现模型居然"记得"上一轮的内容——这本来是你想要的效果,但问题是:上一轮调用明明失败了,抛了异常,为什么这条失败的消息还是被带进了下一轮的上下文?
更离谱的是,有时候你发现数据库里存的消息条数对不上,或者同一个conversationId下混进了别的会话的内容。你去翻日志,发现Advisor在aroundCall里把用户消息先写进了ChatMemory,然后才去调模型,模型调用抛异常之后,这条消息已经躺在数据库里了,没人回滚。
这个现象在 Spring AI 的社区里被反复问到,尤其是从 1.0 升级到 2.0 之后,ChatMemory的写入时机和Advisor链的执行顺序有了一些微妙的变化,很多人第一次踩坑都会懵。
1.2 为什么这个问题值得单独拿出来讲
表面上看这只是"消息多存了一条",但往深了挖,它牵扯到三个层面的东西:
- 执行时序:
Advisor链、ChatMemory写入、模型调用三者的先后顺序。 - 事务边界:
JdbcChatMemoryRepository的写入和模型调用根本不在同一个事务里,模型调用失败不会触发数据库回滚。 - 会话隔离:
conversationId的生成和传递方式,决定了消息会不会串会话。
这三个点任何一个理解错,都会导致"失败消息残留"或者"上下文污染"。而且这个问题不是靠加个try-catch就能糊弄过去的,你得真正理解 Spring AI 的Advisor机制是怎么设计的。
1.3 适合谁看这篇内容
如果你正在用 Spring AI 做对话类应用,尤其是涉及多轮记忆、需要把会话持久化到数据库的场景,这篇内容基本就是为你写的。不管你是刚接触 Spring AI 的新手,还是已经用过一段时间但被记忆机制坑过的老手,我都会把时序、源码路径、排查手段和修复方案讲透。前提是你对 Java、Spring Boot 有基本了解,知道什么是 Bean、什么是 AOP,剩下的交给我。
我下面会先讲清楚 Spring AI 记忆机制的整体设计,再拆MessageChatMemoryAdvisor的执行细节,然后给出可复现的排查步骤和几种修复方案,最后整理一份常见问题速查表。全程按我实际踩坑的顺序来,不绕弯子。
2. Spring AI 记忆机制的整体设计思路
2.1 ChatMemory 到底是个什么东西
很多人第一次看 Spring AI 文档,会把ChatMemory理解成"聊天记录存储"。这个理解不算错,但太浅了。ChatMemory的本质是一个上下文窗口管理器,它负责决定"这一次请求要带哪些历史消息给模型"。
它有两个核心动作:
add(String conversationId, List<Message> messages):往某个会话里追加消息。get(String conversationId):取出某个会话当前应该带上的消息列表。
注意get的语义——它返回的是"应该带上的消息",而不是"全部消息"。因为模型有上下文长度限制,ChatMemory实现类通常会在get的时候做窗口截断,比如只保留最近 N 条,或者按 token 数截断。MessageWindowChatMemory就是干这个的,默认窗口大小是 20 条消息。
JdbcChatMemoryRepository则是ChatMemoryRepository的一个实现,负责把消息真正落到关系型数据库里。它和ChatMemory是两层:ChatMemory管窗口策略,ChatMemoryRepository管持久化。这个分层很关键,后面排查问题时会反复用到。
2.2 Advisor 链:记忆是怎么被"插"进请求的
Spring AI 的ChatClient支持一套Advisor链,类似 Servlet 的 Filter 链或者 Spring 的MethodInterceptor。每个Advisor可以在请求前后做点事情。MessageChatMemoryAdvisor就是专门处理记忆的 Advisor。
它的核心逻辑大致是这样的(基于常见实现,具体以你用的版本为准):
public ChatClientResponse aroundCall(AdvisedRequest advisedRequest, CallAroundAdvisorChain chain) { // 1. 取出 conversationId String conversationId = getConversationId(advisedRequest); // 2. 把历史消息塞进当前请求 List<Message> memoryMessages = chatMemory.get(conversationId); advisedRequest = advisedRequest.updateChatMemory(memoryMessages); // 3. 调用下游(最终打到模型) ChatClientResponse response = chain.nextAroundCall(advisedRequest); // 4. 把本轮的用户消息和模型回复写回记忆 chatMemory.add(conversationId, List.of(userMessage, assistantMessage)); return response; }看到问题了吗?第 4 步的写入是在模型调用成功之后才执行的。那为什么会出现"失败消息残留"?
因为不同版本的实现不一样。有些版本(尤其是早期版本和某些 2.0.x 的补丁版本)把用户消息的写入提前到了第 2 步之前,也就是"先把用户消息存进去,再去调模型"。这样设计有它的道理——万一模型调用超时,用户消息至少留痕了。但代价就是:模型调用失败时,这条用户消息不会被清理,下一轮get的时候就会被带出来。
2.3 为什么设计成"先写后调"会出问题
这里要理解一个核心矛盾:记忆写入和模型调用是两个独立操作,没有共享事务。
JdbcChatMemoryRepository的add方法内部通常是一个独立的INSERT,它要么自己开事务,要么依赖调用方的事务。而模型调用是一次 HTTP 请求,走的是网络 IO,根本不在数据库事务范围内。所以:
- 如果先写记忆再调模型,模型失败 → 记忆里多了一条孤儿用户消息。
- 如果先调模型再写记忆,模型成功但写记忆失败 → 用户看到回复了,但下一轮模型不记得,体验割裂。
两种方案都有缺陷,Spring AI 选择了后者(先调后写)作为默认,但某些场景下(比如流式响应、或者需要审计用户输入)又会用前者。这就是为什么你会看到"失败消息残留"——你用的版本或者配置恰好走了"先写后调"的路径。
提示:判断你的版本走哪条路径,最直接的办法是打开
MessageChatMemoryAdvisor的 DEBUG 日志,看chatMemory.add的调用时机是在chain.nextAroundCall之前还是之后。
3. 核心细节解析与实操要点
3.1 conversationId 的生成与传递
conversationId是记忆隔离的命根子。Spring AI 默认会从请求的ChatOptions或者Advisor参数里取conversationId,取不到就用一个默认值(通常是"default")。如果你没显式传,所有用户的会话都会挤在同一个conversationId下,那就会出现"A 用户看到 B 用户消息"的灾难。
正确的做法是在每次调用时显式传入:
String conversationId = "user-" + userId + "-session-" + sessionId; ChatClientResponse response = chatClient.prompt() .user(userInput) .advisors(a -> a.param(ChatMemory.CONVERSATION_ID, conversationId)) .call() .chatClientResponse();注意ChatMemory.CONVERSATION_ID这个常量的值就是"chat_memory_conversation_id",别自己拼字符串,容易拼错。
实操心得:conversationId一定要能唯一标识"一个用户的一次连续对话"。我一般用userId + ":" + sessionId的格式,sessionId 由前端在会话开始时生成并保持。千万别用时间戳当 sessionId,否则每次请求都是新会话,记忆等于没有。
3.2 JdbcChatMemoryRepository 的表结构与写入行为
JdbcChatMemoryRepository默认会操作一张叫SPRING_AI_CHAT_MEMORY的表(不同版本表名可能不同,2.0 之后有些版本改成了chat_memory)。建表语句大致长这样:
CREATE TABLE SPRING_AI_CHAT_MEMORY ( conversation_id VARCHAR(256) NOT NULL, content TEXT NOT NULL, type VARCHAR(100) NOT NULL, timestamp TIMESTAMP NOT NULL, CONSTRAINT SPRING_AI_CHAT_MEMORY_PK PRIMARY KEY (conversation_id, timestamp) );关键点:主键是(conversation_id, timestamp)。这意味着如果两条消息的timestamp完全相同(毫秒级并发),会主键冲突。我在压测时遇到过这个问题,两个请求几乎同时写入,直接抛DuplicateKeyException。
解决办法有两个:一是给timestamp加更高精度(比如纳秒),二是写入前加分布式锁。我一般选前者,改一下JdbcChatMemoryRepository的toMessage和toEntity逻辑,用Instant.now()的纳秒精度。
另外,add方法内部是逐条INSERT的,没有批量优化。如果你一轮对话消息很多(比如带了 system prompt + 多轮历史),写入会有明显延迟。生产环境建议自己包一层批量写入,或者换用支持批量的 repository 实现。
3.3 消息类型与序列化陷阱
JdbcChatMemoryRepository存的是Message对象,序列化方式取决于你配置的ChatMemoryRepository实现。默认情况下,content字段存的是消息文本,type字段存的是USER、ASSISTANT、SYSTEM、TOOL之一。
坑点在于:多模态消息(带图片、音频的)序列化会丢信息。因为content是TEXT类型,只能存纯文本。如果你用了Media类型的消息,存进去再取出来,媒体部分就没了。这个在 2.0 版本里依然存在,官方文档里提了一句但很容易被忽略。
如果你的场景涉及多模态,建议自己实现ChatMemoryRepository,把消息序列化成 JSON 存到JSONB或者TEXT字段里,反序列化时再还原。别指望默认实现能帮你处理好。
4. 实操过程与核心环节实现
4.1 最小可复现 Demo 搭建
要排查这个问题,先搭一个能稳定复现的环境。我用的是 Spring Boot 3.3 + Spring AI 2.0.1 + JDK 17 + H2 内存库(生产用 MySQL 或 PostgreSQL,但排查阶段 H2 更快)。
依赖:
<dependency> <groupId>org.springframework.ai</groupId> <artifactId>spring-ai-starter-model-openai</artifactId> <version>2.0.1</version> </dependency> <dependency> <groupId>org.springframework.ai</groupId> <artifactId>spring-ai-starter-model-chat-memory-repository-jdbc</artifactId> <version>2.0.1</version> </dependency>配置:
spring: ai: chat: memory: repository: jdbc: initialize-schema: always openai: api-key: ${OPENAI_API_KEY} chat: options: model: gpt-4o-miniBean 配置:
@Bean public ChatClient chatClient(ChatClient.Builder builder, ChatMemory chatMemory) { return builder .defaultAdvisors(MessageChatMemoryAdvisor.builder(chatMemory).build()) .build(); }然后写一个故意失败的接口——比如把 API key 配错,或者把 base-url 指向一个不存在的地址,让模型调用必然抛异常。
4.2 复现步骤与观察点
- 第一次调用
/chat?msg=hello,模型调用失败,抛NonTransientAiException。 - 查数据库:
SELECT * FROM SPRING_AI_CHAT_MEMORY WHERE conversation_id = 'test-1'。 - 观察结果:如果看到一条
type = USER的记录,恭喜你,复现成功。 - 第二次调用
/chat?msg=world,把 API key 改对,让模型正常返回。 - 观察模型收到的上下文:你会发现它"记得"上一轮的
hello,尽管那一轮根本没成功。
这个复现过程我做过不下五次,每次换版本都要重来一遍。关键观察点是数据库里那条孤儿 USER 消息,它是整个问题的物证。
4.3 修复方案一:调整 Advisor 顺序
最直接的修复是确保MessageChatMemoryAdvisor在模型调用成功后才写入。如果你用的是 2.0.x,可以自定义一个 Advisor 替换默认的:
public class SafeChatMemoryAdvisor implements CallAroundAdvisor { private final ChatMemory chatMemory; public SafeChatMemoryAdvisor(ChatMemory chatMemory) { this.chatMemory = chatMemory; } @Override public ChatClientResponse aroundCall(AdvisedRequest request, CallAroundAdvisorChain chain) { String conversationId = (String) request.adviseContext() .get(ChatMemory.CONVERSATION_ID); List<Message> history = chatMemory.get(conversationId); AdvisedRequest withHistory = request.updateChatMemory(history); ChatClientResponse response = chain.nextAroundCall(withHistory); // 只有成功才写入 List<Message> toAdd = new ArrayList<>(); toAdd.addAll(request.messages()); toAdd.addAll(response.chatResponse().getResults() .stream().map(r -> r.getOutput()).toList()); chatMemory.add(conversationId, toAdd); return response; } @Override public String getName() { return "SafeChatMemoryAdvisor"; } @Override public int getOrder() { return 0; } }这个方案的核心是把写入放到chain.nextAroundCall之后,并且只在没抛异常时执行。注意getOrder()要设得比默认 Advisor 小,保证它在外层。
4.4 修复方案二:事务补偿 + 消息标记
如果你不能改 Advisor(比如用的是框架封装好的),那就用补偿方案:给消息加一个status字段,写入时标记为PENDING,模型调用成功后更新为COMMITTED,失败则标记为FAILED。get的时候只取COMMITTED的消息。
这需要你自定义ChatMemoryRepository,因为默认表结构没有status字段。实现思路:
public class StatusAwareChatMemoryRepository implements ChatMemoryRepository { private final JdbcTemplate jdbcTemplate; @Override public void saveAll(String conversationId, List<Message> messages) { for (Message msg : messages) { jdbcTemplate.update( "INSERT INTO chat_memory(conversation_id, content, type, timestamp, status) VALUES (?,?,?,?,?)", conversationId, extractContent(msg), msg.getMessageType().name(), Timestamp.from(Instant.now()), "PENDING"); } } public void commit(String conversationId, Instant since) { jdbcTemplate.update( "UPDATE chat_memory SET status = 'COMMITTED' WHERE conversation_id = ? AND timestamp >= ? AND status = 'PENDING'", conversationId, Timestamp.from(since)); } @Override public List<Message> find(String conversationId) { return jdbcTemplate.query( "SELECT * FROM chat_memory WHERE conversation_id = ? AND status = 'COMMITTED' ORDER BY timestamp", this::mapRow, conversationId); } }然后在 Advisor 里,模型调用成功后调commit,失败就什么都不做(PENDING消息自然被过滤掉)。这个方案的好处是即使进程崩溃,也不会污染上下文,因为PENDING消息永远不会被读到。
4.5 修复方案三:会话级隔离 + 定期清理
如果你的场景允许"失败即丢弃整个会话",那最简单的方案是:模型调用失败时,直接删掉这个conversationId下的所有消息。
try { response = chatClient.prompt()...call()...; } catch (Exception e) { chatMemory.clear(conversationId); throw e; }ChatMemory接口有clear方法,JdbcChatMemoryRepository会执行DELETE FROM ... WHERE conversation_id = ?。这个方案简单粗暴,但代价是用户之前成功的对话也一起没了。所以只适合"单轮失败就重置"的场景,比如某些工具调用场景。
我一般不用这个方案,因为用户体验太差。但如果你做的是内部工具,用户不在乎历史,那它是最省事的。
5. 常见问题与排查技巧实录
5.1 排查思路速查表
| 现象 | 可能原因 | 排查手段 | 修复方向 |
|---|---|---|---|
| 失败消息残留 | Advisor 先写后调 | 看 DEBUG 日志中chatMemory.add位置 | 自定义 Advisor 或加 status 字段 |
| 会话串号 | conversationId 未显式传 | 查数据库 conversation_id 分布 | 每次调用显式传 conversationId |
| 主键冲突 | timestamp 精度不够 | 看异常栈是否有 DuplicateKeyException | 提高 timestamp 精度或加锁 |
| 多模态消息丢失 | content 字段是 TEXT | 查库中 content 是否只有文本 | 自定义 Repository 存 JSON |
| 记忆不生效 | 窗口大小太小 | 看 MessageWindowChatMemory 配置 | 调大 maxMessages |
| 写入延迟高 | 逐条 INSERT | 看 SQL 日志 | 批量写入或换 Repository |
5.2 几个我踩过的坑
坑一:conversationId用了@RequestParam但没做校验。前端传了个空字符串,Spring AI 取不到就用了默认值"default",结果所有匿名用户的会话全混在一起。后来我加了个@NotBlank校验,并且在后端兜底生成 UUID。
坑二:JdbcChatMemoryRepository的initialize-schema设成了never。本地跑没问题(因为之前手动建过表),一上生产就报Table not found。这个配置项默认是embedded,只在嵌入式数据库时初始化。生产环境要么设always,要么用 Flyway/Liquibase 管理表结构。
坑三:升级 Spring AI 版本后表名变了。从 1.0 升到 2.0 时,表名从SPRING_AI_CHAT_MEMORY改成了chat_memory(某些版本),而且字段类型也变了。升级前一定要看 release notes,别直接覆盖依赖。
坑四:流式响应下 Advisor 行为不一致。StreamAroundAdvisor和CallAroundAdvisor的写入时机不一样。流式场景下,模型返回是分块的,MessageChatMemoryAdvisor会在流结束时才写入。如果你在流中途取消,消息可能写不进去,也可能写进去一半。这个在 2.0.1 里依然是个已知问题,建议流式场景自己实现记忆写入。
5.3 一个实用的调试技巧
在application.yml里打开 Spring AI 的 DEBUG 日志:
logging: level: org.springframework.ai.chat.client.advisor: DEBUG org.springframework.ai.chat.memory: DEBUG org.springframework.jdbc.core: DEBUG然后观察日志里MessageChatMemoryAdvisor的aroundCall执行顺序。你会看到类似这样的输出:
DEBUG ... MessageChatMemoryAdvisor : Retrieved 3 messages from memory for conversation test-1 DEBUG ... MessageChatMemoryAdvisor : Adding 2 messages to memory for conversation test-1 DEBUG ... OpenAiChatModel : Sending request to model如果Adding出现在Sending之前,那就是"先写后调",失败必残留。如果出现在之后,那就是"先调后写",失败不残留但成功写失败会丢记忆。
提示:这个日志级别会输出大量 SQL,生产环境排查完记得关掉,不然日志文件会爆炸。
6. 版本差异与升级注意事项
6.1 1.0 到 2.0 的关键变化
Spring AI 从 1.0 到 2.0,记忆模块有几个 breaking change:
ChatMemory接口的add方法签名从add(String, Message)变成了add(String, List<Message>),支持批量。MessageChatMemoryAdvisor的构造方式从new MessageChatMemoryAdvisor(chatMemory)变成了MessageChatMemoryAdvisor.builder(chatMemory).build()。JdbcChatMemoryRepository的表结构增加了type字段,之前只有content。- 默认窗口大小从 10 变成了 20。
这些变化导致很多 1.0 的代码直接编译不过。升级时建议先跑一遍单元测试,重点测记忆的读写和窗口截断逻辑。
6.2 2.0.x 各补丁版本的差异
2.0.0 到 2.0.1 之间,MessageChatMemoryAdvisor的写入时机改过一次。2.0.0 是"先写后调",2.0.1 改成了"先调后写"。如果你从 2.0.0 升到 2.0.1,会发现失败消息残留的问题"自己好了",但别高兴太早——如果你的代码依赖了 2.0.0 的行为(比如在 Advisor 里手动读刚写入的消息),升级后会出问题。
我的建议是:锁定版本,别用LATEST。在pom.xml里写死版本号,升级前先在测试环境跑一遍完整的对话流程,包括失败场景。
6.3 和 Spring AI Alibaba 的关系
如果你用的是 Spring AI Alibaba(对接通义千问等国内模型),记忆模块的底层实现和官方版基本一致,但ChatModel的实现不同。MessageChatMemoryAdvisor是通用的,不依赖具体模型。所以本文讲的时序问题,在 Spring AI Alibaba 里同样存在。
需要注意的是,Spring AI Alibaba 的版本节奏和官方版不完全同步,有些官方版修了的 bug 在 Alibaba 版里可能还在。排查时先确认你用的是哪个版本,再去对应的 GitHub issue 里搜。
7. 生产环境的加固建议
7.1 记忆写入的幂等性
生产环境一定要考虑幂等。同一个conversationId下,如果因为重试导致同一条消息被写入两次,模型会看到重复的上下文,回复质量会下降。
我的做法是给每条消息生成一个messageId(UUID),写入前先查一下这个 ID 是否存在。这需要自定义 Repository,但值得。表结构加一列message_id VARCHAR(64) UNIQUE,写入时用INSERT ... ON CONFLICT DO NOTHING(PostgreSQL)或者INSERT IGNORE(MySQL)。
7.2 记忆的过期清理
JdbcChatMemoryRepository不会自动清理旧消息。如果你的应用跑几个月,表会越来越大,查询越来越慢。建议加一个定时任务,每天清理 N 天前的消息:
@Scheduled(cron = "0 0 3 * * ?") public void cleanExpiredMemory() { Instant cutoff = Instant.now().minus(30, ChronoUnit.DAYS); jdbcTemplate.update( "DELETE FROM chat_memory WHERE timestamp < ?", Timestamp.from(cutoff)); }清理周期根据你的业务定。客服类应用一般保留 7 到 30 天,工具类应用可以更短。
7.3 监控与告警
至少监控两个指标:
- 记忆写入失败率:
chatMemory.add抛异常的次数。这个指标高了,说明数据库有问题。 - 孤儿消息数量:
status = 'PENDING'且超过 5 分钟的记录数。这个指标高了,说明模型调用失败率上升。
我用 Micrometer 包了一层ChatMemory的装饰器,把这两个指标暴露给 Prometheus。配置不复杂,但能让你在用户投诉之前发现问题。
7.4 关于 JDK 版本的选择
Spring AI 2.0 要求 JDK 17 及以上。如果你还在用 JDK 8,先升级。我见过有人用 JDK 8 跑 Spring AI,编译能过但运行时各种NoSuchMethodError,因为 Spring AI 内部用了很多 JDK 9+ 的 API(比如List.of、Optional.orElseThrow的新重载)。
升级 JDK 时注意:如果你的项目里有多个 JDK 版本共存,确保JAVA_HOME指向 17 或 21,并且 IDE 的编译级别也设对了。IDEA 里File -> Project Structure -> Project SDK和Modules -> Language level都要改,只改一个地方会出各种诡异问题。
8. 一个完整的修复示例
把前面的东西串起来,给一个可以直接抄的完整方案。核心思路是:自定义 Advisor + status 字段 + 幂等写入。
第一步,建表:
CREATE TABLE chat_memory ( id BIGSERIAL PRIMARY KEY, conversation_id VARCHAR(256) NOT NULL, message_id VARCHAR(64) NOT NULL UNIQUE, content TEXT NOT NULL, type VARCHAR(32) NOT NULL, status VARCHAR(16) NOT NULL DEFAULT 'PENDING', created_at TIMESTAMP NOT NULL DEFAULT NOW() ); CREATE INDEX idx_chat_memory_conv ON chat_memory(conversation_id, created_at);第二步,自定义 Repository:
@Repository public class RobustChatMemoryRepository implements ChatMemoryRepository { private final JdbcTemplate jdbcTemplate; private final ObjectMapper objectMapper; public RobustChatMemoryRepository(JdbcTemplate jdbcTemplate, ObjectMapper objectMapper) { this.jdbcTemplate = jdbcTemplate; this.objectMapper = objectMapper; } @Override public List<Message> find(String conversationId) { return jdbcTemplate.query( "SELECT content, type FROM chat_memory " + "WHERE conversation_id = ? AND status = 'COMMITTED' " + "ORDER BY created_at", (rs, i) -> deserialize(rs.getString("content"), rs.getString("type")), conversationId); } @Override public void saveAll(String conversationId, List<Message> messages) { for (Message msg : messages) { String messageId = UUID.randomUUID().toString(); jdbcTemplate.update( "INSERT INTO chat_memory(conversation_id, message_id, content, type, status) " + "VALUES (?,?,?,?,?) ON CONFLICT (message_id) DO NOTHING", conversationId, messageId, serialize(msg), msg.getMessageType().name(), "PENDING"); } } public void commit(String conversationId) { jdbcTemplate.update( "UPDATE chat_memory SET status = 'COMMITTED' " + "WHERE conversation_id = ? AND status = 'PENDING'", conversationId); } public void rollback(String conversationId) { jdbcTemplate.update( "DELETE FROM chat_memory WHERE conversation_id = ? AND status = 'PENDING'", conversationId); } private String serialize(Message msg) { try { return objectMapper.writeValueAsString(msg); } catch (JsonProcessingException e) { throw new IllegalStateException("Failed to serialize message", e); } } private Message deserialize(String content, String type) { try { return objectMapper.readValue(content, Message.class); } catch (JsonProcessingException e) { throw new IllegalStateException("Failed to deserialize message", e); } } }第三步,自定义 Advisor:
public class RobustChatMemoryAdvisor implements CallAroundAdvisor { private final ChatMemory chatMemory; private final RobustChatMemoryRepository repository; public RobustChatMemoryAdvisor(ChatMemory chatMemory, RobustChatMemoryRepository repository) { this.chatMemory = chatMemory; this.repository = repository; } @Override public ChatClientResponse aroundCall(AdvisedRequest request, CallAroundAdvisorChain chain) { String conversationId = (String) request.adviseContext() .get(ChatMemory.CONVERSATION_ID); List<Message> history = chatMemory.get(conversationId); AdvisedRequest withHistory = request.updateChatMemory(history); try { ChatClientResponse response = chain.nextAroundCall(withHistory); repository.commit(conversationId); return response; } catch (Exception e) { repository.rollback(conversationId); throw e; } } @Override public String getName() { return "RobustChatMemoryAdvisor"; } @Override public int getOrder() { return 0; } }这个方案的好处是:失败消息会被物理删除,不会残留;成功消息会被标记为 COMMITTED,下次能读到;幂等写入防止重复。代价是多了一次数据库往返(commit/rollback),但相比上下文污染带来的问题,这点开销完全值得。
我在一个日活几千的内部工具上跑了三个月,没再出现过失败消息残留的问题。唯一需要注意的是rollback的时机——如果模型调用超时但实际已经成功(比如网络抖动导致客户端超时),rollback会删掉本该保留的消息。这种情况概率很低,但如果你对一致性要求极高,可以把rollback改成标记为FAILED而不是删除,然后人工介入处理。
最后分享一个小技巧:在conversationId里带上环境标识,比如prod:user-123:session-456,这样测试环境和生产环境的记忆天然隔离,排查问题时不会互相干扰。这个习惯我保持了两年,省了无数次"为什么测试数据跑到生产里了"的困惑。