在构建基于大语言模型(LLM)的对话系统时,一个核心挑战是:LLM 本身是无状态的。每一次 API 调用都像一次“全新”的对话,模型不会自动记住你上一次说了什么。这就像每次和朋友聊天,对方都完全忘了你们之前的对话内容——显然,这不是我们想要的智能助手。
那么,如何让 LLM “记住”历史对话?答案是:手动维护对话历史,并在每次请求中带上它。而 LangChain 正是为解决这一问题提供了强大的 Memory 模块。本文将结合一段实际代码,详细解析 LangChain 中如何通过 RunnableWithMessageHistory 实现带记忆的对话系统。
一、为什么 LLM 需要“记忆”?
首先,我们必须明确一个事实:
LLM 的 API 调用本质上是无状态的,就像 HTTP 请求一样。
这意味着:
- 第一次请求:“我叫陈总,喜欢喝白兰地”
- 第二次请求:“我叫什么名字?”
模型在处理第二次请求时,完全不知道你之前说过什么。除非你主动把第一次的对话内容也发给它。
因此,实现“记忆”的本质方法是:
messages = [
{ role: 'user', content: '我叫陈总,喜欢喝白兰地' },
{ role: 'assistant', content: '好的,陈总!' },
{ role: 'user', content: '我叫什么名字?' }
]
每次调用 LLM 时,把整个 messages 数组作为上下文传入。
但这会带来两个现实问题:
-
工程复杂度:你需要可靠地存储、更新、检索每轮对话的历史。
-
成本与性能:随着对话轮数增加,消息长度(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)
这段代码做了两件事:
- 使用
.pipe()插入一个 debug 节点,打印最终发送给模型的完整 prompt(包含 system + history + user input)。 - 将构造好的 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 如何自动化记忆管理:
- 自动读取历史:每次
invoke时,从getMessageHistory()获取当前会话的历史。 - 自动注入 Prompt:将历史消息填充到
ChatPromptTemplate中的{history}占位符。 - 自动保存新消息:在模型返回后,将本次的用户输入和模型回复追加到
messageHistory中。
整个过程对开发者透明,你只需关注业务逻辑(即 input 内容),无需手动拼接消息列表。
四、重要提醒:记忆的成本与边界
虽然 LangChain 让记忆变得简单,但我们必须牢记:
- Token 成本:每轮对话都会增加上下文长度,直接提高 API 调用费用。
- 上下文窗口限制:当历史消息过长,可能超出模型最大 token 限制。
- 内存存储局限:
InMemoryChatMessageHistory仅适合单机、短期会话。真实应用需集成 Redis、PostgreSQL 等。
因此,在实际项目中,你可能还需要:
- 实现 摘要记忆(Summary Memory):定期将历史压缩成摘要。
- 使用 窗口记忆(Window Memory):只保留最近 N 轮对话。
- 配置 最大 token 限制:自动清理旧消息。
但这些属于进阶优化。对于初学者,掌握本文所述的基础记忆机制,已是迈向智能对话系统的关键一步。
结语
LLM 本身没有记忆,但通过 LangChain 的 RunnableWithMessageHistory,我们可以轻松赋予它“记住过去”的能力。本文代码虽短,却完整展示了从 Prompt 设计、历史存储到自动注入的全过程。希望你能通过这段注释详尽的示例,真正理解 LangChain Memory 的工作原理,并在自己的项目中灵活运用。
记住:让机器“记住”你,不是魔法,而是精心设计的上下文管理。