别让 AI 刚聊完就忘了你:手把手教你在 LangChain 中实现 LLM 记忆

80 阅读8分钟

大家好,我是你们的 AI 开发探路者!

不知道大家在刚接触大模型 API 开发时,有没有遇到过这种“渣男/渣女”行为:你刚深情款款地自我介绍:“你好,我叫陈汉升,我最喜欢喝茅台。” AI 回复得挺客气:“你好汉升,久仰大名。”

结果你紧接着问一句:“我叫什么名字?”

它居然回你:“对不起,我不知道您的名字,请问怎么称呼?”

那一刻,是不是感觉所有的感情都终究是错付了?别急着摔键盘,今天我们就来深度解析 LangChain 如何通过 RunnableWithMessageHistoryInMemoryChatMessageHistory 为无状态的 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": "我叫什么名字?"}]

但是,如果我们手动去维护这个数组,会面临几个头疼的问题:

  1. 管理繁琐:要区分不同用户的 sessionId,别让张三的记忆串到李四那去。
  2. Token 爆炸:对话长了,Token 开销巨大,怎么截断或总结?
  3. 代码耦合:业务逻辑里塞满了数组操作,太不优雅!

为了解决这些痛点,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';

导入需要的包,除了之前学过的ChatDeepSeekChatPromptTemplate还加入了这次的主角 RunnableWithMessageHistoryInMemoryChatMessageHistory,在下文会详细讲解这两兄弟,这里先简单记住前者是让工作流带着记忆执行,后者是自动存储每次对话的方便工具。

1. 初始化模型

const model = new ChatDeepSeek({
    model: 'deepseek-chat',
    temperature: 0
});

langchain让我们不用配置base-urlapi-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 自动注入对话历史能力,其工作流程如下:

    1. 接收原始 Chain:包装一个原本不感知上下文的 runnable,使其具备记忆功能。
    1. 按会话隔离历史:通过 getMessageHistory(sessionId) 动态获取与当前会话绑定的 ChatMessageHistory 实例。
    1. 注入用户输入:将调用时传入的 { input: "..." } 映射到 prompt 中的 {input} 占位符。
    1. 自动填充历史:将 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 的哪个变量里。

五、 为什么这套方案更专业?

看到这里,你可能会问:“我不就是想实现个记忆吗,至于写这么多代码吗?”

其实,这种做法有三个巨大的优势:

  1. 解耦存储与逻辑:

    你可以随时把 InMemoryChatMessageHistory 换成 RedisChatMessageHistory,而不需要修改一行 runnable 的逻辑。

  2. 自动化管理:

    你不需要手动 push 消息到数组里,RunnableWithMessageHistory 在 invoke 结束时会自动帮你完成存储动作。

  3. 支持多会话隔离:

    通过 sessionId,你可以同时处理成千上万个用户的聊天,而不会发生张冠李戴的情况。


六、 总结与进阶建议

今天我们通过对比无状态调用与 LangChain 记忆组件,实现了让 AI 记住用户名字的功能。总结一下:

  • InMemoryChatMessageHistory 是消息的“仓库”。
  • RunnableWithMessageHistory 是消息的“搬运工”兼“调度员”。
  • 通过 sessionId 实现多会话管理。