ARTICLE DETAIL

资讯详情

深耕网站建设与运营推广的一线实战洞察。

LangChain4j 的聊天记忆别只放内存了,持久化这次讲具体:ChatMemoryStore 落库与 TokenWindow 裁剪实战

LangChain4j 的聊天记忆别只放内存了,持久化这次讲具体:ChatMemoryStore 落库与 TokenWindow 裁剪实战 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/apiAPI Key控制台创建的密钥sk-xxxxxxxxModel 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对应的OpenAiChatModelBeanConfiguration 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末尾不要带/v1LangChain4j 的 OpenAI 客户端会自己拼接路径。如果你填成https://taotoken.net/api/v1请求会变成/api/v1/v1/chat/completions直接 404。我试过在配置里多写一段路径排查了半小时才发现是重复拼接。依赖方面pom.xml至少要有dependency groupIddev.langchain4j/groupId artifactIdlangchain4j-open-ai/artifactId version0.35.0/version /dependency dependency groupIddev.langchain4j/groupId artifactIdlangchain4j/artifactId version0.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 ListChatMessage getMessages(Object memoryId) { return chatMemoryDao.loadMessages(memoryId.toString()).stream() .map(ChatMessageJsonCodec::deserialize) .toList(); } Override public void updateMessages(Object memoryId, ListChatMessage 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 Servicepublic 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动态构建 ChatMemoryStore 从数据库读历史。这样多实例部署时任意节点都能拿到同一份记忆。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里加一行日志打印每次回读的消息条数和估算 tokenOverride public ListChatMessage getMessages(Object memoryId) { ListChatMessage 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 UnauthorizedAPI Key 没配或配错。检查TAOTOKEN_API_KEY环境变量是否注入application.yml里是否写成了字面量${TAOTOKEN_API_KEY}而没被解析。另外确认 Key 没有多余空格复制时容易带上换行。local proxy failed / connection refusedBase 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 又不丢关键信息。
返回列表