资讯动态

Spring AI生产化改造:对话记忆落Redis、向量检索落ES,重启不再丢上下文

发布时间:2026/10/16 7:59:51 来源:尧图企业网站定制
你有没有遇到过这种情况本地调试什么问题都没有用户连续追问好几轮AI都能根据上下文给到连贯的回答。结果一上生产环境就翻车——用户刷新一下页面、负载均衡把请求切到另一台实例、或者你发个版本重启一下应用AI立刻“失忆”开始反问“你是谁”“我们刚才聊到哪了”。多数人第一反应是模型不行或者Prompt没写对。但我做过一次生产化改造之后可以负责任地说大部分“失忆”根本原因在Spring AI默认把对话记忆存在了JVM内存里。内存一没上下文就没了。想根治这个问题最直接的做法就是把状态迁出进程——对话记忆落到Redis向量检索落到Elasticsearch让应用重启之后还能把上下文找回来。这篇内容我会把整个改造思路、关键代码、序列化陷阱、ES索引配置、多实例部署问题以及实测“重启不丢”的验证过程完整过一遍。如果你正在用Spring AI做聊天机器人、知识库问答或者想把原型项目推向生产环境这篇文章应该能帮你省掉不少弯路。1. 改造前复盘本地记忆的两种典型“失忆”场景1.1 单实例重启就清空内存态ChatMemory的天然缺陷先说说Spring AI里对话记忆的默认实现。早期的Spring AI提供了ChatMemory接口常用实现是InMemoryChatMemory。从名字就能看出来它内部只是一个并发Map以会话ID为key存消息列表。这个实现简单、零依赖写Demo没问题但生产环境里它就是最大的隐患。我举个例子。你本地起一个服务聊十轮模型能准确记得第一轮提到的细节。接着你重新打包、重启服务再用同一个会话去问“我最初说的那件事是什么”模型直接傻眼。因为在JVM进程里维护的历史消息随着进程销毁一起没了。用户感知到的就是“AI失忆了”。这里需要理解Spring AI的对话机制每次请求时MessageChatMemoryAdvisor会从ChatMemory里读取历史消息塞进Prompt上下文。如果存储层没有数据Advisor拿到的历史列表就是空的模型自然只能根据当前这一条消息回答。所以问题不是模型记性差而是我们没给模型提供记忆来源。只要存储还在应用进程内部这个问题在架构上就无解——进程生命周期就是记忆生命周期。1.2 多实例轮询导致上下文错乱比“重启丢”更隐蔽的坑单实例重启丢还能通过“少发布、别重启”来逃避但多实例部署就逃不掉了。我用某团队的项目做过一次演练部署了两台实例前面挂着负载均衡。第一次请求打到实例A第二次请求被负载均衡切到实例B。因为两台实例各有一份独立的本地内存实例B里根本没有实例A写入的对话历史于是用户发现AI说话上下文接不上甚至每刷一次页面回答风格都变一次。有些团队会尝试用会话粘滞Session Sticky来解决让同一用户的请求始终落在同一台实例上。但粘滞只能缓解不能根治实例发布重启、连接池重建、负载均衡节点扩缩容都会让粘滞失效。就算粘滞一直生效单实例重启后内存照样清空。归根到底只要对话状态不在所有实例共享的存储里分布式环境下就一定会出现上下文断裂。想通这点之后改造方向就非常清晰了把对话记忆从JVM内存挪到外部存储把向量数据也从本地临时索引挪到共享的向量存储里。于是就有了这篇文章的标题——记忆落Redis向量落ES重启再也不丢。2. 对话记忆落Redis从继承ChatMemory接口开始2.1 为什么选Redis而不是MySQL对话记忆存Redis这在当时几乎没有犹豫。对话历史的读写特征是高频、单条数据量小、只有最近的若干条有价值而且天然需要过期策略。Redis的String结构做覆盖写、设置TTL过期时间都很直接读写延迟通常在亚毫秒级别。如果存MySQL你得建表、设计清理任务、面对慢查询风险为了几十条文本消息动用数据库性价比太低。Redis还可以用主从、哨兵、集群来保证可用性和Spring Boot的集成也成熟所以它是这个场景里最自然的选项。需要注意的是这一步不是换个存储就完事还要处理序列化、TTL设计和接口语义对齐。下面把核心实现说透。2.2 自定义RedisChatMemory核心写法和序列化陷阱Spring AI的ChatMemory接口抽象得很干净大致是三个方法add()写入/覆盖某个会话的历史消息get()读取最近N条clear()清空。我们要做的就是用Redis的String结构来实现这三个方法。我这里给出一个精简但完整的写法public class RedisChatMemory implements ChatMemory { private final StringRedisTemplate redisTemplate; private final ObjectMapper objectMapper; private final Duration ttl; public RedisChatMemory(StringRedisTemplate redisTemplate, ObjectMapper objectMapper, Duration ttl) { this.redisTemplate redisTemplate; this.objectMapper objectMapper; this.ttl ttl; } private String key(String conversationId) { return chat:memory: conversationId; } Override public void add(String conversationId, ListMessage messages) { ListMemoryMessage dtos messages.stream() .map(MemoryMessage::fromMessage) .collect(Collectors.toList()); String json objectMapper.writeValueAsString(dtos); redisTemplate.opsForValue().set(key(conversationId), json, ttl); } Override public ListMessage get(String conversationId, int lastN) { String json redisTemplate.opsForValue().get(key(conversationId)); if (json null) { return List.of(); } ListMemoryMessage dtos objectMapper.readValue( json, new TypeReferenceListMemoryMessage() {}); return dtos.stream() .map(MemoryMessage::toMessage) .skip(Math.max(0, dtos.size() - lastN)) .collect(Collectors.toList()); } Override public void clear(String conversationId) { redisTemplate.delete(key(conversationId)); } }这里有几个细节必须展开说。第一为什么用StringRedisTemplate而不是RedisTemplateStringRedisTemplate默认使用String序列化器key和value都是字符串不会出现常见的乱码问题。如果你用默认的RedisTemplate配合JDK序列化存进去可能是一堆二进制乱码排查起来非常痛苦所以这里直接选StringRedisTemplate更省事。第二为什么要把Message转成MemoryMessage。Spring AI里的Message是一个接口有UserMessage、AssistantMessage、SystemMessage等多种实现里面还带着metadata等复杂结构。如果你图省事直接把接口类型交给Jackson序列化反序列化时大概率会碰到多态类型解析问题。版本一变、类加载路径一变就可能直接抛异常。稳妥做法是自定义一个精简的记录类型只保留我们真正关心的人类可读字段public record MemoryMessage( String role, String content, MapString, Object metadata) { public static MemoryMessage fromMessage(Message message) { return new MemoryMessage( message.getMessageType().name(), message.getText(), message.getMetadata()); } public Message toMessage() { if (USER.equals(role)) { return new UserMessage(content, metadata); } if (ASSISTANT.equals(role)) { return new AssistantMessage(content, metadata); } return new SystemMessage(content, metadata); } }这样我们序列化的是一份完全可控的JSON不依赖框架内部类的任何特殊标记。虽然损失了“原样保存”的便利性但换来了跨版本稳定这个取舍非常值。第三为什么用String覆盖写而不是Redis List。可能有人觉得对话历史是线性消息天然适合List结构。但注意ChatMemory的add()语义是“把当前传入的完整消息列表作为该会话的最新状态”不是“追加一条”。如果用List结构执行add()之前要先清空再全量重推稍不注意就会产生重复消息。用String一次性覆盖写入逻辑直接、排查简单而且每次读出来也只是反序列化一个整体字符串性能上没有任何问题。对话历史不过几十上百条消息单次读写毫秒级完成完全够用。2.3 TTL、Key设计与Redis持久化的配套考虑对话记忆不能无限膨胀所以设置TTL是必须的。一般建议比用户会话的活跃周期略长比如24小时或48小时。如果你做的是客服场景用户可能隔几天再来那可以把TTL设到7天同时做一个定时清理任务去淘汰明显过期的会话。我习惯在key里带上业务前缀和用户维度像chat:memory:{userId}:{sessionId}这样即使同一个用户在PC端、App端各开一个会话也不会串上下文。这里还要考虑Redis自身的持久化。很多人改造完应用层就以为万事大吉但Redis如果没开持久化一旦它自己重启应用层做得再好也白搭。生产环境我建议至少开启AOFappendonly yes appendfsync everysec再加一份RDB作为兜底快照。这样Redis进程挂了之后能从磁盘恢复才真正对得起“重启再也不丢”这句话。如果你的Redis是主从架构还要确保从节点和主节点都配置了持久化否则主节点宕机切从从节点只有空数据一样会出问题。3. 向量搜索迁到Elasticsearch语义检索的地基工程3.1 向量库选型为什么在ES生态里落这个对话记忆是结构化的文本用Redis就能解决但知识库问答场景里还需要向量检索。Spring AI提供了VectorStore接口官方实现有Redis、Elasticsearch、PGVector、Milvus等。我最终选Elasticsearch的原因很接地气团队运维栈里本来就有ELKES的运维经验和告警体系都是现成的不需要再额外引一套专用向量数据库。ES从8.x开始内置了dense_vector字段类型和HNSW索引做中小规模的向量检索完全够用。所谓向量检索简单理解就是把“语义相近”的文本转化成一串数字然后用数学距离度量相似度。你可以把它类比成给每段文本算一个“语义指纹”查询的时候把问题的指纹和库里所有文本的指纹做比对找到最接近的几段再把这些原文交给大模型作为参考知识。3.2 索引Mapping第一次就要写对dims、similarity与HNSWES里向量字段的Mapping必须提前设计好这是一步到位的活。很多朋友栽在维度不匹配上你用的Embedding模型输出1536维向量但索引里定义的是768维写入文档时直接报错。所以第一步要确定Embedding模型比如用text-embedding-3-small输出是1536维用本地BGE模型常见是768或1024维。定下来之后索引Mapping就要按这个维度写死。我给出一个ES 8.x的mapping示例PUT /ai_memory_doc { mappings: { properties: { content: { type: text }, embedding: { type: dense_vector, dims: 1536, similarity: cosine, index: true, index_options: { type: hnsw, m: 16, ef_construction: 100 } } } } }这里有几个点提醒一下。similarity选cosine比较符合文本语义相似度场景index设为true表示启用HNSW近似索引否则这个字段只能暴力计算数据量上来后会慢到没法用。另外ES的mapping一旦创建dims等参数基本就不能改了所以初期就要规划清楚。如果文档结构会演进更稳妥的做法是使用索引别名底层加版本号需要调整mapping时重建一个新索引再切换别名而不是在线修改旧索引。如果你不想手动建索引Spring AI的ElasticsearchVectorStore也可以在初始化时自动建索引但它默认的mapping不是你能控制的。我实践中更倾向自己提前建好索引再让应用层只负责读写减少“自动建出来但不满足需求”的窘境。3.3 Spring AI接ESVectorStore的注入和使用逻辑接入代码其实不复杂核心是注册一个VectorStore的Bean。我这里写一个典型配置Bean VectorStore vectorStore(EmbeddingModel embeddingModel, ElasticsearchClient elasticsearchClient) { return new ElasticsearchVectorStore( ElasticsearchVectorStoreOptions.builder() .indexName(ai_memory_doc) .dimensions(1536) .similarity(Similarity.COSINE) .build(), elasticsearchClient, embeddingModel ); }需要注意的是Spring AI不同版本里ElasticsearchVectorStore的构造函数和参数类可能有差异建议以你实际依赖的版本API为准。整体思路是不变的把EmbeddingModel和ES客户端交给VectorStore写入时它会自动把文本转成向量再入库检索时它会自动调用ES的knn查询。写入向量之前长文本要切分。直接拿一篇几千字的文章去Embedding效果很差而且ES单条文档过大也不合理。我习惯用Spring AI的TokenTextSplitter做切分把每段控制在300词左右保存时记下原始来源TextSplitter splitter new TokenTextSplitter(400, 100, 50, 1000, true); ListDocument chunks splitter.split( new Document(这里是被索引的正文内容, Map.of(source, 某文档ID))); vectorStore.add(chunks);检索时用自然语言问句去查指定topK和相似度阈值ListDocument docs vectorStore.similaritySearch( SearchRequest.builder() .query(Spring AI生产化改造要注意什么) .topK(5) .similarityThreshold(0.5) .build());得到的候选文档片段拼进Prompt模型就能基于真实的内部知识库来回答而不是凭空发挥。这一步完成后即使应用重启、实例间切换向量数据也都稳稳躺在ES里语义检索结果不会因为进程重新拉起而改变。4. 生产环境缝合会话ID传递、多实例与配置固化4.1 动态conversationId从请求头解析到Advisor注入存储层就绪之后还有一个关键问题conversationId从哪里来在写Demo时大家经常写死一个固定ID。但生产环境里每个用户、每个会话都必须有独立ID而且这个ID要能从请求里解析出来。我用的是最简单的方案让前端在请求头里传X-Chat-Id后端用一个Filter把它读出来放进请求上下文。然后构造ChatClient时把“获取当前会话ID”的逻辑作为Supplier传给MessageChatMemoryAdvisorBean ChatClient chatClient(ChatModel chatModel, ChatMemory chatMemory) { return ChatClient.builder(chatModel) .defaultAdvisors(new MessageChatMemoryAdvisor( chatMemory, () - ChatContext.getCurrentSessionId() )) .build(); }这样每次请求进来Advisor都会先从请求上下文拿到会话ID再去Redis里加载对应的历史消息。不同用户之间互不干扰同一个用户多轮对话也能保持上下文。如果接的是微信公众号这类渠道可以用“用户标识消息类型”拼成会话ID保证多端隔离。这个设计的核心思路是会话ID由请求链路动态决定而不是写死在业务代码里。否则你只是把存储换成了Redis但所有用户还是共用同一个会话那问题比不改造更大。4.2 多实例部署下的路由策略有Redis兜底之后还要不要粘滞对话记忆和向量都放进共享存储后理论上不再依赖粘滞会话。但我在生产环境仍然建议保留粘滞配置原因很简单减少不必要的Redis读写。虽然Redis性能很好但每次请求都读一次历史、写一次历史频率还是相当高。如果应用本身无状态负载均衡可以做least_conn或一致性哈希让同一用户的请求尽量落到同一实例即使落到不同实例因为记忆在Redis上下文依然连贯。这里有个容易忽视的点粘滞会话在实例重启后通常会失效但因为对话状态不在本地内存里所以实例重启并不会导致用户上下文丢失。用户感知上最多是本次请求稍微慢一点说明架构已经达到了“状态与实例解耦”的目标。4.3 Redis持久化与配置清单把易变项钉死在配置里改造完后我把所有易变配置统一放到配置文件里方便不同环境切换。下面这个表格是我实际使用时的配置项建议配置项建议值备注spring.data.redis.hostredis.internal生产环境用内网域名spring.data.redis.port6379默认端口spring.data.redis.password${REDIS_PASSWORD}从环境变量读取spring.elasticsearch.urishttp://es.internal:9200ES节点地址spring.ai.elasticsearch.indexai_memory_doc向量索引名chat.memory.ttl24h会话记忆有效期chat.vector.topk5检索候选数量chat.vector.threshold0.5相似度阈值在配置TTL参数时我从配置中心读取Duration初始化RedisChatMemory时传入Bean ChatMemory chatMemory(StringRedisTemplate redisTemplate, ObjectMapper objectMapper, Value(${chat.memory.ttl:24h}) Duration ttl) { return new RedisChatMemory(redisTemplate, objectMapper, ttl); }这样既不写死又能支持运维在不停机的情况下调整会话保留时长。还要提醒一句Redis连接池、ES连接池的路由和超时参数也值得单独调优特别是接口调用量上来之后默认连接池很容易被打满。5. 重启不丢的实测过程5.1 验证方法设计kill进程、重启、再追问改造完成之后我在测试环境做了一轮“刻意制造失忆”的实测。整个过程很简单但能完整验证“重启再也不丢”是否真的成立。第一步启动应用用curl发起对话让模型记住一个自定义信息curl -X POST http://localhost:8080/chat \ -H Content-Type: application/json \ -H X-Chat-Id: test-session-001 \ -d {message: 我叫老王请记住这个名字}再发一条追问确认当前会话内上下文有效curl -X POST http://localhost:8080/chat \ -H Content-Type: application/json \ -H X-Chat-Id: test-session-001 \ -d {message: 我叫什么名字}此时模型应该能正确回答出“老王”。第二步直接kill -9干掉应用进程模拟生产环境最恶劣的崩溃场景kill -9 $(pidof java)第三步重新启动应用再用同一个X-Chat-Id发一条追问curl -X POST http://localhost:8080/chat \ -H Content-Type: application/json \ -H X-Chat-Id: test-session-001 \ -d {message: 你还记得我的名字吗}改造前的现象是模型完全不记得甚至会把这个问题当成全新对话。改造后模型能准确说出“你叫老王”。我也顺便做了向量检索的验证向ES里写入几份测试文档重启应用后重新检索返回结果和重启前完全一致。5.2 边界情况与后续优化哪些坑可以提前避开实测通过不代表可以高枕无忧有几个边界情况我建议提前规划。第一TTL导致的长期记忆缺失。Redis里的对话记忆有24小时或48小时的有效期过期后用户重新发起对话模型会“忘记”更早之前的事情。如果产品需要跨月、跨年的长期记忆就不能只靠Redis而是要把重要信息定期抽成摘要写到数据库或专门的长期存储里在下一次对话开始时把摘要作为“初始记忆”载入。第二向量索引的膨胀和重建。ES索引越写越大检索效率和维护成本都会上升。后续应该考虑按时间或业务模块分索引用别名做切换。因为ES的mapping字段维度不可变索引重建需要走“新索引别名切换”的流程这个操作一定要先在测试环境验证一次。第三Redis和ES本身的可用性。应用层“重启不丢”依赖于Redis和ES还活着。所以Redis至少要做主从哨兵ES至少要部署多节点这已经不是Spring AI的问题而是基础设施清单里必须覆盖的部分。这次改造让我最深的体会是拿到一个“AI应用”之后不能只盯着Prompt和模型参数状态存储的架构设计才是决定生产环境稳定性的关键因素。对话记忆放Redis是因为它快、稳定、有TTL向量数据放ES是因为它和团队的运维体系天然匹配。你完全可以根据自己的情况换成PGVector或者其他存储但核心原则只有一条——别把状态留在JVM内存里。如果你正在用Spring AI做项目我建议趁线上还没出“失忆”事故尽早把这个改造做了把ChatMemory换到Redis真的只需要半天工作量。

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价 →
↑