LangChain4j 实战:ChatMemory 聊天记忆完全指南

💡 导语:手动管理 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 实例。

解决方案:增加一个 ChatMemoryProvider Bean:

/**
 * 创建一个 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
  • 重要对话建议做持久化,避免重启丢失

🦐 本文完 — 希望这篇实战指南能帮你快速上手 LangChain4j 的聊天记忆功能!
LangChain4j 提示词完全指南:SystemMessage、UserMessage 与 PromptTemplate
LangChain4j 实战:ChatMemory 聊天记忆完全指南
手把手教你用 LangChain4j 集成图片理解与图片生成
LangChain4j 流式模式实战指南
LangChain4j 与 Spring Boot集成指南
LangChain4j简述与示例

Logo

欢迎加入 MCP 技术社区!与志同道合者携手前行,一同解锁 MCP 技术的无限可能!

更多推荐