[LangGraph] 长期记忆

长期记忆是需要长久保持的,长期记忆通常用于存储:

  • 用户画像
  • 偏好
  • 历史习惯
  • 长期事实(不会频繁变)

例如:

  • 用户是前端工程师
  • 用户更喜欢中文回答
  • 用户不需要基础解释
  • 用户常问某一类问题

凡是“不应该被时间旅行影响的事实”,就应该放进长期记忆,而非短期记忆。下面是短期记忆和长期记忆两者之间的对比:

维度 短期记忆(Short-term) 长期记忆(Long-term)
关注点 当前任务 / 当前流程 用户 / 世界的长期事实
生命周期 仅在当前线程或会话中有效 跨 Graph / 跨会话和线程
是否分支 ✅ 会(时间旅行) ❌ 不应该
是否可回溯 ✅ checkpoint 一般不需要
修改频率 高频 低频
典型内容 当前会话中的消息、对话历史 用户的长期信息、偏好、背景
设计目标 可纠错、可回放 可积累、可复用

一句话总结:短期记忆是可以频繁修改的,而长期记忆一般是不会去修改的。

添加长期记忆

和短期记忆一样,langgraph 中提供了内存和数据库的形式。

这里选择 InMemoryStore 内存的形式来做介绍。

InMemoryStore 是一个轻量级的存储方式,下面是一个快速上手的代码:

import { InMemoryStore } from "@langchain/langgraph";

// 实例化
const store = new InMemoryStore(); 

const userId = "user-1";

// 长期记忆的命名空间
// 1. 长期记忆的类别
// 2. 区分不同的用户
const namespace = ["user_profile", userId];

// 这是一个包含长期事实的信息
const userInfo = "User name is Bob, works as a developer.";

// 将信息存储到store里面
// 1.命名空间	2. key	3. 具体的数据
await store.put(namespace, "memory-key", { data: userInfo });

// 从store里面搜索信息
const memories = await store.search(namespace, { query: "Bob" });
console.log(memories);  

在 langgraph 中,长期记忆的内容是存储在 store 中的,可以通过 userId 来区分不同用户的记忆。

长期记忆是跨会话的,和当前对话的上下文无关,因此可以在不同的 thread_id(对话 ID)中共享。

  1. 读取记忆:使用 store.search 方法,可以读取已经存储的长期记忆。例如,系统可以在每次对话开始时加载用户的记忆,进行个性化回应。

    const memories = await store.search(namespace, { query: "Bob" });
    const memoryText = memories.map((m: any) => m.value.data).join("\n") || "";
    
  2. 写入记忆:当用户提供新的信息,或者 AI 收到一些关键的指示(如“记住我叫什么名字”),你可以通过 store.put 方法将这些信息写入长期记忆。

    const memory = "User name is Alice";
    await store.put(namespace, "memory-key", { data: memory });
    

如何检查,提取需要被长期存储的信息

1. “工具调用”模式(最推荐,最省钱 )
不设置专门的检查节点,而是给 AI 一个工具(例如 save_user_preference)。
做法: 只有当 AI 觉得用户说的话里有“金子”(比如“我不吃花生”或“我偏好 Python”)时,它才会主动调用这个工具。
优点: 如果用户只是说“你好”或“谢谢”,AI 就不会触发存储逻辑,节省 Token。
缺点: 需要模型足够聪明,能自主判断哪些信息值得记录。

import { tool } from "@langchain/core/tools";
import { z } from "zod";

const saveUserFact = tool(
  async ({ fact }, { configurable }) => {
    const { userId, store } = configurable;
    // Save to cross-thread memory
    await store.put([userId, "memories"], "facts", { fact });
    return `Memory updated: ${fact}`;
  },
  {
    name: "save_user_fact",
    description: "Saves a persistent fact about the user for future sessions.",
    schema: z.object({ fact: z.string() }),
  }
);

// Bind to your model
const modelWithTools = model.bindTools([saveUserFact]);

2. “会话结束”总结模式(最完整)
等一轮对话结束(或达到一定对话轮数)后,再运行一个总结节点。
做法: 该节点回顾过去 10-20 条消息,提取核心事实并存入 Store。
优点: 上下文更完整,AI 能看清整件事的来龙去脉再做决定。
缺点: 记忆有延迟,通常要到下一次开启新对话时,AI 才会“想起”这些事。

const memoryNode = async (state: typeof MessagesState.State, config: RunnableConfig) => {
  const { store, userId } = config.configurable;
  
  // 1. Ask LLM to summarize the interaction
  const response = await model.invoke([
    ["system", "Extract key user preferences or facts from this chat."],
    ...state.messages
  ]);

  // 2. Persist to long-term Store
  await store.put([userId, "profile"], "latest_summary", { content: response.content });
  
  return {}; // No change to the chat messages
};

3. “异步反思”模式(体验最好)
在 LangGraph 中,你可以让回复用户和更新记忆并行执行。
做法: 立即给用户返回答案(保证不卡顿),同时在后台启动一个“反思节点”去分析输入并更新长期存储。
优点: 用户感知不到延迟。

const loadMemoryNode = async (state: typeof MessagesState.State, config: RunnableConfig) => {
  const { store, userId } = config.configurable;
  
  // Get the saved data
  const savedData = await store.get([userId, "profile"], "latest_summary");
  
  if (savedData) {
    // Inject memory into the system prompt
    const memoryPrompt = `Remember: ${savedData.value.content}`;
    return { messages: [new SystemMessage(memoryPrompt)] };
  }
  return {};
};

Code:

import "dotenv/config";
import readline from "readline-sync";
import { ChatOpenAI } from "@langchain/openai";
import {
  StateGraph,
  START,
  END,
  InMemoryStore,
  MemorySaver,
} from "@langchain/langgraph";
import { BaseMessage, HumanMessage } from "@langchain/core/messages";
import { z } from "zod";

// 图的状态结构,里面只有一项,消息
const StateSchema = z.object({
  messages: z.array(z.custom<BaseMessage>()),
});

// 根据Schema生成对应的ts类型
type TState = z.infer<typeof StateSchema>;

// 模型
const model = new ChatOpenAI({
  model: "gpt-4o-mini",
  temperature: 0.5,
});

const store = new InMemoryStore(); // 做长期记忆
const checkpointer = new MemorySaver(); // 做短期记忆

// 节点函数 - 聊天
async function chatNode(state: TState, config): Promise<Partial<TState>> {
  // 1. 从长期记忆里面获取信息
  // 2. 和大模型进行交流 - 会把长期记忆的信息带过去
  // 3. 判断新的这一轮会话,有没有信息需要存入到长期记忆里面
  const userId = config.configurable.userId;
  const namespace = ["user_profile", userId];

  // 取出长期记忆
  // 记忆里面如果有东西:
  /**
   * [
      {
        key: "xxxx",  
        value: {
          data: "我叫张三。"  // 记忆内容
        }
      },
      {
        key: "xxxx",  
        value: {
          data: "我喜欢编程。"  // 另一条记忆内容
        }
      }
    ]
   * 
   */
  const memories = await store.search(namespace, {});
  // console.log("memories>>>", memories);
  // 取出记忆的内容,组装成一个字符串
  // "我叫张三。\n 我喜欢编程。"
  const memoryText = memories?.map((m) => m.value.data).join("\n") || "";

  // 提示词
  const systemPrompt = `
你是一个持续与用户聊天的助手。
以下是你已知的用户长期信息(如果有):
${memoryText || "(暂无)"}
`;

  // 和大模型进行交流
  const response = await model.invoke([
    { role: "system", content: systemPrompt }, // 系统设定
    ...state.messages,
  ]);

  // 现在我们其实已经有了模型的回复
  // 先不着急推入到 messages 里面
  // 先把用户发的消息取出来,判断用户发的消息是否需要存储到长期记忆里面
  const lastMsg = state.messages.at(-1); // 取出最后一条内容(用户发的)

  const lastUserMsg =
    typeof lastMsg?.content === "string" ? lastMsg.content : "";

  // 需要判断是否有存储到长期记忆里面的必要
  if (shouldSaveToMemory(lastUserMsg)) {
    // 如果进入此分支,说明要做长期记忆
    // 需要做一个信息的提取
    const memory = extractMemory(lastUserMsg);
    if (memory) {
      // 提取到信息了,存
      await store.put(namespace, crypto.randomUUID(), {
        data: memory,
      });
      console.log("🧠 [长期记忆已保存]:", memory);
    }
  }

  return { messages: [...state.messages, response] };
}

function shouldSaveToMemory(text: string): boolean {
  // 定义一组关键字,如果用户说的话包含这些关键词,就认为是个人信息
  const keywords = ["我是", "我叫", "我在", "我的工作", "我喜欢", "记住"];
  return keywords.some((k) => text.includes(k));
}

function extractMemory(text: string): string | null {
  if (text.includes("我叫")) {
    return text;
  }
  if (text.includes("我是")) {
    return text;
  }
  if (text.includes("我喜欢")) {
    return text;
  }
  if (text.includes("我的工作是")) {
    return text;
  }
  return null;
}

const graph = new StateGraph(StateSchema)
  .addNode("chatNode", chatNode)
  .addEdge(START, "chatNode")
  .addEdge("chatNode", END)
  .compile({
    checkpointer, // 注入短期记忆检查点
    store, // 注入长期记忆存储
  });

async function main() {
  const config = {
    configurable: {
      userId: "bill",
      thread_id: "bill-chat",
    },
  };

  let state: TState = {
    messages: [],
  };

  console.log("🤖 聊天开始(Ctrl+C 退出)");

  while (true) {
    // 获取用户在终端的输入
    const input = readline.question("\n你:");

    // 将用户的输入封装成 HumanMessage 对象,加入到本地状态
    state.messages.push(new HumanMessage(input));

    const result = await graph.invoke(state, config);

    // console.log("result>>>", result);

    const aiMsg = result.messages.at(-1);

    console.log("\nAI:", aiMsg?.content);

    state = result; // 更新本地状态
  }
}

main();

posted @ 2026-03-12 14:17  Zhentiw  阅读(0)  评论(0)    收藏  举报