跳到正文

记忆

什么是记忆?

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

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

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

理解和实现在您的应用中这两者都很重要。

短期记忆

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

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

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

管理长对话历史记录

长对话对当今的 LLM 构成了挑战。完整的历史记录甚至可能无法完全放入 LLM 的上下文窗口中,导致不可恢复的错误。即使您的 LLM 技术上支持完整的上下文长度,大多数 LLM 在处理长上下文时性能仍然不佳。它们会被过时或离题的内容“分心”,同时还会导致响应时间变慢和成本增加。

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

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

编辑消息列表

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

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

在 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 },
  };
}

当在键“myList”下返回更新时,LangGraph 将调用“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>,
});

然后,我们可以生成聊天历史记录的摘要,使用任何现有摘要作为下一个摘要的上下文。这个 summarizeConversation 节点可以在 messages 状态键中积累一定数量的消息后被调用。

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 文档存储在存储(store)中(参考文档)。每个记忆都组织在自定义的 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 的目标是为您提供低级别的原语,以便基于内存存储(Store)直接控制应用程序的长期记忆。

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

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

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

您想将记忆作为单个配置文件还是作为文档集合进行管理?

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

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

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

您想将记忆作为更新的指令还是少样本示例呈现给您的代理?

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

将记忆框架为“学习规则或指令”通常意味着将系统提示的一部分专门用于 LLM 可以自行管理的指令。在每次对话后,您可以提示 LLM 评估其表现并更新指令,以便在将来更好地处理此类任务。这种方法允许系统动态更新和改进其自身行为,可能在各种任务上带来更好的性能。这对于难以事先指定指令的任务特别有用。

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

我们将在以下部分中扩展关于写入、管理以及召回和格式化记忆的技术。

写入记忆

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

在关键路径中写入记忆

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

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

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

在后台写入记忆

这涉及将记忆更新作为概念上独立的任务进行,通常是作为一个完全独立的图或函数。由于它在后台发生,因此不会产生延迟。它还将应用程序逻辑与记忆逻辑分开,使其更模块化且易于管理。它还允许您分离记忆创建的时机,从而避免重复工作。您的代理可以专注于完成其当前任务,而无需有意识地考虑需要记住什么。

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

管理记忆

一旦您解决了记忆调度问题,思考如何用新信息更新记忆就非常重要。

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

我们将在下面概述这两种方法之间的一些权衡,理解大多数人会发现结合方法并在两者之间找到平衡点是最合适的。

管理个人资料

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

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

管理记忆集合

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

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

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

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

表示记忆

一旦您保存了记忆,您检索并将记忆内容呈现给 LLM 的方式对于您的 LLM 在其响应中整合这些信息的程度起着重要作用。以下部分介绍了一些常见方法。请注意,这些部分也将很大程度上指导您如何编写和管理记忆。记忆中的一切都是相互关联的!

更新自身指令

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

应用此方法的一种方式是使用“反思”或“元提示”步骤。用当前的指令集(来自系统提示)和与用户的对话来提示 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,以说明预期行为。虽然可以使用各种最佳实践来生成少样本示例,但挑战通常在于根据用户输入选择最相关的示例。

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

有关 LangSmith 中动态少样本示例选择的示例用法,请参见此操作视频。另请参见这篇博客文章,展示了如何使用少样本提示提高工具调用性能;以及这篇博客文章,介绍了如何使用少样本示例使 LLM 与人类偏好对齐。