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

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、工具、兜底都没变------本课只换底层中间件,业务行为一个字没改。

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

  1. ⭐ 改 key 前缀 :把 .prefix("lc:l11:chat:memory:") 改成你们公司的前缀(比如 mycorp:chat:v1:),重启后用 docker exec redis-lc-l11 redis-cli --scan --pattern '*' 看新 key 长什么样。答案就在源码 AgentConfig.java 那一行,跑出来你就懂"多应用共用一个 Redis 怎么隔离"了。
  2. ⭐⭐ 看窗口遗忘 :把 maxMessages(10) 改成 2,同一个 sessionId 连续聊 5 轮,再回头问最早那句,观察它是不是"忘了"。答案在窗口记忆的语义里------窗口只留最近 N 条,更早的真会被丢掉。
  3. ⭐⭐⭐ 看重复灌库 :临时把 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 重跑。

相关推荐
IT规划师19 小时前
Redis 数据类型全解 - Hash 还是 String + JSON?一文终结存储选型之争(三)
redis·.net core·哈希表·.net10
captain3761 天前
MyBatis(入门5)其他查询操作
java·数据库·spring boot·mybatis
夕除1 天前
Redis 过期策略
redis·笔记·面试
小此方1 天前
Re:Mysql数据库基础篇(八):详细讲解复合查询、多表查询、自连接、多行、多列子查询与合并查询
数据库·mysql
xszivvvv1 天前
Mariadb数据库frm文件异常,如何恢复数据
数据库·mariadb
smile_life_1 天前
MySQL 设置 防止账户被暴力破解
数据库·mysql
ShineWinsu1 天前
对于Qt:QRadioButton常见属性以及QButtonGroup的解析
数据库·c++·qt·microsoft·gui·qradiobutton·qbuttongroup
测试盐1 天前
oracle数据库卡顿排查
数据库·oracle
滕州市燕猫虎计算机科技工作室个体工商户1 天前
MySQL、MVCC、多版本并发控制
数据库·mysql·mvcc·数据库事务