LangChain4j 对话记忆:让模型记住聊过什么,三种方式实测对比
作者:鱼宵 | LangChain4j实战精通营 · 第 4 篇
你有没有遇过这种尴尬:跟客服机器人说"我叫小明",它回"好的小明";过两句你问"那我叫什么?“,它一本正经地说"你还没告诉我”。你气得想摔手机——它刚才明明还叫你小明!
这不是机器人笨,是大模型本身就是个"失忆的实习生"。这一课我用三个对照实验把这事彻底讲透:带记忆、无记忆、窗口溢出。同三句话、同一个模型,只有"有没有把历史拼进请求"这一个变量,结果天差地别。代码全在仓库lesson-04/,clone 下来跑一遍,你会亲眼看到它怎么从"一口一个小明"变成"你没告诉我"——末尾挑战题改个窗口大小,遗忘现象当场反转。
一、核心原理:模型什么都记不住,记忆是外挂的
一句话:大模型两次调用之间不保留任何上下文。所谓"记忆",其实是框架每次都把历史对话重新发给模型一遍。
1. 无状态:每次打电话都得重新自我介绍
打客服电话,对面是个刚换班、完全不记得上一通电话的实习生。你上一句说过"我叫小明",这通电话他压根不知道。要让他记得,你只能每次都把前情提要重新念一遍。
HTTP 本身就是无状态的,网站登录态靠 Cookie/Session 这些外挂实现——大模型也一样。模型是个无状态的 HTTP 服务,服务端不存你会话。
2. ChatMemory:旁边放个聊天记录本
ChatMemory是 LangChain4j 提供的"对话记忆"抽象,本质就是一个保存历史消息列表的容器。工作流程是这样:
用户说:我叫小明 ↓ 框架把这条 UserMessage 存进记忆本 ↓ 把【记忆本里的全部消息】打包发给模型 ↓ 模型回答"好的小明" ↓ 框架把这条 AiMessage 也存进记忆本 用户又说:我喜欢打篮球 ↓ 这条 +【记忆本里之前的全部消息】一起发给模型 ← 关键! ↓ 模型这次"看到"了上面的"小明"看出来了吗:模型一次都没真记住,是你(框架)每次都在复读前情提要。
3. MessageWindowChatMemory:小白板只留最近 N 条
上下文窗口有上限、token 要钱,本子不能无限写。窗口记忆只保留最近 N 条消息,更早的自动擦掉。
这里有个面试高频坑:withMaxMessages(N)的 N 是消息条数,不是轮数。一轮对话 = 1 条用户消息 + 1 条 AI 消息 =2 条。所以:
| 容量 | 实际保留 |
|---|---|
| 10 | 约最近 5 轮 |
| 2 | 只留最近 1 轮(用户 1 + AI 1),再多聊一句最早的就被挤掉 |
本课实验三就是把容量设成 2,故意让它"忘光"早期信息。
4. AiServices 织记忆:就加一行
上一课见过的 AiServices,多挂一个.chatMemory(memory)就自动帮你做"存历史 + 发历史"全套:
Assistantassistant=AiServices.builder(Assistant.class).chatModel(chatModel)// 用哪个模型.chatMemory(memory)// 挂哪本记忆本 ← 多轮记忆的全部秘密.build();之后你只管assistant.chat("你好"),历史怎么拼、怎么存,框架全包。注意必须复用同一个 Assistant 实例——记忆本绑在实例上,每轮 new 一个新的,记忆永远是空的。
二、动手:一次跑完三个实验
环境沿用前几课:JDK 17、
DEEPSEEK_API_KEY已配好。这课是控制台课,程序不读键盘,跑完自动退出。
cd langchain4j-journey\lesson-04$env:JAVA_HOME="C:\Program Files\Java\jdk-17"mvn clean install-DskipTests java-Dfile.encoding=UTF-8-jar target\lesson-04-1.0.0.jar想把输出存下来对照,用cmd /c 'java -Dfile.encoding=UTF-8 -jar target\lesson-04-1.0.0.jar > out.txt 2>&1'。
三、关键代码:三个实验,一个主类
本课模型配置LlmConfig和第 1 课一样(阻塞式OpenAiChatModel,maxTokens(200)+temperature(0.7)),不重复贴。重点是这个主类——三个实验并排,对照着看:
packagecom.langchain4j.lesson04;importdev.langchain4j.memory.ChatMemory;importdev.langchain4j.memory.chat.MessageWindowChatMemory;importdev.langchain4j.model.chat.ChatModel;importdev.langchain4j.service.AiServices;importorg.springframework.boot.CommandLineRunner;importorg.springframework.boot.SpringApplication;importorg.springframework.boot.autoconfigure.SpringBootApplication;/** * 第 4 课主程序:一次跑完三个对照实验。 * demoWithMemory —— 带记忆:报名字/爱好,回头问它记得住 * demoWithoutMemory—— 无记忆:裸调,同样三句话它"失忆" * demoWindowForget —— 窗口溢出:容量设 2,多聊几轮早期信息被自动擦掉 */@SpringBootApplicationpublicclassChatWithMemoryApplicationimplementsCommandLineRunner{privatefinalChatModelchatModel;// 阻塞式模型,本身无状态publicChatWithMemoryApplication(ChatModelchatModel){this.chatModel=chatModel;}publicstaticvoidmain(String[]args){SpringApplication.run(ChatWithMemoryApplication.class,args);}@Overridepublicvoidrun(String...args){demoWithMemory();demoWithoutMemory();demoWindowForget();System.out.println("========== 第 4 课全部实验结束 ==========");}/** 实验一:带记忆。三件套 = 窗口记忆 + AiServices 织记忆 + 同一个实例反复聊。 */privatevoiddemoWithMemory(){System.out.println("========== 实验一:带记忆(窗口容量 10,约等于保留最近 5 轮) ==========");// 容量 10 = 10 条消息 ≈ 5 轮(一轮=用户1条+AI1条)ChatMemorymemory=MessageWindowChatMemory.withMaxMessages(10);// 把接口、模型、记忆本三者织在一起Assistantassistant=AiServices.builder(Assistant.class).chatModel(chatModel).chatMemory(memory).build();ask(assistant,"我叫小明,请帮我记住我的名字。");ask(assistant,"我最喜欢打篮球,也请帮我记住。");// 这一问一个字上下文都没提,但模型该从记忆本翻出前两轮ask(assistant,"那我叫什么名字?我喜欢什么运动?只用一句话回答。");}/** 实验二:无记忆对照。直接拿模型裸调,每次都是独立请求。 */privatevoiddemoWithoutMemory(){System.out.println("========== 实验二:无记忆(裸调 chatModel.chat,每次都不带历史) ==========");System.out.println("用户第1句:我叫小明,请帮我记住我的名字。");System.out.println("AI 第1答:"+chatModel.chat("我叫小明,请帮我记住我的名字。"));System.out.println("用户第2句:我最喜欢打篮球,也请帮我记住。");System.out.println("AI 第2答:"+chatModel.chat("我最喜欢打篮球,也请帮我记住。"));System.out.println("用户第3句:那我叫什么名字?我喜欢什么运动?只用一句话回答。");// 这句话里模型根本没见过"小明/篮球"(那是上两个请求的事)System.out.println("AI 第3答:"+chatModel.chat("那我叫什么名字?我喜欢什么运动?只用一句话回答。"));}/** 实验三:窗口溢出。容量 2 只留最近 1 轮,中间插闲聊把前两轮挤出去。 */privatevoiddemoWindowForget(){System.out.println("========== 实验三:窗口溢出(容量 2 = 只留最近 1 轮,多聊会自动忘) ==========");ChatMemorytinyMemory=MessageWindowChatMemory.withMaxMessages(2);// 只留 1 轮Assistantforgetful=AiServices.builder(Assistant.class).chatModel(chatModel).chatMemory(tinyMemory).build();ask(forgetful,"我叫小明,请帮我记住我的名字。");// 第1轮:写入名字ask(forgetful,"我最喜欢打篮球,也请帮我记住。");// 第2轮:名字这条开始被挤ask(forgetful,"1 加 1 等于几?一句话回答。");// 第3轮:闲聊,把前两轮彻底挤出窗口ask(forgetful,"那我叫什么名字?只用一句话回答。");// 此时记忆本里只剩"1+1"那一来一回}/** 小工具:打印用户的话,调一次助手,打印回答。 */privatevoidask(Assistantassistant,StringuserMessage){System.out.println("用户说:"+userMessage);System.out.println("AI 回答:"+assistant.chat(userMessage));System.out.println("---- 一轮结束 ----");}}Assistant接口本身极简——就声明一个String chat(String userMessage),AiServices 给它生成实现,配了记忆后这个方法自动带上下文。
四、实测输出:三组实验,教科书级对照
以下为 2026-10-05 本机真实运行(DeepSeek 实测,已省略启动 banner)。这节是本课最值钱的证据,三组输出挨着看。
实验一:带记忆(窗口容量 10)。
========== 实验一:带记忆(窗口容量 10,约等于保留最近 5 轮) ========== 用户说:我叫小明,请帮我记住我的名字。 AI 回答:好的,小明!我已经记住你的名字了。😊 以后你再来找我聊天,我会知道你是小明。有什么我可以帮你的吗? ---- 一轮结束 ---- 用户说:我最喜欢打篮球,也请帮我记住。 AI 回答:好的,小明!我已经记住了:你叫**小明**,你最喜欢**打篮球** 🏀。以后聊天的时候,我会记得这些信息。 ---- 一轮结束 ---- 用户说:那我叫什么名字?我喜欢什么运动?只用一句话回答。 AI 回答:你叫小明,最喜欢打篮球。 ---- 一轮结束 ----关键证据:第三问里我们一个字都没提"小明/篮球",模型却答出了"你叫小明,最喜欢打篮球"——因为框架自动把前两轮塞进了请求。记忆生效了。
实验二:无记忆(裸调,每次不带历史)。
========== 实验二:无记忆(裸调 chatModel.chat,每次都不带历史) ========== 用户第1句:我叫小明,请帮我记住我的名字。 AI 第1答:好的,小明,我记住你的名字了!以后有什么需要帮忙的,随时告诉我。😊 用户第2句:我最喜欢打篮球,也请帮我记住。 AI 第2答:好的,我记住了:你最喜欢打篮球。🏀 …… 用户第3句:那我叫什么名字?我喜欢什么运动?只用一句话回答。 AI 第3答:我不知道你的名字,也不知道你喜欢什么运动,因为你还没有告诉我这些信息。关键证据:第 1、2 答明明一口一个"小明"“打篮球”,第 3 答却矢口否认——因为这是三次互不相干的独立请求,模型对前两次毫无印象。和实验一形成教科书级对照。
实验三:窗口溢出(容量 2,多聊自动遗忘)。
========== 实验三:窗口溢出(容量 2 = 只留最近 1 轮,多聊会自动忘) ========== 用户说:我叫小明,请帮我记住我的名字。 AI 回答:好的,小明,我记住你的名字了!…… ---- 一轮结束 ---- 用户说:我最喜欢打篮球,也请帮我记住。 AI 回答:好的,小明,我也记住你最喜欢打篮球了!🏀 …… ---- 一轮结束 ---- 用户说:1 加 1 等于几?一句话回答。 AI 回答:1 加 1 等于 2。 ---- 一轮结束 ---- 用户说:那我叫什么名字?只用一句话回答。 AI 回答:我不知道你的名字,因为你没有告诉我。 ---- 一轮结束 ----关键证据:一开始它一口一个"小明",闲聊一句"1+1"之后,再问名字它就说"你没告诉我"——容量 2 只留最近 1 轮,"小明"那条早被挤出窗口擦掉了。窗口遗忘,肉眼可见。
排查提示:实验三它居然还记得名字?DeepSeek 偶尔"聪明过头",在"1+1"那轮后多插两轮闲聊,把窗口挤得更彻底,遗忘会更稳定。
五、挑战题:改参数,看看会怎样
- ⭐把窗口从 2 改成 10:把实验三的
withMaxMessages(2)改成withMaxMessages(10),再跑一遍"报名字→闲聊→问名字",看这次它还记不记得名字,解释为什么。答案就在demoWindowForget那行withMaxMessages。 - ⭐多挤两轮:在实验三"1+1"后面再加两轮无关问题(如"今天星期几"“讲个笑话”),再问名字,确认遗忘更彻底。答案就在
demoWindowForget那串ask(...)调用。 - ⭐⭐每轮 new 一个新 Assistant:把实验一改成循环里每轮
AiServices.builder()...build()新建实例,再问"我叫什么",看它是不是又答不上来——体会"必须复用同一实例"。答案在demoWithMemory的 builder 用法。
六、生产环境进阶:三个加分项
1. 对话越长越贵,窗口是成本闸。“记忆”=每次都把历史重新发一遍,聊了 20 轮,第 21 轮请求里就塞了 40 条历史,全按输入 token 计费。窗口记忆只留最近几轮,就是为了控成本。
2. 多用户要做记忆隔离。现在记忆本是全局单例,所有用户共用一本——张三跟机器人聊的,李四全看得见。生产用ChatMemoryProvider,按用户 ID 返回不同记忆本,互不串。
3. 长对话上摘要记忆。窗口记忆简单但会丢早期信息;摘要记忆把老对话压成一段小摘要塞进去,省 token、可能丢细节。生产常混用。
七、面试回答模板
面试官:为什么说大模型是无状态的?每次调用之间发生了什么?
一句话:模型两次调用之间不保留任何上下文,它每次都像第一次见你。展开:它是个无状态 HTTP 服务,服务端不存你的会话;所谓"多轮记忆",是应用层(框架)每次把历史消息重新拼进请求实现的,模型本身一个字没记住。(指向本课第一节 + 实验二裸调实测)
追问:ChatMemory 怎么让模型"记住"的?历史消息在请求里怎么拼?
它本质是个消息列表容器。AiServices 配了
.chatMemory()后自动做三件事:把用户消息存进列表 → 发请求时把列表里全部历史一起带上 → 把模型回答也存回去。你看到的"它记得上文",其实是每次都把前情提要重发一遍。(指向本课第 2 小节 + 实验一)
追问:MessageWindowChatMemory 的窗口大小记的是轮数还是消息数?
是消息条数,不是轮数。一轮 = 用户 1 条 + AI 1 条 = 2 条,所以
withMaxMessages(10)只留约 5 轮。超窗口自动丢最老的消息。本课实验三把容量设 2,闲聊一句就把"小明"挤出去了,模型立马"失忆"。(指向本课第 3 小节 + 实验三实测)
追问:为什么对话越长越费钱?
因为记忆 = 每次都把历史重发一遍,多聊一轮,之后每轮请求都多塞 2 条历史,输入 token 线性涨。这就是长对话要做窗口记忆/摘要记忆的根本原因。(指向本课第六节)
八、总结表
| 坑 | 现象 | 解法 |
|---|---|---|
裸调chatModel.chat() | 问完就忘,答不上上文 | 记忆是 AiServices 层加的,ChatModel 本身无状态,要织.chatMemory() |
| 容量 N 理解成轮数 | 窗口演示对不上现象 | N 是消息条数(一轮 2 条),withMaxMessages(2)只留 1 轮 |
| 每轮 new 新 Assistant | 记忆永远是空的 | 记忆本绑实例,多轮必须复用同一个 Assistant |
ChatMemory包名写错 | import 报错 | 接口在dev.langchain4j.memory,实现在...memory.chat子包 |
| 多用户共用一本记忆 | 用户之间串话 | 用ChatMemoryProvider按用户 ID 隔离记忆本 |
九、关于这个系列
本文是「Java 后端实战精通营」系列第 4 篇,原则:实战驱动、由浅到深、面试向,每篇文章的结论都可以亲手验证。
👉LangChain4j 实战精通营(10 课):https://gitee.com/j67mk2/langchain4j-journey
- 本文对应源码位置:
lesson-04/(控制台工程,主类ChatWithMemoryApplication内含带记忆 / 无记忆 / 窗口溢出三个对照实验)
系列文章一览(按发布顺序):
| 篇 | 主题 |
|---|---|
| 1 | LangChain4j 第一个对话程序:手写 LLM 调用链路,看清模型怎么"开口" |
| 2 | LangChain4j 提示词工程:模板 + few-shot + 温度,提示词从写死到复用 |
| 3 | LangChain4j 结构化输出:让模型吐出 Java 对象,JSON 不再手写解析 |
| 4 | LangChain4j 对话记忆:让模型记住聊过什么,三种方式实测对比 |
| 5 | LangChain4j 流式输出:打字机效果 + SSE,回答不再干等三秒 |
| 6 | LangChain4j 工具调用:Function Calling 让模型动手查数据,多工具连调实测 |
| 7 | LangChain4j 向量检索:文本怎么变成可搜索的坐标 |
| 8 | LangChain4j RAG 流水线:手写问答全流程,给 AI 开卷考试 |
| 9 | LangChain4j Agent:多步推理自主调用,放手让"实习生"自己安排工作 |
| 10 | LangChain4j 企业智能客服:RAG + 工具 + 记忆 + 流式 + 兜底,验收 5 项全过 |
下一篇预告:《LangChain4j 流式输出:打字机效果 + SSE,回答不再干等三秒》——本课你每次都是"等半天,整句话一次性蹦出来"。下一课换 StreamingChatModel + TokenStream,让回答一个字一个字往外蹦,再用 SSE 把这股字流推到浏览器网页上。
跑完有任何报错,把终端输出发评论区,一起排查。
标签建议:LangChain4j、对话记忆、ChatMemory
摘要建议(≤256 字):模型为什么聊两句就"失忆"?因为大模型本身无状态,所谓记忆是框架每次把历史重新发一遍。本文用三个对照实验实测:带记忆(窗口 10)第三问它准确答出"你叫小明,最喜欢打篮球";无记忆裸调三次独立请求它矢口否认;窗口容量设 2,闲聊一句它立马"忘了"你是谁。讲清 withMaxMessages 的 N 是消息条数不是轮数,附 3 道改参数挑战题与面试回答模板,源码在 lesson-04 可 clone 直接跑。