LangChain4j 生产化改造:记忆落 Redis、向量落 ES,重启再也不丢
作者:鱼宵 | LangChain4j 实战精通营 · 第 11 篇
上一课我们攒出了企业客服"小星",前端能聊天、能查订单、还能拿知识库回答年假问题。那天我美滋滋地给同事演示,聊到一半手贱把服务重启了一下------结果再问"我刚才说我叫什么",它一脸懵:"您好,请问有什么可以帮您?"
完蛋。记忆全没了。
更扎心的是,这种"失忆"还只是表面。它的记忆存在 JVM 内存里,向量库也是 InMemoryEmbeddingStore 那座内存小仓库------重启即丢、多实例还各记各的。说白了,这就是个"教室里的 demo",离能上线还差一层。这篇就把这层补上:记忆搬去 Redis,向量搬去 Elasticsearch,业务代码一行不动。代码全在仓库的 lesson-11/ 目录,clone 下来照着命令重跑,你也能看到"杀掉 java 进程、换个新进程,同一用户的记忆居然还在"。
一、核心原理:为什么 demo 一重启就"失忆"
先搞懂一个关键抽象:ChatMemoryStore。
窗口记忆 MessageWindowChatMemory 决定的是"留最近 10 条消息",这叫窗口;而这些消息从哪取、往哪存,是另一件事,叫 Store。LangChain4j 把后者抽成了一个只有三个方法的接口:getMessages(memoryId)、updateMessages(memoryId, 列表)、deleteMessages(memoryId)。窗口和存储正交------你可以窗口 10 条 + Redis,也可以窗口 5 条 + 数据库。L10 用的是默认的内存实现,重启就被收走。
类比:
MessageWindowChatMemory是"只看最近 10 页的作业本",ChatMemoryStore是作业本背后的档案室。L10 的档案室就是一张内存桌子;本课换成"Redis 档案室",永久保存。
RedisChatMemoryStore 把 sessionId 拼成 Redis key,把这段会话的全部历史序列化成 JSON 存进去。它是全局单例,内部一条连接池,按 memoryId 分 key,天然多会话隔离。
快递柜类比:Redis 是一整面快递柜墙,sessionId 是取件码,历史是柜子里的包裹。重启进程相当于换了个快递员,柜子里的包裹还在。
向量库同理。L10 的内存向量库像办公桌上的小抽屉,只有自己能用、下班就清空;本课换 ElasticsearchEmbeddingStore,向量和原文落到 ES 索引,检索变成 KNN 查询,像楼下那座专业图书馆,谁都能查、开多久都在。
最值得想明白的是这张表(面试高频):
| 数据 | 存在哪 | 重启应用进程后 | 删掉中间件容器后 |
|---|---|---|---|
| 聊天记忆(历史消息) | Redis | 还在(从 Redis 读回) | 没了(数据卷没了) |
| FAQ 向量+原文 | Elasticsearch | 还在(索引还在) | 没了(需重新灌库) |
看出来了吗?落中间件不等于永不丢------落的是"进程外",重启 JVM 不丢,但 docker compose down -v 删了数据卷照样没。
二、动手:先起两个中间件,再跑应用
环境:Windows + JDK 17 + Maven 3.9+ + Docker Desktop 已启动。本课应用端口 8087。
第 0 步:检查环境。
powershell
java -version # 期望 True
[bool][Environment]::GetEnvironmentVariable('DEEPSEEK_API_KEY') # 期望 True
Get-NetTCPConnection -LocalPort 8087 -State Listen -ErrorAction SilentlyContinue # 无输出=空闲
docker ps --filter "name=es-lc-l11" --filter "name=redis-lc-l11" # 没起就下一步起
第 1 步:起 Redis + ES 两个容器。
powershell
cd langchain4j-journey\lesson-11
docker compose up -d
ES 首次启动要 20~60 秒初始化 node,日志停在那不是卡死。确认就绪:
powershell
curl.exe -s http://localhost:9200 # 看到 "number" : "8.19.22" 即 ES 就绪
docker exec redis-lc-l11 redis-cli ping # 期望返回 PONG
第 2、3 步:编译 + 启动。
powershell
cd langchain4j-journey\lesson-11
$env:JAVA_HOME="C:\Program Files\Java\jdk-17" # Maven 必须跑在 JDK17,每个新窗口设一次
mvn clean install -DskipTests # 看到 BUILD SUCCESS
mvn spring-boot:run # 注意:必须在 lesson-11 目录下启动(要读 docs/company-faq.md)
第 4 步:访问。
浏览器开 http://localhost:8087/。命令行测(中文要 URL 编码):
powershell
curl.exe -s "http://localhost:8087/chat?sessionId=t1&message=$([uri]::EscapeDataString('年假能休几天'))"
三、关键代码:只改两处,业务一行不动
相比 L10,本课就动了两个 Bean:记忆挂 Redis、向量库换 ES。其余 Controller、Service、工具、人设、前端全没碰。
改造点①:RedisChatMemoryStore------记忆挂 Redis。
java
package com.langchain4j.lesson11.config;
import dev.langchain4j.community.store.memory.chat.redis.RedisChatMemoryStore;
import dev.langchain4j.community.store.memory.chat.redis.StoreType;
import dev.langchain4j.memory.chat.MessageWindowChatMemory;
import dev.langchain4j.service.AiServices;
import dev.langchain4j.store.memory.chat.ChatMemoryStore;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
@Configuration
public class AgentConfig {
// 共享的"记忆外部仓库":全局单例,内部一条 Redis 连接池,所有会话共用
@Bean
public ChatMemoryStore chatMemoryStore() {
return RedisChatMemoryStore.builder()
.host(System.getenv().getOrDefault("REDIS_HOST", "localhost")) // Redis 主机
.port(Integer.parseInt(System.getenv().getOrDefault("REDIS_PORT", "6390"))) // 本课 Redis 在 6390
.prefix("lc:l11:chat:memory:") // key 前缀:最终 key = lc:l11:chat:memory:sessionId,避免和别的应用撞 key
// ★关键坑:默认 StoreType.JSON 走 RedisJSON 模块的 JSON.GET 命令,普通 redis:7-alpine 没装该模块,
// 会报 "ERR unknown command 'JSON.GET'"。选 STRING 退化成普通 GET/SET(value 仍是一段 JSON 文本),任何 Redis 都能跑。
.storeType(StoreType.STRING)
.build();
}
// 组装客服 Agent:窗口记忆不变,但背后的"历史消息"读写 Redis
// chatMemoryProvider(memoryId -> ...) 里换成 builder 三件套
// .id(memoryId) 这段记忆属于哪个会话
// .maxMessages(10) 窗口只留最近 10 条,更早的自动遗忘(省 token)
// .chatMemoryStore(...) 历史不存内存,读写 Redis → 重启不丢、多实例可共享
// 不写 .chatMemoryStore(...) 会怎样?默认退回 InMemoryChatMemoryStore,重启照样丢,本课主演示不成立。
// customerAgent = AiServices.builder(CustomerAgent.class)
// .chatModel(chatModel).streamingChatModel(streamingChatModel)
// .chatMemoryProvider(memoryId -> MessageWindowChatMemory.builder()
// .id(memoryId).maxMessages(10).chatMemoryStore(chatMemoryStore).build())
// .tools(orderTool, inventoryTool).systemMessage(SYSTEM_PROMPT).build();
}
这段代码最值钱的一行是 .storeType(StoreType.STRING)------我第一次跑没写它,直接被 ERR unknown command 'JSON.GET' 教做人:普通 Redis 没装 RedisJSON 模块,默认 JSON 模式根本起不来。
改造点②:ElasticsearchEmbeddingStore------向量库换 ES,还得幂等灌库。
java
package com.langchain4j.lesson11.rag;
import dev.langchain4j.data.embedding.Embedding;
import dev.langchain4j.data.segment.TextSegment;
import dev.langchain4j.model.embedding.EmbeddingModel;
import dev.langchain4j.store.embedding.EmbeddingSearchRequest;
import dev.langchain4j.store.embedding.elasticsearch.ElasticsearchEmbeddingStore;
import jakarta.annotation.PostConstruct;
import org.springframework.stereotype.Component;
import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.charset.StandardCharsets;
@Component
public class KnowledgeBase {
private final EmbeddingModel embeddingModel;
private ElasticsearchEmbeddingStore store; // 向量库:L10 是内存版,本课是 ES 版
private static final String INDEX_NAME = "lc-l11-faq";
private static final int DIMENSION = 384; // all-MiniLM-L6-v2 输出 384 维
public KnowledgeBase(EmbeddingModel embeddingModel) { this.embeddingModel = embeddingModel; }
@PostConstruct
public void init() throws Exception {
// ① 连 ES:indexName 不存在会自动建索引(含 384 维 dense_vector 映射)
store = ElasticsearchEmbeddingStore.builder()
.serverUrl("http://localhost:9200")
.indexName(INDEX_NAME)
.dimension(DIMENSION)
.build();
// ② 幂等判断:ES 是外部中间件,数据不随 JVM 重启消失。
// 已有数据就跳过灌库------否则每次重启都多写 12 条,索引里堆出 24/36 条重复文档。
if (alreadyLoaded()) {
System.out.println("检测到 ES 索引已有数据,跳过灌库(重启复用)");
return;
}
// ③ 读文件 → 切 12 条 → 逐条嵌入写 ES(与 L10 完全一样)
String markdown = Files.readString(Path.of("docs/company-faq.md"), StandardCharsets.UTF_8);
for (String chunk : markdown.split("(?=### )")) {
if (chunk.trim().startsWith("### ")) {
Embedding embedding = embeddingModel.embed(chunk.trim()).content();
store.add(embedding, TextSegment.from(chunk.trim()));
}
}
}
// 检索:API 和 L10 一字不差,底层从"内存算余弦"换成"ES KNN",对上层透明。
// search() 里还是 EmbeddingSearchRequest.queryEmbedding(q).maxResults(k).minScore(s),
// CustomerService / ChatController 所以一行没动------这就是面向接口编程的红利。
}
注意这个 alreadyLoaded():拿个占位问题搜一下,能捞到就说明库里有货。为什么 L10 不需要它?因为 L10 内存库每次启动都是空的,必须重灌;ES 库重启后数据还在,不判重就重复灌库。这是两类数据最本质的工程差异。
中间件怎么起:docker-compose.yml。
yaml
redis-lc-l11:
image: redis:7-alpine
ports: ["6390:6379"] # 本机 6379 被别的 Redis 占了,用 6390
command: ["redis-server", "--appendonly", "yes"] # 开 AOF,演示 Redis 自己也落盘
es-lc-l11:
image: docker.elastic.co/elasticsearch/elasticsearch:8.19.22
ports: ["9200:9200", "9300:9300"]
environment:
- discovery.type=single-node # 单节点,免集群选主、省内存
- xpack.security.enabled=false # 本地教学关账号密码;生产必须开!
- ES_JAVA_OPTS=-Xms1g -Xmx1g # ES 8.x 默认堆太大,内存小的机器固定 1g
四、实测输出:杀掉进程换个新进程,记忆还在
以下是 2026-10-05 23:42 本机真实运行(DeepSeek,应用 8087,ES 8.19.22,Redis 6390)。
先看 ES 索引------12 条文档,重启两次还是 12 条(幂等生效):
text
$ curl.exe -s "http://localhost:9200/_cat/indices/lc-l11-faq?v"
health status index uuid pri rep docs.count docs.deleted store.size
yellow open lc-l11-faq kQ0-rOQ7RbOjnJ_ZDYsBIQ 1 1 12 0 90.6kb
再看记忆------在旧进程里聊过,然后杀掉 java 进程、换新进程(PID 27540 → 24636),再问:
text
$ /chat?sessionId=mem-A&message=我叫什么名字?我最喜欢的编程语言是什么?
答: 你叫鱼宵,最喜欢的编程语言是 Java。请问还有什么可以帮你的吗?
$ /chat?sessionId=mem-B&message=我之前说我在哪个城市?
答: 您之前说您所在的城市是北京。请问有什么可以帮您的吗?
进程都换了,mem-A 还记得"鱼宵/Java"、mem-B 还记得"北京"------记忆是从 Redis 读回来的。重启前 Redis 里能看到这些 key:
text
$ docker exec redis-lc-l11 redis-cli --scan --pattern 'lc:l11:*'
lc:l11:chat:memory:demo
lc:l11:chat:memory:mem-A
lc:l11:chat:memory:mem-B
两个 sessionId 交错不串台:
text
A-R1 你好,我叫鱼宵...最喜欢Java。 → 答: 你好,鱼宵,欢迎加入星辰科技!
B-R1 你好,我叫李四...城市是北京。 → 答: 你好,李四!我是...小星
A-R2 我刚才说我叫什么名字? → 答: 你刚才说你叫鱼宵...最喜欢 Java
B-R2 我刚才说我叫什么名字? → 答: 您刚才说您叫李四,所在城市是北京
B-R2 答的是"李四/北京",没串到 A 的"鱼宵"。Redis 按 prefix + sessionId 分 key,天然隔离。
知识题带引用、兜底题走 400,和 L10 表现一致:
text
问: 年假能休几天?
答: 根据公司资料,入职满 1 年享 5 天年假,之后每多 1 年增加 1 天,上限 15 天......
citations[0] score=0.888 ### 2. 年假和请假怎么申请?入职满 1 年享 5 天年假......
问: 帮我订一张明天飞北京的机票
答: 抱歉,这个问题我暂时无法解答,请联系人工客服 400-800-8888(工作日 9:00-18:00)。
RAG、工具、兜底都没变------本课只换底层中间件,业务行为一个字没改。
五、挑战题:改个参数,看看会怎样
- ⭐ 改 key 前缀 :把
.prefix("lc:l11:chat:memory:")改成你们公司的前缀(比如mycorp:chat:v1:),重启后用docker exec redis-lc-l11 redis-cli --scan --pattern '*'看新 key 长什么样。答案就在源码AgentConfig.java那一行,跑出来你就懂"多应用共用一个 Redis 怎么隔离"了。 - ⭐⭐ 看窗口遗忘 :把
maxMessages(10)改成2,同一个 sessionId 连续聊 5 轮,再回头问最早那句,观察它是不是"忘了"。答案在窗口记忆的语义里------窗口只留最近 N 条,更早的真会被丢掉。 - ⭐⭐⭐ 看重复灌库 :临时把
alreadyLoaded()改成永远返回false(直接去掉判断),重启两次,跑curl.exe -s http://localhost:9200/lc-l11-faq/_count看文档数是不是变成 24、36。这道题跑过一次,你就一辈子记得"外部持久化存储为什么要幂等灌库"。
六、生产环境进阶:三个加分项
1. xpack.security 教学关了,生产必须开。 本课 xpack.security.enabled=false 是本地教学捷径,连上 ES 不用账号密码。真上线必须开启 HTTPS + 账号密码,别把内网 ES 裸奔出去。
2. 记忆要设 TTL。 聊天记录一直堆 Redis 既费空间又有合规风险。RedisChatMemoryStore.builder() 支持 .ttl(...)(毫秒),给会话记忆设个过期时间,既省内存又符合个保法"用户数据可删"的要求。
3. 坐标别照抄旧教程。 1.x 里 Redis 记忆集成已迁到 langchain4j-community-redis(旧的 langchain4j-redis 在 1.0 后停更),版本走 beta 线(本机可用 1.20.0-beta30);ES 集成叫 langchain4j-elasticsearch(1.21.0-beta31)。写坐标前查 Maven 元数据反查,别拿两年前的博客直接抄。
七、面试回答模板
面试官:多轮记忆为什么不能放 JVM 内存?怎么持久化?
一句话:记忆就是每轮把历史消息塞进上下文,持久化就是把这段消息列表存进 Redis,重启后按 sessionId 读回。展开:LLM 本身无状态,所谓记忆是我们每轮自己拼历史;LangChain4j 的
ChatMemoryStore接口把"窗口大小"和"存储位置"解耦,窗口 10 条配 Redis Store,重启进程后同一 sessionId 再来问,框架从 Redis 读回历史拼上下文。(指向本课第一节 / lesson-11 的 AgentConfig.java)
追问:Redis 里一个会话的记忆长什么样?key 怎么设计?key =
prefix + sessionId,比如lc:l11:chat:memory:mem-A,value 是一段序列化的 JSON 消息数组(SYSTEM/USER/AI 消息)。加前缀是为了多应用共用一个 Redis 不撞 key;StoreType 选 STRING 是因为普通 Redis 没装 RedisJSON 模块,选 JSON 会报JSON.GET未知命令。(指向本课第三节)
追问:向量库从内存换 ES,工程上改了几处?为什么上层不用动?只换了
EmbeddingStore的实现:InMemoryEmbeddingStore→ElasticsearchEmbeddingStore,检索请求EmbeddingSearchRequest契约不变,底层从"内存算余弦"换成"ES KNN",对 Service/Controller 透明。要注意 ES 是外部持久化存储,启动必须加幂等判断,否则每次重启重复灌库。(指向本课第三节 KnowledgeBase.java)
八、总结表
| 坑 | 现象 | 解法 |
|---|---|---|
| JAVA_HOME 指向 JDK8 | mvn 跑在 Java 8 报错 | 构建前 $env:JAVA_HOME="C:\Program Files\Java\jdk-17" |
| RedisChatMemoryStore 默认 JSON | 报 ERR unknown command 'JSON.GET' |
加 .storeType(StoreType.STRING) |
| ES 8.x 默认堆太大 | 内存小的机器启动即 OOM 或极慢 | compose 里 ES_JAVA_OPTS=-Xms1g -Xmx1g |
| ES 8.x 默认开安全 | 客户端连不上、要账号密码 | 教学 xpack.security.enabled=false,生产必开 |
| ES 首次启动慢 | curl :9200 要等 20~60 秒 |
日志停住不是卡死,轮询等 |
| 向量库换 ES 后重复灌库 | 重启两次索引堆出 24/36 条 | KnowledgeBase 启动先 alreadyLoaded() 判重 |
| 不在 lesson-11 目录启动 | 报"找不到 docs/company-faq.md" | 启动目录必须是 lesson-11 |
| 坐标照抄旧教程 | langchain4j-redis:1.21.0 不存在 |
用 langchain4j-community-redis beta 线 |
九、关于这个系列
本文是「Java 后端实战精通营 」系列第 11 篇,原则:实战驱动、由浅到深、面试向,每篇文章的结论都可以亲手验证。
👉 LangChain4j 实战精通营:https://gitee.com/j67mk2/langchain4j-journey
- 本文对应源码位置 :
lesson-11/(在 L10 基础上改造,核心看config/AgentConfig.java记忆挂 Redis、rag/KnowledgeBase.java向量库换 ES,外加新增docker-compose.yml起 Redis 6390 + ES 9200)
系列文章一览(主线 10 篇 + 生产增强 3 篇,按发布顺序):
| 篇 | 主题 |
|---|---|
| 1 | LangChain4j 第一个对话程序:手写 LLM 调用链路,看清模型怎么"开口" |
| 2 | LangChain4j 提示词工程:模板 + few-shot + 温度,提示词从写死到复用 |
| 3 | LangChain4j 结构化输出:让模型吐出 Java 对象,JSON 不再手写解析 |
| 4 | LangChain4j 对话记忆:让模型记住聊过什么,三种方式实测对比 |
| 5 | LangChain4j 流式输出:打字机效果 + SSE,回答不再干等三秒 |
| 6 | LangChain4j 工具调用:Function Calling 让模型动手查数据,多工具连调实测 |
| 7 | LangChain4j 向量检索:文本怎么变成可搜索的坐标 |
| 8 | LangChain4j RAG 流水线:手写问答全流程,给 AI 开卷考试 |
| 9 | LangChain4j Agent:多步推理自主调用,放手让"实习生"自己安排工作 |
| 10 | LangChain4j 企业智能客服:RAG + 工具 + 记忆 + 流式 + 兜底,验收 5 项全过 |
| 11 | LangChain4j 生产化改造:记忆落 Redis、向量落 ES,重启再也不丢 |
下一篇预告:《LangChain4j 可观测与透明化:traceId 串起全链路,SSE 直播思考过程》------数据不丢了,但系统还是个黑盒:线上问答慢了、答错了,你怎么知道是检索没命中还是模型调错了工具?下一课给每次请求发一个"快递单号" traceId,把检索、工具、模型串到 Zipkin,再把思考过程实时直播到前端。本课的 ES/Redis 容器直接复用,不用重起。
跑完有任何报错,把终端输出发评论区,一起排查。
标签建议 :LangChain4j、Redis、Elasticsearch
摘要建议(≤256 字):demo 级客服一重启就失忆?本文把多轮记忆从 JVM 内存搬到 Redis(RedisChatMemoryStore 按 sessionId 分 key、StoreType.STRING 避坑),把向量库从内存换成 Elasticsearch(KNN 检索契约不变、启动幂等灌库)。实测杀掉 java 进程换新进程后,同一用户记忆还在、FAQ 索引 12 条不重复灌。附完整 docker compose 起中间件命令、改造代码与面试回答模板,源码在 gitee lesson-11 可 clone 重跑。