掌握LangChain4j聊天记忆实战

📅 2026/7/17 15:58:58
掌握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️⃣ TokenWindowChatMemoryToken 窗口更复杂的选项同样作为滑动窗口运行保留最近的N 个 Token而非消息条数消息是不可分割的 — 如果一条消息放不下整条淘汰需要一个Tokenizer来计算每个ChatMessage中的 Token 数 持久化让记忆不随程序重启而消失默认情况下ChatMemory实现在内存中存储ChatMessage。程序一重启记忆就没了 如果需要持久化可以实现自定义的ChatMemoryStore将消息存储到你选择的任何持久化存储中。核心接口class PersistentChatMemoryStore implements ChatMemoryStore { Override public ListChatMessage getMessages(Object memoryId) { // TODO: 通过 memoryId 从持久化存储中获取所有消息 // 可使用 ChatMessageDeserializer.messagesFromJson(String) 反序列化 } Override public void updateMessages(Object memoryId, ListChatMessage messages) { // TODO: 通过 memoryId 更新持久化存储中的所有消息 // 可使用 ChatMessageSerializer.messagesToJson(ListChatMessage) 序列化 } 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.clear()时如果不用可留空“注意从ChatMemory中淘汰的消息也会从ChatMemoryStore中淘汰。淘汰时会调用updateMessages()传入不含被淘汰消息的列表。⚙️ SystemMessage 的特殊处理SystemMessage是一种特殊类型的消息处理规则与其他消息不同✅一旦添加始终保留— 不会被淘汰一次只能保存一条SystemMessage相同内容的新 SystemMessage 会被忽略不同内容的新 SystemMessage 会替换之前的 工具消息的特殊处理如果包含ToolExecutionRequest的AiMessage被淘汰随后的孤立ToolExecutionResultMessage也会自动被淘汰。这是为了避免与某些 LLM 提供商如 OpenAI出现问题 — 它们禁止在请求中发送孤立的工具执行结果。 实战示例环境准备模块结构新建memorymok模块pom.xml 依赖?xml version1.0 encodingUTF-8? project xmlnshttp://maven.apache.org/POM/4.0.0 xmlns:xsihttp://www.w3.org/2001/XMLSchema-instance xsi:schemaLocationhttp://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd modelVersion4.0.0/modelVersion parent groupIdcom.whc/groupId artifactIdlangChain4j-whc/artifactId version1.0-SNAPSHOT/version /parent artifactIdlangchain4j-whc-memory/artifactId properties maven.compiler.source17/maven.compiler.source maven.compiler.target17/maven.compiler.target project.build.sourceEncodingUTF-8/project.build.sourceEncoding /properties dependencies dependency groupIdorg.springframework.boot/groupId artifactIdspring-boot-starter-web/artifactId /dependency !-- langchain4j 核心 -- dependency groupIddev.langchain4j/groupId artifactIdlangchain4j-open-ai/artifactId /dependency dependency groupIddev.langchain4j/groupId artifactIdlangchain4j/artifactId /dependency dependency groupIddev.langchain4j/groupId artifactIdlangchain4j-reactor/artifactId /dependency !-- 阿里百炼平台 -- dependency groupIddev.langchain4j/groupId artifactIdlangchain4j-community-dashscope-spring-boot-starter/artifactId /dependency !-- 工具库 -- dependency groupIdorg.projectlombok/groupId artifactIdlombok/artifactId optionaltrue/optional /dependency dependency groupIdcn.hutool/groupId artifactIdhutool-all/artifactId version5.8.22/version /dependency dependency groupIddev.langchain4j/groupId artifactIdlangchain4j-spring-boot-starter/artifactId /dependency /dependencies build plugins plugin groupIdorg.springframework.boot/groupId artifactIdspring-boot-maven-plugin/artifactId /plugin /plugins /build /projectapplication.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 注解的 WiringModeAiService注解的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); /** 流式聊天 */ FluxString streamChat(String userMessage); /** 带 MemoryId 的流式聊天 */ FluxString 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 FluxString streamChat(RequestParam String userMessage) { return memoryAssist.streamChat(userMessage); } GetMapping(value /stream-chat-with-memory, produces MediaType.TEXT_PLAIN_VALUE) public FluxString 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替代chatMemoryAiService(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 MapString, String MAP DB .hashMap(messages, STRING, STRING) .createOrOpen(); Override public ListChatMessage getMessages(Object memoryId) { String json MAP.get((String) memoryId); log.info(getMessage-json:{}, json); return messagesFromJson(json); } Override public void updateMessages(Object memoryId, ListChatMessage 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 的流式聊天 */ FluxString streamChat(MemoryId String memoryId, UserMessage String userMessage); }ControllerSlf4j RestController RequiredArgsConstructor public class MemoryStoreController { private final MemoryStoreAssist memoryStoreAssist; GetMapping(value /stream/memory/store, produces MediaType.TEXT_PLAIN_VALUE) public FluxString streamChatWithMemory( RequestParam String memoryId, RequestParam String userMessage) { return memoryStoreAssist.streamChat(memoryId, userMessage); } }测试持久化验证用户 1派大星用户 2佩奇新发送的请求中包含了前面的消息用户 3听风轩听风轩再次访问后台日志因为已经进行了持久化所以会把用户前面的 message 也带上。查看持久化数据获取用户 1 的数据请求也会附带上以前的 message 信息获取用户 3 的数据可以看到 Map 中对所有聊天数据按用户进行了独立存储。“✅结论持久化测试成功即使程序重启用户的对话记忆也不会丢失。 总结功能实现方式适用场景简单记忆MessageWindowChatMemory快速原型、消息条数固定Token 控制TokenWindowChatMemory成本敏感、Token 预算有限多用户隔离ChatMemoryProviderMemoryId多用户系统持久化自定义ChatMemoryStore需要跨会话保留记忆“最佳实践开发阶段用MessageWindowChatMemory快速验证生产环境推荐TokenWindowChatMemory控制成本多用户场景务必使用ChatMemoryProvider重要对话建议做持久化避免重启丢失