掌握LangChain4j聊天记忆实战
“💡导语:手动管理
ChatMessage太麻烦了?LangChain4j 提供了开箱即用的ChatMemory抽象,让你轻松实现对话记忆功能。本文将从基础概念到持久化实战,带你完整掌握聊天记忆的核心用法。
📖 什么是 ChatMemory?
手动维护和管理ChatMessage是很麻烦的。因此,LangChain4j 提供了ChatMemory抽象以及多种开箱即用的实现。
ChatMemory可以作为独立的低级组件使用,也可以作为高级组件(如 AI 服务)的一部分。
作为ChatMessage的容器(由List支持),ChatMemory提供了以下核心能力:
🔄淘汰策略— 自动淘汰旧消息,控制上下文长度
💾持久化— 将对话数据存储到磁盘或数据库
⚙️对 SystemMessage 的特殊处理— 系统消息始终保留
🔧对工具消息的特殊处理— 自动清理孤立的工具调用结果
🔍 记忆 vs 历史:两个容易混淆的概念
“⚠️注意:"记忆"和"历史"是相似但不同的概念。
概念 | 说明 | 特点 |
|---|---|---|
📜历史(History) | 保持用户和 AI 之间的所有消息完整无缺 | 用户在 UI 中看到的内容,代表实际对话 |
🧠记忆(Memory) | 保存部分信息呈现给 LLM,使其表现得像"记住"了对话 | 可以淘汰消息、总结、注入额外信息等 |
LangChain4j 目前只提供"记忆",而不是"历史"。如果你需要保存完整的历史记录,请手动进行。
🗑️ 淘汰策略:为什么需要它?
淘汰策略是必要的,原因有三个:
📏 适应上下文窗口— LLM 一次能处理的 Token 数量有上限,对话过长时必须淘汰旧消息
💰 控制成本— 每个 Token 都有成本,淘汰不必要的消息可以降低费用
⚡ 控制延迟— 发送给 LLM 的 Token 越多,处理时间越长
两种开箱即用的实现
1️⃣ MessageWindowChatMemory(消息窗口)
较简单的一种,作为滑动窗口运行:
保留最近的N 条消息
淘汰不再适合的旧消息
⚠️ 每条消息的 Token 数量可能不同,适合快速原型开发
2️⃣ TokenWindowChatMemory(Token 窗口)
更复杂的选项,同样作为滑动窗口运行:
保留最近的N 个 Token(而非消息条数)
消息是不可分割的 — 如果一条消息放不下,整条淘汰
需要一个
Tokenizer来计算每个ChatMessage中的 Token 数
💾 持久化:让记忆不随程序重启而消失
默认情况下,ChatMemory实现在内存中存储ChatMessage。程序一重启,记忆就没了 😅
如果需要持久化,可以实现自定义的ChatMemoryStore,将消息存储到你选择的任何持久化存储中。
核心接口
class PersistentChatMemoryStore implements ChatMemoryStore { @Override public List<ChatMessage> getMessages(Object memoryId) { // TODO: 通过 memoryId 从持久化存储中获取所有消息 // 可使用 ChatMessageDeserializer.messagesFromJson(String) 反序列化 } @Override public void updateMessages(Object memoryId, List<ChatMessage> messages) { // TODO: 通过 memoryId 更新持久化存储中的所有消息 // 可使用 ChatMessageSerializer.messagesToJson(List<ChatMessage>) 序列化 } @Override public void deleteMessages(Object memoryId) { // TODO: 通过 memoryId 删除持久化存储中的所有消息 } }使用方式
ChatMemory chatMemory = MessageWindowChatMemory.builder() .id("12345") .maxMessages(10) .chatMemoryStore(new PersistentChatMemoryStore()) .build();方法调用时机
方法 | 调用时机 | 说明 |
|---|---|---|
updateMessages() | 每次添加新消息时 | 每次 LLM 交互调用 2 次(添加 UserMessage 和 AiMessage 各一次) |
getMessages() | 用户请求所有消息时 | 每次 LLM 交互调用 1 次 |
deleteMessages() | 调用 | 如果不用可留空 |
“📌注意:从
ChatMemory中淘汰的消息也会从ChatMemoryStore中淘汰。淘汰时会调用updateMessages(),传入不含被淘汰消息的列表。
⚙️ SystemMessage 的特殊处理
SystemMessage是一种特殊类型的消息,处理规则与其他消息不同:
✅一旦添加,始终保留— 不会被淘汰
🔒一次只能保存一条SystemMessage
🚫相同内容的新 SystemMessage 会被忽略
🔄不同内容的新 SystemMessage 会替换之前的
🔧 工具消息的特殊处理
如果包含ToolExecutionRequest的AiMessage被淘汰,随后的孤立ToolExecutionResultMessage也会自动被淘汰。
这是为了避免与某些 LLM 提供商(如 OpenAI)出现问题 — 它们禁止在请求中发送孤立的工具执行结果。
🚀 实战示例
环境准备
模块结构
新建memorymok模块:
pom.xml 依赖
<?xml version="1.0" encoding="UTF-8"?> <project xmlns="http://maven.apache.org/POM/4.0.0" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd"> <modelVersion>4.0.0</modelVersion> <parent> <groupId>com.whc</groupId> <artifactId>langChain4j-whc</artifactId> <version>1.0-SNAPSHOT</version> </parent> <artifactId>langchain4j-whc-memory</artifactId> <properties> <maven.compiler.source>17</maven.compiler.source> <maven.compiler.target>17</maven.compiler.target> <project.build.sourceEncoding>UTF-8</project.build.sourceEncoding> </properties> <dependencies> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-web</artifactId> </dependency> <!-- langchain4j 核心 --> <dependency> <groupId>dev.langchain4j</groupId> <artifactId>langchain4j-open-ai</artifactId> </dependency> <dependency> <groupId>dev.langchain4j</groupId> <artifactId>langchain4j</artifactId> </dependency> <dependency> <groupId>dev.langchain4j</groupId> <artifactId>langchain4j-reactor</artifactId> </dependency> <!-- 阿里百炼平台 --> <dependency> <groupId>dev.langchain4j</groupId> <artifactId>langchain4j-community-dashscope-spring-boot-starter</artifactId> </dependency> <!-- 工具库 --> <dependency> <groupId>org.projectlombok</groupId> <artifactId>lombok</artifactId> <optional>true</optional> </dependency> <dependency> <groupId>cn.hutool</groupId> <artifactId>hutool-all</artifactId> <version>5.8.22</version> </dependency> <dependency> <groupId>dev.langchain4j</groupId> <artifactId>langchain4j-spring-boot-starter</artifactId> </dependency> </dependencies> <build> <plugins> <plugin> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-maven-plugin</artifactId> </plugin> </plugins> </build> </project>application.yml 配置
server: port: 9005 servlet: encoding: enabled: true force: true charset: UTF-8 spring: application: name: langchain_whc_memory ai: dashScope: apiKey: ${AI_DASHSCOPE_API_KEY} modelName: ${AI_DASHSCOPE_MODEL_NAME} baseUrl: ${AI_DASHSCOPE_BASE_URL}配置类
@Slf4j @Configuration public class LlmConfig { @Value("${ai.dashScope.apiKey}") private String dashScopeApiKey; @Value("${ai.dashScope.modelName}") private String dashScopeModelName; @Value("${ai.dashScope.baseUrl}") private String dashScopeBaseUrl; @Bean public ChatModel chatModel() { return OpenAiChatModel.builder() .apiKey(dashScopeApiKey) .modelName(dashScopeModelName) .baseUrl(dashScopeBaseUrl) .build(); } @Bean public StreamingChatModel streamingChatModel() { return OpenAiStreamingChatModel.builder() .apiKey(dashScopeApiKey) .modelName(dashScopeModelName) .baseUrl(dashScopeBaseUrl) .build(); } /** * 基于消息窗口的 ChatMemory — 保留最近 N 条消息 */ @Bean public ChatMemory messageWindowChatMemory() { return MessageWindowChatMemory.withMaxMessages(10); } /** * 基于 Token 窗口的 ChatMemory — 保留最近 N 个 Token */ @Bean public ChatMemory tokenWindowChatMemory() { TokenCountEstimator tokenCountEstimator = new OpenAiTokenCountEstimator(dashScopeModelName); return TokenWindowChatMemory.withMaxTokens(3000, tokenCountEstimator); } }“📌 根据前面介绍的两种淘汰策略,这里分别定义了两个 Bean,方便对比测试。
Service 层
AiService 注解的 WiringMode
@AiService注解的WiringMode有两种模式:
ALL— 自动将应用中所有组件注入 AI Service(同类型多个会抛异常)
EXPLICIT— 只注入显式指定的组件,通过属性名指定 Bean 名称
“💡 如果需要自己对 AiService 中的参数赋值,需要设置
mode = EXPLICIT。
NoMemoryAssist(无记忆模式)
@AiService(wiringMode = EXPLICIT, chatModel = "chatModel") public interface NoMemoryAssist { /** * 聊天(无记忆) */ String chat(String userMessage); }MemoryAssist(带记忆模式)
@AiService(wiringMode = EXPLICIT, chatModel = "chatModel", streamingChatModel = "streamingChatModel", chatMemory = "messageWindowChatMemory") public interface MemoryAssist { /** 普通聊天 */ String chat(String userMessage); /** 带 MemoryId 的聊天 — 区分不同用户 */ String chat(@MemoryId int memoryId, @UserMessage String userMessage); /** 流式聊天 */ Flux<String> streamChat(String userMessage); /** 带 MemoryId 的流式聊天 */ Flux<String> streamChat(@MemoryId int memoryId, @UserMessage String userMessage); }Controller 层
NoChatMemoryController(无记忆测试)
@RestController @RequiredArgsConstructor public class NoChatMemoryController { private final NoMemoryAssist noMemoryAssist; @GetMapping("/noMemory/chat") public String chat(@RequestParam String userMessage) { return noMemoryAssist.chat(userMessage); } }ChatMemoryController(带记忆)
@Slf4j @RestController @RequiredArgsConstructor public class ChatMemoryController { private final MemoryAssist memoryAssist; @GetMapping("/chat") public String chat(@RequestParam String userMessage) { return memoryAssist.chat(userMessage); } @GetMapping("/chat-with-memory") public String chatWithMemory(@RequestParam int memoryId, @RequestParam String userMessage) { return memoryAssist.chat(memoryId, userMessage); } @GetMapping(value = "/stream-chat", produces = MediaType.TEXT_PLAIN_VALUE) public Flux<String> streamChat(@RequestParam String userMessage) { return memoryAssist.streamChat(userMessage); } @GetMapping(value = "/stream-chat-with-memory", produces = MediaType.TEXT_PLAIN_VALUE) public Flux<String> streamChatWithMemory(@RequestParam int memoryId, @RequestParam String userMessage) { return memoryAssist.streamChat(memoryId, userMessage); } }🧪 测试验证
踩坑记录:Token 估算器报错
启动时报错:
Caused by: java.lang.IllegalArgumentException: Model 'qwen-max' is unknown to jtokkit原因分析:OpenAiTokenCountEstimator底层使用jtokkit库,该库只支持 OpenAI 官方模型(GPT-4、GPT-3.5 等),不支持阿里通义千问模型。
查看源码可以发现,getEncodingForModel()方法对ModelType枚举进行判断:
里面没有包含阿里模型 😅
解决方案:使用gpt-3.5-turbo作为 Token 估算的替代模型:
@Bean public ChatMemory tokenWindowChatMemory() { // 使用 gpt-3.5-turbo 作为 token 计算模型 // 因为 jtokkit 库不支持 dashscope 的 qwen-max 模型 TokenCountEstimator tokenCountEstimator = new OpenAiTokenCountEstimator("gpt-3.5-turbo"); return TokenWindowChatMemory.withMaxTokens(3000, tokenCountEstimator); }测试一:无记忆聊天
第一次问"我是派大星",AI 进入了角色扮演的状态。
第二次问"我是谁",AI完全不记得刚才的对话,给出了一个通用的回答。
“✅结论:不带记忆的聊天无法联系上下文,每次都是全新的对话。
测试二:带记忆聊天(chat 接口)
第一次说"我是派大星"。
第二次问"我是谁",AI记得刚才的对话,回答"你是派大星呀!"
“✅结论:
chat接口已经自带记忆功能,可以联系上下文。
踩坑记录:多用户记忆的 NPE 问题
使用@MemoryId参数时报错:
java.lang.NullPointerException at java.base/java.util.Objects.requireNonNull(Objects.java:208) at dev.langchain4j.service.DefaultAiServicesInvoker.invoke(DefaultAiServicesInvoker.java)原因分析:使用@MemoryId注解时,系统需要一个ChatMemoryProvider来为每个memoryId创建独立的聊天记忆实例。但当前配置只提供了一个具体的ChatMemory实例。
解决方案:增加一个ChatMemoryProviderBean:
/** * 创建一个 ChatMemoryProvider 实例,用于根据 memoryId 提供独立的聊天记忆 */ @Bean public ChatMemoryProvider messageChatMemoryProvider() { return memoryId -> MessageWindowChatMemory.withMaxMessages(10); }修改 Service 接口,使用chatMemoryProvider替代chatMemory:
@AiService(wiringMode = EXPLICIT, chatModel = "chatModel", streamingChatModel = "streamingChatModel", chatMemoryProvider = "messageChatMemoryProvider") public interface MemoryAssist { // ... }测试三:分用户记忆(memoryId)
用户 1:派大星
用户 2:酷奇
用户 2 追问:我是谁?
“AI 记得用户 2 是"酷奇"!
用户 1 追问:我是谁?
“AI 记得用户 1 是"派大星"!两个用户的记忆完全独立。
“✅结论:分用户记忆成功!不同
memoryId的对话历史完全隔离。
💾 记忆持久化实战
持久化示例
参考官网案例,我们来做一个基于文件的持久化示例。
定义持久化类
“⚠️注意:这里 Map 的 key 和 value 都是 String 类型,所以
memoryId也需要改成 String 类型。
@Slf4j @Component public class PersistentChatMemoryStore implements ChatMemoryStore { // 使用 DBMaker 进行文件级持久化 private static final DB DB = DBMaker .fileDB("multi-user-chat-memory.db") .transactionEnable() .make(); // 哈希映射表:memoryId -> JSON 格式的聊天消息 private static final Map<String, String> MAP = DB .hashMap("messages", STRING, STRING) .createOrOpen(); @Override public List<ChatMessage> getMessages(Object memoryId) { String json = MAP.get((String) memoryId); log.info("getMessage->json:{}", json); return messagesFromJson(json); } @Override public void updateMessages(Object memoryId, List<ChatMessage> messages) { String json = messagesToJson(messages); MAP.put((String) memoryId, json); log.info("updateMessages->json:{}", json); DB.commit(); } @Override public void deleteMessages(Object memoryId) { MAP.remove((String) memoryId); log.info("deleteMessages->memoryId:{}", memoryId); DB.commit(); } }改造配置类
这一次使用TokenWindowChatMemory进行测试:
/** * 基于 Token 窗口的持久化 ChatMemory */ public ChatMemory tokenWindowChatMemory(String memoryId) { TokenCountEstimator tokenCountEstimator = new OpenAiTokenCountEstimator("gpt-3.5-turbo"); return TokenWindowChatMemory .builder() .id(memoryId) .maxTokens(3000, tokenCountEstimator) .chatMemoryStore(PersistentChatMemoryStore.INSTANCE) .build(); } @Bean public ChatMemoryProvider tokenChatMemoryProvider() { return memoryId -> tokenWindowChatMemory((String) memoryId); }Assist 接口
@AiService(wiringMode = EXPLICIT, chatModel = "chatModel", streamingChatModel = "streamingChatModel", chatMemoryProvider = "tokenChatMemoryProvider") public interface MemoryStoreAssist { /** 带 MemoryId 的流式聊天 */ Flux<String> streamChat(@MemoryId String memoryId, @UserMessage String userMessage); }Controller
@Slf4j @RestController @RequiredArgsConstructor public class MemoryStoreController { private final MemoryStoreAssist memoryStoreAssist; @GetMapping(value = "/stream/memory/store", produces = MediaType.TEXT_PLAIN_VALUE) public Flux<String> streamChatWithMemory( @RequestParam String memoryId, @RequestParam String userMessage) { return memoryStoreAssist.streamChat(memoryId, userMessage); } }测试:持久化验证
用户 1:派大星
用户 2:佩奇
新发送的请求中包含了前面的消息:
用户 3:听风轩
听风轩再次访问
后台日志:因为已经进行了持久化,所以会把用户前面的 message 也带上。
查看持久化数据
获取用户 1 的数据:
请求也会附带上以前的 message 信息:
获取用户 3 的数据:
可以看到 Map 中对所有聊天数据按用户进行了独立存储。
“✅结论:持久化测试成功!即使程序重启,用户的对话记忆也不会丢失。
📝 总结
功能 | 实现方式 | 适用场景 |
|---|---|---|
简单记忆 | MessageWindowChatMemory | 快速原型、消息条数固定 |
Token 控制 | TokenWindowChatMemory | 成本敏感、Token 预算有限 |
多用户隔离 | ChatMemoryProvider+ | 多用户系统 |
持久化 | 自定义 | 需要跨会话保留记忆 |
“💡最佳实践:
开发阶段用
MessageWindowChatMemory快速验证生产环境推荐
TokenWindowChatMemory控制成本多用户场景务必使用
ChatMemoryProvider重要对话建议做持久化,避免重启丢失
