ARTICLE DETAIL

资讯详情

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

LangChain4j(4)——聊天记忆Chat Memory:用TaoToken统一Key跑通多轮对话记忆

LangChain4j(4)——聊天记忆Chat Memory:用TaoToken统一Key跑通多轮对话记忆 1. 为什么你的 LangChain4j 多轮对话总是“失忆”很多人第一次用 LangChain4j 接大模型时都会遇到一个很迷惑的现象明明上一句刚说完“我叫张三”下一句问“我叫什么”模型却一脸茫然地回你“抱歉我不知道你的名字”。这不是模型笨而是你根本没把上下文传给它。大模型本身是无状态的。每一次chat()调用对它来说都是一次全新的、互不相干的请求。它不会自动记住你上一轮说了什么就像你每次打电话都换了一个完全不认识你的客服。所谓“聊天记忆 Chat Memory”本质上是由客户端也就是你的 Java 程序负责把历史消息攒起来每次请求时一并带上让模型“看起来”记住了上下文。LangChain4j 把这个攒历史、传历史、淘汰旧历史的活儿封装成了ChatMemory抽象。它解决的核心痛点有三个第一自动维护消息列表你不用手动List.add第二提供淘汰策略防止历史无限膨胀把 token 烧光第三支持持久化让服务重启后记忆还在。这篇聚焦的是落地层面最容易踩坑的部分怎么用 TaoToken 统一 Key 和 API 通道接入模型怎么配置MessageWindowChatMemory和TokenWindowChatMemory这两种淘汰策略怎么把记忆存到内存或外部存储以及怎么用连续追问验证上下文到底有没有保留住。适合已经跑通过 LangChain4j 单轮对话、正准备做多轮会话的 Java 后端同学。下面所有代码都可以直接复制改 Key 就跑。2. TaoToken 前置准备统一 Key 与 API 通道接入 LangChain4j在写记忆代码之前得先把模型通道打通。LangChain4j 支持很多模型提供商但如果你每个项目都去申请不同厂商的 Key、记不同的 Base URL维护成本会很高。用 TaoToken 的好处是一个 Key、一个 Base URL就能通过 OpenAI 兼容协议访问多种模型LangChain4j 里直接用OpenAiChatModel指向它即可不用为每个厂商写一套 builder。先拿到凭证。打开官网 https://taotoken.net/?utm_sourcetaotoken_aicg_blog_endutm_mediumcsdnutm_campaignrewriteutm_content 注册后进入控制台 https://taotoken.net/console 创建 API Key。然后在 API Keys 页面 https://taotoken.net/api-keys 复制你的 Key形如sk-xxxxxxxx。模型对话入口在 https://taotoken.net/model-chat 可以先去那里手动试一句确认 Key 是通的。接下来是 Maven 依赖。LangChain4j 的 OpenAI 集成模块版本建议用 0.35.0 及以上早期版本ChatMemory的 API 有差异。在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关键配置是 Base URL。TaoToken 的 API 地址是 https://taotoken.net/api 注意这里不加任何 UTM 参数直接写这个根路径。LangChain4j 的OpenAiChatModel会自动在末尾拼/v1/chat/completions所以 Base URL 填https://taotoken.net/api即可。如果你填成带/v1的会变成/v1/v1/...导致 404。模型 ID 怎么填去模型对话页面看看当前可用的模型名比如gpt-4o-mini、claude-3-5-sonnet这类。填错模型名会返回model not found。Key、Base URL、Model ID 这三件套必须同时正确缺一个都跑不通。一个容易忽略的点OpenAiChatModel默认会读环境变量OPENAI_API_KEY如果你代码里显式传了.apiKey()就以代码为准。建议把 Key 放在环境变量或配置中心别硬编码进 Git。下面这段是基础模型构建后面所有记忆示例都复用它import dev.langchain4j.model.openai.OpenAiChatModel; import java.time.Duration; OpenAiChatModel model OpenAiChatModel.builder() .baseUrl(https://taotoken.net/api) .apiKey(System.getenv(TAOTOKEN_API_KEY)) .modelName(gpt-4o-mini) .temperature(0.7) .timeout(Duration.ofSeconds(60)) .maxRetries(2) .logRequests(true) .logResponses(true) .build();logRequests和logResponses在调试记忆问题时特别有用你能在控制台看到每次实际发出去的消息列表里到底带了几条历史。生产环境记得关掉否则日志量很大。3. MessageWindowChatMemory 与 TokenWindowChatMemory 配置差异现在进入正题。LangChain4j 的ChatMemory接口有两个最常用的实现MessageWindowChatMemory按消息条数淘汰TokenWindowChatMemory按 token 数量淘汰。选哪个取决于你的场景如果对话短、每条消息长度差不多按条数简单直接如果消息长短差异大比如有的用户爱发长文按 token 更精确能真正控制成本。先看MessageWindowChatMemory。它的核心参数是maxMessages表示最多保留多少条消息。注意这里的“条”是UserMessage、AiMessage、SystemMessage都算一条。当你设置maxMessages(10)第 11 条进来时最老的那条会被踢掉。它还有一个chatMemoryStore参数用于持久化不传则默认用内存存储。import dev.langchain4j.memory.ChatMemory; import dev.langchain4j.memory.chat.MessageWindowChatMemory; ChatMemory messageWindowMemory MessageWindowChatMemory.builder() .maxMessages(10) .build();再看TokenWindowChatMemory。它需要你传一个TokenCountEstimator因为 LangChain4j 自己不知道你的模型怎么分词。最省事的做法是用OpenAiTokenizer它按 OpenAI 的分词规则估算。核心参数是maxTokens表示历史消息占用的 token 上限。import dev.langchain4j.memory.chat.TokenWindowChatMemory; import dev.langchain4j.model.openai.OpenAiTokenizer; ChatMemory tokenWindowMemory TokenWindowChatMemory.builder() .maxTokens(1000, new OpenAiTokenizer(gpt-4o-mini)) .build();两者的淘汰逻辑有个重要区别MessageWindowChatMemory是整条整条地丢可能一次丢一条TokenWindowChatMemory在超限时会尽量丢到不超为止可能一次丢好几条。另外TokenWindowChatMemory在计算时会保留SystemMessage优先淘汰UserMessage和AiMessage这点比按条数更智能。如果你想让记忆持久化比如存到 Redis 或数据库需要实现ChatMemoryStore接口。它只有三个方法getMessages(Object memoryId)、updateMessages(Object memoryId, ListChatMessage messages)、deleteMessages(Object memoryId)。下面是一个内存版实现生产环境换成 Redis 即可import dev.langchain4j.store.memory.chat.ChatMemoryStore; import dev.langchain4j.data.message.ChatMessage; import java.util.*; public class InMemoryChatMemoryStore implements ChatMemoryStore { private final MapObject, ListChatMessage store new HashMap(); Override public ListChatMessage getMessages(Object memoryId) { return store.getOrDefault(memoryId, new ArrayList()); } Override public void updateMessages(Object memoryId, ListChatMessage messages) { store.put(memoryId, new ArrayList(messages)); } Override public void deleteMessages(Object memoryId) { store.remove(memoryId); } }然后把它挂到记忆对象上并指定memoryId区分不同用户ChatMemoryStore store new InMemoryChatMemoryStore(); ChatMemory memory MessageWindowChatMemory.builder() .id(user-1001) .maxMessages(10) .chatMemoryStore(store) .build();id就是memoryId同一个用户每次请求都用同一个 id才能取到同一份历史。多用户场景下每个用户一个 id互不干扰。这一步是很多人做多轮对话时忘记的结果所有用户共享一份记忆串得乱七八糟。4. 连续追问验证上下文保留完整可运行示例配置讲完了现在写一个能真正跑起来、能验证记忆生效的完整例子。思路是用同一个ChatMemory对象连续发三轮消息看模型能不能记住第一轮告诉它的名字以及能不能在第三轮引用第二轮的内容。import dev.langchain4j.data.message.AiMessage; import dev.langchain4j.data.message.UserMessage; import dev.langchain4j.memory.ChatMemory; import dev.langchain4j.memory.chat.MessageWindowChatMemory; import dev.langchain4j.model.openai.OpenAiChatModel; import java.time.Duration; public class ChatMemoryDemo { public static void main(String[] args) { OpenAiChatModel model OpenAiChatModel.builder() .baseUrl(https://taotoken.net/api) .apiKey(System.getenv(TAOTOKEN_API_KEY)) .modelName(gpt-4o-mini) .temperature(0.7) .timeout(Duration.ofSeconds(60)) .logRequests(true) .logResponses(true) .build(); ChatMemory memory MessageWindowChatMemory.builder() .id(demo-user) .maxMessages(10) .build(); // 第一轮告诉模型名字 memory.add(UserMessage.from(你好我叫张三是一名 Java 后端工程师)); AiMessage reply1 model.chat(memory.messages()).aiMessage(); memory.add(reply1); System.out.println(第一轮回答 reply1.text()); // 第二轮追问名字 memory.add(UserMessage.from(请问我叫什么名字)); AiMessage reply2 model.chat(memory.messages()).aiMessage(); memory.add(reply2); System.out.println(第二轮回答 reply2.text()); // 第三轮追问职业验证更早的上下文 memory.add(UserMessage.from(我从事什么工作)); AiMessage reply3 model.chat(memory.messages()).aiMessage(); memory.add(reply3); System.out.println(第三轮回答 reply3.text()); } }运行后第二轮应该能答出“你叫张三”第三轮应该能答出“你是 Java 后端工程师”。如果第二轮就答不出名字说明记忆没生效去检查是不是每次chat()都新建了ChatMemory或者memory.add()漏了。这里有个细节model.chat(memory.messages())传的是整个消息列表而不是单条消息。memory.messages()返回的是ListChatMessage包含历史所有消息。模型收到的是一个完整的对话数组自然就能理解上下文。如果你想用TokenWindowChatMemory验证同样的流程只需把构建那行换掉ChatMemory memory TokenWindowChatMemory.builder() .id(demo-user) .maxTokens(500, new OpenAiTokenizer(gpt-4o-mini)) .build();其余代码完全不变。maxTokens(500)意味着历史消息超过 500 token 就开始淘汰。你可以故意发几条长消息观察logRequests里实际发出的消息条数是不是变少了以此确认淘汰策略在工作。验证成功的结果长这样控制台打印出三轮回答第二轮包含“张三”第三轮包含“Java 后端工程师”。同时logRequests里能看到每次请求的messages数组长度在增长到第 11 条时开始稳定在 10 条。这就是记忆窗口在起作用。5. 本篇常见报错排查401、local proxy failed、reading choices、OAuth跑记忆示例时报错基本集中在通道层而不是记忆逻辑本身。下面按真实报错逐个拆。401 Unauthorized。最常见。原因通常是 Key 没读到、Key 写错、或者 Base URL 拼错导致请求打到了别的服务。先确认System.getenv(TAOTOKEN_API_KEY)返回的不是 null可以在代码里打印一下 Key 的前 6 位。再确认 Base URL 是https://taotoken.net/api没有多余斜杠。如果用了.apiKey(sk-xxx)硬编码检查有没有复制时带上空格。local proxy failed / connection refused。这个报错说明你的程序在尝试走本地代理但代理没开。常见于开发机设置了http_proxy环境变量或者 IDE 里配了代理。解决办法是在启动参数里加-Dhttp.proxyHost -Dhttp.proxyPort清空或者直接检查系统环境变量。注意这里说的是本地网络配置问题不是让你去配什么特殊通道纯粹是清掉多余的代理设置让请求直连。reading choices 相关报错比如Cannot invoke java.util.List.get(int) because choices is null。这通常意味着返回的 JSON 结构和你预期的不一样。可能是模型名填错服务端返回了错误对象而不是正常的 chat completion也可能是 Base URL 少了/api或多了/v1。打开logResponses(true)把原始响应打出来看一般就能定位。还有一种情况是流式和非流式混用chat()是同步非流式别传流式参数。OAuth 相关报错。如果你用的是某些需要 OAuth 的模型通道可能会看到 token 过期或 scope 不足。TaoToken 走的是 API Key 模式正常不会触发 OAuth。如果看到这类报错先确认你没有误配成别的认证方式检查 builder 里是不是混入了.oauthToken()之类的参数。清掉只用.apiKey()。排查顺序建议固定成先看 Key 是否存在 → 再看 Base URL 是否精确 → 再看模型名是否可用 → 最后看网络是否直连。这四步能解决 90% 的接入问题。记忆逻辑本身的 bug 很少无非是memory.add()漏调用或者memoryId不一致导致取不到历史。6. 把记忆接进真实项目Coding Plan 与持久化建议单机 demo 跑通后真实项目要考虑的是并发、持久化和成本。先说并发MessageWindowChatMemory默认的内存存储不是线程安全的多个请求同时操作同一个memoryId会出问题。生产环境务必换成基于 Redis 的ChatMemoryStore实现利用 Redis 的原子操作保证一致性。每个memoryId对应一个 Redis List 或 Hash读写都走序列化。再说成本控制。TokenWindowChatMemory比MessageWindowChatMemory更适合生产因为它能精确控制历史占用的 token。建议把maxTokens设成模型上下文窗口的 30% 到 50%给当前问题和回答留足空间。比如模型窗口 8K历史控制在 2K 到 3K 比较稳。同时开启logResponses观察usage字段看看实际消耗再回头调参数。如果你在做的是长期编码助手或 Agent 类应用历史会很长单靠窗口淘汰会丢失早期关键信息。这时候可以考虑分层记忆近期消息用TokenWindowChatMemory保留细节早期消息做摘要后存成一条SystemMessage或单独的摘要记忆。LangChain4j 本身不直接提供摘要功能但你可以自己调一次模型生成摘要再塞回去。对于需要长期跑、多会话隔离的编码场景可以了解下 Coding Plan https://taotoken.net/coding-plan 它面向的就是这类持续性的开发辅助需求。接入文档在 https://taotoken.net/doc 里面有各语言 SDK 的完整参数说明遇到 builder 参数不确定时去查比翻源码快。Claude Code 相关的接入说明在 https://taotoken.net/ClaudeCodeAnthropic 如果你同时用命令行工具和 Java 服务可以统一用同一个 Key 管理。最后给一个实用技巧在ChatMemoryStore的updateMessages里加一层日志记录每次写入的消息条数和memoryId。线上出问题时你能快速判断是记忆没写进去还是写进去了但读取时 id 对不上。这个日志比在业务代码里到处打点有效得多。记忆这东西写对了是体验写错了就是烧钱把窗口参数和持久化盯紧多轮对话才算真正落地。
返回列表