Spring AI 记忆机制踩坑:失败消息残留与上下文污染排查修复
发布时间:2026/10/27 1:30:47 锦皓数字建站

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, ListMessage 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. 把历史消息塞进当前请求 ListMessage 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 groupIdorg.springframework.ai/groupId artifactIdspring-ai-starter-model-openai/artifactId version2.0.1/version /dependency dependency groupIdorg.springframework.ai/groupId artifactIdspring-ai-starter-model-chat-memory-repository-jdbc/artifactId version2.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?msghello模型调用失败抛NonTransientAiException。查数据库SELECT * FROM SPRING_AI_CHAT_MEMORY WHERE conversation_id test-1。观察结果如果看到一条type USER的记录恭喜你复现成功。第二次调用/chat?msgworld把 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); ListMessage history chatMemory.get(conversationId); AdvisedRequest withHistory request.updateChatMemory(history); ChatClientResponse response chain.nextAroundCall(withHistory); // 只有成功才写入 ListMessage 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, ListMessage 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 ListMessage 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 日志批量写入或换 Repository5.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 changeChatMemory接口的add方法签名从add(String, Message)变成了add(String, ListMessage)支持批量。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下如果因为重试导致同一条消息被写入两次模型会看到重复的上下文回复质量会下降。我的做法是给每条消息生成一个messageIdUUID写入前先查一下这个 ID 是否存在。这需要自定义 Repository但值得。表结构加一列message_id VARCHAR(64) UNIQUE写入时用INSERT ... ON CONFLICT DO NOTHINGPostgreSQL或者INSERT IGNOREMySQL。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);第二步自定义 RepositoryRepository 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 ListMessage 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, ListMessage 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); } } }第三步自定义 Advisorpublic 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); ListMessage 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这样测试环境和生产环境的记忆天然隔离排查问题时不会互相干扰。这个习惯我保持了两年省了无数次为什么测试数据跑到生产里了的困惑。
锦
锦皓数字建站
深耕本土企业品牌数字化升级,专注原创端正雅致商务官网,从视觉设计到稳定运维全程保驾护航。