Skip to content

内存

什么是记忆?

Memory 是一种认知功能,使人们能够存储、检索并使用信息以理解当前和未来。想象一下与一个忘记你告诉他们的所有事情的同事合作时的挫败感,需要不断重复!随着AI代理承担越来越多涉及多次用户交互的复杂任务,为它们配备记忆对于提高效率和用户满意度同样至关重要。有了记忆,代理可以从反馈中学习并适应用户的偏好。本指南涵盖两种基于回忆范围的记忆类型:

短期记忆,或称为 thread 范围内的记忆,可以在与用户进行的一次对话线程中随时被回忆。LangGraph 将短期记忆作为代理的 state 的一部分进行管理。State 通过 checkpointer 持久化到数据库中,因此可以随时恢复线程。当图被调用或步骤完成时,短期记忆会更新,并且在每个步骤开始时读取 State。

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

对于您的应用来说,了解和实现这两种记忆都很重要。

短期记忆

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

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

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

管理长对话历史

长对话对当今的大型语言模型(LLMs)提出了挑战。完整的对话历史可能甚至无法适应LLM的上下文窗口,导致不可恢复的错误。即使你的LLM从技术上支持完整的上下文长度,大多数LLM在长上下文中表现仍然不佳。它们会被过时或离题的内容所“分心”,同时响应时间变慢且成本更高。

管理短期记忆是一种在精确率与召回率与你应用程序的其他性能需求(延迟和成本)之间取得平衡的练习。一如既往,重要的是要批判性地思考如何为你的LLM表示信息并审视你的数据。我们在下面介绍了一些常见的用于管理消息列表的技术,希望为你提供足够的背景信息,以便你在自己的应用程序中选择最佳的权衡方案:

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

编辑消息列表

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

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

在 LangGraph 中,从列表中删除内容的典型技术是返回一个节点更新,告诉系统删除列表的一部分。你可以定义这个更新看起来是什么样子,但一种常见的方法是让你返回一个对象或字典,指定保留哪些值。

def manage_list(existing: list, updates: Union[list, dict]):
    if isinstance(updates, list):
        # 正常情况,添加到历史中
        return existing + updates
    elif isinstance(updates, dict) and updates["type"] == "keep":
        # 你可以决定它看起来像什么。
        # 例如,你可以简化并只接受一个字符串 "DELETE"
        # 并清除整个列表。
        return existing[updates["from"]:updates["to"]]
    # 等等。我们定义如何解释更新

class State(TypedDict):
    my_list: Annotated[list, manage_list]

def my_node(state: State):
    return {
        # 我们为字段 "my_list" 返回一个更新,说明
        # 只保留索引 -5 到末尾的值(删除其余部分)
        "my_list": {"type": "keep", "from": -5, "to": None}
    }

每当在键 "my_list" 下返回更新时,LangGraph 将调用 manage_list "reducer" 函数。在该函数内,我们定义可以接受的更新类型。通常,消息将被添加到现有列表中(对话将增长);然而,我们还支持接受一个允许你“保留”状态某些部分的字典。这使你可以编程方式丢弃旧的消息上下文。

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

API Reference: RemoveMessage | AIMessage | add_messages

from langchain_core.messages import RemoveMessage, AIMessage
from langgraph.graph import add_messages
# ... 其他导入

class State(TypedDict):
    # add_messages 默认会根据 ID 将新消息插入到现有的列表中
    # 如果返回了 RemoveMessage,它会根据 ID 从列表中删除消息
    messages: Annotated[list, add_messages]

def my_node_1(state: State):
    # 向 state 中的 `messages` 列表添加一个 AI 消息
    return {"messages": [AIMessage(content="Hi")]}

def my_node_2(state: State):
    # 删除 state 中 `messages` 列表中除了最后两个消息以外的所有消息
    delete_messages = [RemoveMessage(id=m.id) for m in state['messages'][:-2]]
    return {"messages": delete_messages}

在上面的例子中,add_messages reducer 允许我们如 my_node_1 所示将新消息追加messages 状态键中。当它看到 RemoveMessage 时,它会从列表中删除具有该 ID 的消息(然后 RemoveMessage 会被丢弃)。有关 LangChain 特有的消息处理,请查看 此指南

有关示例用法,请参阅本指南 和我们 LangChain Academy 课程的第二模块。

总结过去的对话

如上所述,修剪或删除消息的问题在于可能会丢失消息队列中的信息。因此,一些应用程序可以从使用聊天模型来总结消息历史记录的更复杂方法中受益。

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

from langgraph.graph import MessagesState
class State(MessagesState):
    summary: str

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

def summarize_conversation(state: State):

    # 首先,我们获取任何现有的摘要
    summary = state.get("summary", "")

    # 创建我们的摘要提示
    if summary:

        # 已经存在摘要
        summary_message = (
            f"This is a summary of the conversation to date: {summary}\n\n"
            "Extend the summary by taking into account the new messages above:"
        )

    else:
        summary_message = "Create a summary of the conversation above:"

    # 将提示添加到我们的历史中
    messages = state["messages"] + [HumanMessage(content=summary_message)]
    response = model.invoke(messages)

    # 删除除了最新的 2 条消息之外的所有消息
    delete_messages = [RemoveMessage(id=m.id) for m in state["messages"][:-2]]
    return {"summary": response.content, "messages": delete_messages}

有关示例用法,请参阅 此指南 和我们 LangChain Academy 课程的第二模块。

知道**何时**删除消息

大多数 LLM 都有最大支持的上下文窗口(以标记数表示)。决定何时截断消息的一个简单方法是计算消息历史中的标记数,并在接近该限制时进行截断。尽管在自己实现时截断操作很简单,但有一些“陷阱”。一些模型 API 进一步限制了消息类型的序列(必须以人类消息开头,不能有连续相同类型的的消息等)。如果你正在使用 LangChain,可以使用 trim_messages 工具,并指定要从列表中保留的标记数,以及用于处理边界的 strategy(例如,保留最后 max_tokens)。

以下是一个示例。

API Reference: trim_messages

from langchain_core.messages import trim_messages
trim_messages(
    messages,
    # 保留消息列表中最后 <= n_count 个标记。
    strategy="last",
    # 记得根据你的模型进行调整
    # 或者传递自定义的 token_encoder
    token_counter=ChatOpenAI(model="gpt-4"),
    # 记得根据所需的对话长度进行调整
    max_tokens=45,
    # 大多数聊天模型期望聊天历史以以下之一开始:
    # (1) 一个 HumanMessage 或
    # (2) 一个 SystemMessage 后跟一个 HumanMessage
    start_on="human",
    # 大多数聊天模型期望聊天历史以以下之一结束:
    # (1) 一个 HumanMessage 或
    # (2) 一个 ToolMessage
    end_on=("human", "tool"),
    # 通常,我们想要保留 SystemMessage
    # 如果它出现在原始历史中。
    # SystemMessage 对模型有特殊指示。
    include_system=True,
)

长期记忆

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

存储记忆

LangGraph将长期记忆以JSON文档的形式存储在存储参考文档)中。每个记忆都组织在一个自定义的namespace(类似于一个文件夹)和一个独特的key(类似于文件名)下。命名空间通常包括用户或组织ID或其他有助于组织信息的标签。这种结构使得记忆可以分层组织。跨命名空间的搜索通过内容过滤器实现。下面是一个示例。

from langgraph.store.memory import InMemoryStore


def embed(texts: list[str]) -> list[list[float]]:
    # 用实际的嵌入函数或LangChain嵌入对象替换
    return [[1.0, 2.0] * len(texts)]


# InMemoryStore 将数据保存到内存字典中。生产环境中应使用基于数据库的存储。
store = InMemoryStore(index={"embed": embed, "dims": 2})
user_id = "my-user"
application_context = "chitchat"
namespace = (user_id, application_context)
store.put(
    namespace,
    "a-memory",
    {
        "rules": [
            "User likes short, direct language",
            "User only speaks English & python",
        ],
        "my-key": "my-value",
    },
)
# 通过ID获取"memory"
item = store.get(namespace, "a-memory")
# 在此命名空间内搜索"memories",按内容等价性进行筛选,并根据向量相似度排序
items = store.search(
    namespace, filter={"my-key": "my-value"}, query="language preferences"
)

关于长期记忆的思考框架

长期记忆是一个复杂的问题,没有放之四海而皆准的解决方案。然而,以下问题提供了一个结构化的框架,帮助您理解各种技术:

记忆的类型是什么?

人类利用记忆来记住事实经历规则。AI代理也可以以同样的方式使用记忆。例如,AI代理可以利用记忆记住有关用户的特定事实以完成任务。我们在下面的部分中进一步扩展了几种类型的记忆。

何时更新记忆?

记忆可以在代理的应用逻辑中作为一部分进行更新(例如,“在热路径上”)。在这种情况下,代理通常会在响应用户之前决定记住某些事实。或者,记忆可以作为后台任务进行更新(在后台/异步运行的逻辑中生成记忆)。我们将在下面的部分中解释这两种方法之间的权衡。

内存类型

不同的应用程序需要各种类型的内存。尽管这种类比并不完美,但研究人类记忆类型可以提供一些启发。一些研究(例如,CoALA论文)甚至将这些人类记忆类型映射到AI代理中使用的类型。

内存类型 存储内容 人类示例 代理示例
语义 事实 我在学校学到的东西 关于用户的事实
情景 经历 我做过的事情 过去的代理操作
程序性 指令 本能或运动技能 代理系统提示

语义记忆

语义记忆在人类和AI代理中都涉及特定事实和概念的保留。在人类中,它可以包括在学校学到的信息以及对概念及其关系的理解。对于AI代理来说,语义记忆通常用于通过记住过去交互中的事实或概念来个性化应用。

注意:不要与“语义搜索”混淆,后者是一种使用“含义”(通常作为嵌入)来查找类似内容的技术。语义记忆是心理学中的一个术语,指的是存储事实和知识,而语义搜索是一种基于意义而非精确匹配的信息检索方法。

剖析

语义记忆可以通过不同方式管理。例如,记忆可以是一个持续更新的“剖析”,包含关于用户、组织或其他实体(包括代理本身)的详细且具体信息。剖析通常只是一个包含各种键值对的JSON文档,这些键值对是你选择用来表示你领域的内容。

在记录剖析时,你希望确保每次都在**更新**剖析。因此,你可能需要传入之前的剖析,并要求模型生成一个新的剖析(或一些JSON补丁以应用于旧的剖析)。当剖析变大时,这可能会变得容易出错,可能受益于将剖析拆分成多个文档,或者在生成文档时进行**严格**解码以确保内存模式保持有效。

集合

或者,记忆可以是一组随着时间推移不断更新和扩展的文档。每个单独的记忆可以更狭窄地限定范围并更容易生成,这意味着你不太可能**丢失**信息。对于大型语言模型(LLM)来说,生成_新_对象以处理新信息比将新信息与现有剖析协调起来更容易。因此,文档集合往往会导致下游更高的召回率

然而,这会将一些复杂性转移到内存更新上。现在模型必须_删除_或_更新_列表中的现有项目,这可能很棘手。此外,某些模型可能默认过度插入,而其他模型可能默认过度更新。请参阅Trustcall包以了解一种管理方法,并考虑评估(例如,使用像LangSmith这样的工具)来帮助你调整行为。

使用文档集合还会将复杂性转移到内存的**搜索**上。当前的Store支持语义搜索按内容过滤

最后,使用一组记忆可能会使向模型提供全面的上下文变得困难。虽然单个记忆可能遵循特定的模式,但这种结构可能无法捕捉完整的上下文或记忆之间的关系。因此,当使用这些记忆生成响应时,模型可能会缺乏在统一剖析方法中更易获得的重要上下文信息。

无论采用哪种内存管理方法,核心点在于代理将使用语义记忆来使其响应有依据,这通常会导致更个性化和相关的交互。

情景记忆

情景记忆在人类和AI代理中都涉及回忆过去的事件或行动。CoALA论文对此进行了很好的阐述:事实可以写入语义记忆,而*经历*可以写入情景记忆。对于AI代理而言,情景记忆通常用于帮助代理记住如何完成任务。

实际上,情景记忆通常通过少样本示例提示实现,其中代理通过学习过去的序列来正确执行任务。有时,“展示”比“告诉”更容易,而大型语言模型(LLM)从示例中学习得很好。少样本学习允许你通过用输入输出示例更新提示来“编程”你的LLM,以说明预期的行为。虽然可以使用各种最佳实践来生成少样本示例,但挑战通常在于根据用户输入选择最相关的示例。

请注意,内存存储只是存储数据作为少样本示例的一种方式。如果你想增加更多开发人员的参与度,或将少样本示例更紧密地与你的评估框架结合,也可以使用LangSmith 数据集来存储数据。然后,可以使用现成的动态少样本示例选择器来达到同样的目标。LangSmith 将为您索引数据集,并启用基于关键字相似性的少样本示例检索(使用类似 BM25 的算法进行关键字相似性检索)。

请查看这个如何使用动态少样本示例选择的视频。另外,请参阅这篇博客文章,展示了如何通过少样本提示提高工具调用性能,以及这篇博客文章,介绍了如何使用少样本示例将LLM与人类偏好对齐。

程序性记忆

程序性记忆在人类和AI代理中都涉及记住用于执行任务的规则。在人类中,程序性记忆就像内化的任务执行知识,比如通过基本的运动技能和平衡来骑自行车。另一方面,情景记忆涉及回忆具体的经历,如第一次成功骑自行车而不使用训练轮,或一次令人难忘的沿风景路线骑行。对于AI代理而言,程序性记忆是模型权重、代理代码和代理提示的组合,这些共同决定了代理的功能。

在实践中,代理修改其模型权重或重写其代码的情况相当少见。然而,代理更常见的是修改自己的提示。

一种有效改进代理指令的方法是通过"Reflection"或元提示。这涉及到用当前的指令(例如,系统提示)以及最近的对话或明确的用户反馈来提示代理。然后,代理根据这些输入来优化自己的指令。这种方法特别适用于那些难以提前指定指令的任务,因为它允许代理从互动中学习和适应。

例如,我们使用外部反馈和提示重写构建了一个Tweet生成器,以生成高质量的Twitter论文摘要。在这种情况下,具体的摘要提示很难在事前指定,但用户很容易批评生成的Tweet并提供关于如何改进摘要过程的反馈。

下面的伪代码展示了如何使用LangGraph内存存储实现这一点,使用存储保存提示,update_instructions节点获取当前提示(以及从state["messages"]中捕获的与用户的对话反馈),更新提示,并将其保存回存储。然后,call_model从存储中获取更新后的提示,并使用它来生成响应。

# 使用指令的节点
def call_model(state: State, store: BaseStore):
    namespace = ("agent_instructions", )
    instructions = store.get(namespace, key="agent_a")[0]
    # 应用逻辑
    prompt = prompt_template.format(instructions=instructions.value["instructions"])
    ...

# 更新指令的节点
def update_instructions(state: State, store: BaseStore):
    namespace = ("instructions",)
    current_instructions = store.search(namespace)[0]
    # 内存逻辑
    prompt = prompt_template.format(instructions=instructions.value["instructions"], conversation=state["messages"])
    output = llm.invoke(prompt)
    new_instructions = output['new_instructions']
    store.put(("agent_instructions",), "agent_a", {"instructions": new_instructions})
    ...

写入记忆

虽然人类常常在睡眠期间形成长期记忆,但AI代理需要不同的方法。代理应该何时以及如何创建新的记忆?至少有两种主要方法供代理写入记忆:“在热路径上”和“在后台”。

在热路径上写入记忆

在运行时创建记忆既有优势也有挑战。从积极的一面来看,这种方法允许实时更新,使新记忆可以立即用于后续的交互中。它还能够提高透明度,因为用户可以在记忆被创建和存储时得到通知。

然而,这种方法也带来了挑战。如果代理需要一个新工具来决定将什么内容提交到记忆中,可能会增加复杂性。此外,关于保存哪些内容到记忆中的推理过程可能会影响代理的延迟。最后,代理必须在创建记忆和其他职责之间进行多任务处理,这可能会影响所创建记忆的数量和质量。

例如,ChatGPT使用了一个save_memories工具,以字符串形式插入或更新记忆,并在每个用户消息中决定是否以及如何使用此工具。请参考我们的memory-agent模板作为实现参考。

在后台写入记忆

将记忆创建作为独立的后台任务,提供了若干优势。它消除了主应用中的延迟,将应用逻辑与记忆管理分离,并允许代理更专注于完成任务。这种方法还可以灵活地安排记忆创建的时间,以避免重复工作。

然而,这种方法也有其自身的挑战。确定记忆写入的频率变得至关重要,因为更新过于稀少可能导致其他线程缺少新的上下文。决定何时触发记忆形成也很重要。常见的策略包括在设定时间后进行调度(如果有新事件发生则重新调度)、使用cron计划,或者由用户或应用逻辑手动触发。

请参考我们的memory-service模板作为实现参考。