LangChain4j ChatMemory 实战指南:会话记忆抽象、淘汰策略与持久化
【免费下载链接】langchain4jLangChain4j is an idiomatic, open-source Java library for building LLM-powered applications on the JVM. It offers a unified API over popular LLM providers and vector stores, and makes implementing tool calling (including MCP support), agents and RAG easy. It integrates seamlessly with enterprise Java frameworks like Quarkus and Spring Boot.项目地址: https://gitcode.com/GitHub_Trending/la/langchain4j
导读
在多轮对话应用中,大语言模型本身不保留任何会话状态,每次交互都必须把此前全部消息重新发送给模型。LangChain4j 提供的ChatMemory抽象正是为了解决这一问题:它以统一 API 管理ChatMessage列表,内置消息淘汰(Eviction)、持久化(Persistence)以及对SystemMessage与工具(Tool)消息的特殊处理。读完本文,你将掌握 LangChain4j 中"记忆(Memory)"与"历史(History)"的本质区别,学会用MessageWindowChatMemory与TokenWindowChatMemory两种开箱即用的实现控制上下文窗口,并能够通过实现ChatMemoryStore将会话记忆持久化到任意存储中。
为什么需要 ChatMemory:Memory 与 History 的区别
LangChain4j 官方文档明确指出:"memory" 和 "history" 是两个相似但截然不同的概念:
- History(历史):完整保留用户与 AI 之间的所有消息,不做任何删改。它就是用户在 UI 中看到的内容,代表"实际说过的话"。
- Memory(记忆):只保留部分信息,并将其呈现给 LLM,使其表现得仿佛"记得"这段对话。依据所选记忆算法,Memory 可能以各种方式改造历史:淘汰某些消息、将多条消息汇总成摘要、剥离消息中不重要的细节、向消息中注入额外信息(如 RAG 检索结果)或额外指令(如结构化输出约束)等。
当前 LangChain4j 只提供 "memory" 而非 "history"。如果你需要保留完整历史,需要自行管理(例如在应用层把全部消息落库)。
这一设计在核心接口上有直接体现:ChatMemory的messages()方法 Javadoc 明确写着"取决于实现,它可能不会返回所有先前添加的消息,而是返回其子集、摘要或组合"(见 ChatMemory.java)。也就是说,ChatMemory天然是一个"按需裁剪过的记忆视图",而不是历史记录。
ChatMemory 核心抽象:接口全景
ChatMemory是会话记忆的统一入口接口,定义于 langchain4j-core/src/main/java/dev/langchain4j/memory/ChatMemory.java:
| 方法 | 作用 |
|---|---|
Object id() | 返回该记忆的唯一 ID,用于区分多用户/多会话 |
void add(ChatMessage message)/add(ChatMessage...)/add(Iterable<ChatMessage>) | 追加一条或多条消息 |
void set(ChatMessage...)/set(Iterable<ChatMessage>) | 用指定消息整体替换当前记忆(自 1.11.0 引入,默认实现为clear()+add(...),用于记忆压缩等场景;LangChain4j 不会自动调用它) |
List<ChatMessage> messages() | 返回当前记忆内容(可能是子集/摘要) |
void clear() | 清空记忆 |
addAsync(List<ChatMessage>)/setAsync(...)/messagesAsync()(1.20.0 起,@Experimental) | 三个异步非阻塞变体,供异步/响应式 AI Service 使用;默认实现返回携带AsyncNotSupportedException的失败 future |
其中addAsync的设计值得注意:它接收一个消息列表(而非单条消息),以便一次"读-改-写"原子完成持久化、减少往返次数;同时其 Javadoc 明确警告"不得对同一个 memory 并发调用该方法",因为读与写之间隔着 future 组合和线程切换,竞争窗口比同步add更宽。clear()会触发 store 的deleteMessages(memoryId)。
ChatMemory既可以作为独立的低层组件直接使用,也可以作为高层组件(如 AI Services)的一部分自动参与对话流程。
淘汰策略(Eviction Policy):控制上下文窗口的两种实现
引入淘汰策略的必要性有三点:
- 适配 LLM 上下文窗口:模型单次能处理的 token 数有上限,对话一旦超出限制就必须淘汰部分消息,通常淘汰最旧的;
- 控制成本:每个 token 都有费用,剔除无关消息可以降低每次调用的开销;
- 控制延迟:发送给模型的 token 越多,处理耗时越长。
LangChain4j 提供两种开箱即用的实现,二者都位于 langchain4j/src/main/java/dev/langchain4j/memory/chat/ 下。
MessageWindowChatMemory:按"条数"滑动的快速原型方案
MessageWindowChatMemory以滑动窗口方式保留最近N条消息,超出即淘汰最旧者。由于每条消息包含的 token 数量不同,按条数裁剪并不精确,因此官方定位是适合快速原型验证。
ChatMemory chatMemory = MessageWindowChatMemory.builder() .id("12345") // 记忆 ID,默认 "default"(ChatMemoryService.DEFAULT) .maxMessages(10) // 最多保留 10 条消息,超出淘汰最旧的 .build();Builder 支持以下配置项(对应 MessageWindowChatMemory.java):
| 配置方法 | 说明 |
|---|---|
id(Object id) | 记忆 ID,默认值为ChatMemoryService.DEFAULT("default"),可用用户 ID / 会话 ID 区分多个对话 |
maxMessages(Integer) | 静态上限:最多保留的消息条数,必须大于 0(构造时通过ensureGreaterThanZero校验) |
dynamicMaxMessages(Function<Object, Integer>) | 动态上限:运行时可根据id实时返回窗口大小,窗口行为始终遵循 provider 最新返回值 |
chatMemoryStore(ChatMemoryStore) | 指定持久化 store,默认使用SingleSlotChatMemoryStore(纯内存) |
alwaysKeepSystemMessageFirst(Boolean) | 新SystemMessage是否总是置于消息列表首位,默认false |
从源码看,maxMessages与dynamicMaxMessages最终都落为同一个maxMessagesProvider字段(MessageWindowChatMemory.java),前者是"忽略 id、永远返回常量"的特例。add()的调用链是:读取当前消息 →appendMessage()执行 SystemMessage 规则与窗口裁剪(ensureCapacity)→ 若列表发生变化则调用store.updateMessages(id, messages)持久化(MessageWindowChatMemory.java)。ensureCapacity的裁剪逻辑:若首位是SystemMessage,则从索引 1 开始淘汰,保证系统消息永远不被移除(MessageWindowChatMemory.java)。
TokenWindowChatMemory:按"token 数"滑动的精确方案
TokenWindowChatMemory同样基于滑动窗口,但以"最近N个 token"为准裁剪消息。消息不可分割:某条消息装不下时会被整体淘汰(即使只超出几个 token)。由于 token 数因模型分词器而异,它必须接收一个TokenCountEstimator来统计每条ChatMessage的 token 数(可选用具体模型提供商的TokenCountEstimator实现,如 OpenAI 的OpenAiTokenCountEstimator)。
ChatMemory chatMemory = TokenWindowChatMemory.builder() .id("12345") .maxTokens(2000, tokenCountEstimator) // 保留最近 2000 个 token .build();Builder 配置项(对应 TokenWindowChatMemory.java):
| 配置方法 | 说明 |
|---|---|
id(Object id) | 同MessageWindowChatMemory |
maxTokens(Integer, TokenCountEstimator) | 静态上限:保留的 token 总量;溢出时从最旧消息起整体淘汰 |
dynamicMaxTokens(Function<Object, Integer>, TokenCountEstimator) | 动态上限:运行时根据 id 动态返回 token 限额 |
chatMemoryStore(ChatMemoryStore) | 默认SingleSlotChatMemoryStore |
alwaysKeepSystemMessageFirst(Boolean) | 默认false |
其ensureCapacity算法(TokenWindowChatMemory.java)会先用estimateTokenCountInMessages统计总 token 数,超出限额时逐条淘汰最旧消息并用estimateTokenCountInMessage递减计数;若列表只剩一条SystemMessage则直接返回,避免删掉系统消息。
选型建议:原型验证、对成本不敏感时用MessageWindowChatMemory;生产环境需要精确控制上下文占用、追求更稳定的成本与延迟时用TokenWindowChatMemory。
持久化:实现 ChatMemoryStore 接入任意存储
默认情况下,两种ChatMemory实现都把消息存放在内存中——SingleSlotChatMemoryStore(SingleSlotChatMemoryStore.java)是一个@Internal的纯内存实现,应用重启后数据即丢失。
要实现持久化,需要自行实现ChatMemoryStore接口(定义于 langchain4j-core/src/main/java/dev/langchain4j/store/memory/chat/ChatMemoryStore.java),它只有三个同步抽象方法:
List<ChatMessage> getMessages(Object memoryId):按记忆 ID 读取全部消息,返回值不得为 null;void updateMessages(Object memoryId, List<ChatMessage> messages):按记忆 ID 覆盖写入全部消息(代表ChatMemory的当前状态);void deleteMessages(Object memoryId):按记忆 ID 删除全部消息。
官方文档给出的示例实现骨架如下:
class PersistentChatMemoryStore implements ChatMemoryStore { @Override public List<ChatMessage> getMessages(Object memoryId) { // TODO: 按 memoryId 从持久化存储中读取全部消息。 // 可用 ChatMessageDeserializer.messageFromJson(String) 和 // ChatMessageDeserializer.messagesFromJson(String) 方便地从 JSON 反序列化消息。 } @Override public void updateMessages(Object memoryId, List<ChatMessage> messages) { // TODO: 按 memoryId 在持久化存储中更新全部消息。 // 可用 ChatMessageSerializer.messageToJson(ChatMessage) 和 // ChatMessageSerializer.messagesToJson(List<ChatMessage>) 方便地将消息序列化为 JSON。 } @Override public void deleteMessages(Object memoryId) { // TODO: 按 memoryId 在持久化存储中删除全部消息。 } } ChatMemory chatMemory = MessageWindowChatMemory.builder() .id("12345") .maxMessages(10) .chatMemoryStore(new PersistentChatMemoryStore()) .build();配合使用时需要注意ChatMemoryStore的三个生命周期语义:
updateMessages()每次有新消息加入ChatMemory时都会被调用。一次 LLM 交互中通常调用两次:一次是加入新的UserMessage时,一次是加入新的AiMessage时。该方法需要用给定 memoryId 关联的全部消息覆盖旧状态。消息可以逐条存储(每条消息一个记录/行/对象),也可以整体存储(整个ChatMemory一个记录/行/对象)。- 从
ChatMemory淘汰的消息也会同步从ChatMemoryStore淘汰:消息被淘汰时,updateMessages()会收到一个不包含被淘汰消息的新列表,持久化层必须整体覆盖,而不是只追加。 getMessages()在每次请求ChatMemory全部消息时被调用(通常每次 LLM 交互一次)。memoryId参数的值即创建ChatMemory时指定的id,可用来区分多个用户和/或多个会话。deleteMessages()在调用ChatMemory.clear()时被触发;如果不用清空功能,可以让该方法留空。
为了简化 JSON 序列化,LangChain4j 核心模块提供了两个工具类:
- ChatMessageSerializer.java:
messageToJson(ChatMessage)与messagesToJson(List<ChatMessage>); - ChatMessageDeserializer.java:
messageFromJson(String)与messagesFromJson(String)。
它们把UserMessage、AiMessage、SystemMessage、ToolExecutionResultMessage等各类型消息统一序列化/反序列化,覆盖了持久化实现中大部分繁琐的类型分支。
注意:官方文档提示"目前唯一的开箱即用实现是
InMemoryChatMemoryStore",并计划逐步加入 SQL 数据库、文档存储等集成。在仓库当前的 集成文档目录 中可以看到目前已收录的 chat memory store 集成索引,其余存储请按上述接口自行接入。
SystemMessage 的特殊处理规则
SystemMessage是特殊消息类型,两种ChatMemory实现对其一视同仁地执行以下规则(源码在 MessageWindowChatMemory.java 与 TokenWindowChatMemory.java 中均有完整实现):
- 一旦加入,
SystemMessage永远被保留,任何淘汰逻辑都不会删除它(窗口裁剪时从索引 1 开始淘汰); - 同一时刻只能持有一条
SystemMessage; - 加入一条内容相同的新
SystemMessage会被直接忽略(appendMessage返回false,不触发持久化); - 加入一条内容不同的新
SystemMessage会替换旧的那条。默认情况下新消息被追加到列表末尾;可以通过设置alwaysKeepSystemMessageFirst(true)让它总是插到列表首位(索引 0)。
这对于"系统提示词固定、随会话动态调整"的场景非常有用:例如切换系统指令时无需手动清理旧指令。
工具(Tool)消息的特殊处理
当包含ToolExecutionRequest的AiMessage被淘汰时,其后续的"孤儿"ToolExecutionResultMessage(工具执行结果消息)也会被自动连带淘汰。这是因为 OpenAI 等部分 LLM 提供商明确禁止在请求中发送没有对应AiMessage的孤立ToolExecutionResultMessage。
相关逻辑在两个实现的ensureCapacity中都有体现:淘汰掉一条携带工具调用的AiMessage后,会继续删除紧随其后的ToolExecutionResultMessage,直至遇到非工具结果消息为止(MessageWindowChatMemory.java)。这保证了对话中工具调用链的完整性,避免发送非法消息导致请求报错。
非阻塞模式下的异步扩展
如果ChatMemory或ChatMemoryStore涉及 I/O(如数据库读写),在 AI Service 以非阻塞(CompletableFuture/Reactive)模式运行时,应实现异步对应方法,避免阻塞线程:
ChatMemory侧:addAsync(List<ChatMessage>)、setAsync(List<ChatMessage>)、messagesAsync()(自 1.20.0 起提供,标注@Experimental);ChatMemoryStore侧:getMessagesAsync(Object)、updateMessagesAsync(Object, List<ChatMessage>)、deleteMessagesAsync(Object)。
两个接口的异步默认实现都返回携带AsyncNotSupportedException的失败 future,即"不会静默地把阻塞 I/O 丢到工作线程"——这是刻意设计,为的是暴露"并非真正非阻塞"的事实。若底层客户端本身是阻塞的,实现方应显式将操作卸载到执行器。详细内容可参考 Non-blocking and Reactive 教程。
与 AiServices 的配合使用
ChatMemory最常见的使用场景是与 AI Services 结合:在构建 AI Service 时传入ChatMemory,框架会自动在每次调用前注入记忆、在调用后写入新消息。若要为每个用户维护独立的对话记忆,只需为每个用户/会话创建带不同id的ChatMemory实例(官方 langchain4j-examples 仓库中提供了ServiceWithMemoryExample、ServiceWithMemoryForEachUserExample、ServiceWithPersistentMemoryExample、ServiceWithPersistentMemoryForEachUserExample等完整示例);结合自定义ChatMemoryStore,即可实现"每用户独立 + 持久化"的生产级会话记忆方案。
工具调用场景下,ChatMemory对工具消息的特殊处理(见上文)与 tools 教程 配合,可保证多轮工具调用在窗口裁剪后依然对 LLM 提供合法、完整的消息序列。
小结
LangChain4j 的ChatMemory用一套小而精的抽象解决了 LLM 应用中最常见的"上下文管理"难题:
- 两种现成实现覆盖了"按条数(快速原型)"与"按 token(精确控制)"两种淘汰需求,且都支持静态或动态窗口大小;
ChatMemoryStore扩展点让持久化只需实现三个方法,配合核心模块提供的 JSON 序列化工具即可接入数据库、缓存或对象存储;- SystemMessage 与工具消息的专属规则保证了系统指令的稳定性和工具调用链的合法性;
- 异步变体为非阻塞 AI Service 提供了无阻塞的记忆读写路径。
从源码结构看,MessageWindowChatMemory与TokenWindowChatMemory共享几乎完全一致的 SystemMessage/工具消息处理逻辑,区别仅在窗口度量单位与裁剪算法,这使你在两者之间迁移时几乎不需要改动业务代码,只需更换 Builder 与估算器。
【免费下载链接】langchain4jLangChain4j is an idiomatic, open-source Java library for building LLM-powered applications on the JVM. It offers a unified API over popular LLM providers and vector stores, and makes implementing tool calling (including MCP support), agents and RAG easy. It integrates seamlessly with enterprise Java frameworks like Quarkus and Spring Boot.项目地址: https://gitcode.com/GitHub_Trending/la/langchain4j
创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考