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 重跑。

相关推荐
小巫山云子2 小时前
Git的用法
大数据·git·elasticsearch
星云API技术笔记2 小时前
企业微信外部群机器人实战:多人同时咨询时,如何准确匹配消息与回复对象
数据库
ClouGence2 小时前
开源数据库管理工具 CloudDM 4.4.0 发布,新增 Kafka、SAP HANA 数据源
数据库·kafka
瀚高PG实验室3 小时前
业务无法通过hgproxy访问数据库
数据库·postgresql·瀚高数据库
鱼很腾apoc3 小时前
【MySQL】 第1期 从核心概念到表约束全解析
linux·c语言·数据库·学习·mysql
10mAh3 小时前
【SQL】NOT IN 遇到 NULL 为什么返回空集?三值逻辑与反关联实战
jvm·数据库·sql
Alinket3 小时前
毫米波雷达 vs 摄像头 vs 手环:养老跌倒监测三类方案怎么选
网络·数据库·健康医疗·信息与通信·iot
七夜zippoe3 小时前
Agent 权限边界:别让 AI 误删你的生产库(安全设计)
数据库·人工智能·安全·ai·权限边界·安全设计
Elasticsearch4 小时前
多租户集群中的 Kubernetes 吵闹邻居:使用 ES|QL 找出它
elasticsearch