深入浅出 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 应用注入记忆能力,打造出真正智能的对话系统。


相关推荐
DogDaoDao4 分钟前
【GitHub】Hermes Agent 深度技术分析
程序员·大模型·github·ai编程·ai agent·智能体·hermers agent
ANnianStriver31 分钟前
PetLumina 03 — 后端目录重构与 Web 管理后台搭建
java·前端·ai·重构·ai编程·claude code
ANnianStriver1 小时前
PetLumina 04 — 管理后台 UI 全面升级
java·ui·ai编程
winlife_1 小时前
全程用 AI 做一款商业级手游 · EP9 收尾与复盘:做到了哪,没做到哪,边界在哪
java·开发语言·人工智能·unity·ai编程·游戏开发·mcp
JAVA9651 小时前
JAVA面试-并发篇 09-LockSupport 和 waitnotify 的区别
java·开发语言·面试
蝎子莱莱爱打怪1 小时前
XZLL-IM干货系列 01|万字拆解分布式 IM 架构:7 个微服务 + 自研 Flutter SDK
java·后端·面试
沉默王二1 小时前
阿里云 OCR+LiteParse,让扫描件 PDF 也能被 RAG 检索到!
github·agent·ai编程
li-xun1 小时前
2026年6月8日博客精选
人工智能·ai·ai编程·每日阅读
ShineWinsu1 小时前
对于Linux:内核是如何组织管理IPC资源的解析
linux·服务器·c++·面试·笔试·线程·ipc
爱码猿1 小时前
后端开发规范SKILL
ai编程