
打造企业级对话记忆池Spring AI MessageChatMemory 与 Redis 的无缝绑定很多团队在把大模型引入业务系统做智能助手或智能客服时第一阶段往往跑得很顺。本地启动一个单节点 Demo几行代码配个InMemoryChatMemory前端连上后多轮对话问答自如上下文连贯老板演示也很满意。但一旦进入灰度发布阶段多台容器挂在负载均衡后面测试同学立刻发来 Bug“刚刚问完上一句刷新一下页面或者再问下一句AI 突然像失忆了一样。”这是微服务架构下非常典型的状态外置问题。Spring AI 默认提供的InMemoryChatMemory只能活在单机堆内存里。在分布式集群中每次用户的 HTTP 请求都会落到不同 Pod内存孤岛让多轮对话瞬间失效。要想在生产环境中跑稳必须把对话历史Chat Memory沉淀到集中式缓存。今天这篇我结合我们团队在生产环境的落地过程聊聊如何基于 Redis 构建一套具备容量截断、多态序列化与高吞吐保证的企业级对话记忆池。为什么默认方案撑不住生产环境翻看 Spring AI 1.0.0 的源码会发现框架为对话记忆定义了核心接口ChatMemorypublic interface ChatMemory { void add(String conversationId, ListMessage messages); ListMessage get(String conversationId, int lastN); void clear(String conversationId); }框架自带的实现是InMemoryChatMemory内部用了一个简单的ConcurrentHashMapString, ListMessage。这在单机压测或本地开发时完全够用但在真实线上存在三个硬伤无法横向扩展微服务无状态化是基本准则。Pod 随时可能因为发布、HPA 自动扩缩容或节点漂移而重启内存中的上下文直接蒸发。多轮膨胀引发 OOM客服会话往往包含冗长的排查沟通如果没有滑动窗口或 TTL 机制单个长会话在内存里无限堆积极易造成堆内存打满。序列化深坑Spring AI 的Message是一个接口派生了UserMessage、AssistantMessage、SystemMessage以及带有工具调用信息的ToolResponseMessage。如果直接扔进 Redis常规的 JSON 序列化工具在反序列化时会因为丢失类型标识而直接报错。因此外置到 Redis 时必须同时解决“多态序列化”、“窗口修剪Sliding Window”与“过期淘汰策略”。RedisChatMemory 的完整工程实现在 Redis 中存储对话历史最契合的数据结构是List。把每个conversationId作为 Key最新的消息推入列表右端读取时通过LRANGE取出最近的 N 条必要时配合LTRIM限制总长度时间复杂度为稳定 O(K)。1. 解决 Jackson 多态反序列化Spring AI 的Message体系包含私有属性和特定子类。如果直接用默认的GenericJackson2JsonRedisSerializer反序列化还原为具体子类时会抛出抽象类不可实例化的异常。我们需要定制专属的ObjectMapper配置类型多态支持。package com.yali.ai.memory; import com.fasterxml.jackson.annotation.JsonTypeInfo; import com.fasterxml.jackson.databind.DeserializationFeature; import com.fasterxml.jackson.databind.ObjectMapper; import com.fasterxml.jackson.databind.jsontype.BasicPolymorphicTypeValidator; import com.fasterxml.jackson.databind.jsontype.PolymorphicTypeValidator; import org.springframework.ai.chat.messages.Message; import org.springframework.data.redis.connection.RedisConnectionFactory; import org.springframework.data.redis.core.RedisTemplate; import org.springframework.data.redis.serializer.Jackson2JsonRedisSerializer; import org.springframework.data.redis.serializer.StringRedisSerializer; public class RedisChatMemoryConfig { public static RedisTemplateString, Message createMessageRedisTemplate(RedisConnectionFactory factory) { ObjectMapper objectMapper new ObjectMapper(); objectMapper.configure(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES, false); // 允许序列化包含 Message 子类的类型信息 PolymorphicTypeValidator ptv BasicPolymorphicTypeValidator.builder() .allowIfBaseType(Message.class) .allowIfSubType(org.springframework.ai.chat.messages.) .build(); objectMapper.activateDefaultTyping(ptv, ObjectMapper.DefaultTyping.NON_FINAL, JsonTypeInfo.As.PROPERTY); Jackson2JsonRedisSerializerMessage serializer new Jackson2JsonRedisSerializer(objectMapper, Message.class); RedisTemplateString, Message template new RedisTemplate(); template.setConnectionFactory(factory); template.setKeySerializer(new StringRedisSerializer()); template.setValueSerializer(serializer); template.setHashKeySerializer(new StringRedisSerializer()); template.setHashValueSerializer(serializer); template.afterPropertiesSet(); return template; } }2. 实现具备容量控制与 TTL 的 Memory 池对话记忆不能无节制膨胀。大模型一次处理的上下文窗口是昂贵且有限的。如果把用户两周前打喷嚏的记录都塞进 Prompt不仅消耗高昂的 Token 成本还会稀释模型的注意力导致注意力分散Lost in the Middle。我们在add写入时执行双动作追加消息并刷新 TTL若列表长度超过预设的上限比如 30 条用LTRIM自动修剪。package com.yali.ai.memory; import org.springframework.ai.chat.memory.ChatMemory; import org.springframework.ai.chat.messages.Message; import org.springframework.data.redis.core.RedisTemplate; import java.time.Duration; import java.util.Collections; import java.util.List; public class RedisChatMemory implements ChatMemory { private final RedisTemplateString, Message redisTemplate; private final String keyPrefix; private final Duration ttl; private final int maxHistorySize; public RedisChatMemory(RedisTemplateString, Message redisTemplate, String keyPrefix, Duration ttl, int maxHistorySize) { this.redisTemplate redisTemplate; this.keyPrefix keyPrefix.endsWith(:) ? keyPrefix : keyPrefix :; this.ttl ttl; this.maxHistorySize maxHistorySize; } private String buildKey(String conversationId) { return keyPrefix conversationId; } Override public void add(String conversationId, ListMessage messages) { if (messages null || messages.isEmpty()) { return; } String key buildKey(conversationId); // 批量追加到 Redis 列表尾部 redisTemplate.opsForList().rightPushAll(key, messages.toArray(new Message[0])); // 维持滑动窗口上限防止无节制膨胀 Long size redisTemplate.opsForList().size(key); if (size ! null size maxHistorySize) { long fromIndex size - maxHistorySize; redisTemplate.opsForList().trim(key, fromIndex, -1); } // 延续会话生存时间例如 24 小时无交互自动失效 redisTemplate.expire(key, ttl); } Override public ListMessage get(String conversationId, int lastN) { String key buildKey(conversationId); Long size redisTemplate.opsForList().size(key); if (size null || size 0) { return Collections.emptyList(); } // 计算最后 lastN 条的起始下标 long start Math.max(0, size - lastN); ListMessage messages redisTemplate.opsForList().range(key, start, -1); return messages ! null ? messages : Collections.emptyList(); } Override public void clear(String conversationId) { redisTemplate.delete(buildKey(conversationId)); } }接入 ChatClient 与生产调优细节完成了基础存储类之后我们需要把RedisChatMemory挂载到 Spring AI 的ChatClient管道中。Spring AI 提供了 Advisor 机制类似 AOP 拦截器最核心的是MessageChatMemoryAdvisor。Configuration public class AiMemoryAutoConfiguration { Bean public ChatMemory chatMemory(RedisConnectionFactory connectionFactory) { RedisTemplateString, Message template RedisChatMemoryConfig.createMessageRedisTemplate(connectionFactory); return new RedisChatMemory( template, ai:chat:memory:, Duration.ofHours(24), 20 // 单个会话最多保留 20 条消息 ); } Bean public ChatClient chatClient(ChatModel chatModel, ChatMemory chatMemory) { return ChatClient.builder(chatModel) .defaultAdvisors( // 每次向模型请求前自动注入该 conversationId 的最近 10 条历史 new MessageChatMemoryAdvisor(chatMemory, default_conv, 10) ) .build(); } }生产环境必须防范的两个边界情况System Prompt 的保留保护MessageChatMemoryAdvisor在提取历史记录并插入大模型请求时如果用户的历史消息超过了设定的lastN很容易把第一条设定的SystemMessage通常包含系统人设和安全红线给截断掉。为了避免这个问题最佳实践是将系统的通用约束写在ChatClient.builder().defaultSystem(你是一位资深电商售前助手...)中而不要把 System 指令作为普通历史消息塞入ChatMemory。defaultSystem会在每次组装 Prompt 时被固定置顶不会被历史滑动窗口挤掉。并发写脏数据与 Pipeline 优化在高并发客服场景中如果同一用户短时间内快速点击多个问题多个请求几乎同时到达不同的后端实例。如果直接使用单条 Redis 命令rightPushAll、trim、expire分开执行不仅增加三次网络 RTT还可能在极端并发下破坏原子性。在流量较大的集群中我们使用 Lua 脚本将追加、裁剪与设置过期时间打包为原子操作-- KEYS[1]: 列表 Key -- ARGV[1]: 最大保留条数 -- ARGV[2]: TTL 秒数 -- ARGV[3...N]: 待追加的序列化 JSON 字符串 for i 3, #ARGV do redis.call(RPUSH, KEYS[1], ARGV[i]) end local len redis.call(LLEN, KEYS[1]) if len tonumber(ARGV[1]) then local start_idx len - tonumber(ARGV[1]) redis.call(LTRIM, KEYS[1], start_idx, -1) end redis.call(EXPIRE, KEYS[1], tonumber(ARGV[2])) return true将这套 Lua 脚本固化在 Java 端执行不仅降低了高并发下的 Redis 负载也消除了竞态条件。线上观察与经验沉淀在把对话池从本地内存迁移到 Redis 并在压测环境验证后我们总结了几条关键数据指标平均网络耗时控制单次从 Redis 批量读取 10 条上下文并反序列化在内网千兆网卡下的耗时稳定在 1.2ms 左右相较于大模型首字输出TTFT动辄 800ms 的耗时这部分网络损耗几乎可以忽略。Key 膨胀监控为会话设置 24 小时滑动 TTL 之后未活跃会话会被 Redis 惰性删除结合定时扫描自动清理内存占用稳定在峰值 4GB 以内支撑数十万日活客服量。做 AI 应用落地最忌讳的是把大模型看作单机玩具。一旦放到高可用、分布式、易波动的生产环境Java 工程师十几年沉淀的缓存治理、连接管理、并发一致性经验依然是支撑整个智能底盘不可动摇的压舱石。