先聊一个很多后端团队都会撞上的场景:Java 服务要接入大模型能力,第一版 Demo 往往做得很快,一个 Controller 把用户问题丢给模型接口,拿到结果直接返回,看起来一切正常。但真有人连续聊上几句话,问题就来了——模型完全记不住上下文,你上一句说“我家猫叫年糕”,下一句问“它几岁了”,模型一脸茫然。这个问题的本质,是聊天机器人缺少记忆。
要解决它,比较稳妥的做法是引入 LangChain4j 做应用编排,再把聊天记忆持久化到 MongoDB。LangChain4j 是 Java 生态里对接大模型应用的老牌框架,提供了一套统一的模型调用、提示词模板、输出解析和记忆管理能力;MongoDB 则承担了记忆的落地存储,天然适合存 JSON 形态的聊天消息。这篇文章就把这套组合从设计思路、代码实现到常见坑位完整拆一遍,适合正在做 Java 后端大模型应用、又不想把记忆只放在内存里的开发者参考。
1. 先盘清楚:聊天记忆到底解决什么问题
1.1 没有记忆的对话,为什么聊不下去
大模型本身是“无状态”的。你每次调用接口,传入的是一段完整文本,模型根据这段文本生成回答,聊完即焚。它不会主动记住你上一次说了什么,更不会理解“刚才那个问题”指的是哪件事。
很多人第一次做对话应用时,以为把用户消息直接透传给模型就行,结果就是对话体验非常割裂。要让模型具备连续对话能力,必须把历史消息一起发给它,让它基于完整上下文生成回复。这个“把历史消息攒起来、拼进请求”的过程,就是聊天记忆的核心功能。
聊天记忆不是简单缓存几条消息就完事,它至少要考虑三件事:存哪些消息、能存多长、怎么在多次请求之间共享。如果只在单机内存里用一个 Map 缓存,进程一重启记忆就没了,多实例部署时用户请求被路由到不同节点也会导致记忆错乱。所以生产的做法,是把记忆落到持久化存储里,按会话 ID 读取和更新。
1.2 LangChain4j 在 Java 生态里的角色
Java 接入大模型,早期方案很原始:自己封装 HTTP 调用、自己拼 Prompt、自己管理历史消息。后来出现了 LangChain4j,它把很多通用流程抽象成了组件,让 Java 开发者不用重复造轮子。
LangChain4j 的核心能力可以分成几块:统一接入各家大模型服务商和本地模型、提供结构化输出与工具调用、内置提示词模板引擎、提供可插拔的记忆机制。其中记忆这一块,框架抽象出了ChatMemory接口,默认实现MessageWindowChatMemory负责按窗口大小保留最近若干条消息,同时通过ChatMemoryStore接口把消息持久化到任意存储介质。
这个设计最大的好处是“存储无关”。你写一个实现了ChatMemoryStore的类,底层接 MongoDB、Redis、MySQL 都行,业务代码不用变。我见过不少团队一开始把记忆写在内存里,后来流量大了要上多实例,只改一个 Store 实现就切到了 Mongo,很省事。
1.3 为什么要用 MongoDB 当记忆存储
聊天消息本质上是结构化文档,每条消息包含角色、文本内容、时间戳、可能的附加元数据,不同消息之间字段还不一定完全一致。这种形态正是 MongoDB 的主场。
对比一下其他存储方案:用 MySQL 存聊天消息当然也可以,但需要提前设计表结构,JSON 字段在关系型里处理起来比较别扭,后续要扩展消息类型还得改表。用 Redis 存的话读写快,但容量有限、持久化策略要额外操心,适合当缓存层而不是唯一存储。MongoDB 的优势在于:文档模型和聊天消息天然匹配,支持二级索引和 TTL 自动过期,水平扩展能力也够,部署运维在云上基本零成本。
当然,我不是说 MongoDB 是唯一答案。小规模单机应用用 SQLite 或本地文件都行,但如果你已经在用 Mongo 做业务数据存储,把那聊天记忆复用同一个库,能少引入一个中间件,链路也更简单。
2. 设计拆解:ChatMemory 与 Mongo 持久化的核心链路
2.1 先搞懂 MessageWindowChatMemory 的运作机制
LangChain4j 的MessageWindowChatMemory是默认的记忆实现,它按照“消息窗口”来管理工作记忆。你可以设置maxMessages,比如 20 条,超过这个数量后最早的消息会被丢弃,保证发给模型的上下文长度可控。
窗口本身只是逻辑概念,真正决定消息存哪里的,是ChatMemoryStore。这两者配合的流程是这样的:用户产生新消息时,业务代码往ChatMemory里 add 一条消息;MessageWindowChatMemory在内部更新自己的窗口列表,然后调用ChatMemoryStore.updateMessages()把整个窗口列表写回存储;下次需要读取记忆时,MessageWindowChatMemory通过ChatMemoryStore.getMessages()把完整列表捞出来重建窗口。
要特别注意一点:updateMessages()收到的是完整窗口列表,不是增量 diff。也就是说每次变化后,整段消息都会被覆盖写一次。这个语义对 Mongo 的写入方式影响很大,后面讲代码实现时会详细说。
2.2 存储层的三个关键操作
自定义一个持久化 Store,其实只需要实现三个方法:
getMessages(Object memoryId):根据会话 ID 返回该会话的全部消息列表。updateMessages(Object memoryId, List<ChatMessage> messages):全量覆盖该会话的消息列表。deleteMessages(Object memoryId):删除该会话的所有记忆。
这三方法对应着“读、写、删”。写操作用 upsert,读操作用按主键查询,删除操作按主键删除。整个逻辑简单直接,没有太复杂的分页或条件查询。
值得思考的是为什么要把 memoryId 设计成Object而不是 String。因为这接口要兼容各种业务场景,有些场景 memoryId 是 Long 类型的用户 ID,有些场景是 String 类型的会话标识。实现 Store 时通常统一转成 String 再当 Mongo 的_id用。
2.3 MongoDB 集合设计与字段规划
我在模拟项目 X 里使用的集合名是chat_memory,每个会话对应一个文档。结构大致如下:
{ "_id": "conversation_12345", "messages": [ { "type": "USER", "text": "我家猫叫年糕", "timestamp": 1710000000000 }, { "type": "AI", "text": "名字好可爱,年糕是什么品种?", "timestamp": 1710000001000 } ], "updatedAt": ISODate("2024-03-10T12:00:00Z") }设计时我建议保留原始字段名:type表示消息角色,text表示消息内容,timestamp表示时间。少做一层业务包装字段,能少写很多转换代码。updatedAt则是给 TTL 索引用的,后面会讲到。
2.4 会话隔离与多租户场景
聊天记忆最重要的边界就是“会话隔离”。A 用户的对话历史绝对不能出现在 B 用户的上下文里。
最简单的方式是用memoryId当隔离维度。如果一个业务里有多个租户,我建议把租户 ID 和会话 ID 拼在一起作为_id,比如tenant_001:conversation_12345。这样数据在物理上就天然隔离了,查互不干扰,删租户时还能按前缀批量清理。
这里有个容易踩的坑:不要把租户维度放到messages数组里,也不要在每个消息里冗余租户字段。因为查询时你永远是通过memoryId直接定位整个文档,不是通过消息内容反查,冗余字段只会带来额外存储成本和索引负担。
3. 实操落地:从依赖到跑通的完整步骤
3.1 引入依赖与版本选择
假设项目使用的是 Maven,需要在pom.xml里引入 LangChain4j 的 BOM 和核心模块,再加上 MongoDB 驱动。
<dependencyManagement> <dependencies> <dependency> <groupId>dev.langchain4j</groupId> <artifactId>langchain4j-bom</artifactId> <version>0.35.0</version> <type>pom</type> <scope>import</scope> </dependency> </dependencies> </dependencyManagement> <dependencies> <dependency> <groupId>dev.langchain4j</groupId> <artifactId>langchain4j</artifactId> </dependency> <dependency> <groupId>org.mongodb</groupId> <artifactId>mongodb-driver-sync</artifactId> <version>4.11.1</version> </dependency> </dependencies>版本号这里仅供参考,实际用的时候去查一下当前最新的稳定版。LangChain4j 的 API 在 0.30 之后相对稳定,如果你用的是更早版本,包的路径可能会略有不同。另外如果你的模型服务商需要单独模块,比如接本地 Ollama 就加langchain4j-ollama,接厂商 SDK 就加对应模块,这里不展开。
3.2 实现 MongoChatMemoryStore
这是整套方案的核心代码。用 MongoDB 官方同步驱动,实现ChatMemoryStore接口。
package com.example.chat.memory; import com.mongodb.client.MongoCollection; import com.mongodb.client.model.Filters; import com.mongodb.client.model.UpdateOptions; import dev.langchain4j.data.message.ChatMessage; import dev.langchain4j.data.message.ChatMessageDeserializer; import dev.langchain4j.data.message.ChatMessageSerializer; import dev.langchain4j.store.memory.chat.ChatMemoryStore; import org.bson.Document; import java.util.Date; import java.util.List; import java.util.Objects; import java.util.stream.Collectors; public class MongoChatMemoryStore implements ChatMemoryStore { private final MongoCollection<Document> collection; public MongoChatMemoryStore(MongoCollection<Document> collection) { this.collection = collection; } @Override public List<ChatMessage> getMessages(Object memoryId) { Document doc = collection.find(Filters.eq("_id", memoryId.toString())).first(); if (doc == null || doc.get("messages") == null) { return List.of(); } List<Document> messageDocs = doc.getList("messages", Document.class); return messageDocs.stream() .map(d -> ChatMessageDeserializer.messageFromJson(d.toJson())) .filter(Objects::nonNull) .collect(Collectors.toList()); } @Override public void updateMessages(Object memoryId, List<ChatMessage> messages) { List<Document> messageDocs = messages.stream() .map(ChatMessageSerializer::messageToJson) .map(Document::parse) .collect(Collectors.toList()); Document update = new Document("$set", new Document("messages", messageDocs) .append("updatedAt", new Date())); collection.updateOne( Filters.eq("_id", memoryId.toString()), update, new UpdateOptions().upsert(true) ); } @Override public void deleteMessages(Object memoryId) { collection.deleteOne(Filters.eq("_id", memoryId.toString())); } }有几个细节需要解释。
第一,序列化用的是 LangChain4j 自带的ChatMessageSerializer,它把消息对象转成 JSON 字符串,再用Document.parse转成 Mongo 文档。反序列化时倒过来,Document.toJson()拿回 JSON 再交给ChatMessageDeserializer。这样能保证消息里的各类字段完整保留,包括AI消息里的工具调用、System消息里的指令内容。
第二,updateMessages用了 upsert。第一次写入时文档不存在,updateOne会在主键匹配不到的情况下插入新文档;后续写入则做字段更新。这个写法避免了额外的一次查询判断,性能更好。
第三,memoryId统一转成了字符串。不管你传入的是 Long 还是 String,都能稳定映射到 Mongo 的_id。注意别直接往_id里塞任意对象,BSON 的_id有类型约束,统一字符串最省心。
3.3 把 Store 接入 AiServices
Store 写好后,创建一个ChatMemory实例并接入服务层。
MongoClient mongoClient = MongoClients.create("mongodb://localhost:27017"); MongoCollection<Document> collection = mongoClient .getDatabase("chatdb") .getCollection("chat_memory"); ChatMemoryStore memoryStore = new MongoChatMemoryStore(collection); ChatMemory chatMemory = MessageWindowChatMemory.builder() .id("conversation_12345") .maxMessages(20) .chatMemoryStore(memoryStore) .build();这里maxMessages(20)的意思是窗口内最多保留 20 条消息。超过之后,最早的消息会被挤掉。这个数值不是越大越好,取决于你用的模型上下文长度和单条消息的平均 token 消耗。一般建议从 10 到 30 之间起步,后面根据实际请求体大小调整。
接入对话服务的方式,推荐使用AiServices框架能力:
ChatLanguageModel model = OpenAiChatModel.builder() .apiKey("your-api-key") .modelName("gpt-4o-mini") .build(); ChatAssistant assistant = AiServices.builder(ChatAssistant.class) .chatLanguageModel(model) .chatMemory(chatMemory) .build();接口ChatAssistant可以定义自己的方法,加上@UserMessage注解指定提示词模板。框架会把ChatMemory里的历史消息自动拼接进请求,不需要你在业务代码里手动管理上下文。这样业务层拿到的是干净整洁的“有记忆的对话能力”。
如果不是走AiServices,也可以手动把历史消息从ChatMemory.messages()取出来拼进请求体,只不过你要自己控制窗口裁剪和消息格式转换,麻烦一点。
3.4 索引与过期策略
集合创建时建议建立两个索引,一个是主键_id的默认唯一索引,另一个是updatedAt的 TTL 索引。
collection.createIndex( Indexes.ascending("updatedAt"), new IndexOptions().expireAfter(30L, TimeUnit.DAYS) );TTL 索引可以让 Mongo 在后台自动清理超过 30 天没有更新的会话文档。这个特性对聊天记忆非常友好,因为很多历史会话属于低频访问数据,不值得永久占用存储空间,又不想专门写一个定时任务去删。
要注意 TTL 索引只能作用于单字段,且字段必须是日期类型。所以每次updateMessages时一定要更新updatedAt,否则过期时间不会随之顺延。
如果业务上有更复杂的清理需求,比如按租户批量删除,可以额外建一个tenantId字段的普通索引,用deleteMany做条件删除。但既然_id已经带了租户前缀,索引就不是必须的了。
4. 常见问题与排查技巧实录
4.1 并发覆盖:多实例同时写入会丢消息
updateMessages是全量覆盖写,天然存在并发覆盖风险。假设两个请求同时到达,A 请求写入后 B 请求用旧窗口覆盖回去,A 的消息就丢了。
这个场景在单机应用里偶尔发生,在多实例部署时更容易出现。解决办法主要有三种:
- 在应用层给同一个会话加锁,比如用分布式锁,保证同一时间只有一个实例在更新该会话的记忆。
- 使用 Mongo 的
$push增量追加而不是整列表覆盖,把updateMessages改成增量操作。 - 接受最终一致,配合写入前先
getMessages再合并。
我的建议是:业务并发量不高时,优先用会话级分布式锁,简单可靠。如果非要高频并发,干脆换一种存储策略,把每条消息作为独立文档存入单独的messages集合,ChatMemoryStore只做增量追加和范围查询,但这样MessageWindowChatMemory的接口语义就不匹配了,需要自己管理窗口裁剪。
4.2 序列化把消息搞坏了
实际操作中,最常见的线上问题就是序列化异常。用ChatMessageSerializer转出来的 JSON 字符串,再Document.parse时偶尔会遇到特殊字符导致解析失败,比如消息里包含控制字符、异常 Unicode 或大段代码。
建议存储时不要走Document.parse这条路径,改为直接构造 BSON 文档:
Document messageDoc = new Document("type", message.type().name()) .append("text", message.text()) .append("timestamp", message.timestamp());这样绕开了 JSON 字符串解析,对特殊字符更友好。代价是要自己处理各个消息子类的字段差异,比如AiMessage可能要存toolExecutionRequests。实际工程里,如果消息类型固定、字段不多,我其实更推荐这种显式映射方式,代码更可控,排查问题也容易。
4.3 上下文太长把 token 撑爆了
maxMessages只控制消息条数,不控制 token 总量。如果某条消息特别长,比如用户粘贴了一整段日志,20 条消息可能轻松超过模型的上下文窗口。
遇到这种问题,先别急着调大模型规格,而是优化记忆策略:
- 把超长消息做截断,只保留前 N 个字符。
- 用摘要代替原始消息,定期调用模型把历史对话压成一段摘要。
- 消息窗口改成按 token 数量裁剪,而不是按条数。
LangChain4j 的MessageWindowChatMemory默认按条数裁,如果你需要按 token 裁,就得自己算每条消息的 token 并动态调整窗口。这块实现起来有一点工作量,但却是上线后最常做的优化。
4.4 连接池与会话超时
MongoDB 驱动默认自带连接池,但如果你在每次请求里新建MongoClient,连接池就形同虚设了。正确做法是把MongoClient作为单例,全局只初始化一次,所有 Store 实例复用它。
另外要注意 MongoDB 的serverSelectionTimeoutMS默认值是 30 秒。如果数据库连接不稳定,一次记忆读写可能会卡住请求 30 秒,这对在线接口是致命的。建议在连接串里显式设置较短超时:
mongodb://localhost:27017/?serverSelectionTimeoutMS=3000&connectTimeoutMS=3000这样数据库不可用时,快速失败返回错误,而不是让用户长时间等待。聊天应用里记忆读写失败可以降级成“无记忆模式”,比直接 500 更友好。
4.5 常见问题速查表
| 症状 | 可能原因 | 排查方向 |
|---|---|---|
| 重启后对话记忆全部丢失 | Store 没接上 Mongo 或用的默认内存实现 | 检查实例是否注入了MongoChatMemoryStore |
| 用户 A 聊到了用户 B 的内容 | memoryId 生成逻辑重复或用了全局固定值 | 检查每个会话是否用独立 ID |
| 消息顺序错乱 | 并发写导致覆盖或读到了旧版本文档 | 加会话锁,检查是否并发写入 |
| 特殊字符导致序列化失败 | 使用Document.parse解析任意 JSON | 改用显式 BSON 字段构造 |
| 请求体 Token 超限 | 窗口条数过大或单条消息太长 | 调小maxMessages,加摘要策略 |
| 数据库不可用时接口卡死 | 默认超时时间太长 | 设置serverSelectionTimeoutMS |
5. 一点实操体会
这套 LangChain4j + MongoDB 的组合,我在模拟项目 X 里完整跑过一遍,从写第一个MongoChatMemoryStore到上线支撑真实流量,前后也就两三天。它最大的好处是轻量——不需要额外引入消息队列或专用向量库,MongoDB 本身已经在很多团队的基础设施里,加一个集合就能把记忆问题解决掉。
但我也要提醒一句:聊天记忆存储只是对话应用的一部分,它解决的是“模型能不记得,”不等于“整个对话系统就完整了”。你还需要考虑提示词注入、敏感信息过滤、多轮工具调用的状态管理等等。记忆存储方案未来如果遇到性能瓶颈,可以考虑把热点会话放到 Redis 做一层缓存,MongoDB 只做最终持久化,读写都能快不少。
最后分享一个小技巧:在开发环境里给MongoChatMemoryStore加上简单的日志,在updateMessages里打印 memoryId 和消息条数。看起来不起眼,但排查“为什么模型不记得”这类玄学问题时会特别有用,你一眼就能看出是哪一环断了:是消息根本没写进去,还是读出来了没拼进请求。先定位问题,再谈优化。