断点续传:LangGraph人机交互与状态恢复

断点续传:LangGraph人机交互与状态恢复------多Agent系统的生存必修课

大家好,我是程序员小策。

先看两行代码:

python 复制代码
from langgraph.checkpoint.memory import MemorySaver

graph = workflow.compile(checkpointer=MemorySaver())

如果你觉得这行代码人畜无害------恭喜你,你还没在生产环境重启过容器。

MemorySaver 这个名字翻译过来就是"内存存储"。内存意味着什么?进程重启,数据清零。你的 Agent 好不容易跑了 15 步,LLM 调了 8 次,马上就出结果了------容器一重启,全没了。

更致命的是:如果你的 Agent 流程里有一个"等待人工审批"的节点呢?审批人打开后台的时候,Agent 的状态去哪找?

今天就用这两行代码作为入口,把 LangGraph 的 checkpoint 机制从原理到生产落地,从头拆到尾。


一、问题的本质:Agent 不是一次性的

传统的 LLM 调用是"一问一答":你发一个问题,模型返回一个答案,结束。

但 LangGraph 构建的多 Agent 系统不是这样。一个典型的审批工作流可能是:

复制代码
用户输入 → 意图识别 → 内容生成 → 人工审批 → 执行 → 通知

这中间有 6 个节点,每个节点都可能失败、超时、或者需要等人工介入。如果你把整个流程的状态放在内存里,任何一个环节出问题,你就得从头再来。

想象你在做一道需要 6 个步骤的菜------切菜、焯水、炒糖色、炖肉、收汁、装盘。做到第 4 步"炖肉"的时候,煤气灶坏了。修好之后,你是从第 1 步"切菜"重新开始,还是从第 4 步"炖肉"继续?

你看,这就是 checkpoint 要解决的问题。

Checkpoint(检查点):LangGraph 在执行图的每个节点(super-step)之后自动保存的一份完整状态快照。它包括当前所有消息、中间变量、以及下一步该往哪个节点走。当流程中断后,你可以从最近的 checkpoint 恢复,而不是从头开始。


二、方案一:MemorySaver------能用,但别上生产

LangGraph 开箱提供了一个 MemorySaver,它把 checkpoint 存在 Python 进程的内存里。

python 复制代码
from langgraph.checkpoint.memory import MemorySaver
from langgraph.graph import StateGraph, START, END
from typing import TypedDict, List
from langchain_core.messages import BaseMessage


class AgentState(TypedDict):
    """Agent 状态定义------LangGraph 会为这个 dict 的每个版本创建 checkpoint"""
    messages: List[BaseMessage]
    current_step: str
    approval_status: str


# 构建一个简单的审批工作流
builder = StateGraph(AgentState)

def prepare_content(state: AgentState) -> AgentState:
    """节点1:准备内容"""
    state["current_step"] = "prepare"
    return state

def human_approval(state: AgentState) -> AgentState:
    """节点2:等待人工审批------这里会触发 interrupt"""
    state["current_step"] = "approval"
    state["approval_status"] = "pending"
    return state

def execute_content(state: AgentState) -> AgentState:
    """节点3:执行已审批的内容"""
    state["current_step"] = "execute"
    return state

builder.add_node("prepare", prepare_content)
builder.add_node("approval", human_approval)
builder.add_node("execute", execute_content)
builder.add_edge(START, "prepare")
builder.add_edge("prepare", "approval")
builder.add_edge("approval", "execute")
builder.add_edge("execute", END)

# 关键行:用 MemorySaver 编译
graph = builder.compile(checkpointer=MemorySaver())

这段代码跑起来没问题。graph.invoke() 每次调用后,LangGraph 自动把整个 AgentState 存到 MemorySaver 里。你可以用同一个 thread_id 多次调用,Agent 会记住之前的对话。

但问题也很明显:

  1. 进程重启数据丢失。K8s 滚动更新、OOM Kill、服务器重启------任何一个都会让你的 MemorySaver 清零。
  2. 无法跨进程共享 。如果你的 FastAPI 服务有多个 worker 进程,每个进程的 MemorySaver 是独立的,同一个 thread_id 在不同 worker 上拿到的状态完全不同。
  3. 没有持久化。你连"看一眼当前有哪些正在等待审批的任务"都做不到------因为数据在内存里,不在数据库里。

那怎么解决?把 checkpoint 存到数据库里。


三、方案二:PostgreSQL 持久化------生产环境的基线

LangGraph 官方提供了 langgraph-checkpoint-postgres 包,让你把 checkpoint 存到 PostgreSQL。这是目前生产环境最常用的方案。

来看看从 GitHub项目 martinpercu/Langchain-Langgraph_Agents-Structure 中提取的生产级代码:

3.1 数据库连接与 FastAPI 生命周期管理

python 复制代码
# db.py ------ 来源:martinpercu/Langchain-Langgraph_Agents-Structure
# 在 FastAPI 启动时初始化 PostgresSaver,关闭时释放连接
import os
from contextlib import asynccontextmanager
from fastapi import FastAPI, Depends
from typing import Annotated
from langgraph.checkpoint.postgres import PostgresSaver

# 为什么用环境变量而不是硬编码?多环境部署时,开发/测试/生产
# 的数据库地址不同,硬编码会导致每次切换环境都要改代码
DB_URI = os.getenv("DB_URI", "postgresql://postgres:postgres@localhost:5432/agent_test")

# 全局单例------整个应用共享一个 checkpointer 实例
# 为什么是全局单例?PostgresSaver 内部维护了连接池,
# 创建多个实例会浪费数据库连接资源
_checkpointer: PostgresSaver | None = None


@asynccontextmanager
async def lifespan(app: FastAPI):
    """
    FastAPI 生命周期管理。
    为什么用 lifespan 而不是中间件?
    lifespan 在应用启动时执行一次,关闭时执行一次,
    确保 checkpointer 在应用接受请求前就绪,在停止后正确释放。
    """
    global _checkpointer
    # from_conn_string 自动从连接字符串创建连接池
    with PostgresSaver.from_conn_string(DB_URI) as checkpointer:
        _checkpointer = checkpointer
        # setup() 创建 checkpoint 所需的数据库表
        # 如果表已存在则跳过,不会重复创建
        _checkpointer.setup()
        yield
    # yield 之后是清理逻辑------FastAPI 关闭时自动执行
    # with 语句退出时自动关闭数据库连接


def get_checkpointer() -> PostgresSaver:
    """依赖注入函数:每个请求通过 FastAPI 的 Depends 获取 checkpointer"""
    if _checkpointer is None:
        raise RuntimeError(
            "Checkpointer 未初始化。请确认 FastAPI 使用了 lifespan 参数。"
        )
    return _checkpointer


# FastAPI 的依赖注入类型别名------类型安全 + 自动注入
CheckpointerDep = Annotated[PostgresSaver, Depends(get_checkpointer)]

3.2 FastAPI 路由层:动态编译 + checkpoint 注入

python 复制代码
# main-2.py ------ 来源:martinpercu/Langchain-Langgraph_Agents-Structure
# 每个请求动态创建 Agent 实例并注入 PostgreSQL checkpointer
from dotenv import load_dotenv
load_dotenv()

from pydantic import BaseModel
from fastapi import FastAPI
from langchain_core.messages import HumanMessage
from fastapi.responses import StreamingResponse
from api.db import lifespan, CheckpointerDep

# 为什么把 lifespan 传给 FastAPI?让 checkpointer 在应用启动时初始化
app = FastAPI(lifespan=lifespan)


class Message(BaseModel):
    message: str


@app.post("/chat/{chat_id}")
async def chat(chat_id: str, item: Message, checkpointer: CheckpointerDep):
    """
    每个对话有独立的 thread_id,PostgreSQL 按 thread_id 隔离 checkpoint。
    为什么 chat_id 直接作为 thread_id?
    同一个 chat_id 的多次请求共享同一个状态------这正是"断点续传"的基础。
    """
    config = {
        "configurable": {
            "thread_id": chat_id,  # 对话隔离的关键
        }
    }
    human_message = HumanMessage(content=item.message)

    # 为什么每次请求都要重新编译 Agent?
    # 因为 checkpointer 是应用级别的单例,但 Agent 图结构可能不同
    # 动态创建确保每个请求使用正确的 checkpointer 实例
    from agents.support_agent.support_agent import make_the_agent
    agent = make_the_agent(config={"checkpointer": checkpointer})

    state = {"messages": [human_message]}
    response = agent.invoke(state, config)
    last_message = response["messages"][-1]
    return last_message.content

3.3 Agent 动态编译

python 复制代码
# support_agent.py ------ 来源:martinpercu/Langchain-Langgraph_Agents-Structure
# 将 Agent 编译包装为函数,支持运行时注入 checkpointer
from langgraph.graph import StateGraph, START, END
from typing import TypedDict

from agents.support_agent.state import State
from agents.support_agent.nodes.conversation.node import conversation_moment
from agents.support_agent.nodes.extractor.node import extractor
from agents.support_agent.nodes.booking.node import booking_node
from agents.support_agent.routes.intention.route import intention_route


def make_the_agent(config: TypedDict):
    """
    动态创建 Agent 实例。
    为什么不直接在模块级别编译?因为模块加载时 checkpointer 还没初始化。
    把这个函数放在函数里,每次调用时传入已初始化的 checkpointer。
    """
    checkpointer = config.get("checkpointer", None)
    builder = StateGraph(State)
    builder.add_node("extractor", extractor)
    builder.add_node("conversation_moment", conversation_moment)
    builder.add_node("booking_node", booking_node)

    builder.add_edge(START, 'extractor')
    builder.add_conditional_edges('extractor', intention_route)
    builder.add_edge('conversation_moment', END)

    # 关键:编译时传入 checkpointer
    # LangGraph 会在每个 super-step 后自动调用 checkpointer.put()
    agent = builder.compile(checkpointer=checkpointer)
    return agent

3.4 Docker Compose:PostgreSQL 基础设施

yaml 复制代码
# docker-compose.yml ------ 来源:martinpercu/Langchain-Langgraph_Agents-Structure
services:
  postgres:
    image: postgres:15-alpine
    container_name: agent_checkpoint_db
    restart: unless-stopped
    environment:
      POSTGRES_DB: agent_test
      POSTGRES_USER: postgres
      POSTGRES_PASSWORD: postgres
    ports:
      - "5432:5432"
    volumes:
      - postgres_data:/var/lib/postgresql/data  # 持久化 checkpoint 数据
    networks:
      - agent_network

volumes:
  postgres_data:

networks:
  agent_network:
    driver: bridge

现在我们有了一个可以存到数据库的 checkpoint 方案。容器重启后,checkpoint 还在 PostgreSQL 里。多个 worker 进程共享同一个数据库,thread_id 的隔离性也保证了。

但这套方案有一个问题------它只解决了"状态保存",没有解决"人工交互"。


四、方案三:人机交互------interrupt() 与 Command(resume=...)

一个真实的审批工作流需要"暂停-等待-恢复"的能力。LangGraph 提供了两个核心 API:

  • interrupt():在某个节点暂停执行,等待外部输入
  • Command(resume=...):从暂停点恢复执行,并传入人工决策

来看看从 GitHub 项目 josephsenior/langgraph-workflow-orchestrator 中提取的生产级审批工作流:

4.1 审批节点:设置 pending 状态

python 复制代码
# approval_node.py ------ 来源:josephsenior/langgraph-workflow-orchestrator
# 在审批节点暂停执行,等待人工决策
from ..models import WorkflowState
from ..nodes.base_node import BaseNode


class ApprovalNode(BaseNode):
    """需要人工审批才能继续执行的节点"""

    def __init__(self, node_id: str, name: str, approval_message: str):
        super().__init__(node_id, name)
        self.approval_message = approval_message

    def _execute(self, state: WorkflowState) -> WorkflowState:
        """
        执行审批节点。
        为什么不在这里直接 interrupt?
        因为 interrupt 需要由 LangGraph 的图引擎触发,
        节点函数只负责设置状态,interrupt 逻辑在路由函数中处理。
        """
        state["approval_status"] = "pending"
        state["human_input"] = {
            "type": "approval",
            "message": self.approval_message,
            "node_id": self.node_id,
        }
        return state

    def process_approval(self, state: WorkflowState, approved: bool) -> WorkflowState:
        """
        处理人工审批结果。
        外部系统(如后台管理页面)调用此方法,
        将审批结果写入 state,然后通过 Command(resume=...) 恢复执行。
        """
        state["approval_status"] = "approved" if approved else "rejected"
        state["human_input"] = {
            "type": "approval",
            "approved": approved,
            "node_id": self.node_id,
        }
        return state

4.2 真正触发中断的基类:BaseNode.call

上面只看到了 ApprovalNode._execute 设置 pending 标志,但谁调了 interrupt()?答案在 BaseNode 的 __call__ 方法中:

python 复制代码
# base_node.py ------ 来源:josephsenior/langgraph-workflow-orchestrator
# 所有节点的基类,封装了 interrupt 的触发逻辑
from langgraph.graph import interrupt
from abc import ABC, abstractmethod


class BaseNode(ABC):
    """
    所有节点的基类。

    为什么 interrupt 要放在 __call__ 里而不是 _execute 里?
    因为 _execute 只负责"业务逻辑"(设置状态),
    而 __call__ 负责"执行控制"(要不要暂停)。
    这样职责分离后,_execute 可以单独做单元测试,
    不需要 mock interrupt 函数。
    """

    def __init__(self, node_id: str, name: str):
        self.node_id = node_id
        self.name = name

    def __call__(self, state):
        """
        LangGraph 引擎实际调用的入口。
        不是调 _execute,而是调 __call__()。
        _execute 是子类重写的业务方法,由 __call__ 内部调用。
        """
        # 先执行子类的业务逻辑
        result = self._execute(state)

        # 判断是否需要暂停:如果 human_input 设置了 type="approval"
        # 说明这是一个需要人审批的节点
        human_input = result.get("human_input")
        if human_input and human_input.get("type") == "approval":
            # ⚠️ 真正的暂停发生在这里!
            # interrupt() 会让 LangGraph 引擎做三件事:
            # 1. 把当前 state 序列化到 checkpoint
            # 2. 在 state 中设置 __interrupt__ 标记
            # 3. 把控制权返回给 invoke 的调用方
            # 调用方(BaseWorkflow.run())看到 __interrupt__ 后,
            # 知道这是"等待审批",而不是"执行完毕"
            interrupt_result = interrupt(human_input["message"])
            # 当外部调用 Command(resume=...) 恢复执行时,
            # interrupt() 会返回 resume 传入的数据
            result["_resume_data"] = interrupt_result

        return result

    @abstractmethod
    def _execute(self, state):
        """子类实现具体的业务逻辑"""
        pass

这个设计回答了上一节你可能会问的问题:ApprovalNode._execute 只设置了 approval_status="pending",但并没有调 interrupt(),那 interrupt 是谁调的?

答案就是现在看到的这个 BaseNode.call 。它像一个包装器 :先调 _execute 执行业务逻辑,再检查结果里有没有 human_input------如果有,就调 interrupt() 暂停引擎。

梳理一下完整的调用链:

复制代码
LangGraph 引擎执行 approval 节点
    │
    ▼
引擎调 BaseNode.__call__(state)      ← 基类的 __call__,不是子类的 _execute
    │
    ▼
__call__ 调 ApprovalNode._execute(state)  ← 子类的业务逻辑
    │
    ▼
_execute 设 approval_status="pending",human_input={message:"请审核"}
    │
    ▼
返回 state 给 __call__
    │
    ▼
__call__ 检查:human_input.type == "approval"?  ← 关键判断
    │
    是 ──▶ 调 interrupt("请审核并批准已准备的内容")  ← 真正暂停
    │         引擎:保存 checkpoint → 返回控制权
    │
    否 ──▶ 直接返回 state,继续执行下一个节点

4.3 审批工作流:完整的暂停-恢复链路

python 复制代码
# approval_workflow.py ------ 来源:josephsenior/langgraph-workflow-orchestrator
# 完整的人机交互审批工作流
from langgraph.graph import END
from ..agents.base_agent import BaseAgent
from ..nodes.approval_node import ApprovalNode
from ..nodes.llm_node import LLMNode
from ..workflows.base_workflow import BaseWorkflow


class ApprovalWorkflow(BaseWorkflow):
    """含人工审批的完整工作流"""

    def __init__(self):
        super().__init__("approval_workflow")
        self._build_workflow()

    def _build_workflow(self):
        # 创建 LLM Agent 实例
        preparer_agent = BaseAgent(model_name="gpt-4", temperature=0.7)
        executor_agent = BaseAgent(model_name="gpt-4", temperature=0.7)

        # 节点1:准备内容(LLM 自动执行)
        prepare_node = LLMNode(
            node_id="prepare",
            name="准备内容",
            agent=preparer_agent,
            prompt_template="请准备以下内容供审批:{data}",
        )

        # 节点2:人工审批(暂停点)
        approval_node = ApprovalNode(
            node_id="approval",
            name="人工审批",
            approval_message="请审核并批准已准备的内容",
        )

        # 节点3:执行(审批通过后自动执行)
        execute_node = LLMNode(
            node_id="execute",
            name="执行",
            agent=executor_agent,
            prompt_template="请执行已审批的内容:{data}",
        )

        # 节点4:通知(无论审批结果都要通知)
        notify_node = LLMNode(
            node_id="notify",
            name="通知",
            agent=executor_agent,
            prompt_template="生成通知消息:{data}",
        )

        # 注册节点
        self.add_node(prepare_node)
        self.add_node(approval_node)
        self.add_node(execute_node)
        self.add_node(notify_node)

        # 定义边
        self.add_edge("prepare", "approval")

        # 审批后的条件路由:通过 → 执行,拒绝 → 通知
        def route_after_approval(state):
            approval_status = state.get("approval_status")
            if approval_status == "approved":
                return "execute"
            else:
                return "notify"

        self.add_conditional_edges(
            "approval", route_after_approval,
            {"execute": "execute", "notify": "notify"}
        )

        self.add_edge("execute", "notify")
        self.add_edge("notify", END)
        self.set_entry_point("prepare")

4.3 基础工作流:checkpoint 与状态管理的骨架

python 复制代码
# base_workflow.py ------ 来源:josephsenior/langgraph-workflow-orchestrator
# 所有工作流的基类,封装了 checkpoint 和状态管理
from typing import Any, Dict, Optional
from langgraph.checkpoint.memory import MemorySaver
from langgraph.graph import StateGraph
from ..models import WorkflowState
from ..nodes.base_node import BaseNode


class BaseWorkflow:
    """LangGraph 工作流基类"""

    def __init__(self, workflow_name: str):
        self.workflow_name = workflow_name
        self.graph = StateGraph(WorkflowState)
        self.nodes: Dict[str, BaseNode] = {}
        # 默认使用 MemorySaver,子类可以覆盖为 PostgresSaver
        self.checkpointer = MemorySaver()
        self.compiled_graph = None

    def compile(self):
        """编译工作流图------传入 checkpointer 启用状态持久化"""
        self.compiled_graph = self.graph.compile(checkpointer=self.checkpointer)
        return self.compiled_graph

    def run(
        self, initial_state: Dict[str, Any], config: Optional[Dict[str, Any]] = None
    ) -> Dict[str, Any]:
        """
        运行工作流。
        每次调用都会自动从 checkpoint 恢复上一次的状态。
        thread_id 是状态隔离的关键------不同的 thread_id 互不干扰。
        """
        if not self.compiled_graph:
            self.compile()

        # 初始化 WorkflowState,填充默认值
        workflow_state: WorkflowState = {
            "messages": initial_state.get("messages", []),
            "data": initial_state.get("data", {}),
            "metadata": initial_state.get("metadata", {}),
            "current_step": "",
            "completed_steps": [],
            "errors": [],
            "human_input": None,
            "approval_status": None,
            "iteration_count": 0,
        }

        config = config or {}
        config.setdefault("configurable", {})
        # thread_id 是 checkpoint 的命名空间
        config["configurable"]["thread_id"] = config.get("thread_id", "default")

        result = self.compiled_graph.invoke(workflow_state, config)
        return result

4.4 状态模型:生产级的类型定义

python 复制代码
# models.py ------ 来源:josephsenior/langgraph-workflow-orchestrator
# 生产级的状态定义,覆盖了工作流的所有可能状态
from datetime import datetime
from enum import Enum
from typing import Any, Dict, List, Optional, TypedDict
from langchain_core.messages import BaseMessage
from pydantic import BaseModel, Field


class WorkflowStatus(str, Enum):
    """工作流生命周期的完整状态枚举"""
    PENDING = "pending"
    RUNNING = "running"
    PAUSED = "paused"       # 等待人工审批时进入此状态
    COMPLETED = "completed"
    FAILED = "failed"
    CANCELLED = "cancelled"


class WorkflowState(TypedDict):
    """
    流经 LangGraph 图的状态字典。
    每个 super-step 后,LangGraph 会序列化这个字典并存入 checkpoint。
    注意:TypedDict 不是运行时类型检查,但给 IDE 和类型检查器提供了提示。
    """
    messages: List[BaseMessage]
    data: Dict[str, Any]
    metadata: Dict[str, Any]
    current_step: str
    completed_steps: List[str]
    errors: List[str]
    human_input: Optional[Dict[str, Any]]
    approval_status: Optional[str]
    iteration_count: int


class WorkflowExecution(BaseModel):
    """工作流执行实例的持久化模型"""
    id: str
    workflow_name: str
    status: WorkflowStatus = WorkflowStatus.PENDING
    state: Dict[str, Any] = Field(default_factory=dict)
    started_at: Optional[datetime] = None
    completed_at: Optional[datetime] = None
    error: Optional[str] = None
    checkpoint_id: Optional[str] = None
    node_history: List[Dict[str, Any]] = Field(default_factory=list)


class Checkpoint(BaseModel):
    """工作流 checkpoint 的持久化模型"""
    id: str
    workflow_id: str
    state: Dict[str, Any]
    node_id: str
    created_at: datetime = Field(default_factory=datetime.now)

五、方案四:自定义 Checkpoint------自己动手写持久化

前面讲的两个方案(PostgresSaver、interrupt)用的都是 LangGraph 官方提供的 checkpointer。但很多时候你需要自己控制存储逻辑------比如公司规定数据必须存到 MongoDB,或者你需要把 checkpoint 和业务数据存在同一个事务里。这时候就只能自己实现了。

LangGraph 提供了 BaseCheckpointSaver 抽象类,你只要实现四个核心方法:

  • put() --- 保存 checkpoint
  • get_tuple() --- 读取 checkpoint
  • list() --- 列出所有 checkpoint
  • put_writes() --- 保存中间写操作

来看看 GitHub 上两个真实的自定义实现。

5.1 agent-kernel:轻量级内存式 checkpointer

yaalalabs/agent-kernel 是一个企业级的 AI Agent 操作系统,它实现了自己的 CheckPointer(BaseCheckpointSaver)------一个基于 dict 存储、支持 pickle 序列化的自定义 checkpointer:

python 复制代码
# langgraph.py ------ 来源:yaalalabs/agent-kernel
# 一个完全自主实现的自定义 CheckPointer,不依赖任何外部数据库
from __future__ import annotations

import asyncio
from typing import Any, AsyncIterator, Iterator, Optional, Sequence

from langchain_core.runnables import RunnableConfig
from langgraph.checkpoint.base import (
    BaseCheckpointSaver,
    Checkpoint,
    CheckpointMetadata,
    CheckpointTuple,
)


class CheckPointer(BaseCheckpointSaver):
    """
    自制的 checkpoint 管理器。
    
    设计思路:
    1. 不依赖外部数据库------用 dict 做存储,pickle 可序列化
    2. 按 thread_id + checkpoint_ns 双层命名空间隔离
    3. 实现了完整的 async 接口,支持异步流式调用
    4. 可以序列化到磁盘------只要 pickle.dump 整个对象就行
    """

    def __init__(self):
        super().__init__()
        # _storage 是核心存储结构:
        # {thread_id: {checkpoint_ns: {checkpoint_data}}}
        self._storage = {}
        # _writes 存储中间写操作------用于流式场景
        self._writes = {}

    def get_tuple(self, config: RunnableConfig) -> Optional[CheckpointTuple]:
        """
        根据 config 读取 checkpoint。
        thread_id 是"对话 ID",checkpoint_ns 是"命名空间"。
        同一个对话的不同子图可以用不同的命名空间隔离。
        """
        thread_id = config.get("configurable", {}).get("thread_id")
        checkpoint_ns = config.get("configurable", {}).get("checkpoint_ns", "")

        if not thread_id:
            return None

        thread_data = self._storage.get(thread_id, {})
        checkpoint_data = thread_data.get(checkpoint_ns)

        if checkpoint_data is None:
            return None

        return CheckpointTuple(
            config=config,
            checkpoint=checkpoint_data["checkpoint"],
            metadata=checkpoint_data.get("metadata", {}),
            parent_config=checkpoint_data.get("parent_config"),
        )

    def put(
        self,
        config: dict,
        checkpoint: Checkpoint,
        metadata: CheckpointMetadata,
        new_versions: dict,
    ) -> dict:
        """
        保存 checkpoint。
        为什么返回 config 而不是 None?
        LangGraph 需要拿到更新后的 config(包含了新 checkpoint 的 ID),
        用于后续的 get_tuple 查询。
        """
        thread_id = config.get("configurable", {}).get("thread_id")
        checkpoint_ns = config.get("configurable", {}).get("checkpoint_ns", "")

        if not thread_id:
            raise ValueError("thread_id is required in config")

        if thread_id not in self._storage:
            self._storage[thread_id] = {}

        # 存储时保留完整上下文------包括 metadata 和 parent_config
        self._storage[thread_id][checkpoint_ns] = {
            "checkpoint": checkpoint,
            "metadata": metadata,
            "parent_config": config.get("parent_config"),
        }

        return config

    def put_writes(
        self,
        config: dict,
        writes: Sequence[tuple[str, Any]],
        task_id: str,
        task_path: str = "",
    ) -> None:
        """
        保存中间写操作。
        流式场景下,子节点的中间输出通过 put_writes 暂存,
        get_tuple 时可以一并返回。
        """
        thread_id = config.get("configurable", {}).get("thread_id")
        checkpoint_ns = config.get("configurable", {}).get("checkpoint_ns", "")

        if not thread_id:
            return

        if thread_id not in self._writes:
            self._writes[thread_id] = {}

        if checkpoint_ns not in self._writes[thread_id]:
            self._writes[thread_id][checkpoint_ns] = []

        self._writes[thread_id][checkpoint_ns].append({
            "task_id": task_id,
            "task_path": task_path,
            "writes": writes,
        })

    def list(
        self,
        config: Optional[dict] = None,
        *,
        filter: Optional[dict[str, Any]] = None,
        before: Optional[dict] = None,
        limit: Optional[int] = None,
    ) -> Iterator[CheckpointTuple]:
        """列出指定 thread_id 的所有 checkpoint"""
        result = []
        if config:
            thread_id = config.get("configurable", {}).get("thread_id")
            if thread_id and thread_id in self._storage:
                thread_data = self._storage[thread_id]
                for ns, data in thread_data.items():
                    checkpoint_config: RunnableConfig = {
                        "configurable": {
                            "thread_id": thread_id,
                            "checkpoint_ns": ns,
                        }
                    }
                    result.append(
                        CheckpointTuple(
                            config=checkpoint_config,
                            checkpoint=data["checkpoint"],
                            metadata=data.get("metadata", {}),
                            parent_config=data.get("parent_config"),
                        )
                    )
                if limit:
                    result = result[:limit]
        return iter(result)

    def delete_thread(self, thread_id: str) -> None:
        """删除指定 thread_id 的所有 checkpoint------清理会话时使用"""
        if thread_id in self._storage:
            del self._storage[thread_id]
        if thread_id in self._writes:
            del self._writes[thread_id]

    # ----- async 接口:代理到同步实现 -----
    async def aget_tuple(self, config: RunnableConfig) -> Optional[CheckpointTuple]:
        return self.get_tuple(config)

    async def alist(self, config, *, filter=None, before=None, limit=None):
        for item in self.list(config, filter=filter, before=before, limit=limit):
            yield item

    async def aput(self, config, checkpoint, metadata, new_versions):
        return self.put(config, checkpoint, metadata, new_versions)

    async def aput_writes(self, config, writes, task_id, task_path=""):
        self.put_writes(config, writes, task_id, task_path)

    async def adelete_thread(self, thread_id: str) -> None:
        self.delete_thread(thread_id)

这个实现最值得学习的地方不是"它用了什么数据库",而是它严格遵循了 BaseCheckpointSaver 的接口契约:

  • put() 必须返回 config,因为 LangGraph 内部要用返回值更新 checkpoint ID 链
  • get_tuple() 必须返回 CheckpointTuple,而不是裸的 dict------因为 LangGraph 需要从 tuple 里拿到 parent_config 来构建状态继承链
  • put_writes()list() 是流式场景的关键------不实现这两个方法,你的 Agent 在流式输出时中间状态会丢失

为什么真实项目会用 dict 存储还要自己实现 checkpointer? 因为 agent-kernel 需要支持 pickle 序列化------把整个 checkpointer 对象序列化到磁盘,实现"全量快照备份"。PostgresSaver 做不到这一点,因为它持有数据库连接,无法序列化。

5.2 DynamoDBSaver:AWS 云原生的企业级实现

如果你的公司用 AWS 作为基础设施,那 LangGraph 官方提供的 langchain-ai/langchain-aws(Stars 2.8k+)里的 DynamoDBSaver 是最成熟的方案之一:

python 复制代码
# saver.py ------ 来源:langchain-ai/langchain-aws(截取核心逻辑)
# DynamoDB 作为 checkpoint 存储 + S3 作为大对象卸载层
from langgraph.checkpoint.base import (
    BaseCheckpointSaver,
    CheckpointTuple,
)
from langchain_core.runnables import RunnableConfig


class DynamoDBSaver(BaseCheckpointSaver):
    """
    基于 DynamoDB + S3 的 checkpoint 管理器。
    
    核心思想:
    1. checkpoint 元数据(thread_id, checkpoint_id, 指针等)存在 DynamoDB
    2. 大于 350KB 的 checkpoint 数据自动卸载到 S3
    3. 支持 TTL 自动过期清理
    4. 支持 checkpoint 压缩
    5. 完整的异步接口(asyncio + run_in_executor)
    """

    def __init__(
        self,
        table_name: str,
        region_name: str | None = None,
        endpoint_url: str | None = None,
        ttl_seconds: int | None = None,
        enable_checkpoint_compression: bool = False,
        s3_offload_config: dict | None = None,
    ):
        super().__init__()
        self.table_name = table_name
        
        # 分层存储架构:
        # - DynamoDB: 存 checkpoint 的元数据(索引、指针)
        # - S3: 存超过阈值的 checkpoint 主体数据(大状态)
        # - 压缩:序列化时做压缩,减少存储成本
        
        # 策略模式------不同的序列化/存储策略可替换
        self.serializer = CheckpointSerializer(
            self.serde, enable_checkpoint_compression
        )
        self.storage = StorageStrategy(
            dynamodb_client=self.client,
            table_name=self.table_name,
            s3_client=s3_client_instance,
            s3_bucket=s3_bucket_name,
            s3_key_prefix=s3_key_prefix,
            ttl_seconds=ttl_seconds,
        )
        self.repo = UnifiedRepository(
            dynamodb_client=self.client,
            table_name=self.table_name,
            serializer=self.serializer,
            storage_strategy=self.storage,
            ttl_seconds=ttl_seconds,
        )

    def get_tuple(self, config: RunnableConfig) -> CheckpointTuple | None:
        """
        从 DynamoDB 读取 checkpoint。
        如果 checkpoint 数据在 S3(超过 350KB),自动从 S3 拉取并反序列化。
        整个过程对调用方透明------调用者不需要知道数据到底存在哪里。
        """
        thread_id = config["configurable"]["thread_id"]
        checkpoint_ns = config["configurable"].get("checkpoint_ns", "")
        checkpoint_id = get_checkpoint_id(config)

        checkpoint_data = self.repo.get_checkpoint(
            thread_id=thread_id,
            checkpoint_ns=checkpoint_ns,
            checkpoint_id=checkpoint_id,
        )
        if not checkpoint_data:
            return None

        # 构造 checkpoint config------用于后续的 parent chain 追踪
        checkpoint_config: RunnableConfig = {
            "configurable": {
                "thread_id": thread_id,
                "checkpoint_ns": checkpoint_data["checkpoint_ns"],
                "checkpoint_id": checkpoint_data["checkpoint_id"],
            }
        }

        # 父 checkpoint 的 config------用于构建状态继承链
        parent_config: RunnableConfig | None = (
            {
                "configurable": {
                    "thread_id": thread_id,
                    "checkpoint_ns": checkpoint_data["checkpoint_ns"],
                    "checkpoint_id": checkpoint_data["parent_checkpoint_id"],
                }
            }
            if checkpoint_data["parent_checkpoint_id"]
            else None
        )

        return CheckpointTuple(
            config=checkpoint_config,
            checkpoint=checkpoint_data["checkpoint"],
            metadata=checkpoint_data["metadata"],
            parent_config=parent_config,
            pending_writes=pending_writes,
        )

    def put(
        self,
        config: RunnableConfig,
        checkpoint: "Checkpoint",
        metadata: "CheckpointMetadata",
        new_versions: "ChannelVersions",
    ) -> RunnableConfig:
        """
        写入 checkpoint。
        如果 checkpoint 序列化后超过 350KB,自动写到 S3(DynamoDB 单条记录 400KB 限制)。
        TTL 过期时间自动设置,不需要手动清理。
        """
        thread_id = config["configurable"]["thread_id"]
        checkpoint_ns = config["configurable"].get("checkpoint_ns", "")
        parent_checkpoint_id = config["configurable"].get("checkpoint_id")

        result = self.repo.put_checkpoint(
            thread_id=thread_id,
            checkpoint_ns=checkpoint_ns,
            checkpoint=checkpoint,
            metadata=metadata,
            parent_checkpoint_id=parent_checkpoint_id,
        )

        return {
            "configurable": {
                "thread_id": result["thread_id"],
                "checkpoint_ns": result["checkpoint_ns"],
                "checkpoint_id": result["checkpoint_id"],
            }
        }

这个实现告诉我们一个重要的工程经验:"分层存储"是处理大 checkpoint 的唯一出路。DynamoDB 单条记录限制是 400KB,而一个包含了 50 轮对话历史的 checkpoint 很容易超过这个值。DynamoDBSaver 的方案是------元数据和索引存 DynamoDB,数据实体存 S3。如果你的 Agent 状态特别大,这个方案可以直接抄。


六、方案五:两层 Checkpoint 架构------用户看到进度,系统记住状态

前面四个方案都在解决同一个问题:"系统崩溃后怎么恢复执行状态"。但如果你真的把 LangGraph 的 checkpoint 当成生产系统的唯一状态源,你会发现一个尴尬的问题

用户打开后台管理页面,想看"我的审批工单现在走到哪一步了",你怎么办?

你总不能对用户说:"稍等,我去 PostgreSQL 的 checkpoint_blobs 表里反序列化一下你的 channel_values,然后解析出来告诉你当前在 approval 节点。"

这就是单层 checkpoint 的致命缺陷:它只解决了"系统怎么恢复",没解决"人怎么知道发生了什么"。

5.1 两层 Checkpoint 的设计思想

把 checkpoint 拆成两层,各司其职:

复制代码
┌─────────────────────────────────────────────────────────────────┐
│                       两层 Checkpoint 架构                        │
├─────────────────────────────────────────────────────────────────┤
│                                                                  │
│  第一层:业务层 Checkpoint(Business Checkpoint)                 │
│  ┌─────────────────────────────────────────────────────────┐   │
│  │ 存储:Redis / MySQL / 业务数据库                          │   │
│  │ 内容:{run_id, status, current_step, progress, ...}      │   │
│  │ 粒度:业务步骤级别("审批中""已完成""已拒绝")              │   │
│  │ 消费者:前端 Dashboard、管理员后台、运营报表                │   │
│  │ 读写频率:每次状态变更写一次,查询可高频                    │   │
│  └─────────────────────────────────────────────────────────┘   │
│                              ↕ 双向同步                          │
│  第二层:执行层 Checkpoint(Execution Checkpoint)                │
│  ┌─────────────────────────────────────────────────────────┐   │
│  │ 存储:PostgreSQL(PostgresSaver)/ DynamoDB              │   │
│  │ 内容:{messages, channel_values, checkpoint_id, ...}     │   │
│  │ 粒度:每个 super-step(LangGraph 自动保存)                │   │
│  │ 消费者:LangGraph 引擎(系统崩溃后精确恢复)                │   │
│  │ 读写频率:每个 super-step 写一次,只有崩溃恢复时读         │   │
│  └─────────────────────────────────────────────────────────┘   │
│                                                                  │
└─────────────────────────────────────────────────────────────────┘

一句话总结:业务层给"人"看------你的工单到哪了;执行层给"系统"用------崩溃后怎么恢复。

5.2 业务层:WorkflowExecution 模型

先从 GitHub 项目 josephsenior/langgraph-workflow-orchestrator 中已有的 WorkflowExecution 模型出发,扩展为一个完整的业务层 checkpoint 管理:

python 复制代码
# models.py ------ 业务层 checkpoint 数据模型
# 设计思路:
# 1. 与 LangGraph 的执行层 checkpoint 完全解耦------改变业务状态不影响执行恢复
# 2. 字段设计面向"人"------管理员一看就知道这个工单卡在哪
# 3. 可以独立扩展(加审批人、加备注、加 SLA 倒计时),不影响执行层
from __future__ import annotations

from datetime import datetime
from enum import Enum
from typing import Any, Optional
from pydantic import BaseModel, Field


class RunStatus(str, Enum):
    """业务层状态------面向用户和管理员"""
    QUEUED = "queued"           # 排队中
    RUNNING = "running"         # 执行中
    AWAITING_APPROVAL = "awaiting_approval"  # 等待人工审批
    APPROVED = "approved"       # 审批通过
    REJECTED = "rejected"       # 审批拒绝
    COMPLETED = "completed"     # 已完成
    FAILED = "failed"           # 执行失败
    CANCELLED = "cancelled"     # 已取消


class BusinessCheckpoint(BaseModel):
    """
    业务层 checkpoint------给"人"看的进度快照。

    与 LangGraph 执行层 checkpoint 的区别:
    - 执行层存的是 messages、channel_values(机器可读,人看不懂)
    - 业务层存的是 status、current_step、progress(人可读,机器不需要)
    - 两者通过 run_id 关联,但各自独立演进
    """
    run_id: str                              # 全局唯一运行 ID
    status: RunStatus = RunStatus.QUEUED     # 当前业务状态
    current_step: str = ""                   # 当前步骤名称(如"经理审批")
    total_steps: int = 0                     # 总步骤数
    completed_steps: int = 0                 # 已完成步骤数
    progress_percent: float = 0.0            # 进度百分比(0-100)

    # 审批相关
    approval_message: str = ""               # 审批提示消息
    approval_deadline: Optional[datetime] = None  # 审批截止时间(SLA)
    approved_by: str = ""                    # 审批人

    # 时间追踪
    created_at: datetime = Field(default_factory=datetime.now)
    started_at: Optional[datetime] = None
    paused_at: Optional[datetime] = None
    completed_at: Optional[datetime] = None

    # 错误信息
    last_error: str = ""                     # 最近一次错误信息
    retry_count: int = 0                     # 重试次数

    # 关联执行层------但只是"引用",不是"包含"
    # 为什么用 run_id 而不是存 checkpoint_id?
    # 因为同一 run_id 下可能有多个执行层 checkpoint(每个 super-step 一个)
    # 业务层只需要知道"属于哪个 run",执行层负责管理具体的 checkpoint 链
    thread_id: str = ""                      # 对应 LangGraph 的 thread_id


class BusinessCheckpointStore:
    """
    业务层 checkpoint 存储管理器。

    设计决策:
    1. 为什么用 Redis 而不是 PostgreSQL?
       - 业务层 checkpoint 查询频率高(前端每 3 秒轮询一次)
       - Redis 的读写延迟在微秒级,PostgreSQL 在毫秒级
       - 业务层数据丢了可以重建(从执行层 checkpoint 反推),Redis 丢数据的代价可接受

    2. 为什么不用 LangGraph 的 PostgresSaver 来存业务状态?
       - PostgresSaver 的表结构是 LangGraph 内部定义的,不适合外部查询
       - 把业务字段塞进 AgentState 会让每次 checkpoint 都携带这些字段,浪费存储
       - 业务状态变更不需要触发 LangGraph 的 checkpoint 保存------那是两个独立的事件
    """
    def __init__(self, redis_client):
        self.redis = redis_client
        self._key_prefix = "biz:checkpoint:"
        self._ttl = 86400 * 7  # 7 天过期

    def _key(self, run_id: str) -> str:
        return f"{self._key_prefix}{run_id}"

    def save(self, cp: BusinessCheckpoint) -> None:
        """保存业务层 checkpoint"""
        self.redis.setex(
            self._key(cp.run_id),
            self._ttl,
            cp.model_dump_json()
        )

    def get(self, run_id: str) -> Optional[BusinessCheckpoint]:
        """读取业务层 checkpoint"""
        raw = self.redis.get(self._key(run_id))
        if raw is None:
            return None
        return BusinessCheckpoint.model_validate_json(raw)

    def update_status(
        self, run_id: str, status: RunStatus,
        current_step: str = "", error: str = ""
    ) -> None:
        """
        原子更新业务状态。
        为什么不直接 save() 而是单独一个 update_status()?
        因为状态变更是高频操作(每次步骤切换都触发),
        只更新变动的字段减少序列化开销。
        """
        cp = self.get(run_id)
        if cp is None:
            return
        cp.status = status
        if current_step:
            cp.current_step = current_step
        if error:
            cp.last_error = error
        if status == RunStatus.AWAITING_APPROVAL:
            cp.paused_at = datetime.now()
        elif status in (RunStatus.COMPLETED, RunStatus.FAILED, RunStatus.CANCELLED):
            cp.completed_at = datetime.now()
        self.save(cp)

    def list_awaiting_approval(self) -> list[BusinessCheckpoint]:
        """
        列出所有等待审批的工单------后台管理页面的核心查询。
        如果只有执行层 checkpoint,这个查询需要扫描 PostgreSQL 的
        checkpoint_blobs 表并反序列化每条记录------完全不可行。
        """
        keys = self.redis.keys(f"{self._key_prefix}*")
        result = []
        for key in keys:
            raw = self.redis.get(key)
            if raw:
                cp = BusinessCheckpoint.model_validate_json(raw)
                if cp.status == RunStatus.AWAITING_APPROVAL:
                    result.append(cp)
        return result

5.3 两层联动:在 LangGraph 节点中同步业务状态

有了业务层存储,接下来要在 LangGraph 的每个节点中更新业务状态。但直接在每个节点函数里写 biz_store.update_status(...) 会让节点代码变得臃肿。更好的做法是用一个包装器统一处理

python 复制代码
# workflow_runner.py ------ 两层 checkpoint 的运行器
# 核心思路:在 LangGraph 的每个 super-step 前后,自动同步业务层状态
from __future__ import annotations

from langgraph.graph import StateGraph
from langgraph.checkpoint.postgres import PostgresSaver
from typing import Any, Callable

from models import (
    BusinessCheckpoint, BusinessCheckpointStore,
    RunStatus,
)


class TwoLayerWorkflowRunner:
    """
    两层 checkpoint 工作流运行器。

    职责:
    1. 管理执行层 checkpoint(PostgresSaver)------LangGraph 自动调用
    2. 管理业务层 checkpoint(BusinessCheckpointStore)------手动同步
    3. 保证两层之间的一致性(至少做到"最终一致")

    为什么不在 LangGraph 的节点里直接写业务状态更新?
    因为那样做会让节点代码和业务逻辑耦合------同一个 Agent 图
    换个业务场景就要改节点代码。用 Runner 包装后,
    节点代码保持纯粹,业务状态同步由 Runner 统一管理。
    """

    def __init__(
        self,
        graph: StateGraph,
        checkpointer: PostgresSaver,
        biz_store: BusinessCheckpointStore,
    ):
        # 执行层:LangGraph 自动管理
        self._compiled = graph.compile(checkpointer=checkpointer)
        # 业务层:手动管理
        self._biz_store = biz_store
        # 节点→步骤名的映射,用于自动更新业务状态
        self._step_names: dict[str, str] = {}
        self._total_steps = 0

    def register_step(self, node_name: str, step_label: str) -> None:
        """
        注册节点对应的业务步骤名。
        例如:register_step("prepare", "内容准备")
              register_step("approval", "经理审批")
        """
        self._step_names[node_name] = step_label
        self._total_steps = len(self._step_names)

    def run(self, run_id: str, initial_state: dict) -> dict:
        """
        执行工作流,自动管理两层 checkpoint。

        执行流程:
        1. 创建/更新业务层 checkpoint(状态→RUNNING)
        2. 调用 LangGraph invoke(执行层自动保存 checkpoint)
        3. 如果遇到 interrupt(审批节点),业务层状态→AWAITING_APPROVAL
        4. 如果执行完成,业务层状态→COMPLETED
        5. 如果执行失败,业务层状态→FAILED + 记录错误
        """
        thread_id = run_id  # 用 run_id 作为 thread_id,保持两层关联

        # 初始化业务层 checkpoint
        biz_cp = self._biz_store.get(run_id)
        if biz_cp is None:
            biz_cp = BusinessCheckpoint(
                run_id=run_id,
                thread_id=thread_id,
                total_steps=self._total_steps,
            )
        biz_cp.status = RunStatus.RUNNING
        biz_cp.started_at = biz_cp.started_at or datetime.now()
        self._biz_store.save(biz_cp)

        config = {"configurable": {"thread_id": thread_id}}

        try:
            # 执行层:LangGraph 自动保存每个 super-step 的 checkpoint
            result = self._compiled.invoke(initial_state, config)

            # 检查是否被 interrupt 挂起(审批节点)
            # LangGraph 在遇到 interrupt() 时会抛出 GraphRecursionError
            # 或者在 state 中留下 __interrupt__ 标记
            if result.get("__interrupt__"):
                biz_cp.status = RunStatus.AWAITING_APPROVAL
                biz_cp.current_step = "等待审批"
                biz_cp.paused_at = datetime.now()
                self._biz_store.save(biz_cp)
            else:
                biz_cp.status = RunStatus.COMPLETED
                biz_cp.completed_steps = self._total_steps
                biz_cp.progress_percent = 100.0
                biz_cp.completed_at = datetime.now()
                self._biz_store.save(biz_cp)

            return result

        except Exception as e:
            # 执行失败:业务层记录错误,执行层 checkpoint 保留现场
            biz_cp.status = RunStatus.FAILED
            biz_cp.last_error = str(e)
            biz_cp.completed_at = datetime.now()
            self._biz_store.save(biz_cp)
            raise

    def resume_after_approval(
        self, run_id: str, approved: bool, approved_by: str = ""
    ) -> dict:
        """
        审批后恢复执行。
        为什么业务层和执行层要分开处理?
        - 业务层:记录审批结果(谁批的、批了什么)
        - 执行层:通过 Command(resume=...) 从 interrupt 点恢复
        """
        biz_cp = self._biz_store.get(run_id)
        if biz_cp is None:
            raise ValueError(f"run {run_id} not found")

        if approved:
            biz_cp.status = RunStatus.APPROVED
        else:
            biz_cp.status = RunStatus.REJECTED
        biz_cp.approved_by = approved_by
        self._biz_store.save(biz_cp)

        # 执行层:恢复 LangGraph 执行
        # PostgresSaver 自动从上次的 checkpoint 恢复,
        # Command(resume=...) 传入审批结果
        from langgraph.types import Command
        config = {"configurable": {"thread_id": run_id}}
        return self._compiled.invoke(
            Command(resume={"approved": approved, "approved_by": approved_by}),
            config,
        )

5.4 FastAPI 集成:前端一眼看到工单进度

把两层 checkpoint 集成到 FastAPI 中,前端就可以通过 API 查询工单状态:

python 复制代码
# api.py ------ 两层 checkpoint 的 FastAPI 路由
from fastapi import FastAPI, Depends
from pydantic import BaseModel

from models import BusinessCheckpointStore, RunStatus
from workflow_runner import TwoLayerWorkflowRunner


class StartRunRequest(BaseModel):
    run_id: str
    content: str


class ApprovalRequest(BaseModel):
    run_id: str
    approved: bool
    approved_by: str = ""


@app.post("/api/runs")
async def start_run(req: StartRunRequest):
    """
    启动一个审批工单。
    前端调用后,可以通过 GET /api/runs/{run_id}/status 轮询进度。
    """
    runner: TwoLayerWorkflowRunner = get_runner()
    runner.run(req.run_id, {"content": req.content})
    return {"run_id": req.run_id, "status": "started"}


@app.get("/api/runs/{run_id}/status")
async def get_run_status(run_id: str):
    """
    查询工单状态------业务层 checkpoint 的核心价值。
    为什么这个接口能毫秒级响应?
    因为业务层 checkpoint 存在 Redis,不是 PostgreSQL。
    执行层的 checkpoint 数据(messages、channel_values)完全不参与这个查询。
    """
    biz_store: BusinessCheckpointStore = get_biz_store()
    cp = biz_store.get(run_id)
    if cp is None:
        return {"error": "run not found"}
    return {
        "run_id": cp.run_id,
        "status": cp.status.value,
        "current_step": cp.current_step,
        "progress": f"{cp.completed_steps}/{cp.total_steps}",
        "progress_percent": cp.progress_percent,
        "approval_message": cp.approval_message,
        "created_at": cp.created_at.isoformat(),
        "last_error": cp.last_error,
    }


@app.get("/api/runs/awaiting-approval")
async def list_awaiting_approval():
    """
    列出所有等待审批的工单------管理员后台的核心接口。
    如果没有业务层 checkpoint,这个接口需要扫描 PostgreSQL
    的 checkpoint_blobs 表并反序列化每条记录------完全不可行。
    """
    biz_store: BusinessCheckpointStore = get_biz_store()
    pending = biz_store.list_awaiting_approval()
    return [
        {
            "run_id": cp.run_id,
            "current_step": cp.current_step,
            "approval_message": cp.approval_message,
            "paused_at": cp.paused_at.isoformat() if cp.paused_at else None,
        }
        for cp in pending
    ]


@app.post("/api/runs/{run_id}/approve")
async def approve_run(run_id: str, req: ApprovalRequest):
    """
    审批通过/拒绝。
    业务层:记录审批结果(谁批的、批了什么)
    执行层:从 PostgresSaver 恢复 LangGraph 状态,通过 Command(resume=...) 继续执行
    """
    runner: TwoLayerWorkflowRunner = get_runner()
    result = runner.resume_after_approval(
        run_id, req.approved, req.approved_by
    )
    return {"run_id": run_id, "status": "approved" if req.approved else "rejected"}

5.5 两层 Checkpoint 的一致性保证

你可能会问:"如果业务层写成功了但执行层写失败了(或者反过来),怎么保证一致性?"

答案是:不需要强一致性,只需要最终一致性。 两层 checkpoint 的设计初衷就是解耦------它们各自独立,通过 run_id 关联。具体来说:

  • 业务层写成功、执行层写失败:前端显示"审批中",但进程重启后执行层没有 checkpoint。此时 LangGraph 会从上一个成功的执行层 checkpoint 恢复------最多丢一个 super-step 的进度,但业务状态不会错(因为前端显示的状态是正确的)。
  • 执行层写成功、业务层写失败 :前端显示"排队中",但执行层已经跑到"审批节点"了。此时用户刷新页面,状态信息可能不准确。解法:在前端查询 /api/runs/{run_id}/status 时,如果发现业务层状态和实际执行状态不一致(比如业务层显示"排队中"但执行层已经有 checkpoint 了),触发一次业务层状态同步。
  • 两者都写了但下次运行时状态对不上:这时候以执行层为准------因为执行层是"真实发生过的事",业务层只是"给用户看的快照"。从执行层 checkpoint 反推当前业务状态,然后修正业务层。

核心原则:执行层是 source of truth,业务层是缓存。 缓存可以丢,可以重建------但执行层的 checkpoint 丢了,就真的丢了。


七、边界情况与陷阱

看起来很完美了对吧?生产环境没这么简单。

陷阱一:checkpoint 膨胀。 每次 invoke() 都会生成一个新的 checkpoint。如果你的 Agent 在一个循环里跑了 100 轮,就会有 100 个 checkpoint 记录。PostgreSQL 的表会越来越大。解法:设置 checkpoint 的 TTL(过期时间),或者定期清理旧的 checkpoint------LangGraph 的 PostgresSaver 支持配置保留策略。自定义 checkpointer 更灵活,比如 agent-kernel 的 delete_thread() 就是专门用来做清理的。

陷阱二:thread_id 冲突。 如果你用自增 ID 作为 thread_id,两个不同的用户可能拿到同一个 ID。解法:用 UUID 作为 thread_id,保证全局唯一。

陷阱三:大状态序列化。 如果你的 AgentState 里存了 100 条消息,每条消息都带 embeddings,checkpoint 的序列化和反序列化会成为性能瓶颈。解法:状态里只存轻量数据,大对象(如向量)存到外部存储,状态里只保留引用。DynamoDBSaver 的 S3 卸载就是这个思路。

陷阱四:并发写入。 同一个 thread_id 同时有两个请求在写 checkpoint,后来的会覆盖先来的。LangGraph 内部通过 config 的版本号机制做了乐观锁,但前提是你不能绕过 LangGraph 直接操作数据库。自定义 checkpointer 需要在 put() 里实现 CAS(Compare-And-Swap)逻辑------agent-kernel 没有做这一点,它默认同一个 thread_id 不会被并发访问。

陷阱五:自定义 checkpointer 的序列化问题。 如果你用 dict 存储(像 agent-kernel),进程重启数据就丢了------内存型自定义 checkpointer 只解决了"接口定制"的问题,没解决"持久化"。要持久化的话,需要在 put() 里写文件或连数据库。


八、高级考量:从单机到分布式

当你把 Agent 部署到 K8s 集群时,还需要考虑以下问题:

多副本的一致性。 所有副本共享同一个 PostgreSQL 实例,checkpoint 的读写天然一致。自定义 checkpointer 如果用了内存存储(dict),多副本场景下各副本数据不一致------必须用外部存储。DynamoDBSaver 在这方面是最优解,DynamoDB 本身就是分布式数据库。

checkpoint 的容灾。 PostgreSQL 本身可以做主从复制。如果主库挂了,checkpoint 数据还在从库。但你的应用需要配置好数据库连接的高可用切换。自定义 DynamoDBSaver 不需要考虑容灾------AWS 自动做。

水平扩展。 如果你的 QPS 很高,可以考虑用 Redis 做 checkpoint 的热存储层------最新几个 checkpoint 走 Redis(读写快),历史 checkpoint 下沉到 PostgreSQL(持久化)。LangGraph 官方提供了 langgraph-checkpoint-redis 参考实现。DynamoDBSaver 天然支持水平扩展,因为 DynamoDB 是按吞吐量自动扩缩容的。


九、方案对比

方案 持久化 人工交互 跨进程 可定制 业务可观测 适用场景
MemorySaver 本地开发调试
PostgresSaver 无人工审批的标准生产环境
PostgresSaver + interrupt 含人工审批的生产环境
自定义 checkpointer(内存型) 需要定制接口逻辑的场景
自定义 checkpointer(DynamoDB+S3) AWS 云原生、超大状态场景
自定义 checkpointer(PostgreSQL) 需要业务逻辑与 checkpoint 耦合的场景
两层 Checkpoint(Redis + PostgresSaver) 需要后台管理、审批仪表盘的生产环境

简单来说:

  • 不想折腾:直接用 PostgresSaver
  • 不想被厂商锁定:自己写一个 PostgreSQL 版的自定义 checkpointer
  • AWS 全家桶用户:直接上 DynamoDBSaver
  • 需要和业务数据存一起:自己实现 BaseCheckpointSaver
  • 需要后台管理面查看工单进度:用两层 Checkpoint 架构

十、面试追问

追问 1:checkpoint 和 LangGraph 的 state 是什么关系?

回答方向:state 是你要保存的数据,checkpoint 是 LangGraph 保存 state 的方式。每个 checkpoint 包含三样东西------当时的 state 值、当前在哪个节点、以及下一步该往哪走。checkpoint 是 state 的快照 + 执行上下文。

追问 2:如果 checkpoint 保存失败,Agent 会怎么处理?

回答方向:LangGraph 在保存 checkpoint 失败时会抛出异常,Agent 执行中断。但已经完成的步骤不会回滚------checkpoint 机制只保证"状态可恢复",不保证"事务性"。如果你需要事务性,需要在业务层做补偿逻辑。自定义 checkpointer 可以在这里做文章------比如在 put() 里实现重试、降级(先写内存再异步刷盘)。

追问 3:为什么不用 Redis 直接替代 PostgreSQL?

回答方向:Redis 的持久化(RDB/AOF)是异步的,极端情况下会丢数据。PostgreSQL 的 WAL 日志是同步写入的,数据可靠性更高。Redis 适合做热数据缓存,PostgreSQL 适合做 checkpoint 的持久化存储。自定义 checkpointer 可以把两者结合起来------Redis 做热读,PostgreSQL 做持久化。

追问 4:多个 Agent 之间如何共享 checkpoint?

回答方向:不共享。每个 Agent 实例有自己的 thread_id 命名空间。如果你需要 Agent 之间通信,用 LangGraph 的 subgraph 机制------子图的状态会被父图统一管理,checkpoint 在父图层面统一保存。自定义 checkpointer 的 checkpoint_ns 参数就是为子图隔离设计的------agent-kernel 用 _storage[thread_id][checkpoint_ns] 实现了这种隔离。

追问 5:让你自己写一个生产级的 checkpointer,你会考虑哪些因素?

回答方向:① 存储选型(内存/文件/数据库/S3)② 序列化性能(pickle/json/protobuf)③ 数据分层(热数据内存 + 冷数据磁盘)④ 并发控制(乐观锁/CAS)⑤ 清理策略(TTL/数量上限/LRU)⑥ 可观测性(每个 checkpoint 的大小、写入耗时、恢复耗时)。

追问 6:业务层 checkpoint 和执行层 checkpoint 不一致了怎么办?

回答方向:以执行层为准。执行层是 source of truth------它是 LangGraph 实际执行过的状态的记录。业务层本质上是执行层的缓存,丢了可以重建。如果发现不一致(比如业务层显示"排队中"但执行层已经有 checkpoint 了),可以通过一个后台任务定期扫描执行层 checkpoint,逆向推导当前业务状态并修正业务层。这个模式在分布式系统中叫"事件溯源"(Event Sourcing)------执行层 checkpoint 就是事件日志,业务层状态是事件日志的物化视图。


十一、总结

Checkpoint 不是"锦上添花",是 Agent 系统的基础设施。

读完这篇你应该能:

  1. 直接把 PostgresSaver + FastAPI 的 checkpoint 方案部署到生产环境
  2. 在自己的 Agent 工作流中实现"暂停-等待人工审批-恢复"的完整链路
  3. 理解了自定义 checkpoint 的核心接口(put/get_tuple/list/put_writes),能自己实现一个
  4. 在面试时说出"checkpoint 的存储可以分层,元数据存 DynamoDB,大对象卸到 S3"
  5. 设计"业务层 + 执行层"两层 checkpoint 架构,让管理员能在后台看到工单进度

如果你正在搭建一个多 Agent 系统,先把 checkpoint 搞定------后面的工具调用、多轮对话、人工审批,全都要依赖它。

相关推荐
BSD_HY5 天前
2026年FSR传感器行业报告:市场规模、竞争格局与发展趋势
人机交互·制造·fsr·薄膜开关·深圳工厂
某林2125 天前
从 Isaac Lab API 踩坑到硬件 MVP 的全链路实战破局
python·机器人·人机交互·ros2
洛星核7 天前
CrewAI 安装、使用方法详细全解
人工智能·github·人机交互·ai编程·agi·智能体
洛星核7 天前
Aider 安装、使用方法详细全解
人工智能·github·人机交互·ai编程·agi
Mr..Jackey8 天前
瑞佑 RUI Builder 图形化 UI 设计工具
arm开发·人工智能·单片机·ui·人机交互·ra8889·lcd控制芯片
元岳数字人小元8 天前
AI 数字人开发公司浅谈 虚拟数字人打造景区新服务
人工智能·人机交互·交互
小玮看世界8 天前
【技术成长实录】北京地铁12号线数据分析系统:从一个观察到一个完整项目的演进之路
python·人机交互·学习方法·cicd·项目交付
byte轻骑兵8 天前
【AVRCP】规范精讲[28]:媒体源上电全流程,蓝牙音频控制启动就靠这一套
网络·音视频·人机交互·媒体·avrcp
kaixinshier9 天前
【无标题】
大模型·人机交互·语音识别·tts·s100p
BSD_HY9 天前
37 载精工深耕|解锁低空经济 + 医疗设备全新人机交互解决方案
人机交互·制造·薄膜开关·深圳工厂