Skip to content

内存

什么是记忆?

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

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

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

理解并为你的应用程序实现这两种记忆都很重要。

IMG_PLACEHOLDER_1

短期记忆

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

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

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

管理长对话历史记录

长对话对当今的大语言模型(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归约器” 函数。在该函数中,我们定义要接受的更新类型。通常,消息会添加到现有列表中(对话会增长);但是,我们还添加了对接受字典的支持,该字典允许你“保留”状态的某些部分。这使你可以以编程方式丢弃旧的消息上下文。

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

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):
    # 向状态中的 `messages` 列表添加一条 AI 消息
    return {"messages": [AIMessage(content="Hi")]}

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

API Reference: RemoveMessage | AIMessage

在上面的示例中,add_messages 归约器允许我们像在 my_node_1 中所示的那样,将新消息追加messages 状态键中。当它看到一个 RemoveMessage 时,它将从列表中删除具有该 ID 的消息(然后 RemoveMessage 将被丢弃)。有关特定于 LangChain 的消息处理的更多信息,请查看这篇关于使用 RemoveMessage 的操作指南

有关示例用法,请参阅此操作指南以及我们 LangChain 学院课程的模块 2。

总结过往对话

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

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

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

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

def summarize_conversation(state: State):

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

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

        # 已经存在一个摘要
        summary_message = (
            f"这是到目前为止对话的摘要:{summary}\n\n"
            "考虑到上面的新消息,扩展摘要:"
        )

    else:
        summary_message = "为上面的对话创建一个摘要:"

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

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

有关示例用法,请参阅此处的操作指南以及我们 LangChain 学院课程的模块 2。

确定**何时**删除消息

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

以下是一个示例。

from langchain_core.messages import trim_messages
trim_messages(
    messages,
    # 保留消息中最后 <= n_count 个令牌。
    strategy="last",
    # 记得根据你的模型进行调整
    # 否则传递一个自定义的令牌编码器
    token_counter=ChatOpenAI(model="gpt-4"),
    # 记得根据所需的对话长度进行调整
    max_tokens=45,
    # 大多数聊天模型期望聊天历史记录以以下两种方式之一开头:
    # (1) 一条人类消息
    # (2) 一条系统消息后跟一条人类消息
    start_on="human",
    # 大多数聊天模型期望聊天历史记录以以下两种方式之一结尾:
    # (1) 一条人类消息
    # (2) 一条工具消息
    end_on=("human", "tool"),
    # 通常,如果原始历史记录中存在系统消息,我们希望保留它。
    # 系统消息包含针对模型的特殊指令。
    include_system=True,
)

API Reference: trim_messages

长期记忆

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 获取“记忆”
item = store.get(namespace, "a-memory")
# 在这个命名空间中搜索“记忆”,根据内容等价性进行过滤,按向量相似度排序
items = store.search(
    namespace, filter={"my-key": "my-value"}, query="language preferences"
)

长期记忆的思考框架

长期记忆是一个复杂的挑战,没有一种通用的解决方案。但是,以下问题提供了一个结构化的框架,帮助你探索不同的技术:

记忆的类型是什么?

人类使用记忆来记住事实经历规则。人工智能代理也可以以同样的方式使用记忆。例如,人工智能代理可以使用记忆来记住关于用户的特定事实以完成任务。我们将在以下部分详细介绍几种类型的记忆。

你想何时更新记忆?

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

内存类型

不同的应用程序需要不同类型的内存。虽然这种类比并不完美,但研究人类的记忆类型可能会很有启发。一些研究(例如CoALA 论文)甚至将这些人类记忆类型映射到了人工智能代理使用的记忆类型上。

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

语义记忆

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

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

档案

语义记忆可以通过不同的方式进行管理。例如,记忆可以是一个单一的、不断更新的“档案”,其中包含关于用户、组织或其他实体(包括代理本身)的范围明确且具体的信息。档案通常只是一个 JSON 文档,其中包含你选择用来表示你的领域的各种键值对。

在记忆档案时,你需要确保每次都**更新**档案。因此,你需要传入之前的档案,并要求模型生成一个新的档案(或者应用一些JSON 补丁到旧档案上)。随着档案变大,这可能会容易出错,并且可能需要将档案拆分为多个文档,或者在生成文档时进行**严格**解码,以确保记忆模式仍然有效。

集合

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

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

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

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

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

情景记忆

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

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

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

有关在 LangSmith 中动态选择少样本示例的示例用法,请参阅此视频。此外,还可以参阅这篇博客文章,展示了如何使用少样本提示来提高工具调用性能,以及这篇博客文章,介绍了如何使用少样本示例使大型语言模型符合人类偏好。

程序记忆

程序记忆,无论是在人类还是人工智能代理中,都涉及记住执行任务所使用的规则。在人类中,程序记忆就像是内化的如何执行任务的知识,例如通过基本的运动技能和平衡骑自行车。而情景记忆则涉及回忆特定的经历,例如你第一次在没有辅助轮的情况下成功骑自行车,或者一次难忘的风景优美的骑行。对于人工智能代理来说,程序记忆是模型权重、代理代码和代理提示的组合,它们共同决定了代理的功能。

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

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

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

下面的伪代码展示了如何使用 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})
    ...

写入记忆

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

热路径写入记忆

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

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

例如,ChatGPT 使用 save_memories 工具以内容字符串的形式更新插入记忆,并针对每条用户消息决定是否使用以及如何使用该工具。请参考我们的 memory-agent 模板作为参考实现。

后台写入记忆

将创建记忆作为一个单独的后台任务有几个优点。它消除了主应用程序的延迟,将应用程序逻辑与记忆管理分离,并允许代理更专注地完成任务。这种方法还能灵活安排记忆创建的时间,以避免重复工作。

然而,这种方法也有其自身的挑战。确定写入记忆的频率至关重要,因为更新不频繁可能会使其他线程无法获得新的上下文。决定何时触发记忆形成也很重要。常见的策略包括在设定的时间段后进行调度(如果有新事件发生则重新调度)、使用定时任务调度,或者允许用户或应用程序逻辑手动触发。

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

Comments