大家好,我是你们的 AI 开发探路者!
不知道大家在刚接触大模型 API 开发时,有没有遇到过这种“渣男/渣女”行为:你刚深情款款地自我介绍:“你好,我叫陈汉升,我最喜欢喝茅台。” AI 回复得挺客气:“你好汉升,久仰大名。”
结果你紧接着问一句:“我叫什么名字?”
它居然回你:“对不起,我不知道您的名字,请问怎么称呼?”
那一刻,是不是感觉所有的感情都终究是错付了?别急着摔键盘,今天我们就来深度解析 LangChain 如何通过 RunnableWithMessageHistory 和 InMemoryChatMessageHistory 为无状态的 LLM 注入“持久记忆” 。
一、 为什么 LLM 总是“鱼的记忆”?
我们要先搞清楚一个硬核的技术背景:LLM API 的调用本质上和 HTTP 请求一样,是无状态(Stateless)的。
这就好比你给饭店打两个电话:
- 第一个电话: “我要点一个鱼香肉丝。”
- 第二个电话: “再加一碗米饭。”
饭店老板(LLM)在接第二个电话时,完全不知道你是谁,也不知道你刚才点了什么。
1. 尴尬的现场直播
我们直接来看一段基于原生 DeepSeek API 的代码演示:
JavaScript
import { ChatDeepSeek } from "@langchain/deepseek";
import 'dotenv/config';
const model = new ChatDeepSeek({
model: "deepseek-chat",
temperature: 0.7,
});
// 第一次通话:自我介绍
const res = await model.invoke("我叫陈汉升,喜欢喝茅台");
console.log("AI:", res.content); // 输出:你好,陈汉升!看来你是个懂生活的人。
// 第二次通话:灵魂拷问
const res2 = await model.invoke("我叫什么名字");
console.log("AI:", res2.content); // 输出:抱歉,作为一个人工智能,我不知道你的具体名字...
问题出在哪?
因为两次 invoke 是完全独立的请求。DeepSeek 并没有在后台偷偷开个小本本记下你的名字。
二、 思考:如何让 AI 记得住?
要让 AI 有记忆,逻辑其实非常朴素:像滚雪球一样,每次发送新问题时,把之前的聊天记录全部塞进 Prompt 里发给 AI。
理想的对话数组应该是这样的:
JSON
[ {"role": "user", "content": "我叫陈汉升,喜欢喝茅台"}, {"role": "assistant", "content": "你好,陈汉升!"}, {"role": "user", "content": "我叫什么名字?"}]
但是,如果我们手动去维护这个数组,会面临几个头疼的问题:
- 管理繁琐:要区分不同用户的
sessionId,别让张三的记忆串到李四那去。 - Token 爆炸:对话长了,Token 开销巨大,怎么截断或总结?
- 代码耦合:业务逻辑里塞满了数组操作,太不优雅!
为了解决这些痛点,LangChain 祭出了它的“记忆双子星”。
三、 LangChain 记忆组件深度拆解
在 LangChain 的哲学里,我们不再手动搬运聊天记录,而是交给专门的模块。
1. InMemoryChatMessageHistory:记忆的“存储器”
InMemoryChatMessageHistory 是一个轻量级的内存存储组件。它的作用就是:专门负责存取消息队列。
- 用处:它像一个位于内存中的数据库,存放着
[HumanMessage, AIMessage, HumanMessage...]这样的列表。 - 局限:数据存在内存里,服务重启就没了。在生产环境,我们通常会换成
RedisChatMessageHistory或数据库存储。
2. RunnableWithMessageHistory:记忆的“调度员”
这是今天的重头戏。RunnableWithMessageHistory 是一个封装器(Wrapper) 。它能包裹住你的业务逻辑(Chain),在执行前自动去“存储器”里拉取历史记录,在执行完后自动把 AI 的回复存回“存储器”。
四、 实战:构建一个“永不忘记”的对话链
我们来看这段专业级实现代码,并分步骤详细解析其语法。
JavaScript
import { ChatDeepSeek } from "@langchain/deepseek";
import { ChatPromptTemplate } from "@langchain/core/prompts";
import { RunnableWithMessageHistory } from "@langchain/core/runnables";
import { InMemoryChatMessageHistory } from "@langchain/core/chat_history";
import 'dotenv/config';
导入需要的包,除了之前学过的ChatDeepSeek和ChatPromptTemplate还加入了这次的主角
RunnableWithMessageHistory与InMemoryChatMessageHistory,在下文会详细讲解这两兄弟,这里先简单记住前者是让工作流带着记忆执行,后者是自动存储每次对话的方便工具。
1. 初始化模型
const model = new ChatDeepSeek({
model: 'deepseek-chat',
temperature: 0
});
langchain让我们不用配置
base-url与api-key,它会帮你搞定只需要你在环境变量里定义了api-key。
2. 定义 Prompt 模板
// 注意这里的 {history} 占位符,它将用于承接历史对话
const prompt = ChatPromptTemplate.fromMessages([ ['system', "你是一个有记忆的助手"],
['placeholder', "{history}"],
['human', "{input}"]
]);
回顾之前使用的是
fromTemplate这里是fromMessages,两者有什么区别?
fromTemplate适用于当你有一个固定的提示结构,只需要填入少量变量时。fromMessages用于构建多轮对话或更复杂的聊天式提示(chat-style prompts),支持角色区分。
placeholder 是 LangChain.js 中的一个特殊消息类型。它的作用是:在运行时动态插入一组已有的消息(通常是对话历史) 。
3. 组装基础 Chain
const runnable = prompt
.pipe((input) => {
console.log(">>> 最终传给模型的完整上下文:", input);
return input;
})
.pipe(model);
在这里我还加入了一个看似很多余的节点,但其实它的作用还挺大,它对工作流不产生任何影响,只是可以作为中转站偷看一下prompt节点到底传给model节点什么信息,方便我们发现bug.
4. 创建内存存储实例(在复杂应用中,这里可以用 Map 根据 sessionId 存储多个实例)
const messageHistory = new InMemoryChatMessageHistory();
该实例用于在内存中存储单次会话的聊天消息,会自动记录后续添加的人类与 AI 消息,并可随时返回完整历史。
但请注意:
- 它不识别会话 ID,仅适用于单一用户或演示场景;
- 若需支持多用户或多会话,应为每个
sessionId创建独立的InMemoryChatMessageHistory实例; - 它不会自动裁剪历史长度,上下文窗口控制需由上层组件(如
BufferWindowMemory或自定义逻辑)处理。
5. 核心:使用 RunnableWithMessageHistory 包装
const chain = new RunnableWithMessageHistory({
runnable, // 原始的无状态 Chain(例如 prompt + LLM)
getMessageHistory: async (sessionId) => {
// 根据会话 ID 返回对应的聊天历史实例
// 此处为简化示例,所有会话共用同一个 history 实例(仅适用于单用户场景)
return messageHistory;
},
inputMessagesKey: 'input', // 指定用户当前输入在调用参数中的字段名,需与 prompt 中的 {input} 一致
historyMessagesKey: 'history', // 指定历史消息在 prompt 中的占位符名称,对应 {history}
});
RunnableWithMessageHistory 的作用是为原始 Chain 自动注入对话历史能力,其工作流程如下:
-
- 接收原始 Chain:包装一个原本不感知上下文的
runnable,使其具备记忆功能。
- 接收原始 Chain:包装一个原本不感知上下文的
-
- 按会话隔离历史:通过
getMessageHistory(sessionId)动态获取与当前会话绑定的ChatMessageHistory实例。
- 按会话隔离历史:通过
-
- 注入用户输入:将调用时传入的
{ input: "..." }映射到 prompt 中的{input}占位符。
- 注入用户输入:将调用时传入的
-
- 自动填充历史:将
getMessageHistory返回的历史消息列表,作为{history}注入 prompt 的placeholder位置,并在调用后自动追加新交互到历史中。
- 自动填充历史:将
💡 注意:若所有会话共用同一个
messageHistory实例(如上例),会导致多用户对话内容相互污染。生产环境中应为每个sessionId创建独立的历史实例。
6. 开始带记忆的对话
console.log("--- 第一轮对话 ---");
const res1 = await chain.invoke(
{ input: '我叫陈汉升,喜欢喝茅台' },
{ configurable: { sessionId: 'makefriend' } } // 必须传入 sessionId
);
console.log("AI:", res1.content);
console.log("\n--- 第二轮对话 ---");
const res2 = await chain.invoke(
{ input: '我叫什么名字' },
{ configurable: { sessionId: 'makefriend' } }
);
console.log("AI:", res2.content);
configurable:是 LangChain 的Runnable接口约定的一个配置字段,用于传入运行时可变的上下文参数(不会作为模型输入,但会影响执行逻辑)。sessionId: 'makefriend':在这里指定了当前对话的会话 ID,值为'makefriend'。
它告诉系统:当前请求属于哪个会话(session) ,从而决定应从何处加载对应的对话历史。
就像在通义千问中点击“新建对话”一样——每个对话拥有独立的历史上下文。
因此,即使用户提出相同的问题,在不同会话中,模型的回答也可能完全不同,因为它所依据的历史记忆是隔离的。
关键点解析(语法与用处):
A. ChatPromptTemplate 中的 placeholder
在定义模板时,我们使用了 ['placeholder', "{history}"]。这就像是在 Prompt 里挖了个坑,LangChain 稍后会自动把历史消息转换成 Message 对象填进去。如果不写这个,模型就拿不到上下文。
B. RunnableWithMessageHistory 的配置项
runnable: 你的核心逻辑(Prompt + LLM)。getMessageHistory: 这是一个异步函数。在多用户场景下,你可以通过sessionId去 Redis 或数据库里查询该用户专属的消息记录。inputMessagesKey: 告诉包装器,哪个字段是用户当前输入的内容。historyMessagesKey: 告诉包装器,要把历史记录注入到 Prompt 的哪个变量里。
五、 为什么这套方案更专业?
看到这里,你可能会问:“我不就是想实现个记忆吗,至于写这么多代码吗?”
其实,这种做法有三个巨大的优势:
-
解耦存储与逻辑:
你可以随时把 InMemoryChatMessageHistory 换成 RedisChatMessageHistory,而不需要修改一行 runnable 的逻辑。
-
自动化管理:
你不需要手动 push 消息到数组里,RunnableWithMessageHistory 在 invoke 结束时会自动帮你完成存储动作。
-
支持多会话隔离:
通过 sessionId,你可以同时处理成千上万个用户的聊天,而不会发生张冠李戴的情况。
六、 总结与进阶建议
今天我们通过对比无状态调用与 LangChain 记忆组件,实现了让 AI 记住用户名字的功能。总结一下:
InMemoryChatMessageHistory是消息的“仓库”。RunnableWithMessageHistory是消息的“搬运工”兼“调度员”。- 通过
sessionId实现多会话管理。