跳到内容

内存

什么是内存?

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

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

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

两者对于理解和在您的应用程序中实施都很重要。

短期记忆

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

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

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

管理长对话历史

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

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

编辑消息列表

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

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

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

然后,我们可以生成聊天历史的摘要,使用任何现有摘要作为下一个摘要的上下文。在 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 都有最大支持的上下文窗口(以 token 为单位)。一种决定何时截断消息的简单方法是计算消息历史中的 token,并在接近该限制时截断。天真截断很容易自行实现,但有一些“陷阱”。某些模型 API 进一步限制了消息类型的顺序(必须以人类消息开头,不能有相同类型的连续消息等)。如果您正在使用 LangChain,您可以使用 trimMessages 实用程序,并指定要从列表中保留的 token 数量,以及用于处理边界的 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" },
});

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

长期记忆远未解决。虽然很难提供通用建议,但我们为您在实施长期记忆时考虑提供了一些可靠的模式。

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

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

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

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

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

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

您想将记忆作为更新的指令还是少量示例呈现给您的 Agent?

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

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

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

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

写入记忆

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

在热路径中写入记忆

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

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

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

在后台写入记忆

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

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

管理记忆

一旦您整理好记忆调度,重要的是要考虑如何使用新信息更新记忆

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

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

管理个人资料

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

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

管理记忆集合

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

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

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

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

表示记忆

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

更新自身指令

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

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

元提示使用过去的信息来改进提示。例如,Tweet 生成器 使用元提示来增强其用于 Twitter 的论文摘要提示。您可以使用 LangGraph 的记忆存储来将更新的指令保存在共享命名空间中。在这种情况下,我们将记忆命名空间为“agent_instructions”,并基于 Agent 键入记忆。

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 数据集 来存储您的数据。然后,动态少量示例选择器可以开箱即用地用于实现相同的目标。LangSmith 将为您索引数据集,并支持检索与用户输入最相关的少量示例,基于关键词相似度(使用类似于 BM25 的算法 进行关键词相似度计算)。

有关 LangSmith 中动态少量示例选择的示例用法,请参阅此操作指南 视频。另请参阅此 博客文章,展示了少量提示以提高工具调用性能,以及此 博客文章,使用少量示例将 LLM 与人类偏好对齐。