写在前面:上篇我们建好了 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 的暂停函数 ------图执行到这一行会停下来,把 hint 和 actionSummary 抛给外部,然后挂起等待。
注意两个前提:
- 中断必须配 checkpointer ------
.compile({ checkpointer: new MemorySaver() })。因为中断后要恢复,恢复靠的就是检查点里存的"执行到哪了"。 - 中断要传信息给外部------
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 是它们的共同记忆。