LLM 本身是无状态的,但借助 LangChain 的 Memory 模块,我们可以轻松为 AI 应用注入记忆能力。本文将通过两个实战案例,逐行解析代码,带你彻底搞懂 LangChain 中的对话记忆机制。
一、LLM 的无状态之痛
当我们调用大语言模型(LLM)的 API 时,每次请求本质上和 HTTP 请求一样,都是无状态的。模型不会记住你上一轮说了什么,也不会知道你的名字、喜好等上下文信息。
javascript
// index.js 中的无状态调用示例
const res = await model.invoke('我叫陈昊,喜欢喝白兰地');
console.log(res.content); // 模型可能回答:"你好陈昊,很高兴认识你..."
const res2 = await model.invoke('我叫什么名字');
console.log(res2.content); // 模型会茫然回答:"抱歉,我不知道你的名字。"
这就带来了一个核心问题:如何让 LLM 拥有记忆?
最朴素的方法就是维护一个对话历史记录,每次调用 LLM 时都将历史记录带上,就像下面这样:
javascript
messages = [
{ role: 'user', content: '我叫陈昊,喜欢喝白兰地' },
{ role: 'assistant', content: '好的,我记住了陈昊喜欢喝白兰地。' },
{ role: 'user', content: '你知道我是谁吗?' }
]
但这种方法有一个明显的副作用:历史记录会像滚雪球一样越滚越大,Token 开销会急剧增加,而且每次请求都要重复发送完整的历史消息,效率低下。
LangChain 作为 LLM 应用开发的主流框架,提供了一套优雅的 Memory 解决方案。下面我们就通过两个完整的代码示例,看看如何使用 LangChain 为 DeepSeek 模型添加记忆能力。
二、环境准备与项目结构
首先,我们需要安装必要的依赖(以 pnpm 为例):
bash
pnpm add @langchain/core @langchain/deepseek dotenv langchain
项目结构如下:
bash
.
├── index.js # 无状态调用示例
├── 1.js # 带记忆的多轮对话示例
├── readme.md # 学习笔记
├── package.json
└── .env # 存放 DEEPSEEK_API_KEY
记得在 .env 文件中配置你的 DeepSeek API Key:
env
DEEPSEEK_API_KEY=sk-xxxxxxxx
项目完整链接:gitee.com/hong-strong...
三、无记忆模式:index.js 逐行解析
先来看一个没有记忆的简单例子,感受一下"无状态"带来的尴尬。
javascript
// 1. 导入 ChatDeepSeek 类
import { ChatDeepSeek } from '@langchain/deepseek';
// 2. 导入 dotenv 读取环境变量
import 'dotenv/config';
// 3. 创建模型实例
const model = new ChatDeepSeek({
model: 'deepseek-chat', // 使用 DeepSeek 对话模型
temperature: 0 // 温度设为 0,让输出更确定
});
// 4. 第一次调用:告诉模型自己的名字和爱好
const res = await model.invoke('我叫张三,喜欢喝白兰地');
console.log(res.content);
console.log('-----------------');
// 5. 第二次调用:询问自己的名字
const res2 = await model.invoke('我叫什么名字');
console.log(res2.content);
逐行解析
- 第 1-2 行 :导入 LangChain 的 DeepSeek 集成模块,以及
dotenv用于加载环境变量。 - 第 5-8 行 :使用
ChatDeepSeek类创建一个模型实例。temperature: 0表示输出的随机性最低,适合需要精确回答的场景。 - 第 11 行 :
model.invoke()方法发送一条用户消息给 DeepSeek API,并返回一个AIMessage对象,其content属性包含了模型的回复。 - 第 15 行 :第二次调用
invoke,发送了一个全新的、没有任何上下文的问题。
运行结果(示例):
diff
我叫张三,喜欢喝白兰地?很高兴认识你,张三!白兰地是一种很优雅的饮品。
-----------------
抱歉,我不知道你的名字。如果你愿意告诉我,我会很高兴记住它。
可以看到,第二次回答时模型已经完全"失忆"了。这就是无状态 API 的典型表现。
四、LangChain Memory 核心概念
在深入解析带记忆的代码之前,先来熟悉几个 LangChain 中与 Memory 相关的核心组件:
| 组件 | 作用 | 说明 |
|---|---|---|
BaseChatMessageHistory |
消息历史记录的抽象基类 | 定义了保存、读取、清除消息的标准接口 |
InMemoryChatMessageHistory |
内存中的消息历史实现 | 将对话记录存储在 RAM 中,适合开发测试 |
RunnableWithMessageHistory |
带历史记录的 Runnable 包装器 | 包装一个普通的 Runnable,自动管理消息历史的加载和保存 |
ChatPromptTemplate |
对话提示词模板 | 可以包含 {history} 占位符,自动注入历史消息 |
其中,RunnableWithMessageHistory 是 LangChain 实现记忆的核心机制。它像一个智能代理,在你每次调用 Runnable 之前,自动从存储中加载历史消息,拼接到提示词中;调用结束后,再将新的对话追加到存储中。
五、记忆模式:1.js 逐行解析
现在来看 1.js,这个文件展示了如何使用 LangChain 让 DeepSeek 模型拥有跨轮次记忆。
代码全景
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';
const model = new ChatDeepSeek({
model: 'deepseek-chat',
temperature: 0
});
// 定义提示词模板,其中 {history} 将被历史消息替换
const prompt = ChatPromptTemplate.fromMessages([
['system', "你是一个有记忆的助手"],
['placeholder', "{history}"],
['human', "{input}"]
])
// 创建一个管道:先处理提示词,再调用模型
const runnable = prompt
.pipe((input) => {
console.log(">>> 最终传给模型的信息(Prompt 内存)");
console.log(input);
return input;
})
.pipe(model);
// 创建内存消息历史存储实例
const messageHistory = new InMemoryChatMessageHistory();
// 使用 RunnableWithMessageHistory 包装 runnable,使其具备记忆能力
const chain = new RunnableWithMessageHistory({
runnable,
getMessageHistory: async () => messageHistory,
inputMessagesKey: 'input',
historyMessagesKey: 'history',
});
// 第一轮对话
const res1 = await chain.invoke(
{ input: '我叫张三,喜欢喝白兰地' },
{ configurable: { sessionId: 'makefriend' } }
)
console.log(res1.content);
// 第二轮对话
const res2 = await chain.invoke(
{ input: '我叫什么名字' },
{ configurable: { sessionId: 'makefriend' } }
)
console.log(res2.content);
逐行深度解析
1. 导入模块(第 1-12 行)
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';
ChatPromptTemplate:用于构建结构化的提示词,支持系统消息、历史消息占位符、用户输入等。RunnableWithMessageHistory:LangChain 提供的可运行对象增强器 ,它接收一个普通的Runnable和一个getMessageHistory函数,返回一个具备自动记忆管理能力的新Runnable。InMemoryChatMessageHistory:一个简单的内存存储实现,实现了BaseChatMessageHistory接口。生产环境中可以替换为 Redis、PostgreSQL 等持久化存储。
2. 创建提示词模板(第 16-20 行)
javascript
const prompt = ChatPromptTemplate.fromMessages([
['system', "你是一个有记忆的助手"],
['placeholder', "{history}"],
['human', "{input}"]
])
LangChain 的提示词模板使用了一种简洁的元组格式:每个元组的第一个元素是角色(system, human, ai, placeholder),第二个元素是内容。
['system', ...]:设置系统提示,定义助手的行为准则。['placeholder', "{history}"]:这是一个占位符 ,RunnableWithMessageHistory会自动将历史消息序列化后插入到这个位置。['human', "{input}"]:表示用户的实时输入,变量名为input。
3. 构建 Runnable 管道(第 22-28 行)
javascript
const runnable = prompt
.pipe((input) => {
console.log(">>> 最终传给模型的信息(Prompt 内存)");
console.log(input);
return input;
})
.pipe(model);
prompt.pipe():将提示词模板与后续处理连接起来。LangChain 的Runnable支持链式调用(类似 Node.js 的 stream.pipe)。- 中间的
(input) => {...}是一个调试节点 ,它不会改变数据,只是打印出最终传给模型的完整消息结构。从打印的内容可以看到,{history}已经被替换成了真正的历史消息数组。 - 最后
pipe(model)表示将格式化后的提示词输入给ChatDeepSeek模型实例。
4. 创建消息历史存储(第 30 行)
javascript
const messageHistory = new InMemoryChatMessageHistory();
这个对象内部维护了一个 messages 数组,并提供 addUserMessage(), addAIMessage(), getMessages() 等方法。因为它是纯内存的,所以应用重启后历史会丢失。生产环境建议使用 RedisChatMessageHistory 或 PostgresChatMessageHistory。
5. 包装为带记忆的链(第 33-37 行)
javascript
const chain = new RunnableWithMessageHistory({
runnable,
getMessageHistory: async () => messageHistory,
inputMessagesKey: 'input',
historyMessagesKey: 'history',
});
runnable:我们之前构建的提示词 → 调试 → 模型的管道。getMessageHistory:一个异步函数,根据配置(如sessionId)返回对应的BaseChatMessageHistory实例。这里我们简单地返回了同一个messageHistory。inputMessagesKey:指定在调用chain.invoke()时,传入的参数对象中哪个字段代表用户的新输入。这里对应{ input: '...' }中的input。historyMessagesKey:指定在提示词模板中,哪个变量名用于接收历史消息。对应我们在模板中写的{history}占位符。
注意:
RunnableWithMessageHistory不仅会自动注入历史,还会在模型返回后,自动将本轮的用户消息和 AI 回复追加到messageHistory中。开发者完全不需要手动管理。
6. 第一次对话(第 40-45 行)
javascript
const res1 = await chain.invoke(
{ input: '我叫张三,喜欢喝白兰地' },
{ configurable: { sessionId: 'makefriend' } }
)
console.log(res1.content);
invoke的第一个参数是输入数据 ,它需要包含inputMessagesKey指定的字段(这里是input)。- 第二个参数是调用配置 ,其中
configurable.sessionId用于区分不同会话。LangChain 会把这个sessionId传给getMessageHistory函数,这样同一个sessionId的多轮对话共享同一份历史记录。 - 由于这是第一轮,
messageHistory为空,所以模板中的{history}被替换为空。模型只看到系统提示和用户输入,然后返回回答。
7. 第二次对话(第 47-52 行)
javascript
const res2 = await chain.invoke(
{ input: '我叫什么名字' },
{ configurable: { sessionId: 'makefriend' } }
)
console.log(res2.content);
第二次调用时,RunnableWithMessageHistory 会先通过 sessionId 找到之前的 messageHistory,读取其中的所有消息(包含第一轮的用户消息和 AI 回复),然后按照模板格式拼接到提示词中。模型就能看到完整的对话历史,因此可以正确回答"你叫陈昊"。
运行结果(示例):
css
>>> 最终传给模型的信息(Prompt 内存)
{
messages: [
['system', '你是一个有记忆的助手'],
['human', '我叫张三,喜欢喝白兰地']
]
}
好的张三。我记住了你喜欢喝白兰地。
>>> 最终传给模型的信息(Prompt 内存)
{
messages: [
['system', '你是一个有记忆的助手'],
['human', '我叫张三,喜欢喝白兰地'],
['ai', '好的,张三。我记住了你喜欢喝白兰地。'],
['human', '我叫什么名字']
]
}
你叫张三,你喜欢喝白兰地。
可以看到第二次调用时,打印出的 messages 数组中已经包含了完整的历史对话。这就是 LangChain Memory 的魔力所在!
六、LangChain Memory 架构与扩展
6.1 消息历史存储的接口设计
BaseChatMessageHistory 是一个抽象基类,定义了四个核心方法:
| 方法 | 作用 |
|---|---|
addUserMessage(content) |
添加一条用户消息 |
addAIMessage(content) |
添加一条 AI 回复消息 |
getMessages() |
获取全部历史消息(返回 BaseMessage[]) |
clear() |
清空历史 |
LangChain 官方提供了多种实现,可以直接用于生产环境:
| 实现类 | 存储后端 | 适用场景 |
|---|---|---|
InMemoryChatMessageHistory |
内存 | 开发测试、单次会话 |
RedisChatMessageHistory |
Redis | 分布式应用、需要持久化 |
PostgresChatMessageHistory |
PostgreSQL | 需要关系型数据库存储 |
MongoDBChatMessageHistory |
MongoDB | 文档型存储 |
FileChatMessageHistory |
本地文件 | 简单场景、Demo |
切换存储方式非常简单,例如改用 Redis:
javascript
import { RedisChatMessageHistory } from '@langchain/redis';
const messageHistory = new RedisChatMessageHistory({
sessionId: 'makefriend',
config: { url: 'redis://localhost:6379' }
});
其他代码完全不需要修改 ,因为 RedisChatMessageHistory 实现了相同的接口。
6.2 RunnableWithMessageHistory 的工作流程
下图展示了 RunnableWithMessageHistory 的内部处理逻辑(文字版):
- 调用前 :根据
sessionId从存储中加载历史消息。 - 构建输入 :将历史消息序列化后放入提示词模板的
{history}位置,同时将用户的{input}放入模板。 - 执行 Runnable:将完整的提示词传递给模型。
- 调用后 :将本轮的用户输入(
input)和模型的输出(output)分别通过addUserMessage和addAIMessage追加到历史存储中。 - 返回结果:将模型的输出返回给调用者。
这样,开发者只需要关注业务逻辑(提示词设计和模型调用),记忆的存取完全由框架自动完成。
6.3 Token 膨胀问题与解决方案
正如 readme.md 中提到的,维护历史记录会导致 Token 开销像滚雪球一样增长。想象一下,如果用户和助手聊了 100 轮,每次请求都要发送这 100 轮的完整消息,成本会迅速飙升。
LangChain 提供了几种缓解策略:
| 策略 | 实现方式 | 原理 |
|---|---|---|
| 消息裁剪 | trimMessages |
只保留最近 N 条消息,丢弃过旧的内容 |
| 总结压缩 | ConversationSummaryMemory |
将历史对话实时总结为一段摘要,用摘要代替原始消息 |
| 缓冲区 | BufferMemory |
保留最后 K 轮对话 |
| 向量存储检索 | VectorStoreRetrieverMemory |
将历史消息向量化,每次只检索与当前问题最相关的几条 |
例如,使用消息裁剪:
javascript
import { trimMessages } from '@langchain/core/messages';
const trimmedHistory = await trimMessages(messages, {
maxTokens: 2000,
strategy: 'last', // 保留最后的消息
tokenCounter: model.tokenizer
});
如果需要更精细的控制,可以使用 ConversationSummaryBufferMemory,它结合了缓冲区与总结:保留最近几轮原始消息,更早的消息被压缩成摘要。
七、LangChain Memory 最佳实践
7.1 为不同会话隔离记忆
在生产环境中,我们需要为每个用户/每个对话 session 维护独立的记忆。RunnableWithMessageHistory 通过 sessionId 完美支持这一点:
javascript
const chain = new RunnableWithMessageHistory({
// ... 其他配置
getMessageHistory: async (config) => {
const sessionId = config.configurable.sessionId;
// 根据 sessionId 返回对应的历史存储实例
return new RedisChatMessageHistory({ sessionId });
}
});
// 用户 A 的会话
await chain.invoke({ input: '我是小明' }, { configurable: { sessionId: 'user-a' } });
// 用户 B 的会话(完全隔离)
await chain.invoke({ input: '我是小红' }, { configurable: { sessionId: 'user-b' } });
7.2 自定义提示词格式
不同的业务场景可能需要不同的历史消息格式。LangChain 允许你完全控制 {history} 的渲染方式。例如,你想要一个更简洁的格式:
javascript
const prompt = ChatPromptTemplate.fromMessages([
['system', '你是客服助手'],
['placeholder', '{history}'],
['human', '用户问:{input}']
]);
或者你希望在历史中明确标出角色:
javascript
const prompt = ChatPromptTemplate.fromMessages([
['system', '你是一个有礼貌的助手'],
['placeholder', '{history}'],
['human', '{input}']
]);
// 框架会自动把历史消息格式化为:
// "Human: xxx\nAI: xxx\nHuman: xxx"
7.3 结合 LCEL 构建更复杂的链
LangChain 的可运行接口(LCEL)非常强大,RunnableWithMessageHistory 可以和其他组件自由组合。例如,给记忆对话加上检索增强生成(RAG):
javascript
const retriever = vectorStore.asRetriever();
const retrievalChain = RunnableMap.from({
context: retriever.pipe(formatDocs),
question: new RunnablePassthrough()
}).pipe(prompt).pipe(model);
const memoryChain = new RunnableWithMessageHistory({
runnable: retrievalChain,
getMessageHistory: async () => messageHistory,
inputMessagesKey: 'question',
historyMessagesKey: 'chat_history'
});
八、总结
| 对比维度 | 无记忆模式 (index.js) | LangChain Memory 模式 (1.js) |
|---|---|---|
| 代码复杂度 | 简单,几行代码 | 稍复杂,需配置模板和包装器 |
| 跨轮记忆 | ❌ 不支持 | ✅ 自动支持 |
| Token 开销 | 每轮独立,无累积 | 会累积,需要管理 |
| 多会话隔离 | 需手动实现 | 通过 sessionId 天然支持 |
| 存储扩展性 | 无 | 可切换 Redis/Postgres 等 |
| 适用场景 | 单轮问答、无状态 API 调用 | 客服机器人、个人助手、游戏 NPC 对话 |
核心要点回顾:
- LLM API 无状态,要实现记忆必须主动传递历史消息。
- LangChain Memory 通过
RunnableWithMessageHistory+BaseChatMessageHistory优雅地解决了记忆管理问题。 InMemoryChatMessageHistory适合开发,生产环境推荐使用RedisChatMessageHistory等持久化实现。- Token 膨胀是记忆必须面对的挑战,需要结合裁剪、总结、向量检索等策略优化。
- LCEL 的管道设计让 Memory 可以灵活嵌入到任意复杂的链中。
理解了上述原理和代码,你就能在自己的项目中轻松为 LLM 应用注入记忆能力,打造出真正智能的对话系统。