首页 / 资讯中心 / 文章详情

Spring AI生产化改造:Redis存会话记忆,ES存向量,重启不丢

Spring AI生产化改造:Redis存会话记忆,ES存向量,重启不丢 ★ FEATURED ARTICLE
搞过 AI 应用的朋友应该都遇到过这种问题对话机器人本地跑得好好的一部署到服务器上重启一次用户之前的聊天记录就全没了。用户问“我们之前聊到哪了”模型一脸茫然。这个项目标题——“Spring AI 生产化改造记忆落 Redis、向量落 ES重启再也不丢”其实就直指 AI 应用从 Demo 走向生产环境时最关键的落地问题状态持久化。不是只有聊天机器人需要记性任何接了知识库问答、需要理解用户上下文的 AI 应用都得面对这个问题。如果你正在用 Spring AI 做项目或者准备用 Spring AI 搭一个生产级应用这个改造思路值得认真参考。我把这次改造的完整过程、踩过的坑、还有最终的配置方案整理成一篇实战记录尽量说透每一步设计的意图方便你直接拿去用。1. 整体改造思路为什么要把“记忆”和“向量”分开存储在动手之前先理清一个核心问题到底什么东西需要持久化在 Spring AI 这类框架里对话上下文通常由两个完全不同的东西组成。第一是会话历史也就是用户和 AI 一来一回的原始消息记录它决定了模型能不能“想起来”上一轮说了什么第二是向量化内容通常来自知识库文档、历史对话的关键片段或者商品信息这类业务数据它提供的是“知识检索”能力让 AI 能基于私域数据回答问题。这两类数据差异巨大存储方案自然要分开设计。会话历史是高频读写、结构简单、明确包含 sessionId 的数据天然适合 Redis 这种内存型存储向量数据要支持相似度检索需要专门的索引结构Elasticsearch 的 dense_vector 能力能直接支持而且生产环境里很多公司本来就有 ES 集群复用现有设施成本最低。回到标题里说的“Spring AI 生产化改造”核心就是两件事把 ChatMemory 的实现从默认的内存 Map 替换成 Redis 存储把 VectorStore 的实现从默认的本地内存向量存储替换成 ES。替换完再验证一个目标重启应用会话历史不丢知识检索也不丢。1.1 职责边界划分改造之前一定要想清楚 Redis 和 ES 各自管什么边界不能糊。Redis 只存用户会话相关的临时状态。注意这里说的“临时”不是“可以丢”而是指它的生命周期跟着会话走、访问频率极高、格式简单通常是序列化后的字符串或 Hash。我用 Redis 存的是 Spring AI 的 ChatMemory 数据也就是每条消息的 messageType、文本内容、产生时间、以及对应的会话 ID。ES 存的是知识库的向量数据。对于文档型知识库需要把每个文档切片成固定大小的 chunk每个 chunk 用 embedding 模型转成向量连同原文、元数据一起写入 ES 的索引。查询的时候把用户当前的问题也转成向量在 ES 里做最近邻检索找到最相关的几个片段拼进 prompt 上下文交给大模型。严格切开之后两类数据的扩容策略、备份策略、性能调优都能独立做。Redis 不够了就加内存ES 索引要重建了不影响会话历史互不干扰。1.2 为什么不用更“省事”的方案有人可能会问Redis 不是也能做向量检索吗为什么要引入 ES这里要说一下我的选型思路。Redis 的向量检索能力RediSearch在轻量场景确实完全够用但我这次改造的目标是生产环境。生产环境里知识库的文档往往要达到数万甚至数十万级别ES 的分布式能力、分页聚合、权限控制、以及和日志系统共用一套基础设施的优势就很明显。更重要的是ES 支持“关键词 BM25 检索 向量相近检索”混合查询这在 RAG检索增强生成场景里特别实用——当向量召回的结果不太自信时用关键词兜底效果会稳定很多。会话历史这一块也不建议直接存 MySQL因为它的写入频次太高。每个对话轮次至少产生两条记录用户消息和 AI 回复长会话聊下来一个小表就能很快膨胀。Redis 的 TTL 机制可以自动清理长时间不活跃的会话配合MAXMEMORY策略能让历史数据自动“冷热分离”省去自己写定时任务清理的麻烦。2. Redis 存储会话记忆Spring AI ChatMemory 持久化落地Spring AI 框架里有一个非常重要的扩展点ChatMemory接口。这个接口定义了四个核心方法分别是添加消息、获取会话历史、清除指定会话历史、清除所有会话历史。框架默认提供的实现叫MessageWindowChatMemory它只在内存里保留一个窗口大小的消息数量根本不做持久化。应用一重启内存清空一切归零。所以改造的第一大步就是写一个自己的ChatMemory实现把消息存进 Redis。2.1 Redis 数据结构设计我采用的方案是用 Redis 的 Hash 结构存储单条消息字段如下keyai:chat:memory:{conversationId}field消息ID用时间戳加随机数生成value序列化后的消息 JSON 字符串每条消息需要保存的信息包括messageType是用户消息还是 AI 回复、textContent消息正文、timestamp消息产生时间。Spring AI 的消息对象是Message接口的实现类序列化的时候需要做一点特殊处理因为接口类型无法直接反序列化我用 Jackson 的JsonTypeInfo标注来保留类型信息。为什么用 Hash 而不是直接存一个 JSON 数组因为 Hash 支持对单个字段的读写。如果将来需要删除某一条历史消息可以直接HDEL不需要把整个列表读出来再序列化回去。另外往里追加消息的时候只用HSET一个字段不会触发并发写竞争在高并发场景下比GET-SET-写回的竞态问题要好处理得多。2.2 核心代码实现这里给出一个简化但可以直接运行的核心实现。注意我用的 Redis 客户端是 Spring Data Redis 自带的StringRedisTemplate序列化方式用的是 Jackson 的 JSON 序列化器。网上很多教程用的是 JDK 序列化存进去的是 Java 二进制流出了 Java 环境根本没法读排查问题的时候会特别痛苦。我统一用 JSON 字符串任何语言、任何工具都能直接查看和解析生产环境排障体验完全不是一个量级。Component public class RedisChatMemory implements ChatMemory { private static final String MEMORY_KEY_PREFIX ai:chat:memory:; private final StringRedisTemplate redisTemplate; private final ObjectMapper objectMapper; public RedisChatMemory(StringRedisTemplate redisTemplate, ObjectMapper objectMapper) { this.redisTemplate redisTemplate; this.objectMapper objectMapper; } Override public void add(String conversationId, ListMessage messages) { String key MEMORY_KEY_PREFIX conversationId; for (Message message : messages) { String field generateMessageId(message); String value serializeMessage(message); redisTemplate.opsForHash().put(key, field, value); } // 设置会话过期时间默认 24 小时无访问自动清理 redisTemplate.expire(key, Duration.ofHours(24)); } Override public ListMessage get(String conversationId, int lastN) { String key MEMORY_KEY_PREFIX conversationId; ListObject values redisTemplate.opsForHash().values(key); return values.stream() .map(value - deserializeMessage((String) value)) .sorted(Comparator.comparing(Message::getTimestamp)) .skip(values.size() - lastN) .collect(Collectors.toList()); } Override public void clear(String conversationId) { String key MEMORY_KEY_PREFIX conversationId; redisTemplate.delete(key); } Override public void clear() { SetString keys redisTemplate.keys(MEMORY_KEY_PREFIX *); if (keys ! null !keys.isEmpty()) { redisTemplate.delete(keys); } } private String serializeMessage(Message message) { try { return objectMapper.writeValueAsString(message); } catch (JsonProcessingException e) { throw new RuntimeException(消息序列化失败, e); } } private Message deserializeMessage(String json) { try { return objectMapper.readValue(json, Message.class); } catch (JsonProcessingException e) { throw new RuntimeException(消息反序列化失败, e); } } private String generateMessageId(Message message) { return System.currentTimeMillis() - message.hashCode(); } }这段代码有几个细节需要特别注意。第一个是get方法里的排序逻辑。Redis 的 Hash 本身不保证字段顺序如果直接遍历 values 返回消息顺序是乱的。必须显式按时间戳排序否则模型看到的对话历史是错乱的回答质量会断崖式下降。这里的时间顺序是整个会话记忆最关键的一环。第二个是lastN参数的处理。Spring AI 在构造 prompt 的时候会按窗口大小取最近若干条消息如果不需要这个限制可以把lastN设成一个很大的数或者直接在实现里返回全部。但生产环境我建议保留这个参数因为 llm 的上下文窗口是有上限的把所有历史全塞进去一是浪费 token 额度二是超长的上下文反而会干扰模型对关键信息的注意力。第三个是clear方法里的keys命令。生产环境 Redis 实例的数据量通常不小keys命令是全库扫描会在大数据量场景下阻塞 Redis 单线程。我这里为了代码简洁演示用了它如果你要上生产建议改用SCAN命令分批迭代删除或者干脆维护一个会话 ID 的集合逐个精准删除。2.3 集成到 Spring AI 调用链实现写完之后还需要让它生效。Spring AI 内部通过构造器注入ChatMemory到ChatClient或Assistant的默认实现里。我用的方式是显式注册一个Bean覆盖掉框架默认的MessageWindowChatMemoryConfiguration public class ChatMemoryConfig { Bean public ChatMemory chatMemory(StringRedisTemplate redisTemplate, ObjectMapper objectMapper) { return new RedisChatMemory(redisTemplate, objectMapper); } }加了这一步之后所有通过ChatClient发起的对话请求都会被新的ChatMemory接管。验证是否生效的方法很简单启动应用跟机器人聊几句话去 Redis 里看ai:chat:memory:*的 key 是否存在、内容是否完整。如果能看到数据说明持久化链路已经通了。2.4 Redis 相关的生产配置变更存储方案换成 Redis 之后Redis 本身的配置也要跟着调整。默认的 Redis 配置在内存不足时会执行noeviction拒绝写入但在会话记忆场景下我更推荐用allkeys-lru淘汰策略。原因很简单长时间不活跃的会话本来就不应该一直占着内存LRU 会自动把冷数据淘汰掉跟业务上的“会话过期清理”逻辑天然契合。TTL 的设置也要看业务场景。我给的示例是 24 小时过期但如果你做的是客服机器人或者教育辅导类应用用户很可能隔一两天才回来24 小时就太短了。实际项目中我建议按产品的用户触达频率来定比如 CRM 场景可以设置 7 天金融客服场景可以设置 30 天。注意一点Redis 的 Hash 在每次HSET的时候都会刷新整个 key 的 TTL所以用户只要持续对话这个会话就不会中途过期。3. 向量落 Elasticsearch知识库持久化的完整链路知识库问答的场景光有会话记忆还不够。用户问的很多问题都跟具体业务知识相关这些问题必须从自己的知识库里检索答案片段喂给大模型做生成。这个能力在 Spring AI 里由VectorStore接口承接默认实现同样只存在本地内存重启即失忆。3.1 ES 索引 Mapping 设计向量落到 ES第一步是建好索引。Spring AI 对 ES 有官方支持引入spring-ai-starter-vector-store-elasticsearch之后大部分底层逻辑框架替你做好了。但索引的 mapping 需要开发者自己规划特别是向量维度这个参数必须跟自己选用的 Embedding 模型严格对齐。举个例子我这次用的 embedding 模型输出是 768 维的向量那 mapping 里的dense_vector维数就要写 768。不同模型输出的维度不一样比如有的开源模型是 1024 维有的商用接口是 1536 维写错维度会导致写入 ES 直接报错这是一个非常基础的坑。下面是这个项目里 ES 索引的核心 mapping 定义{ mappings: { properties: { content: { type: text, analyzer: standard }, embedding: { type: dense_vector, dims: 768, index: true, similarity: cosine }, metadata: { properties: { source: {type: keyword}, docId: {type: keyword}, chunkIndex: {type: integer}, timestamp: {type: date} } } } } }content字段存的是文档切片后的原始文本embedding字段存的是这切片的向量表示。similarity选了cosine余弦相似度因为我要检索的是语义相关的最相似文本片段不是精确匹配。如果你的场景更偏向“关键词命中”或者“精确匹配”那dot_product或者l2_norm可能更适合这是由业务的检索语义决定的。metadata 里存文档的来源信息、文档 ID、切片序号、时间戳。这些看似琐碎的元数据非常有用。检索到某个片段后AI 回答时能告诉用户答案出处Reindex 的时候用 docId 做幂等处理时间戳能支持按时间段过滤的知识检索业务价值很高。3.2 文档写入流程知识库的文档矢量化不是一步到位的完整的处理管线长这样第一步文档拆解。把 PDF、Word、Markdown 文档读进来按固定大小切块。切块大小我建议控制在 200 到 500 字之间。太小了检索单元过于碎片化语义不完整太大了向量包含的信息太杂检索精度下降。文本重叠可以设置 30 到 60 字主要目的是防止一句话恰好被切块边界劈成两半导致语义断裂。第二步Embedding 映射。每个 chunk 调用 embedding 模型转成向量。这里有个性能优化点一批一批地调用不要一条一条地循环调接口。批量调用不仅减少网络往返开销很多 embedding 服务对批量请求还有速度优势。一次提交 20 到 50 个 chunk 比较合适具体要看模型的限制和你的超时配置。第三步写入 ES。Spring AI 的ElasticsearchVectorStore提供了add代理集合的方法直接传ListDocument就行。Document 里可以携带自定义 metadata框架会自动映射到前面设计的metadata字段。RequiredArgsConstructor Service public class KnowledgeBaseService { private final VectorStore vectorStore; private final EmbeddingModel embeddingModel; public void saveDocument(String docId, String title, String content) { ListString chunks splitContent(content, 400, 60); ListDocument documents new ArrayList(); for (int i 0; i chunks.size(); i) { MapString, Object metadata new HashMap(); metadata.put(docId, docId); metadata.put(title, title); metadata.put(chunkIndex, i); documents.add(new Document(chunks.get(i), metadata)); } vectorStore.add(documents); } }写完整之后需要验证 ES 里能查到数据。ES 的 Dev Tools 里执行一条search请求就能确认GET /your-vector-index/_search { query: { match_all: {} }, size: 5 }能返回文档记录说明写入链路没问题。此时把应用 kill 掉再重启数据还在ES 持久化的效果就已经达到了。3.3 检索与对话编排存储只是基础真正的核心价值在检索。当用户发来一个问题时检索流程是问题文本走 Embedding 模型转成向量 → 在 ES 里做 kNN 搜索 → 取回 N 个相关片段 → 拼进 prompt → 调用大模型生成回答。Spring AI 对这块也做了简化直接调用vectorStore.similaritySearch(query)接口就能拿到相关文档。但我在项目里做了一层封装在向大模型提交之前先过滤一轮置信度太低的检索结果直接丢弃不浪费 token。这个“先检索、后过滤”的步骤很多人会忽略但实际上对回答质量的影响非常显著。如果你用的是 Spring AI 的新版本QuestionAnswerAdvisor已经帮你把“检索 → 组装 prompt → 回答”整个流程封装好了只要配置一个搜索请求就能直接使用代码量很低。我项目里实际用的是这个封装简单场景真的不要自己造轮子框架给你铺好的路不要硬去踩旁边的地。3.4 ES 与 Redis 在数据生命周期上的配合到这里Redis 和 ES 的配合关系已经很明显了。用户每一次对话会产生两类数据一类是对话历史由ChatMemory负责写入 Redis另一类是知识库的向量数据由VectorStore负责写入 ES。但如果要做“基于历史对话的知识检索”这两者的配合就要更精细。我有一个实际项目里这么处理的每轮对话结束之后把“用户问题 AI 回答”拼接成一个文本片段embedding 之后也写入 ES同时保留 Redis 里的原始会话记录。这样后续用户再问类似问题时ES 不仅能检索到知识库文档还能检索到之前有过的相似问答回答质量明显提升。本质上这等于把 Redis 的会话记忆和 ES 的向量记忆打通了是一种性价比很高的增强方案。4. 重启验证与常见问题排查实录改造做完最重要的就是验证“重启再也不丢”这个目标到底达成了没有。光靠看代码判断是不作数的要模拟真实的故障场景。4.1 重启验证实验我的验证步骤是这样设计的第一步启动应用在会话里跟 AI 连续聊 5 轮以上确保产生足够的会话历史。第二步人为制造一些知识库内容写入 3 到 5 份文档确保向量索引中有数据。第三步记录当前的 Redis 会话 key 列表和 ES 文档数量。第四步直接kill -9杀掉应用进程模拟最严重的故障场景。第五步重新启动应用用原来的会话 ID 继续对话问 AI“我刚才跟你说了什么”如果它能准确复述说明会话记忆持久化生效。第六步调用知识库检索接口确认 ES 里的向量数据还在检索结果和重启前一致。这个实验做完之后我还在容器环境里做了一次验证。因为 Docker 容器重启之后文件系统的临时数据往往会丢失如果应用没有外置存储历史一定清零。验证结果证明这套改造的可靠性没有问题。4.2 高频踩坑记录改造过程中我踩过不少坑挑几个有代表性的分享出来。一个是序列化器的选择问题。刚开始我用的是 Java 原生序列化测试阶段一切正常直到有一次 Redis 里的数据需要通过工具排查结果打开全是二进制乱码根本没法定位问题。后来统一改成 Jackson JSON 序列化之后所有数据在 redis-cli 里直接可读排障效率提升了一个数量级。这个细节建议所有用 Spring Data Redis 做持久化的项目重点关注。另一个是 ES 索引的dense_vector和文本字段混用的数据一致性。ES 对dense_vector字段要求只能存储向量数据不能存储其他类型。如果你在 Document 的 content 字段里塞了非文本内容写入会静默失败或报错。我遇到过几次索引结构正确、所有文档都写入失败的情况排查了很久才发现是 metadata 里某个字段类型和 mapping 冲突。还有一个容易被忽略的坑是Spring AI 的ChatMemory是从ChatClient里取的如果你用的是ChatClient的Builder模式每次构建都有独立的ChatMemory引用会导致不同客户端之间的记忆不互通。解决方法是把ChatClient声明为单例 Bean或者显式指定同一个ChatMemory实例。4.3 常见问题排查速查表这里整理一份实操过程中最常用的排查速查表按问题现象分类可以直接抄作业问题现象可能原因排查步骤解决方案重启后会话历史丢失ChatMemory 没被替换成 Redis 实现检查 Spring 启动日志中 ChatMemory 的 Bean 类型确认RedisChatMemory被Primary或Bean正确注册Redis 有数据但 AI 不认账消息顺序错乱检查get方法是否按时间排序显式按timestamp排序后再返回ES 写入报维度错误Embedding 模型维度与 mapping 不一致查看报错中的维度信息统一模型和 mapping 的 dims 配置检索结果质量差文档切块太大/太小抽查切片内容的语义完整性调整 chunk size 到 200-500 字重叠 30-60 字会话偶尔全部消失Redis TTL 设置过短查看auth.chatMemorykey 过期时间按业务需要调大 TTL知识库检索超时单次请求向量化过多文档查看应用日志中的耗时分布批量写入设置 20-50 个 chunk 一批遇到问题先别慌着重启按表格里的排查步骤走一遍大部分情况能找到根因。实战经验告诉我很多“诡异”的问题最后都是配置细节或者数据格式问题而不是框架本身的缺陷。5. 改造收益总结与后续扩展方向这次改造做下来最直观的收益就是应用重启以后用户的会话记忆和知识库检索能力都能无缝恢复。交付给业务方之后再也没有出现过“用户聊到一半机器人突然失忆”的投诉。这个收益虽然看起来是技术层面的但对用户体验和商业可信度的提升是实打实的。另外一个间接收益是排障效率的显著提升。以前本地内存里的会话数据是黑盒出了问题只能靠日志猜现在所有会话历史存在 Redis 里可以直接查询、直接修改、直接复现 bug。向量数据存在 ES 里可以用 kibana 直接看数据内容和索引状态。整个系统的可观测性上了一个大台阶这对生产系统的日常运维价值巨大。后续有几个方向值得继续做。一是把 Redis 会话记忆和 ES 向量检索彻底打通实现“记忆增强检索”让 AI 不仅能记住本轮聊天的内容还能从历史海量对话中检索相关背景回答会更像人。二是引入消息队列做异步向量化文档上传后不用同步等 embedding 完成用户体验会好很多。三是给 Redis 会话记忆加上热点会话的本地缓存减少高频访问对 Redis 的压力让系统扛住更大的并发。这套改造方案不复杂核心就是两个持久化存储选型加上 Spring AI 框架提供的扩展点。但正是这些“简单”的改动把一个玩具级的 AI 应用变成了真正能支撑业务的生产系统。个人实际做下来的体会是AI 应用落地最难的不是模型调优而是那些看起来不起眼的状态管理问题。把记忆、知识、状态都放到该放的地方应用才能真正开始有价值。
阅读完成 · 觉得有帮助?
咨询建站