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,互不干扰。
五、挑战题:改参数,看看会怎样
- ⭐ 验证隔离:开两个浏览器标签页(各自动生成不同 sessionId),分别自我介绍不同名字,互相提问,观察互不串台;再跑
docker exec redis redis-cli KEYS "springai:lesson11:chatmemory:*"看几个 key。答案在源码RedisChatMemoryRepository.java的KEY_PREFIX里。 - ⭐⭐ 换相似度门槛:把
similarityThreshold(0.5)调到0.8,问"CRM怎么收费"这类短问题,观察是不是更容易走兜底——体会"检索阈值 = 兜底触发器"。答案在CustomerChatClientConfig.java的 RAG 顾问那行。 - ⭐⭐ 给记忆加 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 单节点起不来 | 日志卡在等 master | discovery.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 篇。系列文章一览(按发布顺序):
| 篇 | 主题 |
|---|---|
| 1 | Spring AI 初体验:配好 yml 就能聊,ChatClient 四步链式调用 |
| 2 | Spring AI 提示词模板:{变量} 参数化 + few-shot,一条提示词反复用 |
| 3 | Spring AI 结构化输出:entity() 把模型回答解析成 JavaBean,别再手撕 JSON |
| 4 | Spring AI 工具调用:@Tool 让大模型自己查订单查库存 |
| 5 | Spring AI 流式输出:Flux + SSE 打字机,回答不再干等三秒 |
| 6 | Spring AI 多模态:给大模型一双眼睛,图片它也能看懂 |
| 7 | Spring AI 向量检索:本地 ONNX 嵌入,文本秒变坐标,知识库零成本起步 |
| 8 | Spring AI RAG 问答助手:回答带引用,AI 不再睁眼说瞎话 |
| 9 | Spring AI Advisor 编排:记忆 + 工具 + RAG 三合一,一个接口全搞定 |
| 10 | Spring AI 企业智能客服:RAG + 工具 + 记忆 + 流式 + 兜底,十课收官 |
| 11 | Spring 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 直接跑。