深入浅出 LangChain Memory:从无状态到有记忆的智能对话

​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() 等方法。因为它是纯内存的,所以应用重启后历史会丢失。生产环境建议使用 RedisChatMessageHistoryPostgresChatMessageHistory

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 的内部处理逻辑(文字版):

  1. 调用前 :根据 sessionId 从存储中加载历史消息。
  2. 构建输入 :将历史消息序列化后放入提示词模板的 {history} 位置,同时将用户的 {input} 放入模板。
  3. 执行 Runnable:将完整的提示词传递给模型。
  4. 调用后 :将本轮的用户输入(input)和模型的输出(output)分别通过 addUserMessageaddAIMessage 追加到历史存储中。
  5. 返回结果:将模型的输出返回给调用者。

这样,开发者只需要关注业务逻辑(提示词设计和模型调用),记忆的存取完全由框架自动完成。

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 对话

核心要点回顾:

  1. LLM API 无状态,要实现记忆必须主动传递历史消息。
  2. LangChain Memory 通过 RunnableWithMessageHistory + BaseChatMessageHistory 优雅地解决了记忆管理问题。
  3. InMemoryChatMessageHistory 适合开发,生产环境推荐使用 RedisChatMessageHistory 等持久化实现。
  4. Token 膨胀是记忆必须面对的挑战,需要结合裁剪、总结、向量检索等策略优化。
  5. LCEL 的管道设计让 Memory 可以灵活嵌入到任意复杂的链中。

理解了上述原理和代码,你就能在自己的项目中轻松为 LLM 应用注入记忆能力,打造出真正智能的对话系统。


相关推荐
怪祝浙8 小时前
AI实战之LangChain开发(prompt;tools;memory)
langchain
:1218 小时前
java面试
java·开发语言·面试
古怪今人8 小时前
大语言模型运行工具及格式 Ollama操作大模型 LangChain应用开发框架【2026】
人工智能·语言模型·langchain
leory8 小时前
什么是ANR?怎么样分析ANR?常见ANR场景及解决方案?
面试
han_9 小时前
如何寻找、安装和管理 AI Skill?
人工智能·ai编程·claude
政采云技术9 小时前
如何从0到1开发一个AI Agent
agent·ai编程
夜雪闻竹9 小时前
Cursor 的 state.vscdb 解析踩坑记
json·aigc·ai编程
财经资讯数据_灵砚智能9 小时前
基于全球经济类多源新闻的NLP情感分析与数据可视化(日间)2026年5月18日
人工智能·python·信息可视化·自然语言处理·ai编程
鹏程十八少9 小时前
Android 无障碍服务失效,一次AccessibilityService“离奇死亡”的完整破案实录
前端·后端·面试