赋予大模型“记忆”:深度解析 LangChain 中 LLM 的上下文记忆实现

引言

"我叫 Agiao,喜欢喝白兰地。"

------ 你说完这句话,转身离开。

再回来问:"我叫什么名字?"

如果 AI 回答"我不知道",那它只是个无状态的 API;

如果它回答"Agiao",那它就拥有了记忆

在人工智能的世界里,"记忆"不是与生俱来的天赋,而是一种精心设计的工程能力。大型语言模型(LLM)本身是无状态的------每一次调用都像一场全新的对话,模型对过去一无所知。然而,现实中的智能交互必须具备上下文连贯性。如何让 LLM "记住你"?这正是我们今天要深入剖析的核心问题。

本文将逐行、逐模块、逐 API 地解析 1.js 文件,揭示 LangChain 如何通过 对话历史管理机制 赋予 LLM 真正的记忆能力。我们将不仅解释"怎么做",更要阐明"为什么这么做",并对比无记忆版本(index.js)的局限,最终构建一个完整、生动、技术扎实的认知图景。


背景:为什么 LLM 默认没有记忆?

首先,让我们回到基础。

LLM API 调用和 http 请求一样,都是无状态的

这意味着每次你向模型发送一条消息(例如 "我叫Agiao"),模型仅基于这条消息生成回复,不会自动保留任何上下文。如果你紧接着再发一条 "我叫什么名字?",模型根本不知道前一条消息的存在。

我们来看 index.js 的实现:

javascript 复制代码
import {ChatDeepSeek} from '@langchain/deepseek'
import 'dotenv/config'

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

// http api 请求
const res = await model.invoke('我叫Agiao,喜欢喝白兰地')
console.log(res.content);

const res2 = await model.invoke('我叫什么名字')
console.log(res2.content);

这段代码做了两件事:

  1. 第一次调用 model.invoke(...),告诉模型你的名字和喜好。
  2. 第二次独立调用 model.invoke(...),询问自己的名字。

由于两次调用之间没有任何信息传递,第二次请求对模型而言完全是孤立的。因此,模型极大概率会回答类似:"抱歉,我不知道您叫什么名字。"------这不是模型笨,而是架构使然。

💡 关键洞察 :要实现记忆,我们必须主动维护对话历史,并在每次请求时将其作为上下文传入模型

但如何高效、结构化、可扩展地做到这一点?这就是 LangChain 的用武之地。


架构预览:LangChain 的记忆抽象

LangChain 提供了一套完整的"记忆"(Memory)模块,其核心思想是:

将对话历史视为一种可插拔的上下文源,在每次调用 LLM 前动态注入到 Prompt 中。

1.js 中,这一思想通过以下组件协同实现:

  • InMemoryChatMessageHistory:存储对话历史。
  • ChatPromptTemplate:定义包含历史占位符的提示模板。
  • RunnableWithMessageHistory:自动管理历史加载与保存的包装器。
  • ChatDeepSeek:底层 LLM 模型。

接下来,我们将逐行拆解 1.js,深入每一个 API 的设计哲学与实现细节。


逐行深度解析 1.js

第一部分:模块导入(Import Statements)

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

  • 来源:@langchain/deepseek

  • 作用:封装 DeepSeek 提供的聊天模型 API,使其符合 LangChain 的 BaseChatModel 接口。

  • 特性:

    • 支持 invoke()stream() 等标准方法。
    • 自动处理消息格式(如 HumanMessage, AIMessage)。
    • 可配置 modeltemperaturemaxTokens 等参数。

ChatPromptTemplate

  • 来源:@langchain/core/prompts

  • 作用:构建结构化的聊天提示(prompt),支持系统消息、用户消息、AI 消息及占位符。

  • 关键能力:

    • 使用 fromMessages() 静态方法从消息数组创建模板。
    • 支持变量插值(如 {input}, {history})。
    • 输出为 Runnable,可与其他组件链式组合(.pipe())。

RunnableWithMessageHistory

  • 来源:@langchain/core/runnables

  • 这是实现记忆的核心抽象!

  • 作用:包装一个普通的 Runnable(如 LLM 调用链),使其具备自动加载和保存对话历史的能力。

  • 工作原理:

    • 在每次 .invoke() 时,先从 getMessageHistory 获取当前会话的历史。
    • 将历史注入到输入中(通过 historyMessagesKey 指定的字段)。
    • 调用内部 runnable
    • 将用户输入和模型输出自动追加到历史记录中。

⚠️ 注意:RunnableWithMessageHistory 并不关心历史如何存储(内存、数据库、Redis),它只依赖 getMessageHistory 函数返回一个实现了 BaseChatMessageHistory 接口的对象。

InMemoryChatMessageHistory

  • 来源:@langchain/core/chat_history

  • 作用:最简单的对话历史存储实现,将所有消息保存在 JavaScript 内存数组中。

  • 接口方法:

    • getMessages():返回当前所有消息。
    • addMessage(message):添加一条新消息。
    • clear():清空历史。
  • 适用场景:单会话演示、测试。不适合生产环境多用户场景(因为所有会话共享同一实例,且进程重启后丢失)。

dotenv/config

  • 加载 .env 文件中的环境变量(如 DEEPSEEK_API_KEY),确保模型认证信息安全。

第二部分:模型初始化

ini 复制代码
const model = new ChatDeepSeek({ model: 'deepseek-chat', temperature: 0});
  • model: 'deepseek-chat':指定使用 DeepSeek 的聊天专用模型。
  • temperature: 0:关闭随机性,确保相同输入总是产生相同输出------这对需要精确记忆的场景至关重要(避免因随机性导致"忘记")。

第三部分:构建带记忆的 Prompt 模板

css 复制代码
const prompt = ChatPromptTemplate.fromMessages([  ['system', "你是一个有记忆的助手"],
  ['placeholder', "{history}"],
  ['human', "{input}"]
])

这是整个记忆机制的提示工程核心

消息结构解析:

  1. ['system', "你是一个有记忆的助手"]

    • 系统消息,用于设定 AI 的角色和行为准则。
    • 告诉模型:"你有能力记住之前的对话,请利用这些信息。"
  2. ['placeholder', "{history}"]

    • 关键! 这不是一个普通字符串,而是一个占位符指令

    • LangChain 在渲染此模板时,会查找输入对象中的 history 字段。

    • 如果 history 是一个消息数组(如 [HumanMessage, AIMessage, ...]),它会自动展开为多条独立消息,插入到此处。

    • 例如,若历史为:

      arduino 复制代码
      [
        new HumanMessage("我叫Agiao"),
        new AIMessage("好的,Agiao!")
      ]

      则最终 prompt 变为:

      css 复制代码
      [  SystemMessage("你是一个有记忆的助手"),  HumanMessage("我叫Agiao"),  AIMessage("好的,Agiao!"),  HumanMessage("{input}")]
  3. ['human', "{input}"]

    • 当前用户输入,通过变量 input 注入。

💡 为什么用 placeholder 而不是直接写 {history} 字符串?

因为 {history} 实际上是一个消息列表 ,而非纯文本。placeholder 告诉 LangChain:"这里要插入一个消息序列,而不是拼接字符串"。


第四部分:构建可运行链(Runnable Chain)

javascript 复制代码
const runnable = prompt
  .pipe((input) => {
    // debug 节点
    console.log(">>> 最终传给模型的信息(Prompt 内存)");
    console.log(input)
    return input;
  })
  .pipe(model);

LangChain 的核心思想是"一切皆可运行(Runnable)"。这里我们构建了一个处理流水线:

  1. prompt :首先应用提示模板,将 {input}{history} 替换为实际内容,生成完整的消息列表。
  2. 调试中间件:打印最终传给模型的消息结构。这对于理解记忆如何工作至关重要。
  3. model:将构造好的消息列表发送给 DeepSeek 模型。

📌 注意:此时的 runnable 仍然不具备记忆能力 !它只是一个"知道如何组织消息"的函数。真正的记忆由外层的 RunnableWithMessageHistory 注入。


第五部分:创建对话历史存储

ini 复制代码
const messageHistory = new InMemoryChatMessageHistory();
  • 创建一个空的内存历史容器。
  • 所有后续对话(用户输入 + AI 回复)都将被自动追加到这里。
  • 由于是全局变量,所有使用该 messageHistory 的会话将共享同一历史(在多用户场景中需按 session ID 分离)。

第六部分:注入记忆能力 ------ RunnableWithMessageHistory

dart 复制代码
const chain = new RunnableWithMessageHistory({
  runnable,
  getMessageHistory: async () => messageHistory,
  inputMessagesKey: 'input',
  historyMessagesKey: 'history',
});

这是整段代码的灵魂所在。让我们逐个参数详解:

🔸 runnable

  • 要包装的原始可运行对象(即前面构建的 prompt → debug → model 链)。
  • RunnableWithMessageHistory 会在调用它之前注入历史,在之后保存新消息。

🔸 getMessageHistory: async () => messageHistory

  • 一个异步函数,返回当前会话对应的 BaseChatMessageHistory 实例。

  • 在本例中,我们直接返回全局的 messageHistory

  • 在真实应用中,这里通常会根据 sessionId 从数据库或缓存中加载对应的历史。例如:

    vbnet 复制代码
    getMessageHistory: async (sessionId) => {
      return new RedisChatMessageHistory(sessionId, redisClient);
    }

🔸 inputMessagesKey: 'input'

  • 指定用户当前输入在调用参数中的字段名。
  • 对应后续的 chain.invoke({ input: '...' })
  • LangChain 会从此字段读取用户消息,并在调用完成后将其存入历史。

🔸 historyMessagesKey: 'hitory'

  • 指定历史消息在输入对象中的字段名。
  • LangChain 会从 getMessageHistory() 获取历史消息,并以 { history: [msg1, msg2, ...] } 的形式注入到 runnable 的输入中。
  • 这个字段名必须与 ChatPromptTemplate 中的占位符 {history} 一致!

工作流程总结

  1. 用户调用 chain.invoke({ input: "..." }, { configurable: { sessionId: "..." } })
  2. RunnableWithMessageHistory 调用 getMessageHistory(sessionId) 获取历史。
  3. 构造输入对象:{ input: "...", history: [prev messages] }
  4. 调用内部 runnable(input)
  5. 获取模型回复。
  6. HumanMessage(input)AIMessage(response) 自动追加到历史中。

第七部分:执行多轮对话

css 复制代码
const res1 = await chain.invoke(
  { input: '我叫Agiao,喜欢喝白兰地' },
  { configurable: { sessionId: 'makefriend' } }
)
console.log(res1.content);

const res2 = await chain.invoke(
  { input: '我叫什么名字' },
  { configurable: { sessionId: 'makefriend' } }
)
console.log(res2.content);

🔹 第一次调用

  • 输入:{ input: '我叫Agiao,喜欢喝白兰地' }

  • 此时 messageHistory 为空,所以 {history} 占位符被替换为空数组。

  • 最终 prompt:

    css 复制代码
    [  SystemMessage("你是一个有记忆的助手"),  HumanMessage("我叫Agiao,喜欢喝白兰地")]
  • 模型回复后,messageHistory 自动更新为:

    css 复制代码
    [  HumanMessage("我叫Agiao,喜欢喝白兰地"),  AIMessage("好的,Agiao!白兰地是种很棒的选择。")]

🔹 第二次调用

  • 输入:{ input: '我叫什么名字' }

  • messageHistory 已包含两条消息。

  • 最终 prompt:

    css 复制代码
    [  SystemMessage("你是一个有记忆的助手"),  HumanMessage("我叫Agiao,喜欢喝白兰地"),  AIMessage("好的,Agiao!白兰地是种很棒的选择。"),  HumanMessage("我叫什么名字")]
  • 模型看到完整上下文,自然能回答:"你叫 Agiao。"

🎯 关键优势:开发者无需手动管理历史拼接、消息格式转换、存储逻辑------全部由 LangChain 自动处理。


调试输出:见证记忆的传递

中间的调试节点会打印:

第一次调用后:

css 复制代码
>>> 最终传给模型的信息(Prompt 内存)
{
  messages: [
    SystemMessage { content: "你是一个有记忆的助手" },
    HumanMessage { content: "我叫Agiao,喜欢喝白兰地" }
  ]
}

第二次调用后:

css 复制代码
>>> 最终传给模型的信息(Prompt 内存)
{
  messages: [
    SystemMessage { content: "你是一个有记忆的助手" },
    HumanMessage { content: "我叫Agiao,喜欢喝白兰地" },
    AIMessage { content: "好的,Agiao!白兰地是种很棒的选择。" },
    HumanMessage { content: "我叫什么名字" }
  ]
}

这清晰展示了历史如何被逐步累积并注入上下文


对比:有记忆 vs 无记忆

特性 index.js(无记忆) 1.js(有记忆)
状态管理 自动维护对话历史
上下文传递 手动拼接(未实现) 自动注入 {history}
多轮连贯性 ❌ 不支持 ✅ 完美支持
Token 效率 每次独立,无冗余 历史随对话增长,需注意长度
扩展性 难以扩展 易替换存储后端(Redis/DB)
代码复杂度 简单但功能有限 稍复杂但功能强大

潜在挑战与优化方向

虽然 1.js 展示了记忆的基本实现,但在生产环境中还需考虑:

1. Token 长度限制

  • 对话历史不断增长,可能超出模型上下文窗口(如 32768 tokens)。

  • 解决方案

    • 使用 WindowChatMessageHistory 仅保留最近 N 条消息。
    • 实现摘要机制:定期将历史压缩为摘要,并作为系统消息注入。

2. 多用户会话隔离

  • 当前 messageHistory 是全局单例,所有用户共享。

  • 解决方案

    • getMessageHistory 中根据 sessionId 返回不同历史实例。
    • 使用 RedisChatMessageHistory 或数据库按 session 存储。

3. 持久化

  • 内存存储在服务重启后丢失。
  • 解决方案:集成持久化存储(如 PostgreSQL、MongoDB)。

4. 长期记忆(Long-term Memory)

  • 对话历史属于短期记忆。
  • 解决方案:结合向量数据库(如 Pinecone)实现基于检索的长期记忆(RAG + Memory)。

结语:记忆,是智能的基石

通过 1.js,我们不仅学会了如何用 LangChain 实现 LLM 的记忆,更理解了其背后的设计哲学:将状态管理与核心逻辑解耦,通过组合式 API 构建可扩展的智能应用

记忆不是魔法,而是一系列精心设计的数据流:

用户输入 → 加载历史 → 注入上下文 → 调用模型 → 保存新状态。

当你下次对 AI 说"还记得我吗?",希望它能微笑着回答:"当然,Agiao。你上次说喜欢白兰地,要不要再来一杯?"

🥂 Cheers to intelligent conversations with memory!


附:完整代码链接: lesson_zp/ai/langchain/memory/demo: AI + 全栈学习仓库

在该项目根目录中需添加 .env 文件,文件中添加DeepSeek大模型的API_KEY

例如:

DEEPSEEK_API_KEY=sk-XXXXXXXXXXXXXXXXXXXXXXXXXXXXX

API_KEY获取地址:DeepSeek 开放平台

相关推荐
KoalaShane2 小时前
Web 3D设计[Three.js]关于右键点击Canvas旋转模型,在其他元素上触发右键菜单问题
前端·javascript·3d
张清悠2 小时前
CSS引入外部第三方字体
前端·javascript·css
追逐梦想之路_随笔2 小时前
手撕Promise,实现then|catch|finally|all|allSettled|race|any|try|resolve|reject等方法
前端·javascript
Tzarevich2 小时前
Tailwind CSS:原子化 CSS 的现代开发实践
前端·javascript·css
微爱帮监所写信寄信2 小时前
微爱帮监狱寄信写信小程序:深入理解JavaScript中的Symbol特性
开发语言·javascript·网络协议·小程序·监狱寄信·微爱帮
神秘的猪头2 小时前
LangChain Tool 实战:让大模型“长出双手”,通过 Tool 调用连接真实世界
langchain·node.js·aigc
前端小臻2 小时前
RustFs 前端开发
javascript·vue.js·rustfs
syt_10132 小时前
js基础之-如何理解js中一切皆对象的说法
开发语言·javascript·原型模式
十五0012 小时前
若依集成微软单点登录(SSO)
javascript·microsoft