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

31 阅读6分钟

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


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

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

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

完整代码

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 节点

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

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

次数triesokmessage
第 1 次1false第1次失败
第 2 次2false第2次失败
第 3 次3true第3次成功

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

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

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

             ┌──────────────────┐
             │                  │
             ▼                  │
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:带"记忆"的访问计数

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:把状态"存档"

const checkpointer = new MemorySaver();
const app = graph.compile({ checkpointer });

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

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

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

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

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

运行结果推演:

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 完整演示了这个场景。

完整代码

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);

流程拆解

第一步:展示转账信息

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

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

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

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 }),这样外部(用户/程序)知道"它想要什么"。

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

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

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

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

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

第四步:终端等用户输入

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

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

第五步:Command 恢复执行

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 + reducerbasic-graph地铁卡余额
节点普通函数basic-graph站点
直线边addEdgebasic-graph轨道
条件边addConditionalEdgesconditional-routing换乘分叉
循环条件边指向自己loop-retry坐过站回头
持久化compile({checkpointer}) + thread_idcheckpointer-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)

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

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

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

const graph = new StateGraph(StateAnnotation)
    .addNode(...)
    .addEdge(...)
    .addConditionalEdges(...)
    .compile({ checkpointer });

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

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

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

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


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