【LangGraph实战】LangGraph 学习笔记(四):持久化——从线程记忆到跨会话长期记忆

👋 欢迎阅读

🔥 愿旖旎 · 个人主页

📘学习专栏: 《算法专栏》《LangChain学习》《贪心算法》

🌄 钱塘江上潮信来,今日方知我是我

✨当前学习内容 : 《LangGraph》

📑 目录


一、前置讲解

在进入正文前,先花 10 秒了解本篇会反复用到的核心概念,带着印象去读正文,学习效率更高。

🧠 前置知识一:持久化(Persistence)

把 AI 应用的状态 存下来,重启、宕机 之后还能接着上次继续,而不是从头开始。

💾 前置知识二:检查点(Checkpoint)

线程在某一时刻的状态快照 :记着状态值 、下一步要执行的节点 ,可以回滚 与重放。

🧵 前置知识三:线程(Thread)与 thread_id

一个 thread_id 就是一次独立会话的身份证 ,不同线程的状态互不干扰。

🗄️ 前置知识四:存储(Store)与命名空间(Namespace)

跨会话的长期记忆仓库 ,用命名空间(元组)按用户、按类型把记忆分门别类放好。

⏪ 前置知识五:时间旅行(重放与更新状态)

拿到历史 config 就能从过去某一步重新执行 ,也能改掉那一步的状态再跑一遍。


二、LangGraph 持久化(Persistence)

2.1 什么是持久化能力?

简单来说,在 LangGraph 中持久化能力 指的是把 AI 应用 的状态 (如对话历史 、中间结果 、用户信息 等)保存 下来,即使程序重启 或系统宕机 ,也能恢复 之前的状态,让 AI"记住"之前发生过的一切。

第一个场景很好理解:你今天和智能助手 聊了很多重要信息,关掉应用、明天重新打开时,你当然希望它还记得你说过的话 ------这就是 AI 应用需要持久化的第一个原因。

再看第二个场景:假设有一个助手,它可以搜索网络。

​

没有持久化时,整个流程是这样的:

步骤 发生的事
1 用户问:"今天的天气怎么样?"
2 助手 调用搜索工具,得到答案:"今天晴天,25 度。"
3 程序崩溃重启
4 用户再问:"那我需要带伞吗?"
5 助手 没有之前的上下文 ,可能又去调用搜索工具 ,而不是基于"今天晴天"这个上下文回答"不需要"

有持久化时,同样的流程会变成:

步骤 发生的事
1 用户问:"今天的天气怎么样?"
2 助手 调用搜索工具 ,得到"今天晴天,25 度"(这个状态 ,包括对话历史 和工具调用结果 ,被自动保存)
3 程序崩溃重启
4 用户 再问:"那我需要带伞吗?"(与之前在同一会话下)
5 LangGraph 加载之前保存的状态 ,状态里记录了"今天晴天"
6 助手 看到上下文 是晴天,直接回答:"今天是晴天,您不需要带伞。"(无需再次调用搜索工具)

💡 两边的差别不在"能不能搜",而在状态有没有被存下来 :没持久化时,崩溃重启等于失忆 ,只能靠再搜一次来补救;有持久化时,重启后加载状态就能接着上下文往下答。

2.2 持久化能力的两种表现

类型 作用
线程级(单对话)持久化 自动保存工作流执行过程中的状态快照 ,维持单次会话 的完整上下文
跨会话持久化 通过存储(Store) 保存用户信息 、偏好设置 等长期数据 ,实现不同对话间 信息的持久化共享

关于线程级持久化,有两个概念容易混,先在这里说清:

易混点 说明
"线程"≠ 操作系统线程 操作系统线程 是进程内的执行单元 ,是操作系统调度的最小单位 ;而这里的线程(Thread) 表示聊天过程中的单次会话 的持久化信息(相当于 AI 开一个新对话),用来隔离不同的聊天会话 ,两者概念完全独立
"状态快照"≠ 之前学的 State 这里的状态 包含了所有必要的上下文信息 ,比如:已经调用过哪些工具 、用户的输入 、聊天历史 、下一步要执行的节点等等

关于跨会话持久化 ,举个例子就很直观:把用户基本情况 (比如有高血压病史 )存进 Store,那么后续无论何时何地、无论新开几个会话窗口 ,都能基于这条用户基本信息来生成结果。

2.3 线程级持久化

2.3.1 线程级持久化是怎么工作的?

当我们开始执行工作流 ,过程中可能发生崩溃或重启导致的中断 等异常情况。根据 LangGraph 的持久化机制 ,线程级持久化 能够自动保存 工作流执行过程中的状态快照 ,维持单次会话 的完整上下文 ------工作流执行到某一步时,它会自动保存当前步骤的状态快照 ,这个状态 包含所有必要的上下文信息(调用过哪些工具、用户输入、聊天历史、下一步要执行的节点等)。

​

线程级持久化机制确保了下面三件事:

保障 说明
状态不丢失 即使应用崩溃、重启 ,或长时间流程被中断 ,恢复时也能从上次停止的地方继续执行 ,而不是从头开始
支持长时间运行的任务 需要与用户多轮交互 (如多步对话助手 )或处理耗时极长 的流程(如等待外部 API 回调)时,持久化必不可少
检查点和回滚 可以把状态保存到某个时间点(检查点) ,并在需要时回滚到该状态

LangGraph 的线程级持久化 是其核心功能,它通过**【线程】** 和**【检查点】**这两个核心部分来实现。

2.3.2 Threads(线程)

在 LangGraph 中,Thread 代表一个独立的工作流执行会话 。可以把它想象成【与某个用户的一次完整对话历史 】或【处理某个特定任务的一次完整执行过程】。

​

Thread 的关键特性:

特性 说明
隔离性 每个 Thread 都完全独立 ,它们的状态互不干扰
持久化单元 Thread 是状态持久化 的基本单位
标识符 通过唯一的 thread_id 来识别
2.3.3 Checkpoints(检查点)

Checkpoint 是 Thread 在特定时刻的**【状态快照】** ,它记录了工作流 执行到某个节点 时的完整状态 。例如在一次会话中,每一次用户输入 和对话结束 后,都可以保存一个最新的**【状态快照】**。

​

Checkpoint 的关键特性之一是"状态快照(StateSnapshot)" :保存了工作流 在某个时间点 的完整状态 ,包含**【状态值】** 、【下一步要执行的节点】 、【与此检查点关联的配置】 和**【与此检查点关联的元数据】**等信息。

python 复制代码
StateSnapshot(
    # 当前状态值(如:对话消息列表)
    values={'messages': [用户消息, AI回复, 用户消息...]},
​
    # 接下来要执行的节点
    next=('generate_response',),
​
    # 配置信息(用于定位和恢复这个检查点)
    config={'configurable': {'thread_id': '123', 'checkpoint_id': 'abc'}},
​
    # 元数据(步骤号、来源、写入信息等)
    metadata={'step': 2, 'source': 'loop', 'writes': {...}},
​
    # 父检查点(形成链表,指向上一个 StateSnapshot 的 config)
    parent_config={'configurable': {'thread_id': '123', 'checkpoint_id': 'def...'}},
​
    # 创建时间
    created_at=''
)

StateSnapshot 的字段可以这样记:

字段 含义
values 当前状态值(如对话消息列表)
next 接下来要执行的节点(元组;为空表示流程已结束)
config 配置信息 (含 thread_id、checkpoint_id),用于定位和恢复这个检查点
metadata 元数据(步骤号、来源、写入信息等)
parent_config 父检查点 的 config,形成链表指向上一个快照
created_at 快照创建时间

第二个关键特性是"版本历史与可恢复点" :一个 Thread 可以有多个 Checkpoints ,形成执行历史 ,使得同一个会话 的历史状态 可以从任意 Checkpoint 追溯 和访问。

2.4 线程级持久化实战

2.4.1 步骤一:配置 checkpointer 持久化存储

在定义图 时,我们需要指定 checkpointer 。LangGraph 支持多种 checkpointer 的定义方式:

方式 写法 适用场景
方式 1:内存存储 InMemorySaver() 开发和测试 :状态保存在程序内存 中,程序重启后状态会丢失
方式 2:Postgres 存储库 独立的、可安装的检查点存储库 生产环境 或需要状态持久化的场景

方式 1 的代码最简单:

​

python 复制代码
from langgraph.checkpoint.memory import InMemorySaver
​
# 定义存储方式
checkpointer = InMemorySaver()
​
# 用 checkpointer 编译图
agent = agent_builder.compile(checkpointer=checkpointer)

💡 LangGraph 提供了几个检查点存储实现 ,所有这些都通过独立的、可安装的库实现,所以方式 2 需要额外安装对应的库(本篇不展开)。

2.4.2 步骤二:使用 Thread 执行

编译好图、准备运行 时,我们需要通过一个 Thread ID 来标识这次执行:

情况 LangGraph 的行为
Thread ID 不存在 创建一个新的 Thread ,从初始状态开始执行
Thread ID 已存在 从 Checkpointer 中加载该 Thread 最后一次保存的状态 ,并从这个状态继续执行

代码示例:

python 复制代码
from langchain.chat_models import init_chat_model
from langchain_core.messages import HumanMessage
from langchain_core.tools import tool
from langgraph.graph import StateGraph, START, END
from langgraph.graph.message import MessagesState
from langgraph.prebuilt import ToolNode, tools_condition
from langgraph.checkpoint.memory import InMemorySaver


# ---------- 1. 定义工具 ----------
@tool
def get_weather(city: str) -> str:
    """查询指定城市的天气"""
    return f"{city}今天晴,气温 18~26 度。"


tools = [get_weather]  # 包装进列表


# ---------- 2. 定义模型并绑定工具 ----------
model = init_chat_model(model="deepseek-chat", model_provider="deepseek")
model_with_tools = model.bind_tools(tools)


# ---------- 3. 自定义状态(MessagesState + 额外字段 llm_calls)----------
# MessagesState 是 LangGraph 提供的【状态模板】,它内部自带了 messages 字段:
#     class MessagesState(TypedDict):
#         messages: Annotated[list[AnyMessage], add_messages]
#                        ↑字段名                    ↑合并规则:追加(不是覆盖)
class AgentState(MessagesState):
    llm_calls: int      # 额外加一个字段:跟踪 LLM 调用次数


# ---------- 4. 定义模型节点 ----------
def llm_call(state: AgentState):
    """模型节点:让 LLM 决定是否调用工具,并累计调用次数"""
    return {
        # 读取:state["messages"] 取出已有的全部消息(历史对话)
        # 写入:用 "messages" 作为 key 返回,新消息会按 add_messages 规则【追加】
        "messages": [model_with_tools.invoke(state["messages"])],
        "llm_calls": state.get("llm_calls", 0) + 1,   # 累计 LLM 调用次数
    }


# ---------- 5. 构建 Agent 图 ----------
builder = StateGraph(AgentState)
builder.add_node("llm_call", llm_call)
builder.add_node("tools", ToolNode(tools))     # 预构建的工具节点

builder.add_edge(START, "llm_call")
# tools_condition:内置条件函数 ------ 模型要调工具就去 tools,否则去 END
builder.add_conditional_edges("llm_call", tools_condition)
# 工具执行完再回到模型,形成循环
builder.add_edge("tools", "llm_call")

agent_builder = builder


# ---------- 6. 使用 InMemorySaver(内存检查点,程序结束即消失)----------
checkpointer = InMemorySaver()

# 编译图,挂上检查点(支持多轮记忆)
agent = agent_builder.compile(checkpointer=checkpointer)

# 第一次执行,创建一个新的 Thread (thread_id="1")
config = {"configurable": {"thread_id": "1"}}
result1 = agent.invoke(
    {"messages": [HumanMessage(content="今天厦门的天气如何?")]},
    config,
)

# result1["messages"] 读的是状态里的 messages 字段(完整对话历史)
for m in result1["messages"]:
    m.pretty_print()

# 第二次执行:同一个 thread_id → 检查点里存着上一轮的消息,模型能"记得"
result2 = agent.invoke(
    {"messages": [HumanMessage(content="我们刚才聊什么了")]},
    config,
)
for m in result2["messages"]:
    m.pretty_print()

运行结果:

复制代码
#第一次打印
================================ Human Message =================================
​
今天厦门的天气如何?
================================== Ai Message ==================================
Tool Calls:
  get_weather (call_00_CBGQs2Zlf6bblh8G2wUJ1568)
 Call ID: call_00_CBGQs2Zlf6bblh8G2wUJ1568
  Args:
    city: 厦门
================================= Tool Message =================================
Name: get_weather
​
厦门今天晴,气温 18~26 度。
================================== Ai Message ==================================
​
今天厦门天气晴朗,气温在 18~26 度之间,比较舒适,适合外出活动。☀️
​
​
#第二次打印
================================ Human Message =================================
​
今天厦门的天气如何?
================================== Ai Message ==================================
Tool Calls:
  get_weather (call_00_CBGQs2Zlf6bblh8G2wUJ1568)
 Call ID: call_00_CBGQs2Zlf6bblh8G2wUJ1568
  Args:
    city: 厦门
================================= Tool Message =================================
Name: get_weather
​
厦门今天晴,气温 18~26 度。
================================== Ai Message ==================================
​
今天厦门天气晴朗,气温在 18~26 度之间,比较舒适,适合外出活动。☀️
================================ Human Message =================================
​
我们刚才聊什么了
================================== Ai Message ==================================
​
我们刚才聊的是天气。你问了今天厦门的天气情况,我帮你查询后告诉你:厦门今天晴,气温 18~26 度,天气比较舒适,适合外出活动。☀️

💡 关键点在于两次 invoke 用的是同一个 config (也就是同一个 thread_id="1")。第二次调用时 LangGraph 先把上一次的完整消息历史 从检查点里加载回来,再把你新说的话接上去,所以模型能回答"我们刚才聊的是天气"。如果第二次换一个 thread_id,它就完全不认识你了 ------这正是 3.1 节要讲的跨会话丢信息问题。

2.5 其他使用方法

下面四个方法都建立在同一个前提上:图必须挂了 checkpointer。

2.5.1 获取最新快照

用 get_state(config) 可以获取编译后的图 的最新状态快照 。分别看执行前 与执行后:

代码示例:

python 复制代码
# ---------- 6. 使用 InMemorySaver(内存检查点,程序结束即消失)----------
checkpointer = InMemorySaver()
​
# 编译图,挂上检查点(支持多轮记忆)
agent = agent_builder.compile(checkpointer=checkpointer)
​
# 第一次执行,创建一个新的 Thread (thread_id="1")
config = {"configurable": {"thread_id": "1"}}
​
# get_state:获取当前线程的【状态快照】(调用前,应该是空的)
print("=" * 30, "调用前的状态快照", "=" * 30)
snapshot = agent.get_state(config)
print(snapshot)
​
result1 = agent.invoke(
    {"messages": [HumanMessage(content="今天厦门的天气如何?")]},
    config,
)
​
for m in result1["messages"]:
    m.pretty_print()
​
# get_state:再次获取状态快照(调用后,已经存了消息)
print("=" * 30, "调用后的状态快照", "=" * 30)
snapshot = agent.get_state(config)
print(snapshot)

运行结果:

复制代码
============================== 调用前的状态快照 ==============================
StateSnapshot(values={}, next=(), config={'configurable': {'thread_id': '1'}}, metadata=None, created_at=None, parent_config=None, tasks=(), interrupts=())
================================ Human Message =================================
​
今天厦门的天气如何?
================================== Ai Message ==================================
Tool Calls:
  get_weather (call_00_74FG6LtV0bSQoUA9cRmK0813)
 Call ID: call_00_74FG6LtV0bSQoUA9cRmK0813
  Args:
    city: 厦门
================================= Tool Message =================================
Name: get_weather
​
厦门今天晴,气温 18~26 度。
================================== Ai Message ==================================
​
今天厦门天气晴朗,气温在 18~26 度之间,比较舒适,适合外出活动。
​
============================== 调用后的状态快照 ==============================
StateSnapshot(values={'messages': [HumanMessage(content='今天厦门的天气如何?',

💡 对比两次输出:调用前 values={}、next=(),说明这个线程还没有任何状态 ;调用后 values 里已经有 messages 列表了,metadata、created_at、parent_config 也都填上了------这就是检查点在保存的直接证据。

2.5.2 获取快照历史记录

调用 get_state_history(config) 可以拿到给定线程 的完整执行历史 ,它返回与该线程 ID 关联的 StateSnapshot 对象列表。

代码示例:

python 复制代码
# ---------- 7. 查看状态【历史记录】----------
# 【区别】
#   get_state(config)         → 只看【当前最新】的一条快照
#   get_state_history(config) → 返回【整个线程的所有历史快照】(可迭代,最新的在最前)
# 每次图执行一步(走一个节点)都会产生一个检查点,历史里就多一条记录
print("=" * 30, "状态历史记录", "=" * 30)
history = list(agent.get_state_history(config))
print(f"共 {len(history)} 条历史快照\n")
​
for i, snap in enumerate(history):
    print(f"--- 第 {i+1} 条(越靠前越新)---")
    print(f"  next(下一步节点): {snap.next}")

运行结果:

复制代码
============================== 状态历史记录 ==============================
共 5 条历史快照
​
--- 第 1 条(越靠前越新)---
next(下一步节点): ()
--- 第 2 条(越靠前越新)---
next(下一步节点): ('llm_call',)
--- 第 3 条(越靠前越新)---
next(下一步节点): ('tools',)
--- 第 4 条(越靠前越新)---
next(下一步节点): ('llm_call',)
--- 第 5 条(越靠前越新)---
next(下一步节点): ('__start__',)

💡 一次"提问 → 调工具 → 回答"的完整流程,历史里就有 5 条 快照(从 __start__ 到结束),越靠前越新 。每一行的 next 就是"如果从这一刻继续跑,下一步该走哪个节点"。

2.5.3 重放(时间旅行)

如果用一个 thread_id 加一个 checkpoint_id (检查点标识符 ,指代线程内的特定检查点 )来调用图,那么**checkpoint_id** 之后的步骤会被重新执行。整体流程是:

  • 先执行一次完整流程 ,拿到完整历史记录;

  • 保存中间某一次快照 ,并重新执行该快照之后的步骤;

  • 再取第二次调用后的完整历史记录 ,验证重放是否成功。

代码示例:

python 复制代码
#接着上面的代码

# ---------- 7. 查看状态【历史记录】----------
print("-" * 80)
print("第一次执行历史:")
to_replay = None
for state in agent.get_state_history(config):
    print(
        "消息数: ", len(state.values["messages"]),
        "下一节点: ", state.next)
    # 找到"调用工具前"的那个状态(此时消息数为 2:用户提问 + 模型要调工具的回复)
    if len(state.values["messages"]) == 2:
        to_replay = state
print("-" * 80)


# ---------- 8. 时间旅行:从历史检查点【重放】执行 ----------
# 原理:把历史某个快照的 config 传给 invoke,图就从那一刻【重新开始执行】
print(f"从 {to_replay.next} 节点开始重新执行,重放配置:{to_replay.config}")
result2 = agent.invoke(None, config=to_replay.config)   # 从该检查点重放
print("-" * 80)

print("第二次执行历史(重放后):")
for state in agent.get_state_history(config):
    print(
        "消息数: ", len(state.values["messages"]),
        "下一节点: ", state.next)
print("-" * 80)

运行结果:

复制代码
--------------------------------------------------------------------------------
第一次执行历史:
消息数:  4 下一节点:  ()
消息数:  3 下一节点:  ('llm_call',)
消息数:  2 下一节点:  ('tools',)
消息数:  1 下一节点:  ('llm_call',)
消息数:  0 下一节点:  ('__start__',)
--------------------------------------------------------------------------------
从 ('tools',) 节点开始重新执行,重放配置:{'configurable': {'thread_id': '1', 'checkpoint_ns': '', 'checkpoint_id': '1f1be374-3f0d-6399-8001-04e5ee0f16c3'}}
--------------------------------------------------------------------------------
第二次执行历史(重放后):
消息数:  4 下一节点:  ()
消息数:  3 下一节点:  ('llm_call',)
消息数:  2 下一节点:  ('tools',)
消息数:  4 下一节点:  ()
消息数:  3 下一节点:  ('llm_call',)
消息数:  2 下一节点:  ('tools',)
消息数:  1 下一节点:  ('llm_call',)
消息数:  0 下一节点:  ('__start__',)
--------------------------------------------------------------------------------

💡 要点:这个物理结构是栈,但是逻辑结构是链表。 从执行结果也能看出来:重放没有覆盖 原来的历史,而是新增 了一串(第二次历史里前 3 条是重放产生的,后面才是第一次执行留下的)。另外 invoke(None, config=...) 的第一个参数传 None,意思是"不给新输入,直接从那个检查点接着跑"。

2.5.4 更新状态

我们还可以编辑图状态 ,用 update_state() 方法做到。下面把用户的输入 换成其他搜索内容:

  • 先执行一次完整流程 ,拿到完整历史记录;

  • 保存第一次调用 LLM 前 的步骤快照 ,修改用户输入 来更新快照 ,并重新执行 更新后的快照步骤。

代码示例:

python 复制代码
# ---------- 7. 第一次执行 ----------
result1 = agent.invoke(
    {"messages": [HumanMessage(content="今天厦门的天气如何?")]},
    config,
)

print("-" * 80)
print("第一次执行历史:")
selected_state = None
for state in agent.get_state_history(config):
    print(
        "消息数: ", len(state.values["messages"]),
        "下一节点: ", state.next)
    # 找到"调用 LLM 前"的步骤(消息数为 1,下一节点是 llm_call)
    if len(state.values["messages"]) == 1:
        selected_state = state
print("-" * 80)


# ---------- 8. 更新历史状态:把用户输入改掉 ----------
# update_state(config, 新值):根据指定检查点,【修改】那一步的状态
# Overwrite:绕过 reducer,直接替换(而不是追加)
print(f"更新前配置:{selected_state.config}")


new_config = agent.update_state(
    selected_state.config,
    {"messages":Overwrite([HumanMessage(content="今天北京的天气如何")])}
)
print("-" * 80)
print(f"更新后配置:{new_config}")


# ---------- 9. 从更新后的状态【重放】执行 ----------
result2 = agent.invoke(None, config=new_config)
for message in result2["messages"]:
    message.pretty_print()

运行结果:

复制代码
--------------------------------------------------------------------------------
第一次执行历史:
消息数:  4 下一节点:  ()
消息数:  3 下一节点:  ('llm_call',)
消息数:  2 下一节点:  ('tools',)
消息数:  1 下一节点:  ('llm_call',)
消息数:  0 下一节点:  ('__start__',)
--------------------------------------------------------------------------------
更新前配置:{'configurable': {'thread_id': '1', 'checkpoint_ns': '', 'checkpoint_id': '1f1be3df-963a-6775-8000-7435bc76596b'}}
--------------------------------------------------------------------------------
更新后配置:{'configurable': {'thread_id': '1', 'checkpoint_ns': '', 'checkpoint_id': '1f1be3df-ad58-6762-8001-101647ec7fa0'}}
================================ Human Message =================================

今天北京的天气如何?
================================== Ai Message ==================================
Tool Calls:
  get_weather (call_00_VLfALCRuA65EDwb5iuqI2476)
 Call ID: call_00_VLfALCRuA65EDwb5iuqI2476
  Args:
    city: 北京
================================= Tool Message =================================
Name: get_weather

北京今天晴,气温 18~26 度。
================================== Ai Message ==================================

今天北京的天气是**晴天**,气温在 **18~26 度**之间。☀️

天气不错,适合外出活动,不过早晚温差有点大,建议带一件薄外套哦。

💡 update_state 返回的是一个新的 config (注意 checkpoint_id 变了),这个新 config 就指向"改过之后 "的那个检查点。为什么要 Overwrite :messages 这个键挂着 add_messages 这个 reducer,如果直接传 {"messages": [新消息]},新消息会被追加 进去,变成"厦门 + 北京"两条提问;用 Overwrite 才能把消息列表整体替换掉,让流程从"北京"这一问重新开始。


三、跨会话持久化(Store)

3.1 Checkpoint 的局限性

3.1.1 跨会话信息丢失

想象一个多会话的 AI 助手场景 :星期一 ,用户首次对话 ;星期二 ,用户开启一个新对话。

代码示例:

python 复制代码
# 星期一,用户首次对话
# thread_id = "day_1":开启一个独立的会话线程
config1 = {"configurable": {"thread_id": "day_1"}}
result1 = graph.invoke(
    {"messages": [HumanMessage(content="我爱吃汉堡,推荐一家餐厅")]},
    config1,
)

# 星期二,用户开启一个新对话
# thread_id = "day_2":换了一个线程,与 day_1 完全隔离
config2 = {"configurable": {"thread_id": "day_2"}}
result2 = graph.invoke(
    {"messages": [HumanMessage(content="我爱吃什么?")]},
    config2,
)
result2["messages"][-1].pretty_print()

运行结果:

复制代码
我不知道你具体喜欢吃什么,但可以根据一些常见的食物类型来猜测。比如,有些人喜欢甜食,如蛋糕和冰淇淋;有些人喜欢咸食,如薯条和披萨;还有些人喜欢健康的食物,如沙拉和水果。你可以告诉我你喜欢的食物类型,我可以给你一些推荐!

问题出现 :AI 不记得用户喜欢汉堡 !每次对话都要"重新认识"。

3.1.2 现实世界的需求:从"单次对话"到"终身服务"

以一个智能客服系统为例,它的实际业务需求包括:

业务需求 说明
识别 VIP 客户 优先服务重要客户
避免重复询问 不问已经问过的问题
基于历史投诉优化服务 参考用户过去的投诉记录

仅是检查点无法满足这些需求:

代码示例:

复制代码
# 编译图,挂上内存检查点(InMemorySaver:程序结束即消失,仅用于演示)
graph = builder.compile(checkpointer=InMemorySaver())

# 第一次投诉:thread_id = "query_1"
# 智能客服在一个独立线程中处理,过程包括:
#   - 搜集用户信息
#   - 了解用户问题与需求
#   - 处理问题
config1 = {"configurable": {"thread_id": "query_1"}}
result1 = graph.invoke(
    {"messages": [HumanMessage(content="我的账户被冻结了")]},
    config1,
)

# 10 天后,用户第二次投诉:thread_id = "query_2"
# 由于换了一个新的 thread_id,检查点完全隔离,
# 智能客服读不到 query_1 的任何历史,因此无法准确解决问题,
# 只能再次从头了解前因后果。
config2 = {"configurable": {"thread_id": "query_2"}}
result2 = graph.invoke(
    {"messages": [HumanMessage(content="我的账户又被冻结了")]},
    config2,
)

要满足共享状态 的需求(如精准识别客户 、保留 VIP 客户关键历史记录 ),就需要引入 Store。

3.2 解决方案:引入 Store

Store 像是一个长期记忆仓库 ,支持我们在执行过程 中保存用户信息 、偏好设置 等长期数据 ,以实现不同对话间 信息的持久化共享。

3.2.1 存储 vs 检查点
概念 保存什么 类比
检查点(Checkpoint) 状态变化历史 时间线
存储(Store) 结构化知识 数据库

​

实际上,使用 Checkpoint + Store 模式才能够真正实现:

能力 由谁提供
单次会话内的状态恢复与时间旅行 Checkpoint 保存状态变化历史 ,支持中断恢复 、回滚 、重放
跨会话的长期记忆与个性化共享 Store 保存用户信息 、偏好设置 、历史记录 等结构化知识 ,在不同 Thread 之间共享
完整的持久化能力 两者结合,让 AI 应用 既能"记住这次对话 ",又能"记住这个用户"

这样才能支撑智能客服 、多会话助手等复杂场景。

​

3.2.2 引入 Store 后,AI 应用架构的范式转变

​

Store 的引入,真正做到了三个转变:

从 到
关注单次交互 关注用户生命周期
处理当前请求 利用历史数据
通用回复 深度个性化

3.3 跨会话持久化实战

要想使用 Store ,需要先创建一个存储实例 ,它同样有**【内存级存储】** 与**【存储库存储】**两种方式:

代码示例:

python 复制代码
# 从 LangGraph 的内存存储模块导入 InMemoryStore
from langgraph.store.memory import InMemoryStore

# 创建一个内存存储实例
store = InMemoryStore()

接着像以前一样,把 Checkpoints 和 Store 一起传给 compile 即可:

代码示例:

python 复制代码
graph = builder.compile(checkpointer=checkpointer, store=store)
3.3.1 内存存储与命名空间

Store 本身是通过 Namespace 区分不同数据的。在 LangGraph 中提供一个简单的内存实现 InMemoryStore 。想要进行存储 ,需要先定义命名空间 :为了区分不同用户 的记忆 ,需要一个"命名空间 "------这就像在数据库 里为每个用户 创建一个独立的文件夹 。命名空间 用于组织记忆 ,通常按业务逻辑 划分,一般用元组来定义:

复制代码
# 使用元组 —— 层次清晰,易于扩展
namespace1 = ("user_123", "preferences", "food")      # 用户食物偏好
namespace2 = ("user_123", "preferences", "music")     # 用户音乐偏好
namespace3 = ("user_123", "conversations", "2025-05") # 用户某月的对话历史

# 使用字符串 —— 扁平且易混淆
namespace4 = "user_123_preferences_food"              # 需要解析,容易出错
namespace5 = "user_123_preferences_music"
namespace6 = "user_123_conversations_2024"
写法 优点 缺点
元组(推荐) 层次清晰 ,易于扩展 拼错层级时不易察觉
字符串 写法扁平 需要解析,容易出错、易混淆

当在对话中获取到用户 的重要信息 时,用 store.put() 方法把记忆 保存到存储中的命名空间:

参数 说明
namespace 决定这条记忆 属于谁 、是什么类型
memory_id 这条记忆条目 的唯一键
memory_content 记忆 的具体内容,一个字典
复制代码
# put(namespace, key, value)
#   - namespace:命名空间,必须是元组,用于隔离不同用户或应用
#                例如 ("users", "user_123") 或 ("memories", "chat")
#   - key      :条目标识,字符串,类似字典的键
#   - value    :要保存的内容,通常是一个字典(也可为其他可序列化类型)
store.put(namespace, memory_id, memory_content)

完整代码:

python 复制代码
from langgraph.store.memory import InMemoryStore
import uuid       # 用于生成唯一 ID


# ---------- 1. 导入并创建存储 ----------
store = InMemoryStore()


# ---------- 2. 定义命名空间 (Namespace) ----------
# 命名空间用于组织记忆,通常按业务逻辑划分,例如按用户。
# 这里我们用一个元组 (用户ID, 记忆类型)
user_id = "user_123"
namespace = (user_id, "preferences")     # 用户 user_123 的偏好记忆


# ---------- 3. 存入一条记忆 (Memory) ----------
# 每条记忆需要一个唯一的 memory_id 和一个 value(通常是字典)
memory_id = str(uuid.uuid4())            # 生成唯一 ID,如 "abc-123-def-456"
memory_value = {"favorite_food": "汉堡", "allergy": "花粉"}

store.put(namespace, memory_id, memory_value)
print("记忆已存入!")


# ---------- 4. 读取记忆 ----------
# 可以搜索某个命名空间下的所有记忆
all_memories = store.search(namespace)
for mem in all_memories:
    print(mem.dict())                    # 记忆对象转成字典查看

运行结果:

复制代码
记忆已存入!
{'namespace': ['user_123', 'preferences'], 'key': '9f478fa0-fde8-4015-bac1-b5c028b5d697', 'value': {'favorite_food': '汉堡', 'allergy': '花粉'}, 'created_at': '2026-10-04T08:37:34.792960+00:00', 'updated_at': '2026-10-04T08:37:34.792960+00:00', 'score': None}
3.3.2 在 LangGraph 中使用 Store

由于要加入 Store ,需要在合适的地方 加入与存储关键信息 相关的代码 。例如,可以在每次调用 LLM 前先做信息收集 ,再带着收集到的共享信息 去调用 LLM。

​

这样,两部分信息 会被收集:一是用户发的消息 ;二是通过工具调用返回的结果信息。

在编译图 时,直接添加编译参数 store:

代码示例:

复制代码
from langgraph.store.memory import InMemoryStore

store = InMemoryStore()

# 用 checkpointer + store 编译图
agent = agent_builder.compile(checkpointer=checkpointer, store=store)

这样,在任何一个节点 的函数中,都可以通过注入 store 参数 来访问这个全局存储。

新增提取用户信息节点 :在这个节点中,需要根据【用户发的消息 】和【工具调用返回的结果 】来采集要收集的信息 ,并用 Store 存起来。两个关键设计:

设计点 说明
怎么拿到 Store 任何节点函数 ,如果需要访问 Store ,可以在参数中声明 store: BaseStore 和 config: RunnableConfig
怎么提取信息 可以通过 LLM 提取用户信息 ,因此定义结构化返回很有必要

完整代码:

python 复制代码
import uuid
import operator
from typing import Optional

from langchain.chat_models import init_chat_model
from langchain_core.runnables import RunnableConfig
from langchain_tavily import TavilySearch
from langgraph.graph import StateGraph, START, END
from langgraph.checkpoint.memory import InMemorySaver
from langgraph.store.base import BaseStore
from langgraph.store.memory import InMemoryStore
from pydantic import BaseModel, Field
from typing_extensions import TypedDict, Annotated
from langchain_core.messages import AnyMessage, HumanMessage, SystemMessage, ToolMessage


# ---------- 步骤 1: 定义工具和模型 ----------
search = TavilySearch(max_results=4)
tools = [search]

model = init_chat_model(model="deepseek-chat", model_provider="deepseek", temperature=0)
model_with_tools = model.bind_tools(tools)


# ---------- 步骤 2: 定义状态 ----------
class MessagesState(TypedDict):
    messages: Annotated[list[AnyMessage], operator.add]
    llm_calls: int


# ---------- 步骤 3: 新增提取信息节点 ----------
class Person(BaseModel):
    """一个人的信息。"""
    name: Optional[str] = Field(default=None, description="这个人的名字")
    height_in_meters: Optional[str] = Field(default=None, description="以米为单位的高度")
    favourite_food: Optional[list[str]] = Field(default=None, description="最喜欢的食物列表")


model_with_structured = model.with_structured_output(Person)


def get_person_by_llm(state: MessagesState, config: RunnableConfig, *, store: BaseStore):
    """通过 LLM 提取用户信息"""
    # 1. 先提取
    people_info = model_with_structured.invoke(
        [
            SystemMessage(
                content="你是一个提取信息的专家,只从文本中提取我的相关信息,不能提取别人的信息。如果你不知道要提取的属性的值,属性值返回null。"
            )
        ]
        + state["messages"][-3:]
    )

    # 2. 再保存
    user_id = config["configurable"]["user_id"]

    namespace1 = (user_id, "info")
    store.put(
        namespace1,
        str(uuid.uuid4()),
        {"name": people_info.name, "height": people_info.height_in_meters},
    )

    namespace2 = (user_id, "preferences")
    store.put(
        namespace2,
        str(uuid.uuid4()),
        {"favourite_food": people_info.favourite_food},
    )

    return {"llm_calls": state.get("llm_calls", 0) + 1}


# ---------- 步骤 4: 模型调用节点 ----------
def llm_call(state: MessagesState, config: RunnableConfig, *, store: BaseStore):
    """LLM 决定是否调用工具"""
    # 搜索用户信息(按命名空间精确搜索)
    user_id = config["configurable"]["user_id"]
    namespace1 = (user_id, "info")
    namespace2 = (user_id, "preferences")

    info_result = store.search(namespace1)
    pref_result = store.search(namespace2)

    return {
        "messages": [
            model_with_tools.invoke(
                [
                    SystemMessage(
                        content="你是一个乐于助人的助手,支持调用工具进行搜索。"
                        "查询 LLM 前可参考以下信息:"
                        f"1. 用户基本情况:{info_result[0].value} "
                        f"2. 用户偏好情况:{pref_result[0].value}"
                    )
                ]
                + state["messages"]
            )
        ],
        "llm_calls": state.get("llm_calls", 0) + 1,
    }


# ---------- 步骤 5: 定义工具节点 ----------
tools_by_name = {tool.name: tool for tool in tools}


def tool_node(state: dict):
    """执行工具调用"""
    result = []
    for tool_call in state["messages"][-1].tool_calls:
        tool = tools_by_name[tool_call["name"]]
        observation = tool.invoke(tool_call["args"])
        result.append(ToolMessage(content=observation, tool_call_id=tool_call["id"]))
    return {"messages": result}


# ---------- 步骤 6: 构建图 ----------
def should_continue(state: MessagesState):
    """根据 LLM 是否调用工具,决定继续循环还是停止"""
    messages = state["messages"]
    last_message = messages[-1]
    if last_message.tool_calls:
        return "tool_node"
    return END


agent_builder = StateGraph(MessagesState)
agent_builder.add_node("llm_call", llm_call)
agent_builder.add_node("tool_node", tool_node)
agent_builder.add_node("get_person_by_llm", get_person_by_llm)

agent_builder.add_edge(START, "get_person_by_llm")
agent_builder.add_edge("get_person_by_llm", "llm_call")
agent_builder.add_conditional_edges(
    "llm_call",
    should_continue,
    ["tool_node", END],
)
agent_builder.add_edge("tool_node", "get_person_by_llm")


# ---------- 步骤 7: 编译(挂检查点 + 存储)----------
checkpointer = InMemorySaver()
store = InMemoryStore()          # ← 没有嵌入模型(无 index)

agent = agent_builder.compile(checkpointer=checkpointer, store=store)


# ---------- 步骤 8: 测试 ----------
config = {"configurable": {"thread_id": "1", "user_id": "user_123"}}

result1 = agent.invoke(
    {"messages": [HumanMessage(content="我叫小明,身高1米75,最喜欢吃汉堡和披萨")]},
    config,
)
print("=" * 30, "第一次执行", "=" * 30)
for m in result1["messages"]:
    m.pretty_print()

print("=" * 30, "Store 中已存的记忆", "=" * 30)
for ns in [("user_123", "info"), ("user_123", "preferences")]:
    print(f"命名空间 {ns}:")
    for mem in store.search(ns):
        print("   ", mem.value)

运行结果:

复制代码
============================== 第一次执行 ==============================
================================ Human Message =================================

我叫小明,身高1米75,最喜欢吃汉堡和披萨
================================== Ai Message ==================================

你好,小明!很高兴认识你 😊

我记住了你的信息:
- **身高**:1.75 米
- **最喜欢的食物**:汉堡 🍔 和披萨 🍕

有什么我可以帮你的吗?比如:
- 推荐适合你身高的穿搭建议
- 找找附近好吃的汉堡店或披萨店
- 计算一下你的理想体重范围
- 或者其他任何问题

随时告诉我!
============================== Store 中已存的记忆 ==============================
命名空间 ('user_123', 'info'):
    {'name': '小明', 'height': '1.75'}
命名空间 ('user_123', 'preferences'):
    {'favourite_food': ['汉堡', '披萨']}

💡 图的循环值得注意:get_person_by_llm(提取并存记忆 )→ llm_call(读记忆再回答 )→ 需要工具就 tool_node → 再回到 get_person_by_llm 。也就是说,每一轮都会重新提取一次用户信息 ,这让记忆能随着对话不断累积更新 。另外 config 里除了 thread_id 还多了一个 user_id ------线程用来隔离会话,user_id 用来跨会话认人,二者分工不同。

3.3.3 语义搜索

Store 的强大之处在于它支持语义搜索 ,而不仅仅是精确匹配 ------这意味着我们可以用自然语言问题 来查找相关记忆。

代码示例:

python 复制代码
# ---------- 步骤 4: 更新模型调用节点(把共享的用户信息加到提示词)----------
def llm_call(state: MessagesState, config: RunnableConfig, *, store: BaseStore):
    """LLM 决定是否调用工具"""
    user_id = config["configurable"]["user_id"]

    # ========== ① 旧写法:按命名空间【精确】搜索 ==========
    # namespace1 = (user_id, "info")
    # namespace2 = (user_id, "preferences")
    #
    # info_result = store.search(namespace1)
    # pref_result = store.search(namespace2)
    #
    # return {
    #     "messages": [
    #         model_with_tools.invoke(
    #             [
    #                 SystemMessage(
    #                     content=f"你是一个乐于助人的助手,支持调用工具进行搜索。"
    #                     f"查询 LLM 前可参考以下信息:"
    #                     f"1. 用户基本情况:{info_result[0].value} "
    #                     f"2. 用户偏好情况:{pref_result[0].value}"
    #                 )
    #             ]
    #             + state["messages"]
    #         )
    #     ],
    #     "llm_calls": state.get('llm_calls', 0) + 1,
    # }

    # ========== ② 新写法:在 Store 中进行【语义搜索】 ==========
    # 这里直接在 user_id 维度下通过语义去找(namespace 只到 user_id 一层)
    namespace = (user_id,)

    # query:按语义找最相关的记忆;limit:最多返回几条
    info_result = store.search(namespace, query="用户基本信息", limit=2)
    pref_result = store.search(namespace, query="用户偏好信息", limit=2)

    return {
        "messages": [
            model_with_tools.invoke(
                [
                    SystemMessage(
                        content="你是一个乐于助人的助手,支持调用工具进行搜索。"
                        "查询 LLM 前可参考以下信息:"
                        f"1. 用户基本情况:{[m.value for m in info_result]} "
                        f"2. 用户偏好情况:{[m.value for m in pref_result]}"
                    )
                ]
                + state["messages"]
            )
        ],
        "llm_calls": state.get("llm_calls", 0) + 1,
    }

Store 要开启语义搜索,必须带上向量索引,两种 Store 的差别如下:

复制代码
# ========== ① 旧写法:不带向量索引的 Store ==========
# 只支持按 namespace 列出记忆,不支持语义搜索(query 参数无效)
# store = InMemoryStore()

# ========== ② 新写法:带【本地嵌入模型】的 Store(支持语义搜索) ==========
store = InMemoryStore(
    index={
        "embed": FastEmbed(),   # 本地部署的嵌入模型(不需要 key)
        "dims": 512,            # 维度必须与嵌入模型一致(bge-small-zh = 512)
        "fields": ["$"],        # 对 value 中的所有字段进行嵌入
    }
)
配置项 作用
embed 用哪个嵌入模型 把记忆转成向量(这里是上一篇文章封装的本地嵌入类,不需要 key)
dims 向量维度 ,必须与嵌入模型一致 (bge-small-zh 是 512)
fields 对 value 里的哪些字段 做嵌入,["$"] 表示所有字段

💡 语义搜索和精确搜索的区别值得记牢:精确搜索 是"把 (user_123, "info") 这个文件夹里的条目录出来",语义搜索 则是"在 (user_123,) 这一层下,按语义 找出跟'用户基本信息'最像的条目"。所以语义搜索要求命名空间放宽到更上一层 ,让不同子类别的记忆一起参与比对------这也是为什么新写法的 namespace 只到 user_id 一层。


四、复盘(附答案)

💡 思考题

  1. 持久化解决的核心问题是什么?没有持久化时,"用户问完天气、程序崩溃、用户再问要不要带伞"会发生什么?

  2. 线程级持久化和跨会话持久化分别解决什么问题?各自对应哪种存储?

  3. LangGraph 里的"线程(Thread)"和操作系统的"线程"是一回事吗?"状态快照"和我们前面学的 State 是同一个东西吗?

  4. StateSnapshot 里 next 和 parent_config 分别是什么?为什么说"物理结构是栈、逻辑结构是链表"?

  5. get_state、get_state_history、重放、update_state 四个用法各解决什么问题?update_state 时为什么常要配 Overwrite?

  6. 为什么只有 Checkpoint 不够,还需要 Store?Store 的命名空间为什么要用元组?语义搜索对 Store 有什么额外要求?

📝 答案

  1. 核心问题是状态会不会丢 :把 AI 应用的状态 (对话历史、中间结果、用户信息)保存 下来,程序重启或宕机 后能恢复 并接着跑 。没有持久化时,崩溃重启等于失忆 ------用户再问"要不要带伞",助手没有之前的上下文 ,很可能再调一次搜索工具,而不是基于"今天晴天"直接回答"不需要"。

  2. 线程级持久化 解决单次会话内的上下文连续 :自动保存工作流执行过程的状态快照 ,支持中断恢复、回滚、重放 ,对应场景二,靠 Checkpointer (如 InMemorySaver)。跨会话持久化 解决用户级别的长期记忆 :保存用户信息、偏好设置 等长期数据,在不同对话间共享 ,对应场景一,靠 Store。

  3. 不是一回事 。操作系统的线程 是进程内的执行单元 ,是操作系统调度的最小单位 ;而这里的 Thread 表示聊天过程中的单次会话 的持久化信息,作用是隔离不同的聊天会话 ,两者概念完全独立 。"状态快照"也不等于 State :快照里的状态 包含所有必要的上下文信息 ------调用过哪些工具、用户输入、聊天历史、下一步要执行的节点等。

  4. next 是下一步要执行的节点 (元组,为空说明流程已结束),parent_config 是父检查点的 config ,把一个个快照串成链表 ,因此历史可以从任意检查点追溯 。说"物理结构是栈、逻辑结构是链表 ",是因为新快照压在栈顶 、越靠前越新,但每个快照又能通过 parent_config 回溯到上一个 ------重放时也是新增一串快照,而不是覆盖旧的。

  5. get_state 看当前最新 的一条快照(执行前 values={}、next=(),执行后已有消息);get_state_history 拿整个线程的全部历史快照 (可迭代、最新的在最前),一次完整流程会有 5 条;重放 是拿历史某个快照的 config 传给 invoke,让图从那一刻重新执行 (invoke(None, config=...) 表示不给新输入);update_state 是直接改掉 某一步的状态,改完返回新的 config (checkpoint_id 变了)。用 Overwrite 是因为 messages 挂着 add_messages reducer,直接传新值会被追加 (变成"西安 + 北京"两条提问),只有 Overwrite 才能把消息列表整体替换。

  6. 因为 Checkpoint 是按线程隔离的 :换一个 thread_id(比如 10 天后用户第二次投诉)就读不到上一次的任何历史,智能客服每次都要重新问一遍 前因后果。Store 是跨线程的长期记忆仓库 ,才能做到识别 VIP、保留关键历史、深度个性化 。命名空间用元组 是为了层次清晰、易于扩展 (("user_123", "preferences", "food")),字符串形式需要解析、容易出错 。语义搜索 要求 Store 带向量索引 :要配置 embed(嵌入模型)、dims(维度必须与模型一致,bge-small-zh 为 512 )、fields(对哪些字段嵌入,["$"] 表示全部),否则 query 参数无效,只能按命名空间精确列出。


🎯 闭幕

​

如果本文对你有帮助,欢迎:

👍 点赞 | ⭐ 收藏 | 👤 关注作者 | 💬 留言交流你的疑问或补充

你的每一次互动都是我继续更新的动力,我们下一篇见!🚀

相关推荐
数智工坊1 小时前
视觉SLAM第2讲|初识SLAM:经典框架、传感器选型与工程环境全梳理
人工智能·深度学习·数码相机·机器人
workflower1 小时前
汽车自动驾驶9 要素
人工智能·机器学习·重构·云计算·汽车·无人机
hhb_6181 小时前
大模型工具链选型实战方案
人工智能
2601_950760791 小时前
GDF-15重组蛋白:从孕期免疫耐受到多发性硬化神经保护的关键调控因子
人工智能·蛋白
楚楚2511 小时前
2026最新6款企业级AI编程软件免费实测深度对比
ai编程
一切皆是因缘际会1 小时前
穿透多维场景
人工智能
kdxiaojie1 小时前
MPU6050学习
笔记·学习·mpu6050
yeflx2 小时前
万向节死锁
python
七牛云行业应用2 小时前
Dots 完整教程:从安装入口、跑第一个任务到给 Codex 派活(2026 年 10 月)
人工智能·大模型·agent