断点续传: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 会记住之前的对话。
但问题也很明显:
- 进程重启数据丢失。K8s 滚动更新、OOM Kill、服务器重启------任何一个都会让你的 MemorySaver 清零。
- 无法跨进程共享 。如果你的 FastAPI 服务有多个 worker 进程,每个进程的 MemorySaver 是独立的,同一个
thread_id在不同 worker 上拿到的状态完全不同。 - 没有持久化。你连"看一眼当前有哪些正在等待审批的任务"都做不到------因为数据在内存里,不在数据库里。
那怎么解决?把 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()--- 保存 checkpointget_tuple()--- 读取 checkpointlist()--- 列出所有 checkpointput_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 系统的基础设施。
读完这篇你应该能:
- 直接把 PostgresSaver + FastAPI 的 checkpoint 方案部署到生产环境
- 在自己的 Agent 工作流中实现"暂停-等待人工审批-恢复"的完整链路
- 理解了自定义 checkpoint 的核心接口(put/get_tuple/list/put_writes),能自己实现一个
- 在面试时说出"checkpoint 的存储可以分层,元数据存 DynamoDB,大对象卸到 S3"
- 设计"业务层 + 执行层"两层 checkpoint 架构,让管理员能在后台看到工单进度
如果你正在搭建一个多 Agent 系统,先把 checkpoint 搞定------后面的工具调用、多轮对话、人工审批,全都要依赖它。