Skip to content

内存

什么是内存?

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

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

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

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

短期记忆

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

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 的 “归约器” 函数。在该函数内,我们定义要接受的更新类型。通常,消息将被添加到现有列表中(对话将增长);然而,我们还添加了接受字典的支持,允许您“保留”某些部分的状态。这使您能够以编程方式删除旧的消息上下文。

另一个常见的方法是允许您返回一组“移除”对象,这些对象指定要删除的所有消息的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="你好")]}

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

在上述示例中,add_messages 归约器允许我们 附加 新消息到 messages 状态键,如 my_node_1 中所示。当它看到 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)

    # 删除除了最近的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)。

以下是一个示例。

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 文档的形式存储在 store 中(参考文档)。每个记忆在一个自定义的 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 将数据保存到内存字典中。在生产使用中请使用 DB 支持的存储。
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": [
            "用户喜欢简短、直接的语言",
            "用户仅使用英语和 Python",
        ],
        "my-key": "my-value",
    },
)
# 通过 ID 获取“记忆”
item = store.get(namespace, "a-memory")
# 在此命名空间内搜索“记忆”,过滤内容相等,按向量相似度排序
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与人类偏好对齐。

程序性记忆

程序性记忆,无论是在human人类还是AI智能体中,都涉及记住用于执行任务的规则。在人类中,程序性记忆就像内化的知识,知道如何执行任务,例如通过基本的运动技能和平衡骑自行车。另一方面,情节记忆涉及回忆特定的经历,例如您第一次成功骑自行车而无需辅助轮的时刻,或通过风景如画的路线进行的难忘骑行。对于AI智能体而言,程序性记忆是模型权重、智能体代码和智能体提示的组合,共同决定智能体的功能。

在实践中,智能体修改其模型权重或重写其代码是相当不常见的。然而,智能体修改自己的提示则更为常见。

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

例如,我们构建了一个Tweet生成器,利用外部反馈和提示重写来生成高质量的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})
    ...

写入记忆

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

在热路径上写入记忆

在运行时创建记忆提供了优势和挑战。一方面,这种方法允许实时更新,使新记忆立即可用于后续交互。它还能够提供透明度,因为用户可以在创建和存储记忆时获得通知。

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

例如,ChatGPT使用save_memories工具将记忆作为内容字符串进行插入,决定何时以及如何使用此工具与每条用户消息。请查看我们的memory-agent模板,作为一个参考实现。

在后台写入记忆

将记忆创建为一个单独的后台任务提供了几个优势。它消除了在主要应用程序中的延迟,将应用逻辑与内存管理分开,并允许智能体专注于完成更具针对性的任务。这种方法还提供了时间灵活性,以避免冗余的工作。

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

请查看我们的memory-service模板,作为一个参考实现。

优云智算