赋予 AI “灵魂”:LangChain.js 中临时与长期记忆的终极实战指南

在构建 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)。

  1. Token 爆炸:随着对话进行,历史消息会迅速耗尽 Token 配额。
  2. Lost in the Middle:研究表明,LLM 对长文本中间部分的信息关注度较低,过多的无关历史会干扰当前的回答。
  3. 成本与延迟:更多的 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 核心概念:实体提取与知识图谱

长期记忆不是把每一句话都存下来,而是提炼

流程通常是:

  1. 观察:捕获当前的对话内容。
  2. 提取:使用 LLM 从对话中提取关键事实(如用户的喜好、名字、重要事件)。
  3. 存储:将这些事实向量化并存入数据库。
  4. 检索:在新对话开始时,根据当前问题检索相关的长期记忆。

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 生态中,强烈建议使用专门的记忆管理服务,如 ZepMem0

以 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) 结合记忆

有时候用户的问题很模糊(例如:"那个怎么样?")。此时直接检索效果很差。

  1. 先查短期记忆,确认"那个"指代什么。
  2. 利用 LLM 生成一个假设性的完整问题。
  3. 用生成的完整问题去检索 RAG 知识库。

第五章:避坑指南与性能优化

5.1 避免"幽灵上下文"

在使用 RunnableWithMessageHistory 时,务必确保 MessagesPlaceholder 的变量名(如 history)与 historyMessagesKey 严格一致。否则 LLM 会收到空的上下文,或者报错。

5.2 序列化陷阱

在 Node.js 中,如果你使用 Redis 存储消息,要注意 AIMessage 等对象包含复杂的方法。不要直接 JSON.stringify 整个对象。

正确做法 :使用 LangChain 提供的 mapChatMessagesToStoredMessagesmapStoredMessagesToChatMessages 工具函数进行转换。

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 真正的"灵魂"吧!

相关推荐
想要成为糕糕手1 小时前
🧵 浏览器里的第二大脑:Web Worker
javascript·react.js·浏览器
逍遥德2 小时前
ECMAScript 各个版本的语法列表
前端·javascript·ecmascript·es6
用户059540174462 小时前
把AI聊天机器人记忆存储测试从3小时压到5分钟,我用Pytest + Docker搭了一套自动化回归
前端·css
明月_清风2 小时前
显存即正义:不同显存容量能训多大的模型?一文说清硬件边界与训练策略
前端·后端·ai编程
weixin_BYSJ19872 小时前
【java项目分享】springboot阅读推荐平台10600
java·javascript·spring boot·python·django·flask·php
Sterting2 小时前
第 9 节:本地存储 — 数据不丢的前端缓存
前端·javascript·缓存
IT_陈寒2 小时前
Vite的HMR在我项目上突然失效,排查三天找到离谱原因
前端·人工智能·后端
人间凡尔赛2 小时前
React Compiler 1.0 正式落地:告别 useMemo / useCallback,2026 前端性能优化的新范式
前端·性能优化·react
灵析表格2 小时前
灵析表格功能函数深度分析报告
前端·数据库·microsoft