让 LLM 拥有记忆:深入理解 LangChain Memory 机制

78 阅读6分钟

在构建基于大语言模型(LLM)的对话系统时,一个核心挑战是:LLM 本身是无状态的。每一次 API 调用都像一次“全新”的对话,模型不会自动记住你上一次说了什么。这就像每次和朋友聊天,对方都完全忘了你们之前的对话内容——显然,这不是我们想要的智能助手。

那么,如何让 LLM “记住”历史对话?答案是:手动维护对话历史,并在每次请求中带上它。而 LangChain 正是为解决这一问题提供了强大的 Memory 模块。本文将结合一段实际代码,详细解析 LangChain 中如何通过 RunnableWithMessageHistory 实现带记忆的对话系统。


一、为什么 LLM 需要“记忆”?

首先,我们必须明确一个事实:

LLM 的 API 调用本质上是无状态的,就像 HTTP 请求一样。

这意味着:

  • 第一次请求:“我叫陈总,喜欢喝白兰地”
  • 第二次请求:“我叫什么名字?”

模型在处理第二次请求时,完全不知道你之前说过什么。除非你主动把第一次的对话内容也发给它。

因此,实现“记忆”的本质方法是:

messages = [
  { role: 'user', content: '我叫陈总,喜欢喝白兰地' },
  { role: 'assistant', content: '好的,陈总!' },
  { role: 'user', content: '我叫什么名字?' }
]

每次调用 LLM 时,把整个 messages 数组作为上下文传入。

但这会带来两个现实问题:

  1. 工程复杂度:你需要可靠地存储、更新、检索每轮对话的历史。

  2. 成本与性能:随着对话轮数增加,消息长度(token 数)不断增长,导致:

    • API 调用费用上升
    • 响应延迟增加
    • 可能超出模型上下文窗口限制

记忆不是免费的,它消耗计算资源 + 金钱。

LangChain 的 Memory 模块正是为了简化这一过程而设计。


二、代码实战:使用 RunnableWithMessageHistory 实现记忆

下面我们逐段解析一段完整的 LangChain 记忆实现代码。

1. 引入必要模块

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:使用 DeepSeek 的聊天模型。
  • ChatPromptTemplate:支持多轮对话格式(system/human/ai)提示模版,比普通字符串prompt更结构化。
  • RunnableWithMessageHistory核心类,用于包装一个普通 runnable,使其具备自动管理聊天历史的能力。
  • InMemoryChatMessageHistory:一个简单的内存存储实现,用于暂存当前会话的消息记录。

注意:InMemoryChatMessageHistory 仅适用于单机会话或演示。生产环境应使用 Redis、数据库等持久化存储。


2. 初始化模型

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

这里创建了一个确定性较强的聊天模型实例(temperature=0 表示输出更稳定)。


3. 构建带占位符的 Prompt 模板

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

关键点在于 ['placeholder', '{history}']

  • placeholder 是 LangChain 的特殊指令,告诉系统:“这里留个位置,运行时我会塞入历史消息”。
  • {history} 是占位符名称,必须与后续配置中的 historyMessagesKey 一致。

注释强调:“有没有记忆跟这个其实没有关系,我们只是在这里表达成这个”。确实,记忆能力不来自 system 提示,而是来自是否传入了历史消息。


4. 构造可运行链(Runnable Chain)

const runnable = prompt.pipe((input) => {
  console.log('>>> 最终传给模型的信息(Prompt 内存)')
  console.log(input)
  return input
}).pipe(model)

这段代码做了两件事:

  1. 使用 .pipe() 插入一个 debug 节点,打印最终发送给模型的完整 prompt(包含 system + history + user input)。
  2. 将构造好的 prompt 传递给模型。

这种中间调试方式非常实用,能清晰看到 LangChain 如何组装上下文。


5. 创建聊天历史存储实例

const messageHistory = new InMemoryChatMessageHistory()

这是一个空的内存聊天历史对象,用于存储该会话的所有消息(用户输入 + 模型回复)。


6. 包装为带记忆的 Runnable

const chain = new RunnableWithMessageHistory({
  runnable,
  getMessageHistory: async () => messageHistory,
  inputMessagesKey: 'input',
  historyMessagesKey: 'history',
})

这是最核心的一步RunnableWithMessageHistory 接收四个关键参数:

参数作用
runnable原始的可运行链(即上面构造的 prompt → debug → model
getMessageHistory返回当前会话的聊天历史存储对象(这里是 messageHistory
inputMessagesKey告诉 LangChain:用户输入字段名是 'input'(对应 invoke({ input: '...' })
historyMessagesKey告诉 LangChain:Prompt 中 {history} 占位符要替换成这个 key 对应的历史

注释精准指出:'placeholder': '{history}' 的 key 名必须与 historyMessagesKey 一致,否则无法正确替换。


7. 执行多轮对话

const res1 = await chain.invoke(
  { input: '我叫陈总,喜欢喝白兰地' },
  { configurable: { sessionId: 'make friend' } }
)
console.log(res1.content)

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

关键细节:

  • sessionId 必须一致:表示这是同一个会话。LangChain 会根据 sessionId 关联到同一个 messageHistory

  • 第一次调用后,messageHistory 会自动保存:

    • 用户消息:“我叫陈总,喜欢喝白兰地”
    • 模型回复(如:“好的,陈总!”)
  • 第二次调用时,LangChain 自动将上述历史插入到 {history} 位置,再发送给模型。

因此,第二次请求时,模型“看到”的完整上下文是:

[system] 你是一个有记忆的助手
[human] 我叫陈总,喜欢喝白兰地
[ai] 好的,陈总!
[human] 我叫什么名字?

于是模型能正确回答:“您是陈总。”


三、LangChain Memory 的工作机制总结

通过上述代码,我们可以清晰看到 LangChain 如何自动化记忆管理:

  1. 自动读取历史:每次 invoke 时,从 getMessageHistory() 获取当前会话的历史。
  2. 自动注入 Prompt:将历史消息填充到 ChatPromptTemplate 中的 {history} 占位符。
  3. 自动保存新消息:在模型返回后,将本次的用户输入和模型回复追加到 messageHistory 中。

整个过程对开发者透明,你只需关注业务逻辑(即 input 内容),无需手动拼接消息列表。


四、重要提醒:记忆的成本与边界

虽然 LangChain 让记忆变得简单,但我们必须牢记:

  • Token 成本:每轮对话都会增加上下文长度,直接提高 API 调用费用。
  • 上下文窗口限制:当历史消息过长,可能超出模型最大 token 限制。
  • 内存存储局限InMemoryChatMessageHistory 仅适合单机、短期会话。真实应用需集成 Redis、PostgreSQL 等。

因此,在实际项目中,你可能还需要:

  • 实现 摘要记忆(Summary Memory):定期将历史压缩成摘要。
  • 使用 窗口记忆(Window Memory):只保留最近 N 轮对话。
  • 配置 最大 token 限制:自动清理旧消息。

但这些属于进阶优化。对于初学者,掌握本文所述的基础记忆机制,已是迈向智能对话系统的关键一步。


结语

LLM 本身没有记忆,但通过 LangChain 的 RunnableWithMessageHistory,我们可以轻松赋予它“记住过去”的能力。本文代码虽短,却完整展示了从 Prompt 设计、历史存储到自动注入的全过程。希望你能通过这段注释详尽的示例,真正理解 LangChain Memory 的工作原理,并在自己的项目中灵活运用。

记住:让机器“记住”你,不是魔法,而是精心设计的上下文管理。