
❝大模型本身是"过目就忘"的——每次调用都是一张白纸。可你用 LangGraph 搭的 Agent,却能续聊、能恢复、甚至能"回到三步之前重新跑"。这背后靠的是两个核心概念:「thread(一次对话)」 和 「Checkpoint(每一步的状态快照)」。这篇文章用 6 张图把 LangGraph 的会话管理彻底讲透,并在最后引申一下生产级框架 DeepAgents 在它之上做了哪些加固。 ❞
先说个反直觉的事实:大模型 API 是**无状态(stateless)**的。它每次被调用,只能看到这一次请求里塞进去的内容,「根本不记得」上一轮你说过什么。
那"连续对话"是怎么来的?靠框架在背后维护一份历史,每次调用都重新递给模型。这层"管历史"的基础设施,就是「会话管理(Session Management)」。
不同框架给出的答案差异很大。LangGraph 的答案尤其有特点——它没有单独造一个"会话模块",而是把会话管理「收进了一个更通用的抽象」:状态图(StateGraph)+ 检查点(Checkpoint)。理解了这套抽象,你不仅懂了它怎么记对话,还顺带理解了它为什么能做到时间旅行、断点续跑这些别家做不到的事。
我们从最外层的 thread 开始往里剥。
在 LangGraph 里,一段连续对话被称为一个 「thread」(线程,但跟操作系统的线程没关系,理解成"对话线"更准确)。每个 thread 由一个 thread_id 唯一标识。

你调用 Agent 时,thread_id 不是写在请求体里,而是放在一个叫 config.configurable 的配置包里传进去:
config = {"configurable": {"thread_id": "abc-123"}}
# 第一轮
await agent.ainvoke({"messages": [HumanMessage("帮我建个项目")]}, config=config)
# 第二轮——只要带上同一个 thread_id,它就接着上一轮聊
await agent.ainvoke({"messages": [HumanMessage("再加个测试")]}, config=config)
注意第二轮:我们只塞了一句新消息,「完全没有手动把第一轮的历史拼进去」。LangGraph 自己根据 thread_id 把这个 thread 之前的状态捞了出来、接着往后跑。这就是 thread 的意义——「它是"自动续聊"的钥匙」。
换一个 thread_id,就是开一段全新的、互不干扰的对话。多用户、多会话的隔离,本质上就是发不同的 thread_id。(DeepAgents 里用 UUID7 来生成新的 thread id。)
那么问题来了:LangGraph 凭什么能根据 thread_id 把历史"捞出来"?答案在下一层——Checkpoint。
这是 LangGraph 会话管理「最该理解的一点」:它的存储模型不是"一个消息数组",而是「一连串状态快照」。

LangGraph 把 Agent 的运行看成一张图,每执行一个节点(比如"调一次模型""跑一个工具"),就把当时的「完整状态」拍一张快照存下来,这张快照就叫一个 「Checkpoint」。
每个 Checkpoint 有三个关键标识:
thread_id:属于哪段对话;checkpoint_id:这是该对话里的第几张快照(单调递增);parent_checkpoint_id:上一张快照是谁——靠它,所有快照串成一条「链」。落到存储里(以 SQLite 为例),就是这样一张表,主键是三元组 (thread_id, checkpoint_ns, checkpoint_id):
PRIMARY KEY (thread_id, checkpoint_ns, checkpoint_id)
-- 还有 parent_checkpoint_id 字段,把快照前后串起来
❝
checkpoint_ns(namespace)是给子图用的命名空间,主 Agent 默认是空串,先不用管它。 ❞
为什么要存"快照链"而不是"消息数组"?因为快照链能干两件数组干不了的事:「精确恢复到任意一步」,以及「从任意一步岔出新分支」(第六节细说)。代价是体积更大——这点 DeepAgents 有专门优化,最后一节会讲。
既然每个 Checkpoint 是"完整状态的快照",那这个"状态"是什么?
在 LangGraph 里,状态(State)是一个「带类型的字典」,里面是若干个字段,每个字段叫一个 「channel(通道)」。对一个对话 Agent 来说,最核心的 channel 就是 messages——对话历史本身。

channel 有个很关键的特性:「它不是简单赋值,而是带"归并规则(reducer)"的」。比如 messages 这个 channel 的规则是"追加"——你这一步返回几条新消息,框架就把它们「追加」到已有历史后面,而不是覆盖。这就是为什么你每轮只用返回新消息,历史会自动累积。
除了 messages,State 里还能放别的 channel。DeepAgents 就额外塞了几个私有字段,比如:
_summarization_event:记录"上次在哪儿压缩过、摘要是什么";_context_tokens:当前上下文用了多少 token。这些私有 channel 跟着 Checkpoint 一起持久化,但「不会」直接发给模型——它们是会话管理自己的"账本"。
一句话记住:「Checkpoint 存的是整个 State(所有 channel),而不只是聊天记录。」 这也是为什么 LangGraph 能恢复的不仅是"说过的话",还有 Agent 运行到一半的各种中间状态。
负责"把 Checkpoint 写进去、读出来"的组件,叫 「Checkpointer」。它是一个接口,后端可以随意替换:

Checkpointer | 存储介质 | 典型场景 |
|---|---|---|
MemorySaver | 进程内存 | 测试、临时跑、demo(重启即丢) |
AsyncSqliteSaver | 本地 SQLite 文件 | 单机持久化(DeepAgents CLI 默认) |
AsyncPostgresSaver | PostgreSQL | 生产、多实例共享 |
用法就是编译 graph 时把它传进去:
from langgraph.checkpoint.memory import MemorySaver
agent = create_agent(model, tools, checkpointer=MemorySaver())
「关键点:没有 checkpointer,就没有记忆。」 如果你编译 graph 时不传 checkpointer,那它就是无状态的——每次调用都从头开始,thread_id 也救不了你。这是新手最容易踩的坑:明明传了 thread_id 却发现不记事,十有八九是忘了配 checkpointer。
DeepAgents 的 CLI 在生产模式下默认用 AsyncSqliteSaver,把所有对话落到 ~/.deepagents/sessions.db 这个文件里。它甚至还会直接查这张表来实现"会话列表""删除会话"这类管理功能,绕过 Agent 直接读底层数据,图个快。
有了 thread + checkpointer,"恢复对话"这件听起来很复杂的事,就变得朴素得不像话:

「继续聊」:用同一个 thread_id 再发一次请求就行。LangGraph 自动找到这个 thread 的最新 Checkpoint,把状态恢复出来,接着往后跑。程序重启过?没关系,只要 checkpointer 是持久化的(SQLite/Postgres),历史还在磁盘上。
「把历史读出来」(比如刷新 UI 要重新渲染对话):调 aget_state:
state = await agent.aget_state(config) # config 里带着 thread_id
messages = state.values["messages"] # 历史消息都在这儿
「手动改状态」(比如插入一条系统提示、或人工干预):调 aupdate_state,它会基于当前快照生成一张新快照。
❝一个工程细节:DeepAgents 在客户端-服务端模式下,服务器重启后可能出现"磁盘上有 Checkpoint、但内存里的 thread 记录没了"的错位,它专门写了个
aensure_thread来先确保 thread 记录存在,再操作状态。生产级框架的麻烦,往往都藏在这种边角料里。 ❞
快照链相比"消息数组"最大的优势,在这一节兑现。

因为每一步都留了快照、快照之间又有父子链,所以你能做两件很酷的事:
「① 时间旅行(time-travel)」:在 config 里指定一个「历史」的 checkpoint_id,就能让 Agent"回到那一刻"的状态。配合 get_state_history 把整条链列出来,你可以挑任意一张快照重放。
# 列出这个 thread 的所有历史快照
async for snapshot in agent.aget_state_history(config):
print(snapshot.config["configurable"]["checkpoint_id"])
# 指定回到某一张
old_config = {"configurable": {"thread_id": "abc-123", "checkpoint_id": "xxx"}}
await agent.ainvoke(new_input, config=old_config)
「② 分叉(fork)」:从历史某张快照重新跑、且给不同的输入,就会「岔出一条新的快照分支」——原来的历史完好无损,新的探索另起一条线。这对"我想试试换个方向会怎样"的场景极其有用。
值得说明的是:「这是 LangGraph 框架层提供的能力」。建立在它之上的 DeepAgents 出于产品简洁性考虑,目前「没有把时间旅行/分叉暴露给终端用户」——它只用 checkpoint_id 做了"会话列表缓存失效"之类的小事。能力在底座,用不用是上层产品的选择。
聊到这里要澄清一个特别容易搞混的概念。我们前面讲的所有东西——thread、Checkpoint——管的都是「一段对话内部」的记忆,是「短期」的、按 thread 隔离的。
但 Agent 还需要另一种记忆:「跨对话的长期记忆」。比如"这个用户偏好用中文""上个月帮他建过一个电商项目"——这些信息不该绑死在某一个 thread 里,换一段新对话也得记得。

LangGraph 把这两件事「分得很干净」:
Checkpointer | Store | |
|---|---|---|
管什么 | 一段对话的状态(messages 等) | 跨对话的长期知识 |
隔离维度 | 按 thread_id | 按命名空间(如按 user) |
生命周期 | 跟随这段会话 | 长期留存 |
典型内容 | 对话历史、运行中间态 | 用户画像、学到的技能/事实 |
设计你自己的 Agent 时,先想清楚每条信息该进哪边:「"只在这次对话有用"的进 Checkpoint,"换个对话也得记得"的进 Store。」 想不清楚,后面就是数据串台和隐私泄露的坑。
LangGraph 给的是通用底座,但直接拿去跑长任务(比如让 Agent 写一整个项目)会遇到两个现实问题。DeepAgents 作为生产级框架,针对性地做了加固:
[图 6:两种记忆 + DeepAgents 的生产级加固] (看下半部分:DeltaChannel + Summarization)
「加固一:DeltaChannel——给快照"瘦身"。」 前面说快照链体积大,到底有多大?如果每张快照都把全量 messages 拷一遍,存储量会随对话轮数按 「O(N²)」 膨胀(第 N 轮要存前 N 条 × N 张快照)。DeepAgents 用一个叫 DeltaChannel 的机制:平时只存"这一步新增了什么(delta)",每隔 50 步才拍一张全量快照,把增长压回 「O(N)」。对长任务来说,这是从"撑不住"到"扛得住"的区别。
「加固二:SummarizationMiddleware——上下文压缩 + 落盘。」 对话太长撑爆窗口时,它会:
/conversation_history/{thread_id}.md);_summarization_event 字段里记下"在哪儿切的、归档到哪了"。妙处在于:压缩是「非破坏式」的——原始历史落了盘,Agent 随时能 read_file 翻回来;摘要、切点都跟着 Checkpoint 一起持久化,所以「恢复对话时连"压缩状态"也能一并恢复」。(这部分我之前单独写过一篇深读,感兴趣的可以翻文末延伸阅读。)
一句话总结这一节:「LangGraph 负责"能记、能恢复",DeepAgents 负责"长期跑也不崩、不丢"。」
回头看,LangGraph 处理会话管理的思路很有辨识度:「它不把"对话记忆"当成一个特殊模块,而是当成"状态图 + 检查点"这套通用机制的一个自然结果。」
thread;Checkpoint 快照;thread_id;Store,跟会话分开。这套设计的好处是「统一而强大」——你为"让 Agent 跑复杂流程"配的检查点机制,顺手就把会话管理给解决了,还白送了时间旅行。代价是抽象层级偏高、快照体积偏大,需要 DeepAgents 这类上层框架再做一层调教,才能稳稳地扛住真实的长任务。
下次你用 LangGraph 搭 Agent,发现"它怎么不记事"时,先别慌——大概率只是忘了给它配一个 checkpointer 而已。