第 06 章

持久化执行

让长时间运行的智能体在关键时刻保存进度,崩了也能从断点精准恢复

检查点 + 回放 + task() —— 从"一次跑完"到"随时中断、随时继续"

跑了半天崩了

你的 Agent 跑到一半,LLM 超时了 / 网络断了 / 服务器重启了 —— 然后呢?

没有持久化执行

  • 调了 3 分钟 LLM,网络闪断,全部白跑
  • 执行到第 5 步崩了,只能从头再来,前 4 步的 API 调用费用打水漂
  • 想暂停等人确认?可以,但恢复后不确定从哪里继续
  • 节点的副作用(发邮件、写数据库)可能重复执行
  • 长时间运行的任务不敢用 Agent,怕中途挂掉无法恢复

有持久化执行

  • LLM 超时?用同一个 thread_id 重新调用,自动从断点恢复
  • 前 4 步的结果已存检查点,只重跑第 5 步
  • interrupt() 暂停 + Command({ resume }) 精准继续
  • task() 包裹副作用,重放时自动跳过已执行的
  • 长时间任务也能放心交给 Agent,崩了就重跑,不用怕
核心区别:普通持久化(第 05 章)只负责"存状态";持久化执行在此基础上增加"安全恢复 + 避免副作用重复"的能力。如果你已经用了 checkpointer,你已经有了持久化执行的基础——只需要理解回放机制和 task()。

开始之前

需要具备

  • 已完成第 05 章持久化,理解检查点、线程、超级步骤
  • 能使用 MemorySaver 编译带持久化的图
  • 理解 getState() / updateState() 的基本用法
  • 熟悉 TypeScript 的 async/await

读完后你应该能

  • 说清"回放"机制的工作原理和它带来的约束
  • 用 task() 包裹副作用,避免重复执行
  • 根据场景选择 exit / async / sync 三种持久化模式
  • 在崩了之后用相同 thread_id 恢复执行
  • 写出符合持久化执行约束的安全代码

暂不涉及

  • 自定义 Checkpointer 后端的实现细节
  • 分布式场景下的一致性保证
  • 时间旅行的完整用法(后续章节)
  • 生产环境部署与监控
前置章节:本章是第 05 章的直接延伸。如果你还没读过持久化章节,请先完成第 05 章。本章所有代码基于 LangGraph.js (TypeScript)。

什么是持久化

一句话:在关键节点保存进度,暂停后能从断点精准恢复

持久化执行(Durable Execution)不是什么新概念——数据库事务、消息队列都有类似的"至少一次"语义。在 LangGraph 中,它的含义很简单:

图:持久化执行的三个层次

适用场景

  • 人机交互(HITL):暂停等人确认,确认后继续
  • LLM 超时:大模型响应慢,超时后重试
  • 网络中断:断网后重连,从断点恢复
  • 长时间任务:多步骤 Agent,中途可能需要数小时

核心前提

  • 你已经用了 checkpointer —— 这是一切的基础
  • 每次调用都传了 thread_id
  • 理解回放(replay)机制 —— 恢复时从头重放
  • 副作用用 task() 包裹
好消息:如果你已经在用 checkpointer(第 05 章),你已经具备了持久化执行的基础。剩下的只是理解回放机制和学会用 task() 保护副作用。

三个前提

两个启用条件 + 一个编码规范

1. 启用持久化 (启用条件)

编译时传入 checkpointer

// MemorySaver 用于开发
const graph = workflow.compile({
  checkpointer: new MemorySaver()
});

2. 指定线程 (启用条件)

每次调用传 thread_id

// 同一任务用相同 thread_id
const config = {
  configurable: {
    thread_id: "task-001"
  }
};

3. 包裹副作用 (编码规范,非启用所必需)

用 task() 包装非确定性操作

思考题:你的 Agent 在执行过程中会调用外部 API、写数据库。如果中途崩溃重启,所有操作会重新执行一遍——API 被重复调用、数据库被重复写入。怎么让重启后跳过已经执行过的副作用?
import { task } from "@langchain/langgraph";

const result = await task(
  "my-api-call",
  async () => fetch(url)
);
区分"启用条件"与"编码规范":持久化本身只需要 checkpointer + thread_id(前两项)就能工作;task() 不是启用持久化的前提,而是避免重放时副作用被重复执行的最佳实践。另外,task() 属于 Functional API——它通常配合 entrypoint 一起使用(import { task, entrypoint } from "@langchain/langgraph"),也可以在 StateGraph 节点内直接调用,本教程示例即采用后者。
// 完整示例:满足三个前提的持久化执行图
import { StateGraph, MemorySaver, START, END, task } from "@langchain/langgraph";
import { Annotation } from "@langchain/langgraph";

const State = Annotation.Root({
  url: Annotation<string>,
  result: Annotation<string>,
});

const fetchNode = async (state) => {
  // 用 task() 包裹网络请求——重放时自动跳过
  const html = await task("fetch-url", async () => {
    const resp = await fetch(state.url);
    return await resp.text();
  });
  return { result: html.slice(0, 100) };
};

const graph = new StateGraph(State)
  .addNode("fetch", fetchNode)
  .addEdge(START, "fetch")
  .addEdge("fetch", END)
  .compile({ checkpointer: new MemorySaver() });  // 前提 1

const config = { configurable: { thread_id: "crawl-1" } };  // 前提 2

📄 完整代码:06_task_wrapping.ts

前提 3 是最容易忽略的:如果你在节点里直接调 API、写数据库、发邮件,恢复时这些操作会重新执行。task() 能让 LangGraph 记住"这个操作已经做过了",重放时跳过。

回放机制

最重要的概念:恢复时从头重放,不是从断点继续

图:回放机制 —— 恢复时的执行路径
回放 ≠ 继续执行:恢复时 LangGraph 会让代码从图的起点重新运行,但已经成功执行过的节点会通过检查点"快进"(replay)。task() 包裹的操作在重放时会被跳过——这就是为什么要用它包裹副作用。

重放时发生了什么

  • 代码从图起点开始重新执行
  • 已完成的节点:通过检查点快速重放
  • task() 包裹的操作:自动跳过
  • 崩溃的节点:重新执行

如果没有 task() 会怎样

  • 节点 A 发了一封邮件,重放时再发一封
  • 节点 B 写了一条数据库记录,重放时再写一条
  • 副作用不受控制,可能造成严重后果

编码原则

理解了回放机制后,写代码时要遵循三条原则

思考题:你的 Agent 有时重放后行为不一致——UUID 每次生成都不同、时间戳每次都变、数据库 INSERT 导致重复记录。怎么写出"即使重放也不出问题"的代码?

原则一:避免重复工作

所有有副作用的操作(API 调用、数据库写入、发邮件)必须用 task() 包裹。这样重放时 LangGraph 知道跳过它们。

// 错误:直接调用
const data = await fetch(url);

// 正确:用 task() 包裹
const data = await task("fetch-data",
  async () => await fetch(url)
);

原则二:包裹非确定性

随机数、时间戳、UUID 等非确定性操作也要用 task() 包裹。否则每次重放都会生成不同的值,导致状态不一致。

// 错误:每次重放结果不同
const id = crypto.randomUUID();

// 正确:重放时使用缓存的结果
const id = await task("gen-id",
  () => crypto.randomUUID()
);

原则三:幂等操作

即使用了 task(),也无法 100% 保证不重复。设计操作时尽量让它执行多次和执行一次效果相同。

// 使用幂等写入:upsert 而非 insert
await task("save-result",
  async () => db.collection.updateOne(
    { _id: taskId },  // 唯一键
    { $set: { result } },
    { upsert: true }
  )
);
三层防护:task()(防止重放重复) + 幂等设计(防止极端情况重复) + checkpointer(保证状态一致)。三者配合才是完整的持久化执行。

📄 完整代码:06_coding_principles.ts

持久化模式

三种模式控制检查点的保存时机和频率

模式 保存时机 安全性 性能 适用场景
exit 仅在图执行完毕时保存 低 最快 短任务、不需要中途恢复
async 异步持久化(默认模式) 中 较块 大多数场景的默认选择
sync 每步同步保存检查点 最高 最慢 关键任务、不能丢失任何状态
思考题:你的图在运行时,每一步都会产生中间结果。你需要决定什么时候把这些中间结果保存到检查点——每步都保存太慢,全部跑完再保存中途崩了就全丢。有没有折中方案?
// 三种模式的使用方式——只在调用时指定
const config = { configurable: { thread_id: "task-1" } };

// exit 模式:只在图结束时保存
await graph.stream({ input: "test" }, { ...config, durability: "exit" });

// async 模式:异步保存(默认)
await graph.stream({ input: "test" }, { ...config, durability: "async" });

// sync 模式:每步同步保存——最安全
await graph.stream({ input: "test" }, { ...config, durability: "sync" });

📄 完整代码:06_durability_modes.ts

如何选择

  • 开发调试:用 exit,跑得快,不用管中途状态
  • 一般场景:用 async(默认),平衡安全和性能
  • 金融 / 医疗 / 关键业务:用 sync,每一步都确认保存

性能影响

  • sync 每步都要等写入完成,速度最慢但最安全
  • async 在后台写入,几乎不影响执行速度
  • exit 只在结束时写一次,中途崩溃会丢失中间状态
提示:如果你没有指定 durability,默认值取决于你使用的 checkpointer。大多数情况下默认值就够用。只有在明确需要更强保证时才切换到 sync。

task() 详解

用 task() 包裹副作用,让重放时自动跳过已执行的操作

思考题:你的 Agent 在执行过程中会调用外部 API、写数据库。如果中途崩溃重启,所有操作会重新执行一遍——API 被重复调用、数据库被重复写入。怎么让重启后跳过已经执行过的副作用?
// task() 的签名
import { task } from "@langchain/langgraph";

// 第一个参数:唯一名称(同一个节点内不能重复)
// 第二个参数:实际要执行的异步函数
const result = await task("fetch-url", async () => {
  const response = await fetch(state.url);
  return await response.text();
});

在节点中使用 task()

import { task } from "@langchain/langgraph";

const researchNode = async (state) => {
  // 任务 1:获取网页内容(副作用 → 用 task)
  const html = await task("fetch-page", async () => {
    const resp = await fetch(state.url);
    return resp.text();
  });

  // 任务 2:调用 LLM 分析(副作用 + 花钱 → 用 task)
  const analysis = await task("analyze", async () => {
    const llm = new ChatOpenAI({ model: "gpt-4o-mini" });
    const result = await llm.invoke(html.slice(0, 3000));
    return result.content;
  });

  // 纯计算不需要 task()——没有副作用,重放也无害
  const summary = analysis.slice(0, 200);

  return { result: summary };
};

const graph = new StateGraph(State)
  .addNode("research", researchNode)
  .addEdge(START, "research")
  .addEdge("research", END)
  .compile({ checkpointer: new MemorySaver() });

📄 完整代码:06_task_wrapping.ts

什么时候用 task()

  • 网络请求(fetch、API 调用)
  • 数据库读写
  • 文件系统操作
  • 发送邮件 / 通知
  • 调用 LLM(花钱的操作)
  • 生成随机值(UUID、时间戳)

什么时候不需要

  • 纯计算(字符串处理、数学运算)
  • 状态读取(state.xxx)
  • 条件判断(if/else)
  • 数据转换(map、filter)
  • console.log(重放时重复打印无害)
判断标准:如果这个操作执行两次会产生不同结果、花两次钱、或造成副作用——就用 task()。task 的名称在同一个节点内必须唯一。

恢复场景

两种核心恢复方式:暂停/恢复 和 故障恢复

场景触发方式恢复方式典型用途
暂停/恢复 节点内调用 interrupt() graph.invoke(null, { ...config, command: new Command({ resume: value }) }) 人机交互、等待审批
故障恢复 LLM 超时、网络中断、进程崩溃 用同一个 thread_id 重新调用 invoke / stream 自动重试、服务重启
状态回滚 手动调用 updateState 修改检查点后重新执行 修正错误、调试
思考题:你的 Agent 有 3 个步骤。运行到第 2 步时,服务器突然崩了。你想在恢复后从第 2 步继续,而不是从头开始。怎么做?

场景一:暂停与恢复(HITL)

import { interrupt, Command } from "@langchain/langgraph";

const reviewNode = async (state) => {
  // 暂停,等待人工审核
  const humanDecision = interrupt("请审核这个结果:" + state.draft);

  // humanDecision 就是恢复时传入的值
  if (humanDecision === "approve") {
    return { status: "approved" };
  }
  return { status: "rejected", feedback: humanDecision };
};

// 恢复执行——传入人工决策
await graph.invoke(
  null,  // 不需要新输入
  {
    ...config,
    command: new Command({ resume: "approve" }),
  }
);

场景二:故障恢复

// 第一次调用——假设在节点 C 崩溃了
try {
  await graph.invoke({ input: "start" }, config);
} catch (e) {
  console.log("崩溃了,准备恢复...");
}

// 恢复:用同一个 thread_id 重新调用,自动从断点恢复
const result = await graph.invoke(
  null,  // 不需要新输入,检查点已有状态
  config  // 同一个 thread_id
);
console.log(result);  // 从崩溃点继续执行

📄 完整代码:06_fault_recovery.ts

记住:两种恢复方式的共同点是都用同一个 thread_id。区别在于:暂停/恢复需要传 Command({ resume });故障恢复只需重新调用即可。

排错指南

持久化执行中的常见问题与修复方案

副作用重复执行

现象:恢复后发现邮件发了两次、数据库写了两条。

原因:忘记用 task() 包裹副作用操作。

修复:所有 API 调用、数据库写入、发邮件等操作都用 task("name", fn) 包裹。

task 名称冲突

现象:报错 "Duplicate task name"。

原因:同一个节点内有两个 task() 用了相同的名称。

修复:确保每个 task 的第一个参数在同一个节点内唯一,例如 "fetch-url-1"、"fetch-url-2"。

恢复后状态不对

现象:恢复后得到的状态和预期不一致。

原因:用了不同的 thread_id,或者恢复时传了新的输入。

修复:恢复时用同一个 thread_id,且传 null 作为输入(除非确实需要新输入)。

恢复后直接结束

现象:调用恢复后,图直接返回了结果,没有继续执行。

原因:图在崩溃前已经执行完毕(到达 END),next 为空数组。

修复:用 getState() 检查 next 字段,确认是否还有未执行的节点。

随机值不一致

现象:恢复后 UUID 或时间戳变了,导致下游逻辑异常。

原因:生成随机值的代码没有用 task() 包裹。

修复:把随机值生成也包在 task() 里,重放时使用缓存值。

sync 模式太慢

现象:图执行速度明显变慢。

原因:用了 durability: "sync",每步都等检查点写入完成。

修复:大多数场景用 "async"(默认)就够了,只在关键业务用 "sync"。

快速自检:①副作用用了 task() 吗?②task 名称唯一吗?③恢复时 thread_id 一致吗?④恢复时输入传的是 null 吗?⑤getState().next 还有值吗?

动手练习

构建一个带 task() 和故障恢复的可靠 Agent

练习 1:基础 task()

  • 构建一个三节点图:fetch → analyze → report
  • 在 fetch 中用 task() 包裹 HTTP 请求
  • 在 analyze 中用 task() 包裹 LLM 调用
  • 故意让 report 抛异常,观察恢复行为

练习 2:暂停与恢复

  • 在 analyze 节点中加 interrupt()
  • 调用图,观察暂停行为
  • 用 getState() 查看 next 字段
  • 用 Command({ resume }) 恢复执行

练习 3:三种模式对比

  • 分别用 exit、async、sync 运行同一个图
  • 在每个节点加 console.time 对比耗时
  • 中途终止,观察恢复后状态差异
// 参考答案:练习 1 & 2
import { StateGraph, MemorySaver, START, END, task, interrupt, Command } from "@langchain/langgraph";
import { Annotation } from "@langchain/langgraph";

const State = Annotation.Root({
  url: Annotation<string>,
  content: Annotation<string>,
  analysis: Annotation<string>,
  report: Annotation<string>,
});

const fetchNode = async (state) => {
  const html = await task("fetch-page", async () => {
    const resp = await fetch(state.url);
    return resp.text();
  });
  return { content: html.slice(0, 500) };
};

const analyzeNode = async (state) => {
  // 等待人工确认分析内容
  const approved = interrupt(`内容预览:${state.content.slice(0, 100)}...确认分析?`);

  const result = await task("llm-analyze", async () => {
    // 模拟 LLM 调用
    return `分析结果:${state.content.length} 字符`;
  });
  return { analysis: result };
};

const reportNode = (state) => {
  return { report: `报告:${state.analysis}` };
};

const graph = new StateGraph(State)
  .addNode("fetch", fetchNode)
  .addNode("analyze", analyzeNode)
  .addNode("report", reportNode)
  .addEdge(START, "fetch")
  .addEdge("fetch", "analyze")
  .addEdge("analyze", "report")
  .addEdge("report", END)
  .compile({ checkpointer: new MemorySaver() });

const config = { configurable: { thread_id: "exercise-1" } };

// 第一次调用——会在 analyze 节点暂停
await graph.invoke({ url: "https://example.com" }, config);

// 查看暂停状态
const snap = await graph.getState(config);
console.log("下一步:", snap.next);  // ["analyze"]

// 恢复——传入确认
await graph.invoke(null, { ...config, command: new Command({ resume: "yes" }) });

本章回顾

持久化执行让长时间运行的智能体变得可靠

核心要点

  • 持久化执行:保存进度 + 暂停 + 恢复 + 继续
  • 三个前提:checkpointer + thread_id + task()
  • 回放机制:恢复时从头重放,已完成的节点快进跳过
  • task():包裹副作用,重放时自动跳过已执行的操作
  • 三种模式:exit(最快)、async(默认)、sync(最安全)
  • 两条原则:副作用用 task()、操作尽量幂等

关键 API 速查

操作方法
包裹副作用task("name", fn)
暂停执行interrupt("message")
恢复执行Command({ resume: value })
故障恢复同 thread_id 重新调用
指定模式{ durability: "sync" }
检查状态getState()
核心结论:持久化执行 = 检查点(保存状态)+ 回放(安全恢复)+ task()(避免副作用重复)。如果你已经用了 checkpointer,你已经走完了 80% 的路——只需要理解回放机制、用 task() 保护副作用、根据场景选择持久化模式。这三步做完,你的 Agent 就能"崩了不怕、随时暂停、精准恢复"。

延伸阅读

继续深入持久化执行,或探索 LangGraph 的其他核心能力

继续本课程

官方文档

进阶主题

  • 人机交互(HITL) — interrupt + resume 的完整模式
  • 时间旅行 — 回放和分叉过去的检查点
  • 生产部署 — PostgresSaver + sync 模式的最佳实践
  • 容错设计 — 结合 task() + 幂等 + 重试策略
学习路径建议:本章是第 05 章的直接延伸。掌握持久化执行后,下一站是流式处理(第 07 章)——让 Agent 的每一步执行都能实时反馈给用户。两者结合,你就有了"可靠 + 实时"的生产级 Agent 基础。