当前位置: 首页 > news >正文

掌握LangChain4j聊天记忆实战

💡导语:手动管理ChatMessage太麻烦了?LangChain4j 提供了开箱即用的ChatMemory抽象,让你轻松实现对话记忆功能。本文将从基础概念到持久化实战,带你完整掌握聊天记忆的核心用法。


📖 什么是 ChatMemory?

手动维护和管理ChatMessage是很麻烦的。因此,LangChain4j 提供了ChatMemory抽象以及多种开箱即用的实现。

ChatMemory可以作为独立的低级组件使用,也可以作为高级组件(如 AI 服务)的一部分。

作为ChatMessage的容器(由List支持),ChatMemory提供了以下核心能力:

  • 🔄淘汰策略— 自动淘汰旧消息,控制上下文长度

  • 💾持久化— 将对话数据存储到磁盘或数据库

  • ⚙️对 SystemMessage 的特殊处理— 系统消息始终保留

  • 🔧对工具消息的特殊处理— 自动清理孤立的工具调用结果


🔍 记忆 vs 历史:两个容易混淆的概念

⚠️注意:"记忆"和"历史"是相似但不同的概念。

概念

说明

特点

📜历史(History)

保持用户和 AI 之间的所有消息完整无缺

用户在 UI 中看到的内容,代表实际对话

🧠记忆(Memory)

保存部分信息呈现给 LLM,使其表现得像"记住"了对话

可以淘汰消息、总结、注入额外信息等

LangChain4j 目前只提供"记忆",而不是"历史"。如果你需要保存完整的历史记录,请手动进行。


🗑️ 淘汰策略:为什么需要它?

淘汰策略是必要的,原因有三个:

  1. 📏 适应上下文窗口— LLM 一次能处理的 Token 数量有上限,对话过长时必须淘汰旧消息

  2. 💰 控制成本— 每个 Token 都有成本,淘汰不必要的消息可以降低费用

  3. ⚡ 控制延迟— 发送给 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.clear()

如果不用可留空

📌注意:从ChatMemory中淘汰的消息也会从ChatMemoryStore中淘汰。淘汰时会调用updateMessages(),传入不含被淘汰消息的列表。


⚙️ SystemMessage 的特殊处理

SystemMessage是一种特殊类型的消息,处理规则与其他消息不同:

  • 一旦添加,始终保留— 不会被淘汰

  • 🔒一次只能保存一条SystemMessage

  • 🚫相同内容的新 SystemMessage 会被忽略

  • 🔄不同内容的新 SystemMessage 会替换之前的


🔧 工具消息的特殊处理

如果包含ToolExecutionRequestAiMessage被淘汰,随后的孤立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

+@MemoryId

多用户系统

持久化

自定义ChatMemoryStore

需要跨会话保留记忆

💡最佳实践

  • 开发阶段用MessageWindowChatMemory快速验证

  • 生产环境推荐TokenWindowChatMemory控制成本

  • 多用户场景务必使用ChatMemoryProvider

  • 重要对话建议做持久化,避免重启丢失

http://www.cnnetsun.cn/news/3459276.html

相关文章:

  • RPCS3深度解析:从模拟器架构到游戏修改生态的完全指南
  • FactoryBluePrints:戴森球计划工厂蓝图库的模块化革命
  • 计算机毕业设计之基于SpringBoot的异宠领养分享交流平台的设计与实现
  • Midscene.js:重新定义跨平台UI自动化的智能视觉方案
  • Tabby终端工具:现代化SSH客户端与串口调试的终极解决方案
  • mkideal/cli性能优化:让你的命令行应用运行更快
  • 潮牌铺货卖家注意:Stussy 全套印花登记版权,调色裁剪二次使用照样收传票
  • Unsloth-Gemma-4-E4B-it-QAT-oQ4安全与伦理:Google DeepMind的责任AI实践指南 [特殊字符]️
  • 客户端Token轮换失败、服务端Channel复用冲突、网络抖动重连风暴——Cursor推送故障排查速查表,
  • HidHide技术深度解析:如何优雅解决Windows游戏设备冲突问题
  • Cursor AI编程实战指南(含12个高频场景模板库,限免72小时)
  • 终极方案:Intel RealSense D455多相机硬件同步实战指南
  • 3个实用场景指南:yuzu模拟器带你轻松玩转Switch游戏
  • 终极 Lua 代码检查工具 selene:10 个技巧提升你的 Lua 代码质量
  • 为什么92%的GraphQL项目仍手工编写Resolvers?AI生成接口的3大隐性风险与防御性编码规范
  • Canva AI批量设计终极闭环(从CSV导入→动态占位→品牌色自动校准→多平台导出),这套工业级流程已被132家企业采购部署
  • 微信机器人避坑指南:从架构到实战30字
  • 后端微服务间的最终一致性:本地消息表与事务消息的实现对比
  • Windows 10与Ubuntu 18.04双系统安装指南
  • macOS微信增强终极指南:3分钟实现消息防撤回与多开功能
  • Arachni安全框架技术深度解析:下一代Web应用安全扫描架构演进与实践
  • 如何用Simple Live实现跨平台直播聚合观看:一站式解决你的直播需求痛点
  • 5个颠覆性技巧:用Loop彻底改变你的macOS窗口管理工作流
  • openEuler SBOM-website社区贡献指南:如何参与开源项目开发
  • 终极指南:如何用思源笔记构建你的第二大脑
  • 构建企业级数据血缘图谱:Marquez实战指南
  • 5分钟快速上手:SD-PPP Photoshop AI插件完整指南
  • 终章:指鹿为马的百年狂欢——旧AI体系崩溃前的最后诊断
  • 朱雀 AI 检测助手会泄露内容吗
  • 5分钟上手SoftwareRenderer:Windows与Linux平台快速启动教程 [特殊字符]