云计算百科
云计算领域专业知识百科平台

LangGraph Checkpoint 入门:用 MemorySaver 为 Agent 装上「可恢复的记忆」

目录

一、为什么 Agent 需要 Checkpoint?

二、先建立正确的心智模型

三、Checkpoint、Thread 与版本链

(一)Checkpointer:状态保存后端

(二)Thread ID:一条状态链的业务标识

(三)Checkpoint 版本链:状态不只有“现在”

(四)一次 invoke 到底发生了什么?

四、代码演示详细说明

(一)代码结构分析

(二)详细演示说明分析

1. 演示 1:MemorySaver 基本用法

为什么 turn_count 是 1,而不是 3?

2. 演示 2:Thread ID 隔离

线上环境应该如何设计 thread_id?

3. 演示 3:状态恢复

一个必须讲清楚的细节

4. 演示 4:查看 Checkpoint 历史

为什么三次 invoke 会有六个以上的版本?

历史顺序也要注意

(三)从历史 Checkpoint 恢复,不等于读取缓存

1. 查看历史状态

2. 从历史状态重新执行

五、Checkpoint 与 Store 不要混为一谈

六、常见问题排查说明及线上考虑基本点说明

(一)常见问题与排查方式

1. 问题 1:忘记传入 thread_id

2. 问题 2:服务重启后 MemorySaver 状态消失

3. 问题 3:不同用户的对话发生串台

4. 问题 4:消息能够累积,普通字段却没有累积

5. 问题 5:修改 State 后,旧 Checkpoint 出现兼容问题

6. 问题 6:Checkpoint 越积越多

裁剪模型上下文

清理 Checkpoint 历史

(二)生产环境还需要考虑什么?

1. 持久化后端

2. 同一线程的并发调用

3. 节点幂等性

4. 敏感数据保护

5. 可观测性

七、如何读懂本次 Demo 的四个结果?

八、总结

一页速查


干货分享,感谢您的阅读!

这是「LangGraph Agent Engineering Mastery」系列 Stage 3「记忆」系列第 2 篇。 本文完整演示 MemorySaver、thread_id、状态恢复和 Checkpoint 历史版本,并重点解释一个容易被误解的问题:内存 Checkpoint 能恢复状态,但不等于真正的跨进程持久化

一、为什么 Agent 需要 Checkpoint?

设想这样一个场景。用户正在和你的 Agent 交谈:

记住,我的密码提示是「蓝色的天空」。

紧接着,你发布了新版本,服务重新部署,原来的 Python 进程退出。几分钟后,用户回来问:

我的密码提示是什么?

Agent 却回答:

抱歉,我们好像刚刚开始聊天。

从模型的角度看,这个回答并没有错。因为大语言模型本身不会自动保存上一次调用的状态;如果应用也没有将消息历史和执行状态存下来,那么每一次请求都只是一次全新的推理。

问题的根源不是模型“记性差”,而是:

Agent 的状态默认只存在于当前执行上下文中。应用不主动保存,状态就不会自动延续。

LangGraph 的 Checkpoint 机制,就是为了解决这个问题。

它会在 Graph 执行过程中保存状态快照,并通过一个稳定的 thread_id 将这些快照组织成独立线程。后续请求再次使用相同的 thread_id 时,Graph 就能够先恢复已有状态,再继续执行。

官方文档将 Checkpointer 定位为线程范围内的状态持久化机制,主要用于多轮对话、人工审批、故障恢复和时间旅行;跨线程共享的长期事实,则更适合放入 Store。

二、先建立正确的心智模型

很多人第一次看到 Checkpoint,会把它理解成“自动保存聊天记录”。这个理解不算错,但还不够完整。

Checkpoint 保存的不只是 messages,而是当前 Graph State 中所有受管理的状态字段。例如:

class ChatState(TypedDict):
messages: Annotated[list[BaseMessage], add_messages]
turn_count: int

这个 State 里包含两个字段:

  • messages:完整的消息序列;

  • turn_count:当前记录的对话轮次。

如果一个更复杂的 Agent 还保存了工具结果、审批状态、检索文档、执行计划、待处理任务或工作流进度,这些字段同样可以成为 Checkpoint 的一部分。

因此,更准确的定义是:

Checkpoint 是 Graph State 在某个执行时刻的状态快照。

LangGraph 会在每个 super-step 边界保存完整的 StateSnapshot。在线性 Graph 中,一个 super-step 往往对应一次节点推进;在并行 Graph 中,则可能有多个节点处于同一个 super-step。

现在针对本次教学,我们完整的代码如下:

"""Demo 02: Checkpoint Basics — MemorySaver、Thread ID、状态恢复。

演示 LangGraph 内置的 Checkpoint 机制:
1. MemorySaver:纯内存的 Checkpoint 后端
2. Thread ID:线程隔离,不同对话互不干扰
3. 状态恢复:中断后从最近一次 Checkpoint 恢复执行

运行方式:
python stages/stage3_memory/02_checkpoint_basics/main.py
"""

from __future__ import annotations

import sys
from pathlib import Path
from typing import Annotated, TypedDict

from langchain_core.messages import BaseMessage, HumanMessage
from langgraph.checkpoint.memory import MemorySaver
from langgraph.graph import END, START, StateGraph
from langgraph.graph.message import add_messages

sys.path.insert(0, str(Path(__file__).resolve().parent.parent.parent.parent))

from shared import get_llm, get_logger, log_step, log_success

logger = get_logger("demo.02_checkpoint_basics")

class ChatState(TypedDict):
messages: Annotated[list[BaseMessage], add_messages]
turn_count: int

def build_checkpoint_graph():
"""构建带 Checkpoint 支持的对话图。"""
llm = get_llm(fallback_to_mock=False)

def chat_node(state: ChatState) -> dict:
log_step(logger, "chat_node", f"第 {state.get('turn_count', 0) + 1} 轮对话")
response = llm.invoke(state["messages"])
return {
"messages": [response],
"turn_count": state.get("turn_count", 0) + 1,
}

graph = StateGraph(ChatState)
graph.add_node("chat", chat_node)
graph.add_edge(START, "chat")
graph.add_edge("chat", END)
return graph

# ============================================================
# 演示 1:基本 Checkpoint 使用
# ============================================================
def demo_basic_checkpoint():
"""演示 MemorySaver 的基本用法。"""
print("\\n— 演示 1: MemorySaver 基本用法 —\\n")

checkpointer = MemorySaver()
graph = build_checkpoint_graph()
app = graph.compile(checkpointer=checkpointer)

config = {"configurable": {"thread_id": "demo-thread-1"}}

conversations = [
"你好,我叫李明",
"我是一名 Python 开发者",
"你还记得我叫什么吗?",
]

results = []
for user_msg in conversations:
print(f" 用户: {user_msg}")
result = app.invoke(
{"messages": [HumanMessage(content=user_msg)], "turn_count": 0},
config=config,
)
ai_msg = result["messages"][-1]
print(f" 助手: {ai_msg.content}")
print()
results.append(result)

snapshot = app.get_state(config)
print(" Checkpoint 状态:")
print(f" 消息数量: {len(snapshot.values.get('messages', []))}")
print(f" 对话轮次: {snapshot.values.get('turn_count', 'N/A')}")
print()

return {"results": results, "checkpointer": checkpointer, "config": config}

# ============================================================
# 演示 2:Thread ID 隔离
# ============================================================
def demo_thread_isolation():
"""演示不同 thread_id 之间的状态隔离。"""
print("\\n— 演示 2: Thread ID 隔离 —\\n")

checkpointer = MemorySaver()
graph = build_checkpoint_graph()
app = graph.compile(checkpointer=checkpointer)

config_alice = {"configurable": {"thread_id": "user-alice"}}
config_bob = {"configurable": {"thread_id": "user-bob"}}

print(" [Alice 的对话]")
app.invoke(
{"messages": [HumanMessage(content="我叫 Alice,我喜欢猫")], "turn_count": 0},
config=config_alice,
)
print(" Alice: 我叫 Alice,我喜欢猫")

print(" [Bob 的对话]")
app.invoke(
{"messages": [HumanMessage(content="我叫 Bob,我喜欢狗")], "turn_count": 0},
config=config_bob,
)
print(" Bob: 我叫 Bob,我喜欢狗")

print("\\n [验证隔离]")
alice_state = app.get_state(config_alice)
bob_state = app.get_state(config_bob)

alice_msgs = alice_state.values.get("messages", [])
bob_msgs = bob_state.values.get("messages", [])

print(f" Alice 消息数: {len(alice_msgs)}")
print(f" Bob 消息数: {len(bob_msgs)}")

alice_has_alice = any("Alice" in str(m.content) or "alice" in str(m.content) for m in alice_msgs)
bob_has_bob = any("Bob" in str(m.content) or "bob" in str(m.content) for m in bob_msgs)
print(f" Alice 对话包含 'Alice': {alice_has_alice}")
print(f" Bob 对话包含 'Bob': {bob_has_bob}")
print()

return {
"alice_msg_count": len(alice_msgs),
"bob_msg_count": len(bob_msgs),
"isolation_verified": True,
}

# ============================================================
# 演示 3:状态恢复
# ============================================================
def demo_state_recovery():
"""演示从 Checkpoint 恢复状态。"""
print("\\n— 演示 3: 状态恢复 —\\n")

checkpointer = MemorySaver()
graph = build_checkpoint_graph()

config = {"configurable": {"thread_id": "recovery-thread"}}

log_step(logger, "阶段 1", "创建对话并积累状态")
app1 = graph.compile(checkpointer=checkpointer)

app1.invoke(
{"messages": [HumanMessage(content="记住:我的密码提示是'蓝色的天空'")], "turn_count": 0},
config=config,
)
app1.invoke(
{"messages": [HumanMessage(content="我最喜欢的编程语言是 Rust")], "turn_count": 0},
config=config,
)
print(" 阶段 1: 已写入 2 轮对话")

state_before = app1.get_state(config)
msg_count_before = len(state_before.values.get("messages", []))
print(f" Checkpoint 中消息数: {msg_count_before}")

log_step(logger, "阶段 2", "模拟进程重启 — 用同一个 checkpointer 创建新的 app")
app2 = graph.compile(checkpointer=checkpointer)

state_after = app2.get_state(config)
msg_count_after = len(state_after.values.get("messages", []))
print(f" 恢复后消息数: {msg_count_after}")

result = app2.invoke(
{"messages": [HumanMessage(content="我最喜欢什么编程语言?")], "turn_count": 0},
config=config,
)
final_msg = result["messages"][-1]
print(" 用户: 我最喜欢什么编程语言?")
print(f" 助手: {final_msg.content}")

log_success(logger, f"状态恢复成功!恢复前 {msg_count_before} 条 → 恢复后 {msg_count_after} 条")
print()

return {
"msg_count_before": msg_count_before,
"msg_count_after": msg_count_after,
"recovery_success": msg_count_after == msg_count_before,
}

# ============================================================
# 演示 4:查看 Checkpoint 历史
# ============================================================
def demo_checkpoint_history():
"""演示查看 Checkpoint 的历史版本。"""
print("\\n— 演示 4: Checkpoint 历史版本 —\\n")

checkpointer = MemorySaver()
graph = build_checkpoint_graph()
app = graph.compile(checkpointer=checkpointer)

config = {"configurable": {"thread_id": "history-thread"}}

steps = ["第一步", "第二步", "第三步"]
for step in steps:
app.invoke(
{"messages": [HumanMessage(content=f"这是{step}")], "turn_count": 0},
config=config,
)

print(" Checkpoint 历史:")
history_count = 0
for state in app.get_state_history(config):
history_count += 1
msg_count = len(state.values.get("messages", []))
ts = state.config.get("configurable", {}).get("checkpoint_id", "N/A")
print(f" 版本 {history_count}: {msg_count} 条消息, checkpoint_id={ts[:20]}…")
if history_count >= 6:
print(" … (更多历史省略)")
break

print(f"\\n 总共 {history_count}+ 个 Checkpoint 版本")
print()

return {"history_count": history_count}

def run_demo() -> dict:
"""运行 Checkpoint Basics 全部演示。"""
print("=" * 60)
print(" Demo 02: Checkpoint Basics — 状态持久化基础")
print("=" * 60)

basic = demo_basic_checkpoint()
isolation = demo_thread_isolation()
recovery = demo_state_recovery()
history = demo_checkpoint_history()

print()
print("=" * 60)
print(" 关键概念回顾")
print("=" * 60)
print(" 1. MemorySaver : LangGraph 内置的纯内存 Checkpoint 后端")
print(" 2. thread_id : 通过 config 传入,实现对话线程隔离")
print(" 3. 状态恢复 : 同一 thread_id 自动从最近 Checkpoint 恢复")
print(" 4. 历史版本 : 每次 invoke 都会创建新的 Checkpoint 版本")
print(" 5. get_state() : 查看当前线程的最新状态")
print(" 6. get_state_history(): 查看所有历史 Checkpoint")
print()

return {
"basic": basic,
"isolation": isolation,
"recovery": recovery,
"history": history,
}

if __name__ == "__main__":
run_demo()

三、Checkpoint、Thread 与版本链

整个 Checkpoint 系统可以拆成三个核心概念。

(一)Checkpointer:状态保存后端

Checkpointer 决定状态保存在哪里。本文代码使用):

from langgraph.checkpoint.memory import MemorySaver

并创建一个纯内存后端:

checkpointer = MemorySaver()

MemorySaver 使用当前 Python 进程的内存保存 Checkpoint,适合:

  • 本地学习;

  • 单元测试;

  • 快速验证;

  • 开发阶段调试。

但它不适合真正的生产持久化,因为 Python 进程退出后,内存中的数据也会消失。官方文档同样明确指出,MemorySaver 和 InMemorySaver 的数据不会在进程重启后保留。

(二)Thread ID:一条状态链的业务标识

每次调用带 Checkpointer 的 Graph 时,都需要传入:

config = {"configurable": {"thread_id": "demo-thread-1"}}

thread_id 可以理解成一把钥匙。

  • 相同的 thread_id:继续读取原有状态;

  • 不同的 thread_id:创建相互隔离的新线程;

  • 没有 thread_id:Checkpointer 不知道应该读取或写入哪条状态链。

官方文档将 thread_id 描述为 Checkpointer 保存和加载状态时使用的主要标识。

(三)Checkpoint 版本链:状态不只有“现在”

同一个线程不是只保存一个最终状态。随着 Graph 不断执行,Checkpointer 会持续产生新的状态版本:

checkpoint_1

checkpoint_2

checkpoint_3

checkpoint_4

这意味着应用不仅能够读取“最新状态”,还可以查看历史状态,并从某个历史 Checkpoint 重新执行后续节点。

这也是 LangGraph 能够实现以下能力的基础:

  • 对话状态恢复;

  • Human-in-the-loop;

  • 故障续跑;

  • 历史调试;

  • Replay;

  • Fork;

  • Time Travel。

(四)一次 invoke 到底发生了什么?

可以把一次调用理解成以下过程:

这里最容易被忽略的是 Reducer

恢复状态并不意味着简单地用新输入覆盖旧状态。每一个 State 字段都有自己的更新规则:

  • 声明了 Reducer 的字段,按照 Reducer 合并;

  • 没有声明 Reducer 的字段,通常由新值直接覆盖旧值。

在本文代码中:

class ChatState(TypedDict):
messages: Annotated[list[BaseMessage], add_messages]
turn_count: int

messages 使用了 add_messages,因此新的消息会追加到已有消息列表中。

turn_count 没有自定义 Reducer,所以它采用默认覆盖行为。

这个差异,正是后面出现“消息数量是 6,但 turn_count 始终是 1”的原因。

四、代码演示详细说明

(一)代码结构分析

本文 Demo 的 Graph 非常简单:

START → chat → END

Graph 的构建代码如下,保持原样:

def build_checkpoint_graph():
"""构建带 Checkpoint 支持的对话图。"""
llm = get_llm(fallback_to_mock=False)

def chat_node(state: ChatState) -> dict:
log_step(logger, "chat_node", f"第 {state.get('turn_count', 0) + 1} 轮对话")
response = llm.invoke(state["messages"])
return {
"messages": [response],
"turn_count": state.get("turn_count", 0) + 1,
}

graph = StateGraph(ChatState)
graph.add_node("chat", chat_node)
graph.add_edge(START, "chat")
graph.add_edge("chat", END)
return graph

chat_node 做了两件事:

  • 将当前 messages 传给模型;

  • 返回一条新的 AI 消息和更新后的 turn_count。

  • 需要注意,build_checkpoint_graph() 返回的只是 Graph Builder。

    真正让 Graph 获得状态保存能力的,是编译时传入的 Checkpointer:

    checkpointer = MemorySaver()
    graph = build_checkpoint_graph()
    app = graph.compile(checkpointer=checkpointer)

    同一份 Graph 结构,可以根据需要挂载不同后端:

    Graph Builder
    ├── compile(checkpointer=MemorySaver)
    ├── compile(checkpointer=SQLite Saver)
    ├── compile(checkpointer=PostgreSQL Saver)
    └── compile(checkpointer=Redis Saver)

    Graph 负责定义执行逻辑,Checkpointer 负责保存执行状态。两者是解耦的。

    (二)详细演示说明分析

    1. 演示 1:MemorySaver 基本用法

    第一个演示连续发起三轮对话:

    conversations = [
    "你好,我叫李明",
    "我是一名 Python 开发者",
    "你还记得我叫什么吗?",
    ]

    所有请求都使用相同的配置:

    config = {"configurable": {"thread_id": "demo-thread-1"}}

    调用代码保持原样:

    result = app.invoke(
    {"messages": [HumanMessage(content=user_msg)], "turn_count": 0},
    config=config,
    )

    三次调用的状态变化可以表示为:

    调用新增 Human 消息新增 AI 消息最终消息数
    第 1 次 1 1 2
    第 2 次 1 1 4
    第 3 次 1 1 6

    第三次调用前,Graph 会先恢复前两次调用留下的消息:

    Human: 你好,我叫李明
    AI: 你好,李明……
    Human: 我是一名 Python 开发者
    AI: 很高兴认识你……
    Human: 你还记得我叫什么吗?

    模型收到的并不只是最后一个问题,而是恢复并合并后的完整消息序列,因此它能够回答“李明”。

    最后通过:

    snapshot = app.get_state(config)

    读取最新状态:

    Checkpoint 状态:
    消息数量: 6
    对话轮次: 1

    为什么 turn_count 是 1,而不是 3?

    关键在于每次调用都显式传入了:

    "turn_count": 0

    而 turn_count 没有 Reducer。

    因此每一次 invoke 恢复旧状态之后,本次输入中的 0 又会覆盖旧值,随后 chat_node 执行:

    state.get("turn_count", 0) + 1

    最终重新得到 1。

    执行过程实际上是:

    上次 Checkpoint:turn_count = 1

    本次输入覆盖:turn_count = 0

    chat_node 加一:turn_count = 1

    这个现象非常有教学价值:

    Checkpoint 负责恢复状态,但状态如何合并,最终由每个字段的 Reducer 决定。

    2. 演示 2:Thread ID 隔离

    第二个演示创建了两个独立线程:

    config_alice = {"configurable": {"thread_id": "user-alice"}}
    config_bob = {"configurable": {"thread_id": "user-bob"}}

    Alice 线程写入:

    HumanMessage(content="我叫 Alice,我喜欢猫")

    Bob 线程写入:

    HumanMessage(content="我叫 Bob,我喜欢狗")

    由于两次调用使用不同的 thread_id,Checkpointer 会为它们分别维护独立的状态链。

    代码随后分别读取两个线程的最新状态:

    alice_state = app.get_state(config_alice)
    bob_state = app.get_state(config_bob)

    运行结果:

    Alice 消息数: 2
    Bob 消息数: 2
    Alice 对话包含 'Alice': True
    Bob 对话包含 'Bob': True

    每条线程中都只有:

    1 条 HumanMessage
    1 条 AIMessage

    状态没有互相混入。

    线上环境应该如何设计 thread_id?

    通常不要直接把 thread_id 写死成:

    "thread_id": "default"

    更稳妥的方式是将用户标识和会话标识组合起来:

    thread_id = f"{user_id}:{conversation_id}"

    其中:

    • user_id 用于区分用户;

    • conversation_id 用于区分同一用户的多个会话。

    例如:

    u1001:conversation-001
    u1001:conversation-002
    u2048:conversation-001

    这样,同一个用户可以拥有多个彼此隔离的对话。还需要注意:

    thread_id 是状态隔离标识,不应直接承担权限校验。

    服务端仍然需要确认当前请求用户是否有权访问这个 thread_id,否则攻击者只要猜到别人的线程标识,就可能尝试读取不属于自己的状态。

    3. 演示 3:状态恢复

    第三个演示先使用 app1 写入两轮对话:

    app1.invoke(
    {"messages": [HumanMessage(content="记住:我的密码提示是'蓝色的天空'")], "turn_count": 0},
    config=config,
    )
    app1.invoke(
    {"messages": [HumanMessage(content="我最喜欢的编程语言是 Rust")], "turn_count": 0},
    config=config,
    )

    这时,Checkpoint 中共有 4 条消息:

    Human:记住,我的密码提示是“蓝色的天空”
    AI:……
    Human:我最喜欢的编程语言是 Rust
    AI:……

    接着,代码重新编译出一个新的 app2:

    app2 = graph.compile(checkpointer=checkpointer)

    因为 app1 和 app2 使用的是同一个 checkpointer 实例,所以 app2 能够读取 app1 已经保存的状态。

    随后提问:

    result = app2.invoke(
    {"messages": [HumanMessage(content="我最喜欢什么编程语言?")], "turn_count": 0},
    config=config,
    )

    模型能够从恢复后的消息历史中找到答案:

    助手:你最喜欢的是 Rust。

    一个必须讲清楚的细节

    原代码中的日志描述是:

    log_step(logger, "阶段 2", "模拟进程重启 — 用同一个 checkpointer 创建新的 app")

    这里的“模拟进程重启”不能按字面理解成真正杀掉并重启 Python 进程。

    代码实际模拟的是:

    重新创建编译后的 Graph 应用,但仍然复用原来的 MemorySaver 对象。

    只要下面这个对象还在内存中:

    checkpointer = MemorySaver()

    状态就还在。

    但真正结束 Python 进程后:

    Python 进程退出

    MemorySaver 对象销毁

    所有内存 Checkpoint 消失

    所以这个 Demo 证明的是:

    只要新的 Graph 应用能够访问同一个 Checkpointer 后端,就可以恢复原有状态。

    它没有证明:

    MemorySaver 可以跨进程重启保存数据。

    真正需要跨进程、跨机器或跨部署恢复时,必须使用外部持久化后端,例如 SQLite、PostgreSQL 或 Redis。

    4. 演示 4:查看 Checkpoint 历史

    代码连续调用三次 Graph:

    steps = ["第一步", "第二步", "第三步"]
    for step in steps:
    app.invoke(
    {"messages": [HumanMessage(content=f"这是{step}")], "turn_count": 0},
    config=config,
    )

    随后遍历历史状态:

    for state in app.get_state_history(config):
    history_count += 1
    msg_count = len(state.values.get("messages", []))
    ts = state.config.get("configurable", {}).get("checkpoint_id", "N/A")
    print(f" 版本 {history_count}: {msg_count} 条消息, checkpoint_id={ts[:20]}…")

    输出类似:

    版本 1: 6 条消息
    版本 2: 5 条消息
    版本 3: 4 条消息
    版本 4: 4 条消息
    版本 5: 3 条消息
    版本 6: 2 条消息

    为什么三次 invoke 会有六个以上的版本?

    因为 Checkpoint 并不是“每次 invoke 只保存一次”。LangGraph 会在 super-step 边界保存状态。对于本文这个只有一个 chat 节点的线性 Graph,一次调用通常至少会经历:

    输入被写入状态

    chat 节点执行完成

    因此历史中可能同时出现:

    5 条消息:新 HumanMessage 已写入,AIMessage 尚未生成
    6 条消息:chat_node 已完成,AIMessage 已写入

    这正好解释了历史输出中的:

    6 → 5 → 4 → 4 → 3 → 2

    其中重复的 4 并不表示重复保存了完全相同的执行位置,而可能分别对应:

    • 上一轮执行完成后的状态;

    • 下一轮输入进入之前或之后的状态边界。

    历史顺序也要注意

    get_state_history(config) 默认按倒序返回状态:

    最新 Checkpoint

    更早的 Checkpoint

    最初的 Checkpoint

    因此输出中的“版本 1”实际上是最新状态,而不是最老状态。官方时间旅行文档也明确说明,get_state_history() 返回的是逆时间顺序。

    (三)从历史 Checkpoint 恢复,不等于读取缓存

    Checkpoint 历史不仅可以查看,还可以用于 Replay 和 Fork。但这里有一个重要区别:

    1. 查看历史状态

    history = list(app.get_state_history(config))

    只是读取历史 StateSnapshot,不会重新调用模型或工具。

    2. 从历史状态重新执行

    如果使用某个历史 Checkpoint 的 config 再次调用 Graph,Checkpoint 之前的步骤不会重新运行,但其后的节点会再次执行。

    这意味着:

    • LLM 可能产生不同回答;

    • 外部 API 会再次请求;

    • 工具调用可能产生新的副作用;

    • Human-in-the-loop 中断可能再次触发。

    官方文档特别提醒:Replay 会重新执行 Checkpoint 后面的节点,而不是简单地从缓存中读取旧结果。

    因此,支持时间旅行的生产 Agent 必须考虑节点的幂等性。例如,下面这些操作不能在 Replay 时无条件重复:

    发送付款
    发送邮件
    创建订单
    删除数据
    调用不可逆的第三方接口

    更稳妥的做法是为副作用操作增加业务幂等键。

    五、Checkpoint 与 Store 不要混为一谈

    很多文章会笼统地把两者都叫作“Agent 记忆”,但它们解决的问题不同。

    维度CheckpointerStore
    保存内容 Graph State 快照 应用定义的数据
    作用范围 单个 thread 可跨 thread
    常见数据 消息、节点状态、执行进度 用户偏好、长期事实、知识条目
    主要用途 多轮对话、恢复、回溯、故障续跑 长期记忆、用户画像、共享知识
    访问方式 在 config 中传入 thread_id 通过命名空间和 key 读写

    例如:

    “这轮对话里,用户刚上传了一个文件”

    适合放入当前线程的 State。而:

    “用户长期偏好使用深色主题”

    更适合放入 Store。

    官方文档将 Checkpointer 定义为 thread-scoped short-term memory,将 Store 定义为跨线程的长期数据存储。

    六、常见问题排查说明及线上考虑基本点说明

    (一)常见问题与排查方式

    1. 问题 1:忘记传入 thread_id

    错误调用:

    app.invoke({"messages": [HumanMessage(content="你好")]})

    挂载 Checkpointer 后,每次调用都应该带上:

    config = {
    "configurable": {
    "thread_id": "some-thread-id"
    }
    }

    否则 Checkpointer 无法确定状态属于哪个线程。

    2. 问题 2:服务重启后 MemorySaver 状态消失

    这是正常行为。MemorySaver 的数据只存在于当前 Python 进程中。

    适用场景:

    学习 / Demo / 测试 / 本地调试

    不适用场景:

    生产服务 / 多进程部署 / 容器滚动发布 / 跨机器恢复

    生产环境应使用外部持久化 Checkpointer。

    3. 问题 3:不同用户的对话发生串台

    最常见原因是所有请求使用了同一个固定值:

    "thread_id": "default"

    结果是所有用户都在读写同一条状态链。推荐至少包含:

    user_id + conversation_id

    并在服务端验证线程归属关系。

    4. 问题 4:消息能够累积,普通字段却没有累积

    Checkpoint 只负责恢复状态。字段究竟是追加、求和、合并还是覆盖,由 Reducer 决定。

    本文中:

    messages: Annotated[list[BaseMessage], add_messages]

    会追加消息。而:

    turn_count: int

    默认会被新输入覆盖。因此,设计 State 时不能只关注字段类型,还要关注字段更新语义。

    5. 问题 5:修改 State 后,旧 Checkpoint 出现兼容问题

    生产系统中的 State Schema 会随着业务迭代发生变化,例如:

    v1:
    messages
    turn_count

    升级后变成:

    v2:
    messages
    turn_count
    user_profile
    workflow_status

    如果新代码直接假设所有历史状态都有新字段,就可能在恢复旧线程时报错。

    可以采用以下策略:

    • 新字段提供默认值;

    • 读取时使用 state.get();

    • 在 State 中增加 schema_version;

    • 编写迁移逻辑;

    • 重大不兼容升级时创建新的线程命名空间。

    6. 问题 6:Checkpoint 越积越多

    长时间运行的 Agent 可能产生大量版本。而且要区分两件事:

    裁剪模型上下文

    减少发送给 LLM 的消息数量,控制 Token 成本。

    清理 Checkpoint 历史

    减少持久化后端中的历史状态版本,控制存储成本。只裁剪上下文,不一定会删除已经保存的历史 Checkpoint。

    生产环境需要单独设计:

    • 数据保留周期;

    • 最近版本保留数量;

    • 冷热数据分层;

    • 会话归档;

    • 用户删除数据流程。

    (二)生产环境还需要考虑什么?

    1. 持久化后端

    生产环境的关键要求不是“对象能复用”,而是:

    进程退出后,状态仍然存在

    还要支持:

    • 多实例访问;

    • 失败重试;

    • 连接池;

    • 备份恢复;

    • 数据清理;

    • 权限控制;

    • 监控告警。

    2. 同一线程的并发调用

    假设同一个 thread_id 同时收到两个请求:

    请求 A:用户上传了一份合同
    请求 B:用户询问上一份合同的结论

    如果两个请求同时恢复同一个旧版本,再各自写回新状态,就可能产生并发冲突。

    常见解决方式包括:

    • 同一 thread_id 串行执行;

    • 使用分布式锁;

    • 使用任务队列;

    • 使用乐观并发控制;

    • 检测 Checkpoint 版本冲突。

    3. 节点幂等性

    有了 Checkpoint 之后,节点可能因为以下原因重新执行:

    • 故障恢复;

    • Retry;

    • Replay;

    • Time Travel;

    • 人工修改状态后续跑。

    纯计算节点通常没有问题。涉及真实副作用的节点需要格外谨慎:

    支付、发信、建单、删除、发布、修改外部系统

    推荐为这些操作保存业务执行标识,避免重复执行。

    4. 敏感数据保护

    本文为了演示状态记忆,使用了:

    密码提示:蓝色的天空

    这只是教学示例。生产环境不应在消息历史或 Checkpoint 中保存:

    • 明文密码;

    • API Key;

    • Access Token;

    • 私钥;

    • 完整支付信息;

    • 不必要的身份证明数据。

    需要保存的敏感字段应进行:

    • 最小化采集;

    • 脱敏;

    • 加密;

    • 权限隔离;

    • 生命周期管理;

    • 审计记录。

    5. 可观测性

    建议在日志和链路追踪中记录:

    thread_id
    checkpoint_id
    checkpoint_ns
    执行节点
    恢复来源
    写入耗时
    状态大小
    历史版本数量

    但不要直接将完整消息或敏感 State 无限制输出到日志。

    七、如何读懂本次 Demo 的四个结果?

    演示证明了什么没有证明什么
    基本 Checkpoint 同一线程的消息可以持续累积 普通字段会自动累加
    Thread ID 隔离 不同线程的状态互不干扰 thread_id 自动提供访问控制
    状态恢复 新 app 可从同一个 Checkpointer 恢复 MemorySaver 能跨进程重启
    历史版本 可以查看多个 StateSnapshot 每次 invoke 只产生一个版本

    最重要的四个结论是:

  • compile(checkpointer=…) 为 Graph 接入状态保存后端。

  • thread_id 决定状态属于哪条线程。

  • Reducer 决定新输入如何与旧状态合并。

  • MemorySaver 只能在当前进程存活期间保存数据。

  • 八、总结

    这一篇,我们用 MemorySaver 给 LangGraph Agent 接入了最基础的 Checkpoint 能力。

    它带来的不只是“记住聊天记录”,而是完整的线程级状态管理:

    compile(checkpointer=…)

    保存 Graph State

    thread_id 隔离线程

    后续 invoke 自动恢复

    get_state 查看最新状态

    get_state_history 查看历史版本

    同时,也要牢牢记住它的能力边界:

    MemorySaver 能让新编译的 app 从同一个内存后端恢复状态,但无法在 Python 进程真正退出后保留数据。

    因此,它最适合用来学习 Checkpoint 的工作方式,而不是直接作为生产持久化方案。

    下一篇进入 Redis Persistence:把 Checkpoint 从 Python 内存迁移到外部存储,实现真正的跨进程恢复,并进一步讨论连接失败时的降级策略、生产配置和状态治理。

    一页速查

    checkpointer = MemorySaver()

    app = graph.compile(checkpointer=checkpointer)

    config = {
    "configurable": {
    "thread_id": "user-id:conversation-id"
    }
    }

    result = app.invoke(
    {"messages": [HumanMessage(content="你好")], "turn_count": 0},
    config=config,
    )

    latest_state = app.get_state(config)

    history = app.get_state_history(config)

    MemorySaver → 当前进程内保存
    thread_id → 线程隔离与状态定位
    get_state → 读取最新 StateSnapshot
    get_state_history → 查看历史版本链
    Reducer → 决定状态如何合并
    持久化 Checkpointer → 实现真正的跨进程恢复

     系列导航:LangGraph从零构建生产级 AI Agent 平台的递进式学习项目-CSDN博客

    赞(0)
    未经允许不得转载:网硕互联帮助中心 » LangGraph Checkpoint 入门:用 MemorySaver 为 Agent 装上「可恢复的记忆」
    分享到: 更多 (0)

    评论 抢沙发

    评论前必须登录!