☰
LangChain4j 的聊天记忆别只放内存了,持久化这次讲具体:ChatMemoryStore 落库与 TokenWindow 裁剪实战
2026/10/7 14:49:23 网站建设 项目流程

1. 从内存态到持久化:LangChain4j 聊天记忆为什么必须落库

LangChain4j 的 ChatMemory 是很多 Java 后端接入大模型时最先接触的组件,它负责决定「模型这一轮能看到哪些历史消息」。默认的MessageWindowChatMemory配合InMemoryChatMemoryStore,在单机 demo 里跑得飞快,但一旦进入真实业务,问题会集中爆发:服务重启后上下文全丢、多实例部署时同一会话被路由到不同节点导致「AI 失忆」、会话历史无限增长把 token 成本顶穿、用户申请删除会话时底层没有按 memoryId 清理的能力。

这些问题的根因,是把「模型看到什么」和「记忆存在哪里」这两件事混在了一起。LangChain4j 的设计其实分得很清楚:ChatMemory管前者,ChatMemoryStore管后者。你只要把 Store 换成自定义实现,记忆就能落到 MySQL、Redis 或文档库,跨实例、跨重启恢复上下文;再配合MessageWindow或TokenWindow两种裁剪策略,就能把每次请求的上下文长度控制在预算内。

这篇面向的是已经用 LangChain4j 跑通过单轮对话、准备把多轮会话搬上生产的 Java 开发者。我会按真实项目落地的顺序讲:先看内存态在哪些场景会翻车,再给出可复制的ChatMemoryStore实现,接着分别配置MessageWindowChatMemory和TokenWindowChatMemory,然后跑多轮对话验证重启恢复,最后把常见报错逐个排掉。全程代码可直接粘进 Spring Boot 工程,数据库用 MySQL 举例,Redis 思路一致。

需要先明确一个边界:ChatMemory 不是完整的历史归档系统。它的职责是「给模型喂最近的关键上下文」,而不是「保存用户说过的每一句话」。真正的全量历史、审计、摘要归档,应该由业务侧的会话表承担。把这两层分开,后面的设计会顺很多。

2. TaoToken 前置准备:拿到 Base URL、API Key 和 Model ID

在写 Store 之前,先把模型调用通道打通,否则后面验证多轮对话时没法确认「记忆恢复」和「模型响应」是不是同一件事。我用 TaoToken 作为模型接入层,它兼容 OpenAI 风格的接口,LangChain4j 的OpenAiChatModel可以直接对接。

你需要准备三样东西,这三件套在任何 LangChain4j 接入场景里都要写全:

配置项取值来源示例
Base URLTaoToken API 地址https://taotoken.net/api
API Key控制台创建的密钥sk-xxxxxxxx
Model ID模型列表里的标识gpt-4o-mini或你选用的模型

先到 TaoToken 控制台 创建 API Key,路径在「API Keys」页面。创建后复制保存,页面只展示一次。模型 ID 可以在模型对话页面确认,选一个支持多轮对话的即可。

拿到三件套后,先在application.yml里配置,避免硬编码:

langchain4j: openai: base-url: https://taotoken.net/api api-key: ${TAOTOKEN_API_KEY} model-name: gpt-4o-mini temperature: 0.7 timeout: PT60S

对应的OpenAiChatModelBean:

@Configuration public class ChatModelConfig { @Value("${langchain4j.openai.base-url}") private String baseUrl; @Value("${langchain4j.openai.api-key}") private String apiKey; @Value("${langchain4j.openai.model-name}") private String modelName; @Bean public ChatModel chatModel() { return OpenAiChatModel.builder() .baseUrl(baseUrl) .apiKey(apiKey) .modelName(modelName) .temperature(0.7) .timeout(Duration.ofSeconds(60)) .build(); } }

这里有个容易踩的点:baseUrl末尾不要带/v1,LangChain4j 的 OpenAI 客户端会自己拼接路径。如果你填成https://taotoken.net/api/v1,请求会变成/api/v1/v1/chat/completions,直接 404。我试过在配置里多写一段路径,排查了半小时才发现是重复拼接。

依赖方面,pom.xml至少要有:

<dependency> <groupId>dev.langchain4j</groupId> <artifactId>langchain4j-open-ai</artifactId> <version>0.35.0</version> </dependency> <dependency> <groupId>dev.langchain4j</groupId> <artifactId>langchain4j</artifactId> <version>0.35.0</version> </dependency>

版本号按你项目实际锁定的来,LangChain4j 迭代较快,ChatMemoryStore接口签名在 0.3x 系列基本稳定。如果你用的是 Spring Boot Starter 方式,把langchain4j-open-ai-spring-boot-starter加进来,配置项前缀会略有不同,但三件套的语义不变。

通道打通后,先用一个最小 main 方法验证单轮能通,再进入记忆持久化。这样后面出问题时,你能快速判断是模型通道的问题还是 Store 的问题。

3. 可复制配置:自定义 ChatMemoryStore 落库与两种窗口裁剪

这一节是全文核心。先给数据库表结构,再给ChatMemoryStore实现,最后分别配置MessageWindowChatMemory和TokenWindowChatMemory。

3.1 聊天记忆表设计

create table ai_chat_memory_message ( id bigint primary key auto_increment, memory_id varchar(128) not null, message_no int not null, message_type varchar(32) not null, message_json json not null, created_time datetime not null default current_timestamp, unique key uk_memory_message_no (memory_id, message_no), key idx_memory_id (memory_id) );

memory_id是会话标识,建议用「租户 + 用户 + 业务号」拼,比如tenant_a:user_1001:order_20260528001,而不是随机 UUID。随机 ID 在跨天追问场景里没法复用,用户第二天继续问同一订单,系统认不出来是同一会话。message_no保证同一会话内消息有序,message_json存序列化后的ChatMessage。

3.2 自定义 ChatMemoryStore 实现

@Repository @RequiredArgsConstructor public class MysqlChatMemoryStore implements ChatMemoryStore { private final ChatMemoryDao chatMemoryDao; @Override public List<ChatMessage> getMessages(Object memoryId) { return chatMemoryDao.loadMessages(memoryId.toString()).stream() .map(ChatMessageJsonCodec::deserialize) .toList(); } @Override public void updateMessages(Object memoryId, List<ChatMessage> messages) { chatMemoryDao.replaceMessages( memoryId.toString(), messages.stream().map(ChatMessageJsonCodec::serialize).toList() ); } @Override public void deleteMessages(Object memoryId) { chatMemoryDao.deleteByMemoryId(memoryId.toString()); } }

ChatMessageJsonCodec是 LangChain4j 自带的编解码工具,能正确处理UserMessage、AiMessage、SystemMessage、ToolExecutionResultMessage等类型。不要自己用 Jackson 直接序列化ChatMessage接口,反序列化时会因为多态类型丢失而报错。

DAO 层用replaceMessages做全量替换,配合唯一键uk_memory_message_no,可以用insert ... on duplicate key update或先删后插。数据量大时建议按memory_id分批,避免单次事务过大。

3.3 MessageWindow 版本配置

ChatMemory chatMemory = MessageWindowChatMemory.builder() .id("tenant_a:user_1001:order_20260528001") .maxMessages(20) .chatMemoryStore(new MysqlChatMemoryStore(chatMemoryDao)) .build();

maxMessages(20)表示保留最近 20 条消息。注意它按「条数」裁剪,不区分消息长短。如果用户发了一条超长文本,20 条也可能撑爆上下文。适合消息长度相对均匀的客服场景。

3.4 TokenWindow 版本配置

TokenCountEstimator tokenCountEstimator = new OpenAiTokenCountEstimator("gpt-4o-mini"); ChatMemory tokenWindowChatMemory = TokenWindowChatMemory.builder() .id("tenant_a:user_1001:order_20260528001") .maxTokens(2500, tokenCountEstimator) .chatMemoryStore(new MysqlChatMemoryStore(chatMemoryDao)) .build();

maxTokens(2500, estimator)按 token 数裁剪,更贴近成本控制。OpenAiTokenCountEstimator需要传入模型名,不同模型的 tokenizer 不同。如果你用的是非 OpenAI 系模型,可以自己实现TokenCountEstimator接口,用近似估算(比如中文按 1 字 ≈ 1.5 token)也能跑。

3.5 挂到 AI Service

public interface CustomerAssistant { String chat(@MemoryId String memoryId, @UserMessage String message); } CustomerAssistant assistant = AiServices.builder(CustomerAssistant.class) .chatModel(chatModel) .chatMemoryProvider(memoryId -> MessageWindowChatMemory.builder() .id(memoryId) .maxMessages(20) .chatMemoryStore(new MysqlChatMemoryStore(chatMemoryDao)) .build()) .build();

chatMemoryProvider是关键:每次调用时按memoryId动态构建 ChatMemory,Store 从数据库读历史。这样多实例部署时,任意节点都能拿到同一份记忆。

4. 验证请求:多轮对话、重启恢复与 Token 消耗观察

配置写完后必须验证三件事:多轮上下文是否生效、重启后能否恢复、Token 是否被窗口控制住。

4.1 多轮对话验证

String memoryId = "tenant_a:user_1001:order_20260528001"; String r1 = assistant.chat(memoryId, "我昨天买的鞋子什么时候发货?"); System.out.println("R1: " + r1); String r2 = assistant.chat(memoryId, "订单号是 20260528001,帮我查一下"); System.out.println("R2: " + r2); String r3 = assistant.chat(memoryId, "那能改地址吗?"); System.out.println("R3: " + r3);

第三轮里没有重复订单号,如果模型能正确关联到前两轮的订单,说明记忆生效。跑完后查数据库:

select message_no, message_type, left(message_json, 80) from ai_chat_memory_message where memory_id = 'tenant_a:user_1001:order_20260528001' order by message_no;

你应该能看到 user/ai 交替的消息记录,message_no连续递增。

4.2 重启恢复验证

停掉服务,重新启动,用同一个memoryId再发一轮:

String r4 = assistant.chat(memoryId, "刚才说的地址修改,进度怎么样了?"); System.out.println("R4: " + r4);

如果 R4 能接上「地址修改」这个上下文,说明 Store 从数据库成功回读。这一步是内存态和持久化的分水岭,内存态在这里必然失忆。

4.3 Token 消耗观察

在MysqlChatMemoryStore.getMessages里加一行日志,打印每次回读的消息条数和估算 token:

@Override public List<ChatMessage> getMessages(Object memoryId) { List<ChatMessage> messages = chatMemoryDao.loadMessages(memoryId.toString()).stream() .map(ChatMessageJsonCodec::deserialize) .toList(); log.info("memoryId={}, loadedMessages={}", memoryId, messages.size()); return messages; }

连续对话 30 轮后观察日志,MessageWindow版本会稳定在 20 条左右,TokenWindow版本的消息条数会随单条长度浮动,但总 token 不会超过 2500。这就是窗口裁剪在起作用。

如果你需要更精细的成本观测,可以在 TaoToken 的模型对话页面手动对比不同窗口参数下的响应差异,确认裁剪没有丢掉关键上下文。

5. 本篇常见错排查:401、local proxy failed、reading choices、OAuth

这一节按真实报错逐个排。每个报错都给出触发条件和修复动作。

401 Unauthorized:API Key 没配或配错。检查TAOTOKEN_API_KEY环境变量是否注入,application.yml里是否写成了字面量${TAOTOKEN_API_KEY}而没被解析。另外确认 Key 没有多余空格,复制时容易带上换行。

local proxy failed / connection refused:Base URL 写错或网络不通。确认base-url是https://taotoken.net/api,不带/v1,不带末尾斜杠。如果你本地配了 HTTP 代理环境变量,先临时清掉再试,避免请求被错误转发。

Error reading choices / choices is null:模型返回体解析失败。常见原因是 Model ID 写错,或者该模型不支持当前请求格式。回到模型对话页面核对 Model ID 拼写,确认它支持 chat completions。

OAuth / token expired:如果你用的是需要 OAuth 的接入方式,token 过期会导致 401。改用 API Key 方式即可绕开。LangChain4j 的OpenAiChatModel走的是 Key 认证,不需要 OAuth 流程。

ChatMessage 反序列化报错:不要用 Jackson 直接反序列化ChatMessage接口,用ChatMessageJsonCodec。如果历史数据里混入了旧版本序列化格式,清掉对应memory_id的记录重跑。

memoryId 串上下文:检查memoryId生成逻辑,确保「租户 + 用户 + 业务号」唯一。如果两个不同订单共用了同一个memoryId,模型会把两个订单的信息混在一起回答。

窗口裁剪后模型答非所问:maxMessages或maxTokens设得太小,把关键上下文裁掉了。先把窗口调大验证,再逐步收紧到成本可接受的值。

6. 语义一致 CTA:把记忆持久化接进你的编码工作流

记忆持久化跑通后,下一步通常是把它接进更完整的 Agent 或编码辅助流程。如果你在做长期编码类项目,需要模型在多轮会话里持续记住项目上下文,可以看 Coding Plan,它更适合长会话、多轮迭代的场景。

接入过程中如果卡在 Key 或权限配置,直接去 API Keys 页面重新生成一个,配合接入文档核对参数。文档里对 Base URL、Model ID 和请求格式有完整说明,比在代码里反复试错快得多。

最后给一个实用建议:把ChatMemoryStore的读写日志和窗口裁剪日志分开打,前者看恢复是否成功,后者看成本是否受控。这两个指标稳定后,再考虑加摘要层,用 conversation summary 替代被裁掉的旧消息,这样既省 token 又不丢关键信息。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询