前言
最近在研究怎么让大模型真正"记住"用户说过的话,而不是每次对话都像失忆了一样从头来过今天就把我的学习笔记、代码示例、踩坑心得全部整合起来,写成这篇超详细的实战指南。
这篇文章的目标很简单:让你看完就能上手,在自己的项目里快速加上"记忆"功能。咱们从最基础的无状态调用开始,一步步深入到多轮对话、Token 控制、多用户场景、持久化存储,最后再聊聊生产环境的最佳实践。
一、为什么 LLM 默认没有记忆?
所有大模型的 API 调用(OpenAI、DeepSeek、Claude、Gemini 等)本质上都是无状态的。每次请求都是独立的,模型完全不知道你上一次说了什么。
来看一个最简单的例子:
JavaScript
import { ChatDeepSeek } from "@langchain/deepseek";
const model = new ChatDeepSeek({
model: "deepseek-chat",
temperature: 0,
});
const res1 = await model.invoke("我叫华高俊,喜欢打和平精英");
console.log(res1.content); // 正常回复
console.log('///////')
const res2 = await model.invoke("我叫什么名字?");
console.log(res2.content); // 模型:???我不知道啊...

第二次问名字,模型直接懵了。因为两次调用之间没有任何关联。
要让模型有记忆,最原始的办法就是手动维护一个消息列表,每次调用都把历史带上:
JavaScript
let messages = [
{ role: "user", content: "我叫华高俊,喜欢打和平精英" },
{ role: "assistant", content: "好的,我记住了!" },
{ role: "user", content: "我叫什么名字?" }
];
await model.invoke(messages);
短期看没问题,但对话轮数一多,这个列表就会像滚雪球一样越来越大,导致:
- Token 消耗暴涨,成本高
- 容易超出模型上下文窗口(比如 128k token 上限)
- Prompt 结构容易写乱
LangChain 的 Memory 模块就是为了优雅地解决这个问题而生的。
二、Memory 的核心:RunnableWithMessageHistory
在现代 LangChain JS(基于 LCEL 架构)里,实现记忆最推荐的方式是使用 RunnableWithMessageHistory。它能自动完成:
- 根据会话 ID 加载历史消息
- 把历史注入到 Prompt 的 {history} 占位符
- 调用模型
- 把本次用户输入和模型回复自动保存回历史
核心代码长这样:
JavaScript
import { ChatPromptTemplate } from "@langchain/core/prompts";
import { RunnableWithMessageHistory } from "@langchain/core/runnables";
import { InMemoryChatMessageHistory } from "@langchain/core/chat_history";
const prompt = ChatPromptTemplate.fromMessages([
["system", "你是一个有记忆的助手,用中文友好回复。"],
["placeholder", "{history}"], // 历史消息注入点
["human", "{input}"], // 当前用户输入
]);
const chain = prompt.pipe(model);
// 多用户存储(生产必备)
const messageHistories = new Map<string, InMemoryChatMessageHistory>();
const chainWithHistory = new RunnableWithMessageHistory({
runnable: chain,
getMessageHistory: async (sessionId) => {
if (!messageHistories.has(sessionId)) {
messageHistories.set(sessionId, new InMemoryChatMessageHistory());
}
return messageHistories.get(sessionId)!;
},
inputMessagesKey: "input", // 输入对象中的键名
historyMessagesKey: "history", // prompt 中的占位符键名
});
调用方式:
JavaScript
await chainWithHistory.invoke(
{ input: "我叫华高俊,喜欢打和平精英" },
{ configurable: { sessionId: "user-001" } }
);
await chainWithHistory.invoke(
{ input: "我是?" },
{ configurable: { sessionId: "user-001" } } // 相同 sessionId 才有记忆
);
第二次调用时,模型就能正确回答"华高俊"。

三、完整调用流程深度拆解
很多新手(包括曾经的我)看到代码能跑,但不清楚内部到底是怎么工作的。下面把一次 invoke 的完整流程拆开来说:
-
接收输入和 sessionId 你调用 chainWithHistory.invoke({ input: "..." }, { configurable: { sessionId: "xxx" } })
-
获取对应会话的历史实例 调用你提供的 getMessageHistory("xxx"),返回一个 BaseChatMessageHistory 实例(比如 InMemoryChatMessageHistory)
-
读取历史消息 自动执行 await history.getMessages(),得到所有之前的消息数组
-
构造完整输入对象
JavaScript{ history: [所有旧消息], input: "当前用户输入" } -
渲染 Prompt
- {history} 被替换成完整的历史消息列表
- {input} 被替换成本次用户输入(作为 HumanMessage)
- 最终形成一个完整的 messages 数组传给模型
-
调用模型得到回复
-
自动保存本次对话(关键!) RunnableWithMessageHistory 会自动:
- history.addMessage(new HumanMessage(本次输入))
- history.addMessage(new AIMessage(模型回复))
-
返回模型回复
整个过程除了提供 getMessageHistory 外,其他全是自动的。这就是为什么说它"优雅"的原因。
四、关键组件逐个详解
1. ChatPromptTemplate.fromMessages
这是构建聊天 Prompt 的首选方式,写法直观:
JavaScript
ChatPromptTemplate.fromMessages([
["system", "系统提示"],
["placeholder", "{history}"], // 必须和 historyMessagesKey 一致
["human", "{input}"], // 必须和 inputMessagesKey 一致
]);
支持的 role 有:system、human、ai、placeholder。还可以插入 few-shot 示例:
JavaScript
[
["human", "你是谁?"],
["ai", "我是华高俊的私人助手。"],
["human", "{input}"]
]
2. InMemoryChatMessageHistory
最简单的内存存储类,内部就是一个 messages: BaseMessage[] 数组。
- 优点:简单、快速、适合本地测试和调试
- 缺点:进程重启就丢失、不支持多实例共享
构造函数支持预加载:
JavaScript
new InMemoryChatMessageHistory([
new HumanMessage("旧消息"),
new AIMessage("旧回复")
]);
手动操作方法:
- await history.getMessages() 获取
- await history.addMessage(msg) 添加
- await history.clear() 清空(比如用户点"新对话")
3. sessionId 的重要性
如果你像这样写:
JavaScript
getMessageHistory: async () => new InMemoryChatMessageHistory()
那所有用户都会共享同一个历史!上线后用户A看到用户B的聊天记录,灾难。
必须用 Map/Set 或数据库按 sessionId(通常是用户ID + 设备ID)隔离。
五、Token 爆炸怎么办?高级 Memory 策略
全量保存历史(Buffer 风格)简单,但对话长了 Token 很容易爆。LangChain 提供了多种控制策略:
-
窗口记忆(Window) 只保留最近 N 轮对话。实现方式:在 getMessageHistory 中 slice:
JavaScriptconst all = await baseHistory.getMessages(); return all.slice(-maxTurns * 2); // 每轮两句 -
摘要记忆(Summary) 用 LLM 把旧对话压缩成摘要,保留关键信息不丢失细节。
-
混合摘要缓冲(Summary Buffer) 最近几轮保留原文,旧的用摘要替换。最推荐的生产方案。
LangChain JS 有现成实现(旧版 Memory 类):
JavaScriptimport { ConversationSummaryBufferMemory } from "langchain/memory"; const memory = new ConversationSummaryBufferMemory({ llm: model, maxTokenLimit: 1000, // 超过就摘要旧部分 });新版 LCEL 也可以通过自定义 getMessageHistory 实现类似逻辑。
六、生产环境必备:持久化存储
InMemory 只能用于开发测试,上线必须换持久化实现:
| 存储 | 包 | 场景 |
|---|---|---|
| Upstash Redis | @langchain/upstash | Serverless(如 Vercel)首选 |
| Redis | @langchain/redis | 高性能传统服务 |
| MongoDB | @langchain/mongodb | 需要复杂查询 |
| Postgres | @langchain/community | 已有 SQL 生态 |
替换方式只需改 getMessageHistory 返回对应类即可,其他代码不动。
七、实际项目中的最佳实践
- 多用户必用 sessionId ,建议格式:user: <math xmlns="http://www.w3.org/1998/Math/MathML"> u s e r I d : c h a t : {userId}:chat: </math>userId:chat:{chatId}
- 提供"清除对话"功能:手动调用 history.clear()
- 监控 Token 使用:用 LangSmith 或自己统计,及时触发摘要
- 结合 RAG:Memory 存对话历史,VectorStore 存知识库,职责分开
- 错误处理:模型调用失败时不要保存本次消息,避免脏数据
- 预加载历史:用户重新打开旧对话时,从数据库加载后 addMessages
八、总结
LangChain 的 Memory 模块本质上就是帮你自动化了"读历史 → 发模型 → 存新消息"这个循环。核心只有三件事:
- 用 ChatPromptTemplate.fromMessages 写好带 {history} 和 {input} 的模板
- 用 RunnableWithMessageHistory 包装 chain
- 提供可靠的 getMessageHistory(开发用 InMemory,上线用 Redis 等)
掌握了这套流程,你就能轻松做出各种有记忆的聊天应用:客服机器人、个人助手、写作搭档、角色扮演......
从我最初的"手动滚雪球"消息列表,到现在几行代码就搞定多用户持久化记忆,LangChain 确实省了很多心。希望这篇干货能帮你在项目里快速落地。