🚅 地铁的"回头路、存档点与人工闸机"(下):LangGraph 的循环、持久化与中断

写在前面:上篇我们建好了 LangGraph 的第一张地铁网------状态(Annotation)、节点(函数)、直线边、条件边(换乘分叉)。但真实的地铁系统还有三样东西没解决:坐过站要回头坐 (循环)、中途出站下次接着坐 (状态持久化)、过闸机遇到人工开箱检查 (中断确认)。今天这三个问题全部安排:loop-retry.mjs 演示循环重试,checkpointer-memory.mjs 演示 MemorySaver 状态持久化,graph-interrupt.mjs 演示中断等人确认。readme 把它们总结成了网状工作流的三大高级能力。以下所有代码均来自课堂真实文件。


一、循环:坐过站就回头再坐一次

真实世界没有一帆风顺------LLM 调用会失败、工具会报错、结果可能不合格。怎么办?重试。

loop-retry.mjs 演示了一个"重试 3 次才成功"的循环。

完整代码

javascript 复制代码
import {
    Annotation, END, START, StateGraph,
} from "@langchain/langgraph";

const StateAnnotation = Annotation.Root({
    tries: Annotation({
        reducer: (_prev, next) => next,
        defaultValue: 0,      // 注意:这里是 defaultValue 而不是 default
    }),
    ok: Annotation({
        reducer: (_prev, next) => next,
        default: () => false, // 默认失败
    }),
    message: Annotation({
        reducer: (_prev, next) => next,
        default: () => "",
    }),
});

const attempt = (state) => {
    const retries = state.tries + 1;
    const ok = retries >= 3;   // 第 3 次才成功
    return {
        tries: retries,
        ok,
        message: ok ? `第${retries}次成功` : `第${retries}次失败`,
    }
}

const graph = new StateGraph(StateAnnotation)
    .addNode("attempt", attempt)
    .addEdge(START, "attempt")
    .addConditionalEdges("attempt", (state) => state.ok ? "done" : "retry", {
        retry: "attempt",   // 没成功 → 回到 attempt 自己(自环)
        done: END,          // 成功了 → 结束
    })
    .compile()

console.log("result", await graph.invoke({tries: 0}))

模拟失败:attempt 节点

javascript 复制代码
const attempt = (state) => {
    const retries = state.tries + 1;
    const ok = retries >= 3;   // 第 1、2 次失败,第 3 次成功
    return {
        tries: retries,
        ok,
        message: ok ? `第${retries}次成功` : `第${retries}次失败`,
    }
}

attempt 节点模拟一个"前两次必失败、第三次才成功"的操作:

次数 tries ok message
第 1 次 1 false 第1次失败
第 2 次 2 false 第2次失败
第 3 次 3 true 第3次成功

关键:条件边指向自己(自环)

javascript 复制代码
.addConditionalEdges("attempt", (state) => state.ok ? "done" : "retry", {
    retry: "attempt",   // 关键:回到自己!
    done: END,
})

注意 retry: "attempt" ------目标节点就是起始节点自己。没成功就从 attempt 再跳回 attempt,形成自环。

sql 复制代码
             ┌──────────────────┐
             │                  │
             ▼                  │
START → attempt ──ok?── 否 ─────┘
             │
           是(第3次)
             ▼
            END

条件边在这里的作用------判断是否继续循环state.ok 为 false 就回到 attempt 再试一次,直到第 3 次 ok 变 true 才走向 END。

为什么循环这么重要?

真实场景里,LLM 的输出经常需要重试:

场景 失败原因 重试策略
JSON 解析失败 LLM 格式跑偏 重新生成
工具调用报错 网络 / 参数问题 换个参数重试
内容审核不过 违反规则 修改后重试
API 限流 频率过高 等待后重试

没有循环能力,这些场景都得自己在 LangChain 外面写 while 循环。LangGraph 把循环内建在图里------节点、条件边、状态三者配合,天然支持"失败→重试→直到成功"。


二、状态持久化:地铁卡里的"存档点"

循环解决了"重复做",但还有个大问题------图执行完,状态就没了。

readme 点出了痛点:

"持久化我们的状态?不要每次重新执行。"

想想真实的对话场景------用户问完一句,Agent 回答完,图结束。下次用户再问,图又从 START 重新开始,完全忘了上次聊到哪。这不是我们想要的------Agent 应该记住"这是你第几次找我"。

readme 的解法:

"用 MemorySaver 来把 state 保存到内存,下次就会基于上次的 state 继续执行。"

checkpointer-memory.mjs:带"记忆"的访问计数

javascript 复制代码
import {
    Annotation, END, START,
    MemorySaver,  // 内存保存器
    StateGraph,
} from "@langchain/langgraph";

const StateAnnotation = Annotation.Root({
    visitCount: Annotation({
        reducer: (_prev, next) => next,
        default: () => 0,
    }),
    message: Annotation({
        reducer: (_prev, next) => next,
        default: () => "",
    }),
})

function recordVisit(state) {
    const visitCount = state.visitCount + 1;
    const message =
        visitCount === 1
        ? "这是你在本会话里第1次进入"
        : `这是你在本会话里第${visitCount}次进入`;
    return { visitCount, message };
}

const graph = new StateGraph(StateAnnotation)
    .addNode("recordVisit", recordVisit)
    .addEdge(START, "recordVisit")
    .addEdge("recordVisit", END)

// 关键:MemorySaver 作为检查点
const checkpointer = new MemorySaver();
const app = graph.compile({ checkpointer });

const user1 = { configurable: { thread_id: "用户-小张" }};
const user2 = { configurable: { thread_id: "用户-小李" }};

const res1 = await app.invoke({}, user1);  // 小张第1次
const res2 = await app.invoke({}, user2);  // 小李第1次
const res3 = await app.invoke({}, user1);  // 小张第2次!
const res4 = await app.invoke({}, user2);  // 小李第2次!

MemorySaver:把状态"存档"

javascript 复制代码
const checkpointer = new MemorySaver();
const app = graph.compile({ checkpointer });

MemorySaver 是一个检查点(checkpointer)------每执行完一个节点,就把当前状态存起来。编译图时传进去,图就拥有了"记忆"。

没有 checkpointer:每次 invoke 都是全新的图,visitCount 从 0 开始。 有 checkpointer:每次 invoke 都能读到上次存的状态,visitCount 接着涨。

thread_id:区分不同用户的"存档槽"

javascript 复制代码
const user1 = { configurable: { thread_id: "用户-小张" }};
const user2 = { configurable: { thread_id: "用户-小李" }};

thread_id存档槽的编号------每个用户一个槽。小张的存档和小李的存档互不干扰。

运行结果推演:

javascript 复制代码
res1: 小张第1次进入,visitCount = 1   ← 存档在小张槽
res2: 小李第1次进入,visitCount = 1   ← 存档在小李槽
res3: 小张第2次进入,visitCount = 2   ← 读到小张槽的1
res4: 小李第2次进入,visitCount = 2   ← 读到小李槽的1

代码注释说:

"session 相关------会话,某人,访问次数。"

同一个图,两个用户,各自的访问次数互不串号------这就是 thread_id 的威力。多用户系统里,thread_id 就是"谁"的身份标识。

为什么要持久化?

readme 给了三个典型场景:

"agent 执行中断,失败.... 暂停,需要授权,MemorySaver 保存状态,之后继续运行。"

场景 状态保存的意义
执行中断 恢复后接着跑,不用从头来
失败 定位到失败节点,修复后续跑
暂停 / 授权 等用户批准后继续
多轮对话 记住上下文,无缝衔接

readme 还提到了更高级的持久化:

"保存到数据库 sqlite,redis 持久化。"

MemorySaver 只把状态存内存 ------重启进程就没了。生产环境要用 SqliteSaver、RedisSaver 等------把状态存到数据库,进程重启、多机部署都不怕丢。


三、中断:过闸机时的人工开箱检查

循环和持久化都是"机器自己搞定"的,但有些场景必须停下来等人类------比如转账。readme 说的:

"harness 中断、回复。"

AI 不能自作主张把钱转出去------它需要停下来问用户:"我要给张三转 $100,确认吗?"

graph-interrupt.mjs 完整演示了这个场景。

完整代码

javascript 复制代码
import {
    Annotation, END, START,
    MemorySaver,
    StateGraph,
    Command,    // 命令节点
    interrupt,  // 中断节点
} from "@langchain/langgraph";
import { createInterface } from "node:readline/promises";

const StateAnnotation = Annotation.Root({
    actionSummary: Annotation({
        reducer: (_prev, next) => next,
        default: () => "",
    }),
    userInput: Annotation({
        reducer: (_prev, next) => next,
        default: () => "",
    }),
})

const showTransfer = () => ({
    actionSummary: "向张三转账 $100",
})

const waitConfirm = (state) => {
    const text = interrupt({   // 中断!等用户输入
        hint: "终端里输入[确认]或者备注后回车,图才会继续",
        actionSummary: state.actionSummary
    });
    return { userInput: String(text) }
}

const graph = new StateGraph(StateAnnotation)
    .addNode("showTransfer", showTransfer)
    .addNode("waitConfirm", waitConfirm)
    .addEdge(START, "showTransfer")
    .addEdge("showTransfer", "waitConfirm")
    .addEdge("waitConfirm", END)
    .compile({ checkpointer: new MemorySaver() })  // 中断必须配 checkpointer!

// 第一次 invoke:走到 waitConfirm 就暂停
const config = { configurable: { thread_id: "interrupt-demo" }}
const paused = await graph.invoke({}, config);
console.log("待你确认", paused.__interrupt__?.[0]?.value);

// 终端等用户输入
const rl = createInterface({ input: process.stdin, output: process.stdout });
const line = (await rl.question(">")).trim();
await rl.close();

// 用 Command 恢复执行
const done = await graph.invoke(new Command({ resume: line }), config);
console.log("done", done);

流程拆解

第一步:展示转账信息

javascript 复制代码
const showTransfer = () => ({
    actionSummary: "向张三转账 $100",
})

showTransfer 节点生成转账摘要------"向张三转账 $100"。

第二步:interrupt------按下暂停键

javascript 复制代码
const waitConfirm = (state) => {
    const text = interrupt({
        hint: "终端里输入[确认]或者备注后回车,图才会继续",
        actionSummary: state.actionSummary
    });
    return { userInput: String(text) }
}

interrupt() 是 LangGraph 的暂停函数 ------图执行到这一行会停下来,把 hintactionSummary 抛给外部,然后挂起等待

注意两个前提:

  1. 中断必须配 checkpointer ------.compile({ checkpointer: new MemorySaver() })。因为中断后要恢复,恢复靠的就是检查点里存的"执行到哪了"。
  2. 中断要传信息给外部------interrupt({ hint, actionSummary }),这样外部(用户/程序)知道"它想要什么"。

第三步:外部拿到暂停信息

javascript 复制代码
const paused = await graph.invoke({}, config);
console.log("待你确认", paused.__interrupt__?.[0]?.value);

第一次 invoke 返回的不是最终结果,而是暂停信号 ------paused.__interrupt__[0].value 里装着 interrupt() 传出的 {hint, actionSummary}。控制台打印:

bash 复制代码
待你确认 { hint: "终端里输入[确认]或者备注后回车,图才会继续", actionSummary: "向张三转账 $100" }

图停在了 waitConfirm 节点,等人类裁决。

第四步:终端等用户输入

javascript 复制代码
const rl = createInterface({ input: process.stdin, output: process.stdout });
const line = (await rl.question(">")).trim();

用 Node.js 的 readline/promises 在终端等待输入------用户敲"确认"或任意备注后回车。

第五步:Command 恢复执行

javascript 复制代码
const done = await graph.invoke(new Command({ resume: line }), config);

new Command({ resume: line }) 告诉图:"用户说了 line,带着这句话继续跑。"图从暂停的 waitConfirm 节点恢复,interrupt() 的返回值就是用户输入的 line,存入 userInput,走向 END。

中断的价值:Human-in-the-loop

readme 说这是:

"暂停,需要授权。"

这套机制叫 Human-in-the-loop(人在回路)------机器做不了主的环节,停下来问人。经典场景:

场景 AI 的行为 人类的角色
转账 展示金额、收款人 → 暂停 确认或取消
发邮件 草拟内容 → 暂停 审阅后发送
删除数据 提示将删除 → 暂停 批准执行
代码上线 准备好变更 → 暂停 审核后放行

AI 负责干活,人类负责拍板。 interrupt 就是给 AI 装的一颗"刹车"------该踩的时候必须踩。


四、网状工作流的完整能力拼图

两期下来,LangGraph 的核心能力拼齐了:

能力 API 课堂 demo 地铁比喻
状态管理 Annotation.Root + reducer basic-graph 地铁卡余额
节点 普通函数 basic-graph 站点
直线边 addEdge basic-graph 轨道
条件边 addConditionalEdges conditional-routing 换乘分叉
循环 条件边指向自己 loop-retry 坐过站回头
持久化 compile({checkpointer}) + thread_id checkpointer-memory 出站存档
中断 interrupt() + Command(resume) graph-interrupt 人工开箱检查

每个能力都是一类真实需求的解法------单 Agent 时代这些全靠自己手写,LangGraph 把它们变成了图的原生能力。

readme 开头那句总结现在可以完整理解了:

"复杂的 Agent 产品基本都是多 Agent 架构。"
"工作节点 + 组织方式(api)。简单 Agent -> 复杂多 Agent 协作。"

多 Agent 协作的地铁网,由这些基本能力铺成:状态在节点间流动(Annotation)、流程按条件分叉(conditional edges)、失败自动重试(循环)、会话记忆跨轮持久(checkpointer)、敏感操作暂停等人(interrupt)------每一个真实 Agent 产品的骨架,都是这样一张图。


五、从 LangChain 到 LangGraph:开发者视角的升级

最后站在开发者视角,对比一下两代框架的"心智模型":

LangChain 的心智模型:管道(Pipeline)

javascript 复制代码
const chain = promptTemplate.pipe(model).pipe(parser);
const result = await chain.invoke({ query: "你好" });

数据从一个模块流向另一个模块------一条直路,没有回头路,没有岔路口。 简单场景下这是优点:好懂、好写。

LangGraph 的心智模型:状态机(State Machine)

javascript 复制代码
const graph = new StateGraph(StateAnnotation)
    .addNode(...)
    .addEdge(...)
    .addConditionalEdges(...)
    .compile({ checkpointer });

数据是"状态",在节点之间流动------有岔路、有回路、有暂停、有存档。 复杂场景下这才是正确答案:分支、循环、持久化、中断,全都有原生支持。

readme 一句话总结了两者关系:

"langchain 工作流编排 线性的。langgraph 工作流编排 网状的。"

线性解决"顺序问题",网状解决"流向问题"。 如果你的流程只是"先后做几件事",LangChain 够了。一旦出现"如果...就...""失败了重试""停下来等确认""记住上次的状态"------请上 LangGraph。


PS:两期下来,我们从"为什么多 Agent"一路建完了 LangGraph 的地铁网------基础轨道、换乘分叉、回头路、存档点、人工闸机。下次看到一个复杂 AI 产品(比如 AutoGen 的法庭辩论、多 Agent 编程团队),别慌------拆开看,就是一张 StateGraph:节点是各司其职的 Agent,边是任务流转的轨道,checkpointer 是它们的共同记忆。

相关推荐
flash俊杰1 小时前
Zod Schema 驱动的 IPC 契约:从入参校验到状态机一致性的三层防御
javascript·typescript
ClouGence1 小时前
Chrome Recorder 能用于长期回归测试吗?
前端·chrome·测试
拖孩1 小时前
这个小程序是 AI 帮我写的,可它里面一个 AI 功能都没有
前端·后端·微信小程序
Nayana2 小时前
《Web 到 HarmonyOS》-- 业务分析:消息推送业务方法
前端
GreenTea2 小时前
发布 3 天登顶 HN:不生成一个字的模型 Jev,我把它的源码和黑料都扒了一遍
前端·后端·算法
前端小崔3 小时前
Three.js 与 Cesium 融合实战问题汇总
前端·three.js·cesium
wizardpisces3 小时前
Claude Code 是 Angular,Codex 是 Vue,DeepSeek 想当 React
前端·人工智能
一条溺水的鱼3 小时前
一次讲透 JS 原型链 —— prototype、__proto__ 和 constructor 到底啥关系
javascript
三乐2283 小时前
一文搞懂 JS 事件流:捕获、冒泡、事件委托
javascript·html