首页 / 文章 / LangGraph Agent如何在重启后存活:检查点、检查点管理器与线程

LangGraph Agent如何在重启后存活:检查点、检查点管理器与线程

了解 LangGraph 持久化机制的运作原理:为何没有它智能体就会丢失所有数据,以及状态、检查点、检查点管理器与线程 ID 是如何让程序在崩溃后能够继续运行的。

4200 词

将所有内容保存在进程内存中的代理,在进程停止的瞬间就会丢失所有数据:无论是部署失败、程序崩溃、未处理的超时,还是单个请求的结束,都足以清除对话记录及所有中间结果。LangGraph通过持久化机制解决了这一问题,该机制会在每一步之后记录图的结构状态,从而使得运行过程可以暂停、恢复并继续,而无需重新开始。本指南将从基础层面构建相关概念模型:没有持久化机制时会出现什么问题、LangGraph的状态与检查点之间的关系、检查点的功能、线程ID如何区分不同的对话,以及内存中的检查点处于何种位置。学习完之后,你将能够为图结构添加持久化功能,明确了解哪些数据会在何时被保存,同时也明白为何内存中的检查点仅适用于开发阶段。

持久性也是若干通常被单独讨论的功能的基础:人工审批流程、多轮次间的工具调用以及长时间运行的智能体,全都依赖于能够暂停当前状态并在之后重新继续的能力。先理解这一点,就能让这些功能显得不那么神秘。

为何智能体需要超越任务本身的状态

试想一下,你花了两个小时输入一份长文档却未保存,随后电源断了。之前的工作就全没了,你只能从第一页重新开始。没有持久性的智能体每次重启时都会处于同样的状况——它无法记住几秒钟前发生的事情,更不用说几天前的事了。

简而言之,持久化意味着将应用程序的状态存储在某种持久化的介质中,这样在程序停止后仍能恢复并继续运行。它就像一个自动保存按钮,在几乎每一步之后都会触发,而无需任何人手动操作。

对于智能体而言,这一点比传统的请求/响应模式更为重要,因为现代智能体很少只是涉及一个问题和一个答案。它们通常会:

  • 在数小时甚至数天内持续进行对话;
  • 暂停并等待用户批准某个操作;
  • 通过多次调用工具来完成一项任务;
  • 在较长时间内逐步推理;
  • 能够在重启或崩溃后仍保留已有的进展。

RAM速度很快,但属于易失性存储:进程退出时其内容会被清除。如果智能体在单个进程的生命周期之外还需要使用某些数据,就必须将其写入持久存储中,之后再读取。在LangGraph中,这种存储以及管理它的机制就是所谓的“持久性”。

智能体缺乏持久性时会出现什么问题

通过具体的故障场景最容易理解这个问题。以下每种情况在实际应用中都很常见。

长时间任务执行到一半时断电

某个智能体正在汇总一份200页的报告,当机器断电时它已经处理到第100页。由于没有保存状态,系统无法记录已有半数工作已完成,因此下一次运行会从第1页开始。

服务器常规重启

该智能体运行在云服务器上,常规部署时会重启它。所有正在对话中的用户都会丢失对话记录,甚至连两分钟前才被告知的用户名也会被遗忘。

多步骤任务执行过程中出现故障

在某个更大任务的处理过程中,智能体调用了外部API但该接口超时,导致Python进程因未处理的异常而终止。出错的步骤会丢失,智能体在此之前完成的所有工作也会随之消失。

需要运行数小时或数天的工作流

有些智能体被刻意设计得运行速度较慢。比如那种用于监控股票价格、只有在达到特定阈值时才会采取行动的智能体,如果它的全部状态都存储在内存中,那么就无法暂停、重新部署或转移到其他机器上,必须从头开始。

需要数小时才能完成的人工审批

智能体会起草一份法律文件,该文件在发送前必须经过律师的审批。在六小时内律师不得查看处理队列。让流程长时间停滞并占用资源是一种浪费,而且如果在等待期间服务器重启,该任务就会消失。

后期失效的多步骤推理

复杂的智能体通常会将工作分解为规划、搜索、验证和总结等循环步骤。如果10个步骤中的第7步失败,重新执行前6步只会浪费时间、API调用次数以及金钱。

为何“直接重新运行”并非可行策略

从零开始重启听起来似乎可以接受,但一旦计算成本就会发现问题:

  • 金钱成本。每次调用大型语言模型都会消耗令牌,因此因为第7步失败而重新执行前6个已成功的步骤,就意味着要为它们支付两次费用。
  • 时间成本。用户不得不等待他们已经等待过的任务被重复处理。
  • 可靠性。那种每次刷新页面都会忘记贷款申请的银行助手显然算不上真正的优质产品。
  • 副作用。如果之前的步骤已经发送了邮件或扣款,再次执行这些操作会造成实际损失。有些步骤并不适合重复执行。
  • 最后一点最为重要。持久化的目的不仅是为了节省精力,更是为了让应用程序能够暂停、出错、重启或等待,然后从实际停止的位置继续运行,从而确保已完成的工作不会丢失。

    持久化的精确定义

    考虑到上述问题,一个更准确的定义很有用:持久化是指系统能够将其内部数据及状态写入持久存储介质中,从而使这些数据在当前运行结束后依然存在,并可被重新加载以从之前停止的位置继续执行。

    该定义包含三种不同的功能:

    1. 状态保存:记录智能体当前所知的信息以及它已经执行过的操作。
    2. 恢复之前的执行状态:即使在重启后也能读取这些记录。
    3. 继续工作流程:从恢复的点继续执行,而非从头开始。

    临时内存与持久存储

    一个常见的混淆点在于数据存储与数据持久化之间的区别。用于保存对话内容的普通 Python 字典属于临时内存:进程结束时,该字典就会消失。而持久化则意味着对数据生成快照,并将其保存在能够超越进程生命周期的地方,通常是数据库中。这就是其核心理念;本指南的其余部分将解释 LangGraph 是如何实现这一功能的。

    持久化在生产环境中的价值

    除了避免工作丢失之外,持久化还能改变你能构建的系统类型。

    • 长时间运行的代理程序。那些需要爬取大量页面、处理大型数据集或等待外部事件的代理程序,可以在任何能够访问存储的机器上随时暂停和恢复。
    • 容错能力。具备容错能力的系统在发生崩溃、网络故障或超时时仍能保持正常运行。有了持久化,失败只会影响正在进行的步骤,而不会导致整个任务失败。
    • 审批工作流。当需要有人审核内容时,代理程序可以按需暂停,而不会丢失任何信息。一旦状态能够持久保存,构建此类工作流就会变得十分简单。
  • 无需自定义代码即可恢复。您不必编写专门的恢复程序,只需加载最新保存的检查点即可继续运行;一旦启用持久化功能,LangGraph会自动处理这部分工作。
  • 可靠的产品。真实用户不会接受每次部署程序后都要求重新开始的情况。持久化功能正是区分脆弱演示版与正式产品的关键所在。
  • 更低成本。那些已经成功完成但耗时较长的操作,比如多次迭代或缓慢的工具调用,无需再次支付费用。
  • 更好的体验。人们期望聊天助手能在他们关闭标签页后再返回时仍记住对话内容,这种记忆功能正是持久化的体现。
  • 一个快速判断你是否需要它的测试:如果现在立即重启该进程,用户会不满意吗?如果是,那么这个图就需要持久化存储。

    状态:真正被保存的内容

    只有理解了状态,LangGraph中的持久化才有意义,因为持久化图实际上就是在恰当的时刻保存其状态。

    LangGraph应用程序就是一个图:由称为节点的一系列步骤通过边连接起来,数据在它们之间流动。当执行流程在各个节点间切换时,这些节点需要一个共享的结构来进行读写操作。这个共享结构就是状态。一个形象的比喻是会议室里的白板:每个节点走到白板前,阅读上面的内容,添加自己的内容后再交给下一个节点。

    状态通常被定义为带类型的字典。第一步就是导入相关库:

    from typing import TypedDict
    

    该架构会列出图结构所使用的键及其类型。此处,状态用于存储消息列表、用户姓名以及步数计数器:

    class State(TypedDict):
        messages: list
        user_name: str
        step_count: int
    

    由于 State 是一个 TypedDict,它实际上就是一个具有固定键集和已声明类型的字典。每个节点会接收当前状态并返回部分更新内容,随后 LangGraph 会将这些更新合并到共享状态中。

    为何一切都以状态为核心

    状态是 LangGraph 应用程序的核心:

    • 节点通过读取状态来决定执行操作;
    • 节点将处理结果写回状态中;
    • 边可以根据状态中的值路由到不同的节点;
    • 持久化功能负责保存和恢复状态。

    一旦理解了这一点,持久化的核心就简化为一句话:每执行一步后,都要将该状态拍成照片并保存在安全的地方。

    每步之后都生成快照

    在接下来的指南中,我们将始终保留这个模型。每当一个节点处理完成时,LangGraph会捕获当前状态并将其写入存储。如果进程在第二个节点处理完成后立即终止,此时生成的快照仍然存在,这样就可以从该点继续执行,而无需从头开始。这个快照有一个名称:检查点。

    检查点:某一时刻的状态快照

    检查点是指在特定时间点上图状态的一个快照。这一术语源自电子游戏领域:它就像一个安全地点,让你可以返回那里,而无需重新玩整个关卡。

    检查点带来的优势

    检查点能够可靠地回答一个问题:在某个步骤执行完成后,系统状态究竟是怎样的?没有检查点的话,你只能知道当前的状态,而且仅限于程序运行期间。而有了检查点,你可以查看运行过程中的任何 earlier 时间点的状态,系统在出现故障后也能恢复到最近的那个检查点状态。

    检查点会自动为你创建

    让许多新手感到意外的是,其实根本无需手动保存检查点。一旦将检查点器附加到图结构中,LangGraph就会在每个超级步骤之后自动写入新的检查点。所谓超级步骤,指的是图结构执行循环中的一次迭代;在简单的线性图中,它对应于一个节点的处理完成;而在具有并行分支的图中,则所有在同一迭代中被调用的节点都属于同一个超级步骤。你只需编写普通的图结构代码,保存操作就会在背后自动完成。

    检查点包含的内容

    检查点通常记录以下信息:

    • id:唯一标识符,按顺序排列以便后续检查点位于早期检查点之后;
    • ts:检查点创建的时间;
    • channel_values:该时刻的状态数据,如消息和其他变量;
    • channel_versions:LangGraph用于追踪状态哪些部分发生变更的内部版本计数器;
    • 元数据:用于记录生成检查点的节点以及步骤编号等信息。

    无需记住这种结构,掌握其工作定义即可:检查点就是某个特定时刻的状态快照加上相关记录。若想了解这些组件在内部是如何存储的,包括待处理的写入操作和数据块,可参阅Inside LangGraph's InMemorySaver中的详细说明。

    逐步了解计数器

    设想有一个只有一个节点的图,该节点会不断递增一个数值,并连续运行三次。第一次运行后保存的状态为{count: 1},第二次为{count: 2},第三次为{count: 3},这些状态各自都作为一个检查点。如果在写入第二个检查点后立即程序崩溃,重启时可以从{count: 2}继续执行,而无需重复前两次的递增操作。

    检查点管理器:负责保存与加载的组件

    如果检查点就是快照,那么检查点管理器就是负责生成快照、存储它们并检索它们的组件。它属于LangGraph的持久化层。

    一个很贴切的类比是带有内置文件柜的相机:每当一个节点处理完成,相机就会拍摄该状态的照片并将其存档。这个文件柜可以是进程内存、本地文件或数据库,具体取决于所选择的检查点器。

    检查点器的三项功能

    检查点器负责:

    1. 保存状态:在每一步之后将新的快照写入存储介质。
    2. 加载状态:当同一线程再次调用图结构时,读取最新的快照(线程相关内容将在下一节介绍)。
    3. 恢复执行:将该快照交给运行时系统,使图结构从停止处继续执行,而非重新开始。

    在编译时附加检查点器

    在编译图时,持久化功能会被启用。你需要导入检查指针类和图构建器;源代码将这段代码标记为 JavaScript,但实际上它是 Python:

    from langgraph.checkpoint.memory import InMemorySaver
    from langgraph.graph import StateGraph
    

    接着创建检查指针并将其传递给 compile() 函数。请注意,在打印出的代码片段中,注释与 checkpointer = InMemorySaver() 这行赋值被合并到了同一行,这会导致赋值语句变成注释的一部分;而在实际代码中,它们应分别位于不同行:

    # ... assume `builder` is a StateGraph you've already defined ...checkpointer = InMemorySaver()
    graph = builder.compile(checkpointer=checkpointer)
    

    那个 checkpointer=checkpointer 参数能够为整个图启用持久化功能。如果没有这个参数,LangGraph 将不会存储任何数据,每次 invoke() 调用都会从空状态开始。

    由此形成的循环是:加载、运行、保存,每完成一步后重复此过程。正因如此,持久化操作看起来是自动完成的:你无需手动调用保存或加载函数,因为编译后的执行流程会将其作为正常运行的一部分来处理。

    有一个容易被忽视的注意事项。检查点仅能持久化其被传递到的图结构。如果同一个文件在未使用检查点的情况下编译出第二个图,那么这个第二个图将完全不具备持久化功能。

    线程:隔离不同的对话

    thread_id值在LangGraph的代码中随处可见,它需要一个准确的解释。线程代表一次连续的对话或任务。每个线程都有唯一的ID,而该对话产生的所有检查点都会被归类到对应的线程下。

    为何每个对话都需要独立的线程

    想象一下,有一个客服聊天机器人需要同时为数千名客户提供服务。其中一名客户询问退款问题,另一名则咨询货物延迟送达的情况。这些都是并行进行的独立对话,如果将它们混为一谈,比如向第一名客户告知第二名客户的包裹情况,那将是一场严重的失误。

    线程ID就像文件夹上的标签一样,可以避免这种情况。每个状态记录都会被归档到唯一的线程ID下,因此不同对话的状态永远不会混在一起。

    在代码中传递线程ID

    线程是通过配置字典来选择的。thread_id位于configurable键下(以下代码片段为Python语言,而非纯文本):

    config = {"configurable": {"thread_id": "customer-a-session-101"}}
    

    每次调用时,该配置都会与输入数据一起传递:

    result = graph.invoke({"messages": [{"role": "user", "content": "Where's my refund?"}]}, config)
    

    每次调用 graph.invoke() 或 graph.stream() 时,都会传入一个包含 thread_id 的 config 对象。LangGraph 会利用它来:

    • 如果存在历史记录需要加载,则查找该线程对应的现有检查点;
    • 在运行持续进行时,以相同的 ID 创建新的检查点。

    如果使用不同的 thread_id,则会得到一个全新的、空白的对话状态,就好像状态已被重置一样,尽管编译后的图结构与检查点实际上是完全相同的对象。

    线程如何对应到实际产品

    • 聊天界面:你打开的每个聊天窗口实际上都是独立的线程,切换聊天窗口就相当于切换线程 ID。在一个对话中说的内容不会泄露到另一个对话中。
  • 支持服务台:每张工单可对应一个线程ID,这样该客户的操作记录就能被单独保存,便于日后查询。
  • 个人助理:负责管理某人日历和待办事项的助理可能会使用与用户账户关联的单一、长期有效的线程,从而使用户的偏好设置能够在多天内保持一致。
  • 实际应用中,线程ID应当有计划地生成并存储,例如可从您数据库中的工单编号或会话ID中获取。即便该ID丢失了,虽然仍有检查点存在,但却没有线索可以指引到它们。

    一个编译后的图结构,多个线程

    无需为每个用户生成独立的图表。一个编译好的图表可以同时服务于任意数量的线程;真正需要的是为每个用户或每段对话分配唯一的线程ID。图表定义了行为逻辑,而线程则决定了该行为作用于谁的状态。

    追踪从启动到崩溃再到恢复的整个运行过程

    将状态、检查点、检查指针以及线程结合在一起,就能完整呈现执行过程中的所有情况。下方的流程图(使用Mermaid语法以文本形式展示)记录了包括崩溃及之后恢复路径在内的整个运行过程:

    flowchart TD
        A[1. Graph starts with invoke] --> B[2. Checkpointer checks thread_id for existing State]
        B --> C{State exists for this thread?}
        C -->|Yes| D[3a. Load last saved State]
        C -->|No| E[3b. Start with fresh empty State]
        D --> F[4. Node executes]
        E --> F
        F --> G[5. State updates in memory]
        G --> H[6. Checkpoint saved to storage]
        H --> I{More nodes to run?}
        I -->|Yes| F
        I -->|No| J[7. Return final result to caller]
        H -.->|💥 Crash happens here| K[Process restarts]
        K --> B
    

    用文字描述的话,其顺序如下:

    1. 图表开始运行。 使用特定的thread_id调用graph.invoke(input, config)方法。
  • 创建或加载状态。检查指针会判断该线程是否已存在检查点。如果存在,则最新的检查点成为起始状态;否则,执行将从空状态开始。
  • 节点根据所获得的状态进行执行。
  • 状态得到更新。节点返回的任何内容都会被合并到当前状态中。
  • 写入检查点。检查指针会将新状态存储在对应的线程ID下。
  • 下一个节点开始运行,然后重复步骤3到5。
  • 发生崩溃,比如在为节点2写入检查点之后、节点3开始执行之前。
  • 执行继续进行。在同一个线程上再次调用该图结构,会加载上次成功写入的检查点,即节点2之后的那个,随后执行将从节点3继续,而非节点1。
  • 无需实现单独的恢复模式。在同一个线程上再次调用图结构即可,LangGraph会自行判断从何处继续执行。

    有一个细节需要准确说明。若要继续被中断或失败的运行,需以None作为输入并使用相同的配置来调用图处理函数,这样就能让LangGraph从保存的检查点继续执行,而非启动新的运行。在现有线程上传递新输入则会基于已保存的状态启动新的运行:带有聚合函数的键(如消息列表)会持续累积,而普通键则会被新值覆盖。可通过graph.get_state(config)查看最新的状态快照,通过graph.get_state_history(config)查看完整的状态序列。

    正是这种机制使得智能体能够有意停止运行,例如等待人类做出决策,然后在数小时或数天后在另一台可以访问相同持久化存储的机器上继续运行。如需直观了解该模式,请参阅使用中断与命令暂停及恢复 LangGraph 智能体。

    最简单的后端:InMemorySaver

    LangGraph 并不强制要求使用某种特定的存储系统。它支持多种后端,即用于保存检查点的位置,这些后端主要在持久性以及可同时访问的进程数量方面存在差异。其中最简单的是 InMemorySaver。

    它的功能及检查点存储方式

    InMemorySaver是一种检查点工具,它将所有检查点存储在当前Python进程的RAM中,以线程ID为索引的普通内存字典形式存在。没有文件也没有数据库,仅有Python对象。

    第一个示例使用带有一个count键的类型化状态,以及一个用于将该值加1的节点。该节点从START连接到END,图结构通过InMemorySaver进行编译,并在thread-1上以初始值1开始运行,最终结果为2。原文标注其为JavaScript,但实际上是Python代码;还需注意,该示例假定TypedDict、StateGraph、START、END和InMemorySaver早已被导入。

    class StateInt(TypedDict):
        count: int
    
    def add_one(state: StateInt) -> dict:
        return {"count": state["count"] + 1}
    
    builder = StateGraph(StateInt)
    builder.add_node("add_one", add_one)
    builder.add_edge(START, "add_one")
    builder.add_edge("add_one", END)
    
    memory = InMemorySaver()
    graph = builder.compile(checkpointer=memory)
    
    config = {"configurable": {"thread_id": "thread-1"}}
    result = graph.invoke({"count": 1}, config)
    print(result)  # {'count': 2}
    

    接下来的代码片段仅用于说明:将图的最简版本与上方更真实的版本进行对比:

    Two ways to write the same graph — minimal vs. real-world.
    

    最简版本除了需要检查点机制和图构建器外,还必须使用asyncio:

    import asyncio
    from langgraph.checkpoint.memory import InMemorySaver
    from langgraph.graph import StateGraph
    

    它使用普通的int作为整个状态,将一个lambda函数注册为节点,将该节点同时标记为起始点和结束点,然后通过ainvoke异步执行图的处理。如所示,多条语句被写在同一行上(例如set_finish_point调用和InMemorySaver()赋值操作),因此在运行前需将它们分开;尽管有相应的标签,这段代码实际上仍是Python编写的:

    builder = StateGraph(int)
    builder.add_node("add_one", lambda x: x + 1)
    builder.set_entry_point("add_one")
    builder.set_finish_point("add_one")memory = InMemorySaver()
    graph = builder.compile(checkpointer=memory)config = {"configurable": {"thread_id": "thread-1"}}
    result = asyncio.run(graph.ainvoke(1, config))
    print(result)  # Output: 2
    

    重点分析以下关键行:

    • InMemorySaver()会在内存中创建一个空的检查点存储结构。
  • builder.compile(checkpointer=memory)会将该存储附加到图中,从而启用持久化功能。
  • config = {“configurable”: [0]}将此次调用与某个特定名称的对话关联起来。
  • graph.ainvoke(1, config)是异步入口点,此处以整数1作为状态在“thread-1”线程上启动。
  • 使用相同的thread_id再次调用时,会基于该线程已保存的检查点进行操作,而非从空历史记录开始。需注意与前文的区别:在这个小型图中,运行已经完成,状态仅为一个被覆盖的值,因此新的输入只会替换它;而将None作为输入则会让未完成的运行继续下去。

    优势

    • 无需配置:不需要数据库或外部服务。
    • 速度极快,因为没有磁盘或网络延迟。
    • 非常适合单元测试。
    • 便于学习和笔记本实验使用。

    局限性

    • 进程停止时所有内容都会消失。 这只是普通的RAM,正是本指南开头所提到的问题。
    • 无法在多个进程或服务器之间共享,因为每个进程都有独立的内存。
    • 不适用于生产环境,因为普通重启就会清除所有对话记录。

    何时使用它

    适用场景包括:

    • 本地开发与调试;
    • 单元测试及CI流水线中的自动化测试;
    • 那些无需在重启后保留数据的快速原型和笔记本应用。

    任何真实用户依赖的功能都需要基于数据库的检查点机制。LangGraph的文档明确指出InMemorySaver仅适用于调试和测试,建议在生产环境中使用如PostgresSaver这样的持久化实现。接下来自然要做的就是搭建这类机制,同时还包括SQLite和Redis等其他后端、基于interrupt()和Command构建的自定义检查点机制及审批流程;关于在Postgres和Redis上运行LangGraph的生产级示例,可参考自托管LangGraph代理服务器一文。

    关键要点

    • 持久化功能会将图的结构状态写入持久存储,这样在系统崩溃、重启或长时间等待后仍能继续运行,而无需重新开始。
  • 重新开始不仅速度慢,还会重复调用付费的LLM服务,并可能再次引发邮件发送或支付等副作用。
  • 状态是在图中流动的共享数据,也正是会被保存的内容。
  • 检查点是在一个超级步骤之后该状态的快照;一旦传递给compile(),检查点管理器会自动创建、存储并重新加载这些检查点。
  • thread_id用于隔离单个对话或任务,这样同一个已编译的图结构就能安全地为多个用户服务。
  • 恢复被中断的运行意味着使用None来调用同一个线程;在现有线程上输入新内容则会在已保存的状态基础上启动新的运行。
  • InMemorySaver非常适合用于测试和原型开发,但由于它存储在进程内存中,生产环境需要基于数据库的检查点管理器。