Spring AI 生产化改造:记忆落 Redis、向量落 ES,重启再也不丢

0 阅读10分钟

Spring AI 生产化改造:记忆落 Redis、向量落 ES,重启再也不丢

作者:鱼宵 | Spring AI 实战精通营 · 第 11 篇

上一课我们攒出了一个能跑的企业智能客服,自己看着挺美——直到我重启了一下进程,然后当着同事的面问它"我刚才叫你什么来着",它一脸懵地回我"您好,请问有什么可以帮您"。更绝的是,公司那 12 条 FAQ 知识库也跟着一起没了,启动日志重新打印"入库 12 条"——等于每次重启都得重新教一遍。

这在 Demo 里无所谓,上了线就是事故。用户昨天刚跟客服聊到一半,你半夜发个版重启,第二天他再问"刚才说到哪了",客服跟失忆了一样。这篇文章就干一件事:把两个"内存玩具"换成真正的中间件,业务行为一行不改。代码全在仓库的 lesson-11/ 目录里,clone 下来照着命令重跑一遍,你会发现"换存储"这件事比想象中简单得多——但踩坑一个都不少。

一、核心原理:只换两个"落地存储",其他一行不动

先看架构对比,一眼就懂本课改了什么:

L10(教学版)                          L11(生产版,本课)
┌─────────────────────────┐            ┌─────────────────────────┐
│ ChatController          │            │ ChatController          │  完全没变
│ ChatClient + Advisor链  │            │ ChatClient + Advisor链  │  完全没变
├─────────────┬───────────┤            ├─────────────┬───────────┤
│ 记忆仓库     │ 向量库    │   ──换──▶ │ Redis(自实现)│ ES        │
│ InMemory    │ SimpleVec │            │ (重启还在) │ (重启还在)│
└─────────────┴───────────┘            └─────────────┴───────────┘

Controller、ChatClient、Advisor 链、@Tool、SystemPrompt、前端——一行没动。变的只是底下两个存储实现。说白了,这就像你家搬了个家,从租的房子(内存)换到自己的房子(Redis/ES),但你的沙发电视(业务代码)原封不动搬过去就行。

1. ChatMemoryRepository:记忆存储的"接口"

L10 里记忆顾问拼历史时,底层调的是一个 ChatMemoryRepository 接口。反查 spring-ai-model-1.0.9.jar 的 javap,这个接口就 4 个方法:

List<String> findConversationIds();                    // 列出所有会话 id
List<Message> findByConversationId(String convId);     // 取某会话全部历史
void saveAll(String convId, List<Message> messages);    // 覆盖式保存
void deleteByConversationId(String convId);              // 清空某会话

类比:记忆顾问是"资料员",ChatMemoryRepository 是"档案柜的抽象接口"。内存柜、Redis 柜都贴同一个接口标签,资料员只管"按会话号取档案还档案",柜子放哪它不关心。

关键事实:Spring AI 1.0.9 官方 BOM 里,ChatMemoryRepository 的实现只有 jdbc / cassandra / neo4j 三种,没有 Redis。所以我们得自己实现这个接口(见第三节)。别去网上找 spring-ai-starter-chat-memory-repository-redis——1.0.9 不存在这个坐标。

2. VectorStore:向量库的"统一抽象"

VectorStore 是另一个面向接口的抽象:add(文档) 入库、similaritySearch(问题) 检索。L10 用内存实现 SimpleVectorStore,本课换成 ElasticsearchVectorStore。业务代码只认 VectorStore 接口,换库只改依赖 + 配置,业务类零修改——这就是依赖倒置的威力。

3. 为什么记忆和向量要"分开落"?

数据选型为什么
多轮记忆Redis(KV)按会话 id 存一段对话,读写频繁、单条小、要快
FAQ 向量Elasticsearch(dense_vector)要支持向量相似度检索,ES 8.x 的 kNN 天生合适

会话状态用 Redis,语义检索用 ES——各干各擅长的事。

二、动手:起 ES + 编译 + 启动

环境:Windows + JDK 17 + Maven 3.9+ + Docker Desktop。Redis 复用本机 6379 那个容器(别停它!)。

第 1 步:起 Elasticsearch(本课自带 compose)。

cd spring-ai-journey\lesson-11
docker compose up -d
# 等 ES 健康(首次约 30~60 秒),直到能看到版本号 JSON:
curl.exe -s http://localhost:9202

ES 容器名 es-sa-l11,HTTP 端口 9202。redis 不用起——直接复用本机 6379 那个公共容器,本课记忆 key 有独立前缀隔离。

第 2 步:编译 + 启动。

$env:JAVA_HOME="C:\Program Files\Java\jdk-17"
mvn clean install -DskipTests
java "-Dfile.encoding=UTF-8" "-jar" "target\lesson-11-1.0.0.jar"

必须在 lesson-11 目录下启动:docs/company-faq.md 和 models/model.onnx 都是相对路径。看到 Tomcat started on port 8103 即成功。

第 3 步:验证中间件里真有数据。

# ES 里应看到本课索引 springai-lesson11-faq
curl.exe -s "http://localhost:9202/_cat/indices?v"
# Redis 里应看到本课记忆 key(独立前缀)
docker exec redis redis-cli KEYS "springai:lesson11:chatmemory:*"

三、关键代码:三个文件,逐行拆解

真正要看的代码就三处:yml(新增 Redis + ES 配置)、RedisChatMemoryRepository(自实现记忆仓库)、CustomerChatClientConfig(记忆仓库那一行换了)。

第一段:application.yml——三处新增配置。

server:
  port: 8103                            # 本课端口:spring-ai L11 = 8103
spring:
  data:
    redis:
      host: localhost
      port: 6379                        # 复用本机现有 redis 容器
  elasticsearch:
    uris: http://localhost:9202         # ES 容器 es-sa-l11
  ai:
    vectorstore:
      elasticsearch:
        index-name: springai-lesson11-faq   # 本课索引名
        dimensions: 384                   # all-MiniLM 向量维度
        initialize-schema: true            # 启动自动建索引
        similarity: cosine
    embedding:
      transformer:
        onnx:
          model-uri: file:./models/model.onnx   # 真模型指本地(踩坑见第五节)

这段 yml 最关键的一行是 model-uri: file:./models/model.onnx——别问,问就是踩过的坑(第五节细讲)。

第二段:RedisChatMemoryRepository.java——自己实现的记忆仓库(本课核心)。

package com.springai.lesson11.config;

import com.fasterxml.jackson.databind.ObjectMapper;
import org.springframework.ai.chat.memory.ChatMemoryRepository;
import org.springframework.ai.chat.messages.*;
import org.springframework.data.redis.core.StringRedisTemplate;
import org.springframework.stereotype.Component;
import java.util.*;

/**
 * 自己实现的 Redis 版 ChatMemoryRepository——把多轮对话消息持久化到 Redis。
 * Spring AI 1.0.9 官方只有 jdbc/cassandra/neo4j 实现,没有 Redis,所以自己写。
 *
 * 存储结构:一个会话 = Redis 一个 key,value 是一段 JSON 数组:
 *   springai:lesson11:chatmemory:{convId} =
 *     [{"t":"USER","c":"我叫王豪杰","m":{}},
 *      {"t":"ASSISTANT","c":"你好王豪杰…","m":{}}, ...]
 */
@Component
public class RedisChatMemoryRepository implements ChatMemoryRepository {

    private static final String KEY_PREFIX = "springai:lesson11:chatmemory:"; // 独立前缀隔离

    private final StringRedisTemplate redisTemplate;
    private final ObjectMapper objectMapper;

    public RedisChatMemoryRepository(StringRedisTemplate redisTemplate, ObjectMapper objectMapper) {
        this.redisTemplate = redisTemplate;
        this.objectMapper = objectMapper;
    }

    /** 取某会话全部历史:读 key → JSON 数组 → 还原成 Message 对象 */
    @Override
    public List<Message> findByConversationId(String conversationId) {
        String json = redisTemplate.opsForValue().get(KEY_PREFIX + conversationId);
        if (json == null || json.isBlank()) return List.of();
        try {
            @SuppressWarnings("unchecked")
            List<LinkedHashMap<String, Object>> rawList = objectMapper.readValue(json, List.class);
            List<Message> messages = new ArrayList<>();
            for (LinkedHashMap<String, Object> raw : rawList) {
                Message msg = toMessage(raw);
                if (msg != null) messages.add(msg);
            }
            return messages;
        } catch (Exception e) {
            // JSON 坏了不能让客服挂掉:降级为空会话
            return List.of();
        }
    }

    /** 覆盖式保存:窗口记忆每次加一条都会把整段历史传进来,整体 set 回去 */
    @Override
    public void saveAll(String conversationId, List<Message> messages) {
        List<Map<String, Object>> out = new ArrayList<>();
        for (Message m : messages) {
            Map<String, Object> item = new LinkedHashMap<>();
            item.put("t", m.getMessageType().name());   // USER / ASSISTANT ...
            item.put("c", m.getText());                  // 消息正文
            item.put("m", m.getMetadata() == null ? Map.of() : m.getMetadata());
            out.add(item);
        }
        try {
            String json = objectMapper.writeValueAsString(out);
            redisTemplate.opsForValue().set(KEY_PREFIX + conversationId, json);
        } catch (Exception e) {
            throw new IllegalStateException("[Redis记忆] 序列化失败", e);
        }
    }

    @Override
    public List<String> findConversationIds() {
        Set<String> keys = redisTemplate.keys(KEY_PREFIX + "*");
        if (keys == null || keys.isEmpty()) return List.of();
        return keys.stream().map(k -> k.substring(KEY_PREFIX.length())).toList();
    }

    @Override
    public void deleteByConversationId(String conversationId) {
        redisTemplate.delete(KEY_PREFIX + conversationId);
    }

    /** 按 type 还原成具体的 Message 子类 */
    @SuppressWarnings("unchecked")
    private Message toMessage(LinkedHashMap<String, Object> raw) {
        String type = (String) raw.get("t");
        String text = (String) raw.get("c");
        Map<String, Object> meta = raw.get("m") instanceof Map ? (Map<String, Object>) raw.get("m") : Map.of();
        try {
            MessageType mt = MessageType.valueOf(type);
            return switch (mt) {
                case USER -> new UserMessage(text);
                case ASSISTANT -> new AssistantMessage(text, meta);
                case SYSTEM -> new SystemMessage(text);
                case TOOL -> null; // 工具消息不进记忆
            };
        } catch (Exception e) {
            return null;
        }
    }
}

这段代码就干了一件事:把 List<Message> 序列化成 JSON 存进 Redis,读出来再还原回去。为什么是"覆盖式"不是 LPUSH 追加?因为 MessageWindowChatMemory 每次加一条都会把整段历史传进来重写——和内存版行为完全一致。

第三段:CustomerChatClientConfig.java——记忆仓库那一行换了。

package com.springai.lesson11.config;

import org.springframework.ai.chat.client.ChatClient;
import org.springframework.ai.chat.client.advisor.MessageChatMemoryAdvisor;
import org.springframework.ai.chat.client.advisor.vectorstore.QuestionAnswerAdvisor;
import org.springframework.ai.chat.memory.*;
import org.springframework.ai.vectorstore.SearchRequest;
import org.springframework.ai.vectorstore.VectorStore;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;

@Configuration
public class CustomerChatClientConfig {

    private static final String SYSTEM_PROMPT = """
            你是「星辰科技」的企业内部智能客服……(与 L10 完全一致,此处省略)
            """;

    @Bean
    public ChatMemory chatMemory(ChatMemoryRepository chatMemoryRepository) {
        return MessageWindowChatMemory.builder()
                .chatMemoryRepository(chatMemoryRepository)   // ← L10 这里是 new InMemoryChatMemoryRepository()
                .maxMessages(20)
                .build();
    }

    @Bean
    public ChatClient customerChatClient(ChatClient.Builder builder,
                                         ChatMemory chatMemory,
                                         VectorStore vectorStore) {
        MessageChatMemoryAdvisor memoryAdvisor = MessageChatMemoryAdvisor.builder(chatMemory).build();
        QuestionAnswerAdvisor ragAdvisor = QuestionAnswerAdvisor.builder(vectorStore)
                .searchRequest(SearchRequest.builder().topK(3).similarityThreshold(0.5).build())
                .build();
        return builder.defaultSystem(SYSTEM_PROMPT)
                .defaultAdvisors(memoryAdvisor, ragAdvisor)
                .build();
    }
}

看到那行注释了吗?整个生产化改造,业务侧就改了这一行——把 new InMemoryChatMemoryRepository() 换成注入容器里的 ChatMemoryRepository(就是上面那个 Redis 实现)。Advisor 链、SystemPrompt、RAG 顾问全没动。这就是面向接口编程的红利。

四、实测输出:重启后记忆还在,本课最关键的证据

以下是 2026-10-05 23:56 本机真实运行(DeepSeek,端口 8103,ES 8.19.22 容器 es-sa-l11,Redis 复用 6379)。

验收① ES 索引可见:

$ curl.exe -s "http://localhost:9202/_cat/indices?v"
health status index                 pri rep docs.count store.size
yellow open   springai-lesson11-faq   1   1        24    172.6kb

docs.count=24 = 两次启动各入库 12 条(重启重复 add 了一遍,内容相同不影响检索)。health=yellow 是单节点副本分片无法分配的正常现象。

验收② 重启后记忆还在(本课最关键):

重启前先在会话 mem-1 聊:

Q[mem-1]: 你好,我叫王豪杰,今天第一天入职星辰科技,我最喜欢的编程语言是Java。
A: 你好王豪杰,欢迎加入星辰科技!……
Q[mem-1]: 我刚才说我叫什么名字?
A: 你刚才说你叫王豪杰。

然后杀掉 java 进程、重新启动(新进程内存全空),再问同一个 sessionId:

Q[mem-1]: 我刚才说我叫什么名字?我最喜欢什么编程语言?
A: 你叫王豪杰,最喜欢的编程语言是 Java。

新进程内存里本没有任何历史,它却答出了"王豪杰/Java"——历史是从 Redis 里 springai:lesson11:chatmemory:mem-1 读回来的。对比 L10:同样操作后 L10 只会说"我不认识你"。

验收③ 两个会话不串台:

Q[mem-1]:    (已自我介绍:王豪杰,喜欢Java)
Q[s-other]: 你好,我叫李雷,是做销售的。
Q[mem-1]:    我最喜欢的编程语言是什么?
A[mem-1]:    你刚才说你最喜欢的编程语言是 Java。
Q[s-other]:  我叫什么?做什么的?
A[s-other]:  您叫李雷,是做销售的。

各记各的——Redis 里也是两个独立 key,互不干扰。

五、挑战题:改参数,看看会怎样

  1. ⭐ 验证隔离:开两个浏览器标签页(各自动生成不同 sessionId),分别自我介绍不同名字,互相提问,观察互不串台;再跑 docker exec redis redis-cli KEYS "springai:lesson11:chatmemory:*" 看几个 key。答案在源码 RedisChatMemoryRepository.java 的 KEY_PREFIX 里。
  2. ⭐⭐ 换相似度门槛:把 similarityThreshold(0.5) 调到 0.8,问"CRM怎么收费"这类短问题,观察是不是更容易走兜底——体会"检索阈值 = 兜底触发器"。答案在 CustomerChatClientConfig.java 的 RAG 顾问那行。
  3. ⭐⭐ 给记忆加 TTL:改 RedisChatMemoryRepository.saveAll,把 set(key, json) 改成 set(key, json, Duration.ofDays(7)),让会话 7 天自动过期——跑 TTL 命令验证。答案就在源码那行 opsForValue().set()。

六、生产环境进阶:五个踩坑,每个都值钱

1. 【最狠】spring-ai-transformers jar 里的 model.onnx 是 Git LFS 指针假文件。 jar 内 model.onnx 只有 0.1KB(真模型 86MB)。不配置 model-uri 时,Spring AI 默认去 github raw 拉真模型,本机直连 LFS 存储永远卡在下载——启动日志停在 tokenizers 后不动。解法:把真 model.onnx 预置到 models/,yml 指 file:./models/model.onnx。面试点:学会用 jstack <pid> 定位"启动卡死"是网络等待还是 CPU 计算。

2. ES 堆内存必须显式卡死。 ES 8.x 默认按机器内存比例申请堆,机器内存有限时会 OOM。docker-compose 里必须 -e ES_JAVA_OPTS=-Xms1g -Xmx1g,否则容器起不来。

3. 本地教学要关 xpack.security。 ES 8.x 默认开安全(https + 账号密码)。本地单节点用 -e xpack.security.enabled=false,客户端走 http 无需账号。生产环境必须打开,这是教学与生产的边界。

4. ES 单节点必须 discovery.type=single-node。 不然 ES 8.x 默认等集群选举,单节点永远起不来。

5. ES 客户端与服务端主版本对齐。 本课 elasticsearch-java 是 8.18.8,服务端是 8.19.22,同属 8.x 向前兼容。别用 7.x 客户端连 8.x,会炸。

七、面试回答模板

面试官:多轮记忆从纯内存换成 Redis 持久化,代码到底改了哪里?

一句话:面向 ChatMemoryRepository 接口编程,换实现只改一行注入。展开:L10 用 new InMemoryChatMemoryRepository(),L11 换成自实现的 RedisChatMemoryRepository(4 个方法,把 Message 序列化成 JSON 存 Redis);MessageWindowChatMemory 这个窗口记忆壳、maxMessages(20)、Advisor 链全没动。因为 1.0.9 官方没有 Redis 记忆仓库,得自己写。(指向本课第三节 / lesson-11 的 RedisChatMemoryRepository.java)

追问:VectorStore 抽象为什么能做到"换 ES 业务代码一行不改"?

业务代码只认 VectorStore 接口,Simple/ES/Milvus 可互换。换库只改 pom 依赖 + yml 配置(index-name、dimensions、similarity),业务类零修改。这就是依赖倒置。(指向本课第一节)

追问:本地起 ES 8.x 单节点要哪几个关键参数?

三个:discovery.type=single-node(不然等集群选举起不来)、ES_JAVA_OPTS=-Xms1g -Xmx1g(卡死堆内存防 OOM)、xpack.security.enabled=false(本地教学关安全,生产必开)。(指向本课第六节)

追问:启动卡死在 tokenizers 不动,怎么排查?

jstack <pid> 看主线程栈——阻塞在 HttpURLConnection.readAllBytes 就是在等网络下载模型。spring-ai-transformers jar 里的 model.onnx 可能是 Git LFS 指针假文件,要指真模型。(指向本课第六节坑 1)

八、总结表

坑现象解法
重启记忆全丢新进程不认识老用户自实现 RedisChatMemoryRepository,历史落 Redis
重启向量全丢每次启动重新入库VectorStore 换 ElasticsearchVectorStore,向量落 ES
官方没有 Redis 记忆仓库找不到 starter 坐标反查 BOM 确认只有 jdbc/cassandra/neo4j,自己写 4 个方法
model.onnx 是 LFS 假文件启动卡死在 tokenizers指真模型 file:./models/model.onnx
ES 堆内存 OOM容器起不来或被杀docker-compose 显式 -Xms1g -Xmx1g
ES 单节点起不来日志卡在等 masterdiscovery.type=single-node
ES 8.x 默认开安全客户端连不上本地教学 xpack.security.enabled=false
Redis key 串别的课误删别人数据独立前缀 springai:lesson11:chatmemory:
重启重复入库docs.count 每次多 12生产上做幂等或先清索引

九、关于这个系列

本文是「Java 后端实战精通营」系列第 11 篇,原则:实战驱动、由浅到深、面试向,每篇文章的结论都可以亲手验证。

👉 Spring AI 实战精通营(10 课):gitee.com/j67mk2/spri…

  • 本文对应源码位置:lesson-11/(自实现 RedisChatMemoryRepository + ES 向量库自动配置 + 客服 ChatClient 总装)

主线 10 篇 + 生产增强 3 篇。系列文章一览(按发布顺序):

篇主题
1Spring AI 初体验:配好 yml 就能聊,ChatClient 四步链式调用
2Spring AI 提示词模板:{变量} 参数化 + few-shot,一条提示词反复用
3Spring AI 结构化输出:entity() 把模型回答解析成 JavaBean,别再手撕 JSON
4Spring AI 工具调用:@Tool 让大模型自己查订单查库存
5Spring AI 流式输出:Flux + SSE 打字机,回答不再干等三秒
6Spring AI 多模态:给大模型一双眼睛,图片它也能看懂
7Spring AI 向量检索:本地 ONNX 嵌入,文本秒变坐标,知识库零成本起步
8Spring AI RAG 问答助手:回答带引用,AI 不再睁眼说瞎话
9Spring AI Advisor 编排:记忆 + 工具 + RAG 三合一,一个接口全搞定
10Spring AI 企业智能客服:RAG + 工具 + 记忆 + 流式 + 兜底,十课收官
11Spring AI 生产化改造:记忆落 Redis、向量落 ES,重启再也不丢

下一篇预告:《Spring AI 可观测与透明化:traceId 串起全链路,思考过程实时直播给用户》——数据落中间件做完了,但线上一次问答慢了你怎么知道卡在哪?下一课给每次请求挂上 traceId、把工具调用和思考过程实时推给前端,ES/Redis 容器直接复用。

跑完有任何报错,把终端输出发评论区,一起排查。


标签建议:SpringAI、Redis、Elasticsearch 摘要建议(≤256 字):L10 的企业智能客服一重启记忆和知识库全丢?本文把内存记忆换成自实现 RedisChatMemoryRepository、内存向量库换成 ElasticsearchVectorStore,业务代码一行不改。附 ES 单节点三参数、LFS 假模型卡死排查、Redis key 隔离等 5 个踩坑,实测验证重启后记忆还在、两会话不串台。源码在 gitee lesson-11 可 clone 直接跑。