一、问题背景:为什么 LLM 需要“记忆”?
大型语言模型(LLM)本身是无状态的——每次调用都像第一次见面。比如:
text
编辑
用户:我叫张三。
LLM:你好,张三!
用户:我叫什么?
LLM:我不知道你是谁。
这是因为第二次请求时,LLM 并不知道上一次对话内容。
✅ 解决方案:
在每次请求中,手动带上之前的对话历史(messages) ,让模型“看到”上下文。
但手动维护历史很麻烦,容易出错。于是 LangChain 提供了标准化工具来管理这个过程。
二、代码整体结构概览
我们用 LangChain 的几个核心模块组合出一个带记忆的聊天链(chain) :
| 模块 | 作用 |
|---|---|
ChatDeepSeek | 调用 DeepSeek 的 LLM 接口 |
ChatPromptTemplate | 构造包含系统提示、历史记录、用户输入的完整 prompt |
InMemoryChatMessageHistory | 在内存中保存对话历史(临时存储) |
RunnableWithMessageHistory | 自动把历史注入到 prompt 中,并更新历史 |
三、逐行代码详解
1️⃣ 导入依赖
ts
编辑
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'; // 加载 .env 中的 API_KEY 等环境变量
- 所有模块都来自 LangChain 生态。
dotenv用于安全地加载DEEPSEEK_API_KEY(需提前配置)。
2️⃣ 初始化 LLM 模型
ts
编辑
const model = new ChatDeepSeek({
model: 'deepseek-chat',
temperature: 0,
});
- 使用 DeepSeek 官方聊天模型
deepseek-chat。 temperature: 0表示输出更确定、更少随机性(适合问答类任务)。
🔍 注意:
ChatDeepSeek是 LangChain 对 DeepSeek API 的封装,底层仍是 HTTP 请求。
3️⃣ 定义 Prompt 模板
ts
编辑
const prompt = ChatPromptTemplate.fromMessages([
['system', "你是一个有记忆的助手"],
['placeholder', "{history}"], // ← 关键!历史消息将插入这里
['human', "{input}"] // 用户当前输入
]);
- 这个模板最终会生成一个消息数组(符合 OpenAI/DeepSeek 的 messages 格式)。
placeholder是 LangChain 特有的语法,表示“此处将被动态替换为历史对话”。
💡 实际运行时,
{history}会被替换成类似:ts 编辑 [ { role: "user", content: "我叫盛困吧..." }, { role: "assistant", content: "好的,盛困吧!..." }]
4️⃣ 构建可运行链(Runnable)
ts
编辑
const runnable = prompt
.pipe((input) => {
console.log(">>> 最终传给模型的信息(Prompt 内存)");
console.log(input);
return input;
})
.pipe(model);
.pipe()是 LangChain 的链式调用方式。- 中间加了一个
console.log用于调试:可以看到最终发给模型的完整消息列表。 - 最后
.pipe(model)表示把构造好的 prompt 发给 LLM。
✅ 这一步还没涉及“记忆”,只是定义了单次调用的流程。
5️⃣ 创建内存中的对话历史
ts
编辑
const messageHistory = new InMemoryChatMessageHistory();
- 这是一个临时存储,保存当前会话的所有消息(user + ai)。
- 数据存在内存中,程序重启就没了(适合 demo;生产环境可用 Redis、数据库等)。
6️⃣ 包装成“带记忆”的链
ts
编辑
const chain = new RunnableWithMessageHistory({
runnable, // 上面定义的基础链
getMessageHistory: async () => messageHistory, // 如何获取历史
inputMessagesKey: 'input', // 用户输入字段名(对应 invoke({ input: ... }))
historyMessagesKey: 'history' // 历史注入到 prompt 的哪个占位符(对应 {history})
});
⚙️ 这是核心魔法所在!
当你调用 chain.invoke(...) 时,LangChain 会自动:
- 从
messageHistory读取历史; - 把历史填入 prompt 的
{history}位置; - 调用 LLM;
- 把用户输入 + LLM 回复追加到
messageHistory中。
7️⃣ 执行两次对话(测试记忆)
ts
编辑
// 第一次:告诉模型你的名字
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);
sessionId: 'makefriend'是会话 ID(虽然这里只用了一个会话,但设计上支持多用户)。- 由于使用了同一个
messageHistory实例,第二次调用时模型“记得”第一次的内容。
✅ 预期输出:
text 编辑 好的,盛困吧!你喜欢喝白兰地,记住了。 你叫盛困吧。
四、关键机制图解(文字版)
text
编辑
用户输入 → [chain.invoke]
↓
获取 messageHistory(当前为空)
↓
构造 prompt:
- system: "你是一个有记忆的助手"
- history: []
- human: "我叫盛困吧..."
↓
调用 DeepSeek API
↓
收到回复 → 将 (user + ai) 消息存入 messageHistory
↓
第二次调用 → 获取 messageHistory(已有两条消息)
↓
构造 prompt:
- system: ...
- history: [
{role: user, content: "我叫盛困吧..."},
{role: assistant, content: "好的..."}
]
- human: "我叫什么名字?"
↓
模型看到完整上下文 → 正确回答“盛困吧”
五、总结要点 ✅
| 要点 | 说明 |
|---|---|
| LLM 本身无记忆 | 必须靠外部传入历史 |
| LangChain 封装了记忆管理 | 通过 RunnableWithMessageHistory + ChatMessageHistory |
| 历史存在哪? | 本例用内存(InMemoryChatMessageHistory),生产可用 Redis/DB |
| Prompt 模板是关键 | 必须预留 {history} 占位符 |
| 会话隔离靠 sessionId | 不同用户用不同 ID,避免记忆混淆 |
六、注意事项 ⚠️
-
Token 限制
历史越长,消耗 token 越多。DeepSeek 有上下文长度限制(如 32768 tokens)。
→ 长对话需做历史摘要或滑动窗口截断。 -
内存不持久
InMemoryChatMessageHistory仅用于测试。真实应用应使用:RedisChatMessageHistory- 自定义数据库存储(如 PostgreSQL + pgvector)
-
不要泄露敏感信息
历史中可能包含用户隐私,注意数据安全与合规