上一篇我们把条件边、ReAct 环和 Command.goto 啃完了------图已经能自己转圈。但说句扎心的:你一关进程,State 全没;想聊第二轮,还得亲手把整段 messages 再塞回去;想「发邮件前先让人点个批准」?图停都停不住。
真正让 Agent 从 demo 能跑 迈向 能上线 的关键基建,叫 Checkpointer :在每个 super-step 把 State 打成存档点,再用 thread_id 当游标指回那份存档。这一篇我们就把它讲透:MemorySaver、生产级 PostgresSaver,以及 interrupt + Command({ resume }) 的人机协作。
老规矩,本文 API 均以官网最新文档核对过(Persistence、Add memory、Interrupts、Checkpointers)。网上教程里
Annotation.Root/MessagesAnnotation仍大量存在------那是 Legacy;本篇继续走StateSchema。interrupt 也不要 当异常去try/catch,官网主路径是读返回值里的__interrupt__。

一、先分清:Checkpointer ≠ Store
官网把持久化拆成两套互补系统,别混为一谈:
| Checkpointer | Store | |
|---|---|---|
| 存什么 | 图的 State 快照(checkpoint) | 你自定义的 KV 数据 |
| 作用域 | 单个 thread | 跨 thread |
| 记忆类型 | 短记忆(会话内) | 长记忆(用户偏好、事实库) |
| 典型用途 | 多轮对话、HITL、时光旅行、故障恢复 | 跨会话记住「用户爱吃辣」 |
| 怎么用 | compile({ checkpointer }) + thread_id |
compile({ store }),节点里读写 |
本篇主讲 Checkpointer。 Store / 长期记忆点到为止,后面专门篇再展开------先把「同一会话里不丢状态、能暂停能恢复」打牢。
二、为何需要 Checkpointer
没有它,每次 invoke 都是一张白纸:
| 需求 | 无 Checkpointer | 有 Checkpointer |
|---|---|---|
| 多轮对话 | 每次手动传入完整 history | 同一 thread_id 自动累积 messages |
| 进程重启 | State 当场蒸发 | 从最近 checkpoint 接着跑 |
| Human-in-the-loop | 中间没法「暂停等人」 | interrupt() 停住,人点完再 resume |
| 调试 / 时光旅行 | 无法回溯 | getState / getStateHistory 可查快照 |
| 生产部署 | 只适合玩具 | Postgres 等落库实现 |
一句话:Checkpointer 是 Agent 会话记忆 + 暂停点的基础设施。
#mermaid-svg-MSNR0N6LhRLQijGA{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;fill:#333;}@keyframes edge-animation-frame{from{stroke-dashoffset:0;}}@keyframes dash{to{stroke-dashoffset:0;}}#mermaid-svg-MSNR0N6LhRLQijGA .edge-animation-slow{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 50s linear infinite;stroke-linecap:round;}#mermaid-svg-MSNR0N6LhRLQijGA .edge-animation-fast{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 20s linear infinite;stroke-linecap:round;}#mermaid-svg-MSNR0N6LhRLQijGA .error-icon{fill:#552222;}#mermaid-svg-MSNR0N6LhRLQijGA .error-text{fill:#552222;stroke:#552222;}#mermaid-svg-MSNR0N6LhRLQijGA .edge-thickness-normal{stroke-width:1px;}#mermaid-svg-MSNR0N6LhRLQijGA .edge-thickness-thick{stroke-width:3.5px;}#mermaid-svg-MSNR0N6LhRLQijGA .edge-pattern-solid{stroke-dasharray:0;}#mermaid-svg-MSNR0N6LhRLQijGA .edge-thickness-invisible{stroke-width:0;fill:none;}#mermaid-svg-MSNR0N6LhRLQijGA .edge-pattern-dashed{stroke-dasharray:3;}#mermaid-svg-MSNR0N6LhRLQijGA .edge-pattern-dotted{stroke-dasharray:2;}#mermaid-svg-MSNR0N6LhRLQijGA .marker{fill:#333333;stroke:#333333;}#mermaid-svg-MSNR0N6LhRLQijGA .marker.cross{stroke:#333333;}#mermaid-svg-MSNR0N6LhRLQijGA svg{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;}#mermaid-svg-MSNR0N6LhRLQijGA p{margin:0;}#mermaid-svg-MSNR0N6LhRLQijGA .label{font-family:"trebuchet ms",verdana,arial,sans-serif;color:#333;}#mermaid-svg-MSNR0N6LhRLQijGA .cluster-label text{fill:#333;}#mermaid-svg-MSNR0N6LhRLQijGA .cluster-label span{color:#333;}#mermaid-svg-MSNR0N6LhRLQijGA .cluster-label span p{background-color:transparent;}#mermaid-svg-MSNR0N6LhRLQijGA .label text,#mermaid-svg-MSNR0N6LhRLQijGA span{fill:#333;color:#333;}#mermaid-svg-MSNR0N6LhRLQijGA .node rect,#mermaid-svg-MSNR0N6LhRLQijGA .node circle,#mermaid-svg-MSNR0N6LhRLQijGA .node ellipse,#mermaid-svg-MSNR0N6LhRLQijGA .node polygon,#mermaid-svg-MSNR0N6LhRLQijGA .node path{fill:#ECECFF;stroke:#9370DB;stroke-width:1px;}#mermaid-svg-MSNR0N6LhRLQijGA .rough-node .label text,#mermaid-svg-MSNR0N6LhRLQijGA .node .label text,#mermaid-svg-MSNR0N6LhRLQijGA .image-shape .label,#mermaid-svg-MSNR0N6LhRLQijGA .icon-shape .label{text-anchor:middle;}#mermaid-svg-MSNR0N6LhRLQijGA .node .katex path{fill:#000;stroke:#000;stroke-width:1px;}#mermaid-svg-MSNR0N6LhRLQijGA .rough-node .label,#mermaid-svg-MSNR0N6LhRLQijGA .node .label,#mermaid-svg-MSNR0N6LhRLQijGA .image-shape .label,#mermaid-svg-MSNR0N6LhRLQijGA .icon-shape .label{text-align:center;}#mermaid-svg-MSNR0N6LhRLQijGA .node.clickable{cursor:pointer;}#mermaid-svg-MSNR0N6LhRLQijGA .root .anchor path{fill:#333333!important;stroke-width:0;stroke:#333333;}#mermaid-svg-MSNR0N6LhRLQijGA .arrowheadPath{fill:#333333;}#mermaid-svg-MSNR0N6LhRLQijGA .edgePath .path{stroke:#333333;stroke-width:2.0px;}#mermaid-svg-MSNR0N6LhRLQijGA .flowchart-link{stroke:#333333;fill:none;}#mermaid-svg-MSNR0N6LhRLQijGA .edgeLabel{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-MSNR0N6LhRLQijGA .edgeLabel p{background-color:rgba(232,232,232, 0.8);}#mermaid-svg-MSNR0N6LhRLQijGA .edgeLabel rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-MSNR0N6LhRLQijGA .labelBkg{background-color:rgba(232, 232, 232, 0.5);}#mermaid-svg-MSNR0N6LhRLQijGA .cluster rect{fill:#ffffde;stroke:#aaaa33;stroke-width:1px;}#mermaid-svg-MSNR0N6LhRLQijGA .cluster text{fill:#333;}#mermaid-svg-MSNR0N6LhRLQijGA .cluster span{color:#333;}#mermaid-svg-MSNR0N6LhRLQijGA div.mermaidTooltip{position:absolute;text-align:center;max-width:200px;padding:2px;font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:12px;background:hsl(80, 100%, 96.2745098039%);border:1px solid #aaaa33;border-radius:2px;pointer-events:none;z-index:100;}#mermaid-svg-MSNR0N6LhRLQijGA .flowchartTitleText{text-anchor:middle;font-size:18px;fill:#333;}#mermaid-svg-MSNR0N6LhRLQijGA rect.text{fill:none;stroke-width:0;}#mermaid-svg-MSNR0N6LhRLQijGA .icon-shape,#mermaid-svg-MSNR0N6LhRLQijGA .image-shape{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-MSNR0N6LhRLQijGA .icon-shape p,#mermaid-svg-MSNR0N6LhRLQijGA .image-shape p{background-color:rgba(232,232,232, 0.8);padding:2px;}#mermaid-svg-MSNR0N6LhRLQijGA .icon-shape .label rect,#mermaid-svg-MSNR0N6LhRLQijGA .image-shape .label rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-MSNR0N6LhRLQijGA .label-icon{display:inline-block;height:1em;overflow:visible;vertical-align:-0.125em;}#mermaid-svg-MSNR0N6LhRLQijGA .node .label-icon path{fill:currentColor;stroke:revert;stroke-width:revert;}#mermaid-svg-MSNR0N6LhRLQijGA :root{--mermaid-font-family:"trebuchet ms",verdana,arial,sans-serif;} invoke round1
checkpoint
invoke same thread
checkpoint
interrupt
等待人审批
Command resume
继续并存档
三、MemorySaver:开发环境首选
MemorySaver 把 checkpoint 放在进程内存里,装包即用、零依赖:
typescript
import {
MemorySaver,
StateGraph,
StateSchema,
MessagesValue,
GraphNode,
START,
END,
} from "@langchain/langgraph";
import { ChatOllama } from "@langchain/ollama";
import { HumanMessage } from "@langchain/core/messages";
const llm = new ChatOllama({ model: "qwen2.5:7b", temperature: 0 });
const State = new StateSchema({
messages: MessagesValue,
});
const agentNode: GraphNode<typeof State> = async (state) => {
const response = await llm.invoke(state.messages);
return { messages: [response] }; // 只返回增量
};
const checkpointer = new MemorySaver();
const graph = new StateGraph(State)
.addNode("agent", agentNode)
.addEdge(START, "agent")
.addEdge("agent", END)
.compile({ checkpointer }); // 关键:compile 时注入
⚠️ 进程一重启,MemorySaver 里的数据全部清空。 本地调试、单测、跟教程跑通 ------ 绝佳;生产请换落库实现。
想本地也落盘、又不想立刻上 Postgres?官还提到 SqliteSaver (文件型),心智模型一样:换 checkpointer 实现,invoke 接口不变。本系列生产示例以 Postgres 为主。
四、thread_id:持久化游标
启用 Checkpointer 之后,每次调用都要带上会话钥匙:
typescript
const config = { configurable: { thread_id: "user-alice-session-1" } };
可以把它想成游戏存档槽位编号:
- 同一个
thread_id:加载上一份 State,messages 继续累积 - 换一个新
thread_id:全新空会话,跟旧槽无关
typescript
const config = { configurable: { thread_id: "demo-thread-1" } };
// 第一轮
await graph.invoke(
{ messages: [new HumanMessage("记住:我的宠物叫小白。")] },
config
);
// 第二轮:同一 thread,不用手动传历史
const result = await graph.invoke(
{ messages: [new HumanMessage("我的宠物叫什么?")] },
config
);
console.log(result.messages.at(-1)?.content);
// 期望提到「小白」
想确认当前线程卡在哪、State 长什么样,可以读快照:
typescript
const snapshot = await graph.getState(config);
console.log(snapshot.values); // 当前 State
console.log(snapshot.next); // 下一步会跑哪些节点
console.log(snapshot.tasks); // 含未完成的 interrupt 任务时能看出来
Postgres 下
thread_id建议 控制在 255 字符以内(列长度有限)。生产里用 UUID 或短哈希当会话 ID 最省心。
五、生产:PostgresSaver
包要单独装:
bash
npm install @langchain/langgraph-checkpoint-postgres
三步走:fromConnString → setup() → compile:
typescript
import { PostgresSaver } from "@langchain/langgraph-checkpoint-postgres";
const checkpointer = PostgresSaver.fromConnString(
process.env.DATABASE_URL! // 例如 postgresql://user:pass@localhost:5432/agent
);
// 首次使用必须建表;表已存在时再跑通常也安全,但别忘了这一步
await checkpointer.setup();
const graph = workflow.compile({ checkpointer });
跟 MemorySaver 的 invoke 姿势完全一样------换实现、不换业务代码。这正是 Checkpointer 抽象的价值:本地内存、文件 Sqlite、Postgres,上层图逻辑一行都不用改。
六、interrupt:人机协作(HITL)
有时节点跑到一半必须等人:发邮件前审批、扣款前二次确认、改 Prompt 后再继续。官网做法是节点里调用 interrupt(payload):
- 图在此处暂停,State 由 Checkpointer 落盘
payload(任意 JSON 可序列化值)出现在调用方返回的__interrupt__- 外部用相同
thread_id再invoke(new Command({ resume: value })) value变成节点里interrupt(...)的返回值,节点继续往下走
⚠️ 与上一篇区分清楚:
Command({ goto, update })是节点函数的返回值 (图内路由);Command({ resume })是invoke/stream的输入 (从图外恢复 interrupt)。多轮普通聊天请继续传普通 State 增量(比如新的messages),别拿Command({ update })当输入凑合。
完整审批流示例
typescript
import {
StateGraph,
StateSchema,
GraphNode,
MemorySaver,
interrupt,
Command,
START,
END,
} from "@langchain/langgraph";
import * as z from "zod";
const State = new StateSchema({
emailDraft: z.string(),
status: z.string().default("pending"),
});
const approveNode: GraphNode<typeof State> = async (state) => {
// 暂停;payload 会出现在 result.__interrupt__
const approved = interrupt({
question: "是否发送以下邮件?",
draft: state.emailDraft,
});
// resume 传入的值会成为上面的 approved
if (approved) {
// 真正发信......(注意:resume 时节点会从头再跑,副作用要幂等)
return { status: "sent" };
}
return { status: "cancelled" };
};
const checkpointer = new MemorySaver();
const graph = new StateGraph(State)
.addNode("approve", approveNode)
.addEdge(START, "approve")
.addEdge("approve", END)
.compile({ checkpointer }); // interrupt 必须有 checkpointer
const config = { configurable: { thread_id: "email-approval-1" } };
// ① 首次 invoke:命中 interrupt,图暂停(不是抛异常)
const paused = await graph.invoke(
{ emailDraft: "您好,这是会议提醒。" },
config
);
console.log(paused.__interrupt__);
// [{ value: { question: '是否发送以下邮件?', draft: '...' }, ... }]
// ② 用户在 UI 点「批准」后,用同一 thread_id resume
const final = await graph.invoke(new Command({ resume: true }), config);
console.log(final.status); // "sent"
关键心智模型:
| 要点 | 说明 |
|---|---|
| 前置条件 | 必须 compile({ checkpointer }),且 invoke 带 thread_id |
| 怎么知道暂停了 | 看返回值的 __interrupt__,不要默认当成 throw |
| resume 返回值 | Command({ resume: X }) 的 X = 节点内 interrupt() 的返回值 |
| 节点重跑 | resume 时从该节点开头重新执行 ,interrupt 之前的代码会再跑一遍 |
| 幂等 | interrupt 前若写了 DB / 发了半截请求,可能重复;副作用放到 interrupt 之后,或做成幂等 |
Human Checkpointer Graph App Human Checkpointer Graph App #mermaid-svg-jTR8KtJRgSdtZkV8{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;fill:#333;}@keyframes edge-animation-frame{from{stroke-dashoffset:0;}}@keyframes dash{to{stroke-dashoffset:0;}}#mermaid-svg-jTR8KtJRgSdtZkV8 .edge-animation-slow{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 50s linear infinite;stroke-linecap:round;}#mermaid-svg-jTR8KtJRgSdtZkV8 .edge-animation-fast{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 20s linear infinite;stroke-linecap:round;}#mermaid-svg-jTR8KtJRgSdtZkV8 .error-icon{fill:#552222;}#mermaid-svg-jTR8KtJRgSdtZkV8 .error-text{fill:#552222;stroke:#552222;}#mermaid-svg-jTR8KtJRgSdtZkV8 .edge-thickness-normal{stroke-width:1px;}#mermaid-svg-jTR8KtJRgSdtZkV8 .edge-thickness-thick{stroke-width:3.5px;}#mermaid-svg-jTR8KtJRgSdtZkV8 .edge-pattern-solid{stroke-dasharray:0;}#mermaid-svg-jTR8KtJRgSdtZkV8 .edge-thickness-invisible{stroke-width:0;fill:none;}#mermaid-svg-jTR8KtJRgSdtZkV8 .edge-pattern-dashed{stroke-dasharray:3;}#mermaid-svg-jTR8KtJRgSdtZkV8 .edge-pattern-dotted{stroke-dasharray:2;}#mermaid-svg-jTR8KtJRgSdtZkV8 .marker{fill:#333333;stroke:#333333;}#mermaid-svg-jTR8KtJRgSdtZkV8 .marker.cross{stroke:#333333;}#mermaid-svg-jTR8KtJRgSdtZkV8 svg{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;}#mermaid-svg-jTR8KtJRgSdtZkV8 p{margin:0;}#mermaid-svg-jTR8KtJRgSdtZkV8 .actor{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:#ECECFF;}#mermaid-svg-jTR8KtJRgSdtZkV8 text.actor>tspan{fill:black;stroke:none;}#mermaid-svg-jTR8KtJRgSdtZkV8 .actor-line{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);}#mermaid-svg-jTR8KtJRgSdtZkV8 .innerArc{stroke-width:1.5;stroke-dasharray:none;}#mermaid-svg-jTR8KtJRgSdtZkV8 .messageLine0{stroke-width:1.5;stroke-dasharray:none;stroke:#333;}#mermaid-svg-jTR8KtJRgSdtZkV8 .messageLine1{stroke-width:1.5;stroke-dasharray:2,2;stroke:#333;}#mermaid-svg-jTR8KtJRgSdtZkV8 #arrowhead path{fill:#333;stroke:#333;}#mermaid-svg-jTR8KtJRgSdtZkV8 .sequenceNumber{fill:white;}#mermaid-svg-jTR8KtJRgSdtZkV8 #sequencenumber{fill:#333;}#mermaid-svg-jTR8KtJRgSdtZkV8 #crosshead path{fill:#333;stroke:#333;}#mermaid-svg-jTR8KtJRgSdtZkV8 .messageText{fill:#333;stroke:none;}#mermaid-svg-jTR8KtJRgSdtZkV8 .labelBox{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:#ECECFF;}#mermaid-svg-jTR8KtJRgSdtZkV8 .labelText,#mermaid-svg-jTR8KtJRgSdtZkV8 .labelText>tspan{fill:black;stroke:none;}#mermaid-svg-jTR8KtJRgSdtZkV8 .loopText,#mermaid-svg-jTR8KtJRgSdtZkV8 .loopText>tspan{fill:black;stroke:none;}#mermaid-svg-jTR8KtJRgSdtZkV8 .loopLine{stroke-width:2px;stroke-dasharray:2,2;stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);}#mermaid-svg-jTR8KtJRgSdtZkV8 .note{stroke:#aaaa33;fill:#fff5ad;}#mermaid-svg-jTR8KtJRgSdtZkV8 .noteText,#mermaid-svg-jTR8KtJRgSdtZkV8 .noteText>tspan{fill:black;stroke:none;}#mermaid-svg-jTR8KtJRgSdtZkV8 .activation0{fill:#f4f4f4;stroke:#666;}#mermaid-svg-jTR8KtJRgSdtZkV8 .activation1{fill:#f4f4f4;stroke:#666;}#mermaid-svg-jTR8KtJRgSdtZkV8 .activation2{fill:#f4f4f4;stroke:#666;}#mermaid-svg-jTR8KtJRgSdtZkV8 .actorPopupMenu{position:absolute;}#mermaid-svg-jTR8KtJRgSdtZkV8 .actorPopupMenuPanel{position:absolute;fill:#ECECFF;box-shadow:0px 8px 16px 0px rgba(0,0,0,0.2);filter:drop-shadow(3px 5px 2px rgb(0 0 0 / 0.4));}#mermaid-svg-jTR8KtJRgSdtZkV8 .actor-man line{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:#ECECFF;}#mermaid-svg-jTR8KtJRgSdtZkV8 .actor-man circle,#mermaid-svg-jTR8KtJRgSdtZkV8 line{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:#ECECFF;stroke-width:2px;}#mermaid-svg-jTR8KtJRgSdtZkV8 :root{--mermaid-font-family:"trebuchet ms",verdana,arial,sans-serif;} invoke draft + thread_id 保存 checkpoint 返回 interrupt 展示草稿等待审批 批准 true invoke Command resume + 同 thread_id 加载 checkpoint status sent
常见坑
- 启用了 checkpointer 却不传
thread_id:无法稳定落盘 / 恢复,多轮像失忆,interrupt 也接不上。 MemorySaver直接上生产 :重启丢会话。生产用PostgresSaver(或其它落库实现);本地持久化可考虑 Sqlite。PostgresSaver忘记await checkpointer.setup():表不存在,首次跑直接炸。- resume 换了另一个
thread_id:等于去另一档存档找进度,恢复必然失败。 - interrupt 前有副作用:节点从头重跑会重复执行。把「真正有副作用的动作」放在 interrupt 之后,或做幂等键。
- 把 interrupt 当异常
try/catch:官网主路径是正常返回 +__interrupt__。别一上来就按 GraphInterrupt 异常流去写,容易跟当前 JS 文档对不上。 thread_id过长(Postgres):超过约 255 字符可能触发数据库列错误,用 UUID 更稳。- 普通多轮对话误用
Command({ update })当输入 :继续聊天传普通 input 对象;resume专用 Command 留给 interrupt。
小结
到这里,LangGraph 基础四件套齐了:
- Checkpointer :每个 super-step 打存档;
compile({ checkpointer })注入。 thread_id:会话游标;同 ID 累积,异 ID 隔离;可用getState窥探快照。MemorySaver/PostgresSaver(及 Sqlite) :开发内存 → 生产落库,接口一致;Postgres 记得setup()。interrupt+Command({ resume }):HITL 暂停与恢复;读__interrupt__;注意节点重跑与幂等。- Store:跨线程长期记忆------本篇只划清边界,不展开。
Phase 1 的 LangGraph 地基可以收束了:核心概念 → State / Graph API → 条件分支与循环 → Checkpointer / HITL 。下一批主题转向站得更高一点的视角------Agent 到底有哪些类型、什么时候用 ReAct、什么时候该上多 Agent / 工作流。