跳过内容

记忆

什么是记忆?

AI 应用中的记忆是指处理、存储并有效回忆过去交互信息的能力。通过记忆,您的智能体可以从反馈中学习并适应用户的偏好。本指南根据记忆回忆的范围分为两部分:短期记忆和长期记忆。

短期记忆,或线程范围的记忆,可以在与用户的单个对话线程中随时从内部回忆。LangGraph 将短期记忆作为智能体状态的一部分进行管理。状态通过检查点持久化到数据库中,以便线程可以随时恢复。当图被调用或步骤完成时,短期记忆会更新,并在每个步骤开始时读取状态。

长期记忆在不同对话线程之间共享。它可以在任何时间任何线程中被回忆。记忆可以限定在任何自定义命名空间内,而不仅仅是单个线程 ID。LangGraph 提供存储参考文档)来让您保存和回忆长期记忆。

两者都对您的应用程序的理解和实现非常重要。

短期记忆

短期记忆让您的应用程序能够记住单个线程或对话中的先前交互。线程将会话中的多个交互组织起来,类似于电子邮件将消息归组到单个对话中。

LangGraph 将短期记忆作为智能体状态的一部分进行管理,通过线程范围的检查点持久化。此状态通常可以包括对话历史以及其他有状态数据,例如上传的文件、检索到的文档或生成的工件。通过将这些存储在图的状态中,机器人可以访问给定对话的完整上下文,同时保持不同线程之间的分离。

由于对话历史是表示短期记忆最常见的形式,因此在下一节中,我们将介绍当消息列表变得很长时管理对话历史的技术。如果您想坚持高层次的概念,请继续阅读长期记忆部分。

管理长对话历史

长对话对当今的 LLM 提出了挑战。完整的历史记录甚至可能无法完全适应 LLM 的上下文窗口,从而导致无法恢复的错误。即使您的 LLM 在技术上支持完整的上下文长度,大多数 LLM 在长上下文中表现仍然不佳。它们会被过时或偏离主题的内容“分散注意力”,同时响应时间更慢且成本更高。

管理短期记忆是平衡精确度与召回率与您应用程序的其他性能要求(延迟和成本)的实践。一如既往,批判性地思考如何为您的 LLM 表示信息并查看您的数据非常重要。我们在下面介绍了几种管理消息列表的常见技术,希望为您提供足够的上下文,以便您为应用程序选择最佳权衡。

  • 编辑消息列表:如何在将消息列表传递给语言模型之前考虑修剪和过滤。
  • 总结过往对话:当您不仅仅想过滤消息列表时,一种常用技术。

编辑消息列表

聊天模型使用消息来接受上下文,其中包括开发者提供的指令(系统消息)和用户输入(人类消息)。在聊天应用程序中,消息在人类输入和模型响应之间交替,导致消息列表随着时间增长。由于上下文窗口有限且富含令牌的消息列表可能成本高昂,许多应用程序可以从使用技术手动删除或遗忘过时信息中受益。

最直接的方法是从列表中删除旧消息(类似于最近最少使用缓存)。

在 LangGraph 中从列表中删除内容的典型技术是从节点返回一个更新,告诉系统删除列表的某一部分。您可以定义此更新的外观,但一种常见的方法是让您返回一个对象或字典,指定要保留哪些值。

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

const StateAnnotation = Annotation.Root({
  myList: Annotation<any[]>({
    reducer: (
      existing: string[],
      updates: string[] | { type: string; from: number; to?: number }
    ) => {
      if (Array.isArray(updates)) {
        // Normal case, add to the history
        return [...existing, ...updates];
      } else if (typeof updates === "object" && updates.type === "keep") {
        // You get to decide what this looks like.
        // For example, you could simplify and just accept a string "DELETE"
        // and clear the entire list.
        return existing.slice(updates.from, updates.to);
      }
      // etc. We define how to interpret updates
      return existing;
    },
    default: () => [],
  }),
});

type State = typeof StateAnnotation.State;

function myNode(state: State) {
  return {
    // We return an update for the field "myList" saying to
    // keep only values from index -5 to the end (deleting the rest)
    myList: { type: "keep", from: -5, to: undefined },
  };
}

LangGraph 将在键为“myList”下返回更新时调用“reducer”函数。在该函数中,我们定义接受哪些类型的更新。通常,消息将被添加到现有列表中(对话将增长);但是,我们也增加了对接受一个字典的支持,该字典允许您“保留”状态的某些部分。这允许您以编程方式删除旧的消息上下文。

另一种常见方法是让您返回一个“删除”对象列表,指定要删除的所有消息的 ID。如果您在 LangGraph 中使用 LangChain 消息和messagesStateReducer reducer(或使用相同底层功能的MessagesAnnotation),您可以使用 RemoveMessage 来实现。

import { RemoveMessage, AIMessage } from "@langchain/core/messages";
import { MessagesAnnotation } from "@langchain/langgraph";

type State = typeof MessagesAnnotation.State;

function myNode1(state: State) {
  // Add an AI message to the `messages` list in the state
  return { messages: [new AIMessage({ content: "Hi" })] };
}

function myNode2(state: State) {
  // Delete all but the last 2 messages from the `messages` list in the state
  const deleteMessages = state.messages
    .slice(0, -2)
    .map((m) => new RemoveMessage({ id: m.id }));
  return { messages: deleteMessages };
}

在上面的示例中,MessagesAnnotation 允许我们将新消息附加到 messages 状态键,如 myNode1 中所示。当它看到 RemoveMessage 时,它将从列表中删除具有该 ID 的消息(然后 RemoveMessage 将被丢弃)。有关 LangChain 特定消息处理的更多信息,请参阅此关于使用 RemoveMessage 的操作指南

有关示例用法,请参阅此操作指南

总结过往对话

如上所示,修剪或删除消息的问题在于我们可能会因为筛选消息队列而丢失信息。因此,一些应用程序受益于使用聊天模型总结消息历史的更复杂方法。

可以使用简单的提示和编排逻辑来实现这一点。例如,在 LangGraph 中,我们可以扩展MessagesAnnotation以包含一个 summary 键。

import { MessagesAnnotation, Annotation } from "@langchain/langgraph";

const MyGraphAnnotation = Annotation.Root({
  ...MessagesAnnotation.spec,
  summary: Annotation<string>,
});

然后,我们可以生成聊天历史的摘要,使用任何现有摘要作为下一个摘要的上下文。当 messages 状态键中累积了若干条消息后,可以调用此 summarizeConversation 节点。

import { ChatOpenAI } from "@langchain/openai";
import { HumanMessage, RemoveMessage } from "@langchain/core/messages";

type State = typeof MyGraphAnnotation.State;

async function summarizeConversation(state: State) {
  // First, we get any existing summary
  const summary = state.summary || "";

  // Create our summarization prompt
  let summaryMessage: string;
  if (summary) {
    // A summary already exists
    summaryMessage =
      `This is a summary of the conversation to date: ${summary}\n\n` +
      "Extend the summary by taking into account the new messages above:";
  } else {
    summaryMessage = "Create a summary of the conversation above:";
  }

  // Add prompt to our history
  const messages = [
    ...state.messages,
    new HumanMessage({ content: summaryMessage }),
  ];

  // Assuming you have a ChatOpenAI model instance
  const model = new ChatOpenAI();
  const response = await model.invoke(messages);

  // Delete all but the 2 most recent messages
  const deleteMessages = state.messages
    .slice(0, -2)
    .map((m) => new RemoveMessage({ id: m.id }));

  return {
    summary: response.content,
    messages: deleteMessages,
  };
}

有关示例用法,请参阅此处的操作指南。

了解何时删除消息

大多数 LLM 都有最大支持的上下文窗口(以令牌计)。决定何时截断消息的一种简单方法是计算消息历史中的令牌数量,并在接近该限制时进行截断。尽管有一些“陷阱”,但朴素的截断很容易自行实现。某些模型 API 进一步限制了消息类型的序列(必须以人类消息开始,不能有连续的相同类型的消息等)。如果您正在使用 LangChain,您可以使用trimMessages实用程序,并指定要从列表中保留的令牌数量,以及用于处理边界的strategy(例如,保留最后 maxTokens)。

下面是一个示例。

import { trimMessages } from "@langchain/core/messages";
import { ChatOpenAI } from "@langchain/openai";

trimMessages(messages, {
  // Keep the last <= n_count tokens of the messages.
  strategy: "last",
  // Remember to adjust based on your model
  // or else pass a custom token_encoder
  tokenCounter: new ChatOpenAI({ modelName: "gpt-4" }),
  // Remember to adjust based on the desired conversation
  // length
  maxTokens: 45,
  // Most chat models expect that chat history starts with either:
  // (1) a HumanMessage or
  // (2) a SystemMessage followed by a HumanMessage
  startOn: "human",
  // Most chat models expect that chat history ends with either:
  // (1) a HumanMessage or
  // (2) a ToolMessage
  endOn: ["human", "tool"],
  // Usually, we want to keep the SystemMessage
  // if it's present in the original history.
  // The SystemMessage has special instructions for the model.
  includeSystem: true,
});

长期记忆

LangGraph 中的长期记忆允许系统在不同对话或会话中保留信息。与线程范围的短期记忆不同,长期记忆保存在自定义的“命名空间”中。

LangGraph 将长期记忆作为 JSON 文档存储在存储参考文档)中。每个记忆都组织在一个自定义的 namespace(类似于文件夹)和一个独特的 key(类似于文件名)下。命名空间通常包括用户或组织 ID 或其他标签,以便更容易组织信息。这种结构实现了记忆的层次化组织。然后通过内容过滤器支持跨命名空间搜索。请参阅下面的示例。

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

// InMemoryStore saves data to an in-memory dictionary. Use a DB-backed store in production use.
const store = new InMemoryStore();
const userId = "my-user";
const applicationContext = "chitchat";
const namespace = [userId, applicationContext];
await store.put(namespace, "a-memory", {
  rules: [
    "User likes short, direct language",
    "User only speaks English & TypeScript",
  ],
  "my-key": "my-value",
});
// get the "memory" by ID
const item = await store.get(namespace, "a-memory");
// list "memories" within this namespace, filtering on content equivalence
const items = await store.search(namespace, {
  filter: { "my-key": "my-value" },
});

为您的智能体添加长期记忆时,重要的是要考虑如何写入记忆、如何存储和管理记忆更新以及如何为应用程序中的 LLM 回忆和表示记忆。这些问题都是相互依存的:您希望如何为 LLM 回忆和格式化记忆,决定了您应该存储什么以及如何管理它。此外,每种技术都有其权衡。适合您的方法很大程度上取决于您应用程序的需求。LangGraph 旨在为您提供低级原语,以便您根据记忆存储直接控制应用程序的长期记忆。

长期记忆远非一个已解决的问题。虽然很难提供通用建议,但我们在下面提供了一些可靠的模式供您在实现长期记忆时参考。

您想在“主路径”还是“后台”写入记忆?

记忆可以作为主应用程序逻辑的一部分(例如,在应用程序的“主路径”上)或作为后台任务(作为基于主应用程序状态生成记忆的单独函数)进行更新。我们在下面的写入记忆部分中记录了每种方法的一些权衡。

您想将记忆作为单个档案还是文档集合来管理?

我们提供两种管理长期记忆的主要方法:单个持续更新的文档(称为“档案”或“模式”)或文档集合。每种方法都有其自身优势,具体取决于您需要存储的信息类型以及您打算如何访问它。

将记忆作为单个持续更新的“档案”或“模式”来管理,在您想要记住有关用户、组织或其他实体(包括智能体本身)的范围明确、具体信息时非常有用。您可以提前定义档案的模式,然后使用 LLM 根据交互来更新它。查询“记忆”很容易,因为它只是对 JSON 文档的简单 GET 操作。我们在记忆档案中更详细地解释了这一点。这种技术可以在已知信息用例中提供更高的精确度,但代价是更低的召回率(因为您必须预测和建模您的领域,并且文档更新往往会更频繁地删除或重写旧信息)。

另一方面,将长期记忆作为文档集合来管理,可以存储无限量的信息。当您想在很长的时间范围内重复提取和记住项目时,这种技术很有用,但随着时间的推移,查询和管理可能会更复杂。与“档案”记忆类似,您仍然为每个记忆定义模式。您将插入新的文档(并在此过程中可能更新或重新情境化现有文档),而不是覆盖单个文档。我们在“管理记忆集合”中更详细地解释了这种方法。

您想将记忆以更新指令的形式还是少样本示例的形式呈现给智能体?

记忆通常作为系统提示的一部分提供给 LLM。为 LLM“构建”记忆的一些常见方法包括以“与用户 A 过去交互的记忆”的形式提供原始信息,作为系统指令或规则,或作为少样本示例。

将记忆构建为“学习规则或指令”通常意味着将系统提示的一部分专门用于 LLM 可以自行管理的指令。在每次对话后,您可以提示 LLM 评估其性能并更新指令,以便将来更好地处理此类任务。我们在本节中更详细地解释了这种方法。

将记忆存储为少样本示例,可以让您以因果关系的方式存储和管理指令。每个记忆都存储一个输入或上下文以及预期的响应。包含推理轨迹(思维链)也有助于提供足够的上下文,以便记忆在将来不太可能被误用。我们在本节中对这个概念进行了更详细的阐述。

在下一节中,我们将详细介绍写入、管理以及回忆和格式化记忆的技术。

写入记忆

人类在睡眠时形成长期记忆,但我们的智能体应该何时以及如何创建新记忆呢?我们看到智能体写入记忆的两种最常见方式是“在主路径中”和“在后台”。

在主路径中写入记忆

这涉及在应用程序运行时创建记忆。举一个流行的生产示例,ChatGPT 使用“save_memories”工具来以内容字符串的形式更新记忆。它在每次收到用户消息时决定是否(以及如何)使用此工具,并将记忆管理与其余用户指令进行多任务处理。

这有几个好处。首先,它“实时”发生。如果用户立即开始一个新线程,该记忆将存在。用户还可以透明地看到记忆何时被存储,因为机器人必须明确决定存储信息并将其与用户关联。

这也有几个缺点。它使智能体必须做出的决策(要提交给记忆的内容)复杂化。这种复杂性会降低其工具调用性能并降低任务完成率。它会减慢最终响应速度,因为它需要决定要提交给记忆的内容。它通常还会导致更少的内容被保存到记忆中(因为助手正在执行多任务),这将导致后续对话中召回率较低

在后台写入记忆

这涉及将记忆更新作为一个概念上独立的任务,通常作为一个完全独立的图或函数。由于它在后台发生,因此不会产生延迟。它还将应用程序逻辑与记忆逻辑分开,使其更具模块化且易于管理。它还允许您分离记忆创建的时机,让您避免冗余工作。您的智能体可以专注于完成其即时任务,而无需刻意思考它需要记住什么。

然而,这种方法并非没有缺点。您必须考虑多久写入一次记忆。如果它不实时运行,用户在其他线程上的交互将无法从新上下文中受益。您还需要考虑何时触发此作业。我们通常建议在某个时间点后安排记忆,如果给定线程上发生新事件,则取消并重新安排未来的记忆。其他流行的选择是按 cron 调度形成记忆,或者让用户或应用程序逻辑手动触发记忆形成。

管理记忆

一旦您解决了记忆调度问题,重要的是要考虑如何用新信息更新记忆

有两种主要方法:您可以持续更新单个文档(记忆档案),或者每次收到新信息时插入新文档。

我们将在下面概述这两种方法之间的一些权衡,并理解大多数人会发现结合方法并折中处理最为合适。

管理个人档案

档案通常只是一个 JSON 文档,包含您选择的各种键值对来表示您的领域。在记忆档案时,您会希望确保每次都更新档案。因此,您会希望传入先前的档案并要求 LLM 生成一个新档案(或应用于旧档案的 JSON 补丁)。

文档越大,越容易出错。如果您的文档变得大,您可能需要考虑将档案拆分成单独的部分。您在生成文档时可能需要使用带重试和/或严格解码的生成,以确保记忆模式保持有效。

管理记忆集合

将记忆保存为文档集合可以简化一些事情。每个单独的记忆可以更狭窄地限定范围且更容易生成。这也意味着您随着时间的推移丢失信息的可能性更小,因为 LLM 为新信息生成对象比它将新信息与密集档案中的信息协调起来更容易。这往往会带来更高的下游召回率。

这种方法将一些复杂性转移到您如何提示 LLM 应用记忆更新上。您现在必须使 LLM 能够删除更新列表中的现有项目。提示 LLM 执行此操作可能很棘手。一些 LLM 可能会默认过度插入;另一些可能会默认过度更新。此处行为的调整最好通过评估完成,您可以使用LangSmith等工具来完成。

这也将复杂性转移到记忆搜索(召回)。您必须考虑使用哪些相关项目。目前我们支持通过元数据过滤。我们很快将添加语义搜索。

最后,这还将一些复杂性转移到您如何为 LLM 表示记忆(以及推而广之,您用于保存每个记忆的模式)。很容易编写出在脱离上下文时容易被误解的记忆。重要的是要提示 LLM 在给定记忆中包含所有必要的上下文信息,以便您在后续对话中使用它时不会错误地误用该信息。

记忆的表示

一旦您保存了记忆,您检索并向 LLM 呈现记忆内容的方式将在很大程度上影响您的 LLM 在其响应中整合该信息的程度。以下部分介绍了几种常见方法。请注意,这些部分也将很大程度上影响您如何写入和管理记忆。记忆中的一切都是相互关联的!

更新自身指令

虽然指令通常是开发者编写的静态文本,但许多 AI 应用程序受益于允许用户个性化智能体在与该用户交互时应遵循的规则和指令。理想情况下,这可以通过其与用户的交互来推断(这样用户就不必在您的应用程序中明确更改设置)。从这个意义上说,指令是一种长篇记忆!

应用此功能的一种方法是使用“反思”或“元提示”步骤。使用当前指令集(来自系统提示)和与用户的对话来提示 LLM,并指示 LLM 完善其指令。这种方法允许系统动态更新和改进其自身行为,从而可能在各种任务上获得更好的性能。这对于先验难以指定指令的任务特别有用。

元提示使用过去的信息来优化提示。例如,推文生成器采用元提示来增强其针对 Twitter 的论文摘要提示。您可以使用 LangGraph 的记忆存储来实现此功能,将更新后的指令保存在共享命名空间中。在这种情况下,我们将记忆命名为“agent_instructions”,并根据智能体对记忆进行键控。

import { BaseStore } from "@langchain/langgraph/store";
import { State } from "@langchain/langgraph";
import { ChatOpenAI } from "@langchain/openai";

// Node that *uses* the instructions
const callModel = async (state: State, store: BaseStore) => {
  const namespace = ["agent_instructions"];
  const instructions = await store.get(namespace, "agent_a");
  // Application logic
  const prompt = promptTemplate.format({
    instructions: instructions[0].value.instructions,
  });
  // ... rest of the logic
};

// Node that updates instructions
const updateInstructions = async (state: State, store: BaseStore) => {
  const namespace = ["instructions"];
  const currentInstructions = await store.search(namespace);
  // Memory logic
  const prompt = promptTemplate.format({
    instructions: currentInstructions[0].value.instructions,
    conversation: state.messages,
  });
  const llm = new ChatOpenAI();
  const output = await llm.invoke(prompt);
  const newInstructions = output.content; // Assuming the LLM returns the new instructions
  await store.put(["agent_instructions"], "agent_a", {
    instructions: newInstructions,
  });
  // ... rest of the logic
};

少样本示例

有时“展示”比“告诉”更容易。LLM 善于从示例中学习。少样本学习允许您通过使用输入-输出示例更新提示来“编程”您的 LLM,以说明预期行为。虽然可以使用各种最佳实践来生成少样本示例,但挑战通常在于根据用户输入选择最相关的示例。

请注意,记忆存储只是将数据存储为少样本示例的一种方式。如果您想有更多的开发者参与,或者将少样本更紧密地与您的评估工具结合,您还可以使用LangSmith Dataset来存储您的数据。然后,动态少样本示例选择器可以开箱即用地实现相同的目标。LangSmith 将为您索引数据集,并根据关键词相似度(使用类似 BM25 的算法进行基于关键词的相似度)启用检索与用户输入最相关的少样本示例。

有关 LangSmith 中动态少样本示例选择的示例用法,请参阅此操作视频。此外,请参阅这篇博客文章,其中展示了少样本提示如何提高工具调用性能,以及这篇博客文章,其中使用少样本示例使 LLM 与人类偏好保持一致。