在构建 RAG(检索增强生成)应用时,我们往往过于关注"检索"的准确性,而忽略了"对话"的连贯性。一个没有记忆的 AI,就像是一个患有严重顺行性遗忘症的患者------它博学多才,却记不住你上一秒说了什么。
在 LangChain.js 的生态中,Memory(记忆)模块是连接 LLM 与用户历史交互的桥梁。它不仅仅是简单的文本拼接,更是一场关于上下文窗口管理 、信息压缩 与持久化存储的艺术。
本文将带你从底层的 BaseChatMessageHistory 到上层的 RunnableWithMessageHistory,再到复杂的长期记忆架构,全方位拆解如何在 Node.js 环境中构建具备"灵魂"的 AI 应用。
第一章:记忆的基石------LangChain.js 消息体系
在深入 Memory 之前,我们必须先理解 LangChain.js 是如何定义"记忆"的。与 Python 版本类似,JS 版本的核心在于 Message(消息) 对象。
1.1 消息的类型
在代码中,记忆本质上是一个 BaseMessage 数组。理解这些类型对于后续处理至关重要:
- HumanMessage: 用户的输入。
- AIMessage: AI 的输出。
- SystemMessage: 系统提示词(通常作为对话的基调)。
- FunctionMessage / ToolMessage: 函数调用的结果(在 Agent 开发中常作为记忆的一部分)。
1.2 为什么不能直接把所有历史塞给 LLM?
这是新手最容易犯的错误。LLM 的 Context Window(上下文窗口)是有限的(例如 4k, 8k, 32k tokens)。
- Token 爆炸:随着对话进行,历史消息会迅速耗尽 Token 配额。
- Lost in the Middle:研究表明,LLM 对长文本中间部分的信息关注度较低,过多的无关历史会干扰当前的回答。
- 成本与延迟:更多的 Token 意味着更高的 API 费用和更长的等待时间。
因此,Memory 的核心价值在于:在有限的窗口内,保留最有价值的信息。
第二章:临时会话记忆------让对话"连贯"起来
临时记忆(Short-term Memory),通常指当前会话窗口内的上下文管理。它的生命周期通常绑定于一次会话(Session)。
2.1 核心组件:ChatMessageHistory
ChatMessageHistory 是 LangChain.js 中用于存储和检索消息列表的标准接口。它本身不决定"如何压缩",只负责"存"和"取"。
最简单的实现是内存存储(仅用于测试):
javascript
import { InMemoryChatMessageHistory } from "@langchain/core/chat_history";
const history = new InMemoryChatMessageHistory();
await history.addUserMessage("你好,我叫小明");
await history.addAIChatMessage("你好小明!有什么我可以帮你的吗?");
const messages = await history.getMessages();
console.log(messages);
// [HumanMessage, AIMessage]
2.2 生产级封装:RunnableWithMessageHistory
在实际的 LangChain.js (v0.1+) 开发中,我们不再手动去 history.getMessages() 然后拼接到 Prompt 里。官方推荐的做法是使用 RunnableWithMessageHistory。这是一个高阶封装,它自动处理了"读取历史 -> 拼接到输入 -> 调用 LLM -> 保存新消息"的全流程。
场景:构建一个有状态的客服机器人
假设我们需要为每个 session_id 维护独立的对话历史。
javascript
import { ChatOpenAI } from "@langchain/openai";
import { ChatPromptTemplate, MessagesPlaceholder } from "@langchain/core/prompts";
import { RunnableWithMessageHistory } from "@langchain/core/runnables";
import { UpstashRedisChatMessageHistory } from "@langchain/community/stores/message/upstash_redis";
// 1. 定义 Prompt,注意这里必须包含 MessagesPlaceholder
// 它是告诉 LLM "这里将插入历史消息" 的占位符
const prompt = ChatPromptTemplate.fromMessages([
["system", "你是一个乐于助人的客服助手。"],
new MessagesPlaceholder("history"), // 关键占位符
["human", "{input}"],
]);
// 2. 定义 LLM
const model = new ChatOpenAI({ temperature: 0.7 });
// 3. 组装链
const chain = prompt.pipe(model);
// 4. 包装进 RunnableWithMessageHistory
const chainWithHistory = new RunnableWithMessageHistory({
runnable: chain,
// 获取历史记录的工厂函数
// 每次调用时,根据 sessionId 返回对应的 History 实例
getMessageHistory: (sessionId) =>
new UpstashRedisChatMessageHistory({
sessionId,
config: {
url: process.env.UPSTASH_REDIS_REST_URL!,
token: process.env.UPSTASH_REDIS_REST_TOKEN!,
},
}),
// 指定输入字段和输出字段
inputMessagesKey: "input",
historyMessagesKey: "history",
});
// 5. 调用
const response = await chainWithHistory.invoke(
{ input: "我刚才提到的订单号是多少?" },
{ configurable: { sessionId: "user_001_session_A" } }
);
技巧点拨:
- 解耦存储 :注意上面的
getMessageHistory。我们将具体的存储实现(Redis)与业务逻辑解耦了。你可以轻松切换成 MongoDB 或 PostgreSQL 实现,只需替换这个类。 - 配置传递 :
sessionId是通过invoke的第二个参数configurable传递的,这是一种非常优雅的运行时配置方式。
2.3 进阶技巧:滑动窗口与截断
默认的 InMemoryChatMessageHistory 或 Redis 实现通常会返回所有 历史。当对话很长时,这会撑爆 Token。我们需要引入缓冲窗口。
虽然 LangChain.js 目前没有像 Python 那样内置极其丰富的 BufferWindowMemory 类,但我们可以通过自定义 getMessageHistory 逻辑来实现:
javascript
// 自定义逻辑:只取最后 N 条消息
const getLastNMessages = async (historyInstance, n = 10) => {
const allMessages = await historyInstance.getMessages();
// 简单的切片操作,只保留最后 N 条
return allMessages.slice(-n);
};
// 在 RunnableWithMessageHistory 中应用
// 注意:这需要稍微修改 RunnableWithMessageHistory 的配置或者手动实现 Wrapper
// 目前 JS 版更推荐直接在 Prompt 层面或自定义 Runnable 中处理截断
更优解: 使用 Token 计数器进行动态截断。在将 messages 传入 LLM 之前,计算 Token 数,如果超过阈值(如 3000 tokens),则从最早的消息开始丢弃,直到符合限制。这比固定条数更安全。
第三章:长期会话记忆------打造"懂你"的 AI
临时记忆解决了"刚才说了什么",而长期记忆(Long-term Memory)解决的是"你是谁"、"我们之前的共识是什么"。在 RAG 系统中,这通常通过 Vector Store(向量数据库) 来实现。
3.1 核心概念:实体提取与知识图谱
长期记忆不是把每一句话都存下来,而是提炼 。
流程通常是:
- 观察:捕获当前的对话内容。
- 提取:使用 LLM 从对话中提取关键事实(如用户的喜好、名字、重要事件)。
- 存储:将这些事实向量化并存入数据库。
- 检索:在新对话开始时,根据当前问题检索相关的长期记忆。
3.2 实战:基于 VectorStore 的长期记忆
我们可以利用 LangChain.js 的 VectorStoreRetrieverMemory 模式。
步骤一:初始化向量存储
这里我们以 ChromaDB 为例(也可以使用 Pinecone, Milvus 等)。
javascript
import { Chroma } from "@langchain/community/vectorstores/chroma";
import { OpenAIEmbeddings } from "@langchain/openai";
const vectorStore = await Chroma.fromExistingCollection(
new OpenAIEmbeddings(),
{ collectionName: "long_term_memory" }
);
步骤二:构建记忆检索链
我们需要创建一个机制,在每次对话前,先去向量库里搜一下有没有关于这个用户的旧闻。
javascript
import { createStuffDocumentsChain } from "langchain/chains/combine_documents";
import { ChatPromptTemplate } from "@langchain/core/prompts";
// 定义一个专门用于注入背景知识的 Prompt
const memoryPrompt = ChatPromptTemplate.fromTemplate(
`以下是关于用户的长期记忆信息,如果与当前问题相关,请参考回答:
<context>
{context}
</context>
当前用户问题: {input}`
);
// 创建检索器
const retriever = vectorStore.asRetriever({ k: 3 }); // 只取最相关的3条
// 创建文档处理链(将检索到的记忆片段合并成字符串)
const combineDocsChain = await createStuffDocumentsChain({
llm: new ChatOpenAI(),
prompt: memoryPrompt,
});
// 创建检索链
const retrievalChain = await createRetrievalChain({
combineDocsChain,
retriever,
});
步骤三:写入长期记忆(后台任务)
你不能在用户等待回复的时候同步做这件事,太慢了。通常的做法是异步执行。
javascript
import { Document } from "@langchain/core/documents";
async function saveToLongTermMemory(sessionId, userMessage, aiResponse) {
// 1. 使用 LLM 提取关键信息
const extractionPrompt = `从以下对话中提取关于用户的关键事实(偏好、个人信息、重要事件)。
如果没有重要事实,返回空。格式为 JSON 数组。
User: ${userMessage}
AI: ${aiResponse}`;
const extractionResult = await llm.invoke(extractionPrompt);
// 假设解析出了 facts = ["用户喜欢喝拿铁", "用户住在上海"]
// 2. 存入向量库
if (facts.length > 0) {
const docs = facts.map(fact => new Document({
pageContent: fact,
metadata: { sessionId, type: "user_preference", timestamp: Date.now() }
}));
await vectorStore.addDocuments(docs);
}
}
3.3 高级技巧:Zep 与 Mem0
自己写提取逻辑很麻烦。在 JS 生态中,强烈建议使用专门的记忆管理服务,如 Zep 或 Mem0。
以 Zep 为例(它有优秀的 JS SDK):
csharp
import { ZepClient } from "@getzep/zep-js";
const client = new ZepClient({ apiKey: "YOUR_API_KEY" });
// 添加消息(自动处理摘要、实体提取、向量化)
await client.memory.add(sessionId, {
messages: [{ role: "human", content: "我喜欢科幻小说" }]
});
// 获取记忆(自动总结 + 向量检索)
const memory = await client.memory.get(sessionId, {
lastn: 5, // 最近的5轮
searchType: "similarity" // 开启语义搜索
});
这种方式将"长期记忆"变成了一个黑盒服务,极大地降低了开发复杂度。
第四章:RAG 中的记忆融合策略
在 RAG 系统中,我们面临双重检索:知识库检索 (外部数据)+ 记忆检索(内部数据)。如何融合是关键。
4.1 策略一:并行检索(Parallel Retrieval)
将"用户问题"同时发给"知识库 Retriever"和"记忆 Retriever"。
javascript
// 伪代码结构
const finalChain = RunnableBranch.from([
// 分支逻辑...
]).pipe({
context: RunnableMap.from({
knowledge: knowledgeRetriever, // 查文档
memory: memoryRetriever // 查历史
}).pipe((inputs) => formatContext(inputs.knowledge, inputs.memory)),
question: (input) => input.question
}).pipe(llm);
4.2 策略二:记忆作为元数据过滤
如果用户的长期记忆中包含了"我只关注金融领域的新闻",那么在检索外部知识库时,应该将这个偏好作为 Metadata Filter 传给向量数据库,从而缩小检索范围。
ini
// 假设从记忆中提取到了 filter criteria
const memoryFilter = { category: "finance" };
const results = await vectorStore.similaritySearch(query, 5, memoryFilter);
4.3 策略三:HyDE (Hypothetical Document Embeddings) 结合记忆
有时候用户的问题很模糊(例如:"那个怎么样?")。此时直接检索效果很差。
- 先查短期记忆,确认"那个"指代什么。
- 利用 LLM 生成一个假设性的完整问题。
- 用生成的完整问题去检索 RAG 知识库。
第五章:避坑指南与性能优化
5.1 避免"幽灵上下文"
在使用 RunnableWithMessageHistory 时,务必确保 MessagesPlaceholder 的变量名(如 history)与 historyMessagesKey 严格一致。否则 LLM 会收到空的上下文,或者报错。
5.2 序列化陷阱
在 Node.js 中,如果你使用 Redis 存储消息,要注意 AIMessage 等对象包含复杂的方法。不要直接 JSON.stringify 整个对象。
正确做法 :使用 LangChain 提供的 mapChatMessagesToStoredMessages 和 mapStoredMessagesToChatMessages 工具函数进行转换。
javascript
import { mapChatMessagesToStoredMessages } from "@langchain/core/messages";
const stored = await mapChatMessagesToStoredMessages(messages);
await redis.set(key, JSON.stringify(stored));
5.3 隐私与安全
长期记忆是隐私泄露的重灾区。
- PII 移除:在存入长期记忆前,必须经过一层 PII(个人身份信息)检测。不要明文存储身份证号、密码等。
- TTL (Time To Live) :给 Redis 中的短期记忆设置过期时间(如 24 小时)。
- 遗忘权:提供 API 允许用户删除特定的记忆片段或清空所有历史。
5.4 调试技巧
记忆系统是黑盒,调试困难。
建议:在开发阶段,使用 LangSmith 或简单的 Console Log 打印出最终发送给 LLM 的完整 Prompt。
javascript
// 调试中间件
const debuggableChain = chain.pipe((output) => {
console.log("=== Final Prompt Sent to LLM ===");
// 这里需要拦截 Input 才能看到,通常在 RunnableMap 中做
return output;
});
看到完整的 Prompt 后,你就能判断是"记忆没取出来"还是"记忆太多导致 LLM 晕了"。
第六章:未来展望------自适应记忆
目前的记忆大多是被动式的。未来的方向是主动式记忆 。
AI 应该能够意识到:"我现在掌握的信息不足以回答这个问题,我需要询问用户以更新我的长期记忆。"
例如:
User: "帮我订机票。"
AI (Internal Thought): 记忆里只有他喜欢靠窗,但不知道目的地。
AI (Response): "好的,还是老规矩帮您选靠窗的位置吗?另外,这次您打算去哪里?"
这种能力需要结合 Agent(智能体) 框架,将 Memory 作为一个 Tool 供 AI 自主调用(read_memory, write_memory),而不仅仅是作为 Prompt 的背景板。
结语
在 JavaScript 生态中构建 RAG 记忆系统,既充满了挑战也充满乐趣。从 InMemoryChatMessageHistory 的简单起步,到 RunnableWithMessageHistory 的工程化封装,再到结合 Vector Store 的长期记忆网络,每一步都是为了让 AI 更像一个人。
记住,Memory 不仅仅是技术实现,更是产品设计。 你需要根据业务场景决定:
- 这个应用需要记住多久的事?(Session 级 vs User 级)
- 需要记住多细的事?(原文 vs 摘要 vs 实体)
- 如何平衡成本与智能度?
希望这篇指南能为你的 LangChain.js 开发之路点亮一盏灯。现在,打开你的 IDE,去赋予你的 AI 真正的"灵魂"吧!