LangGraph 状态图:从节点、边到中断恢复,手写一个多 Agent 工作流

0 阅读8分钟

LangGraph 状态图:从节点、边到中断恢复,手写一个多 Agent 工作流

摘要:LangChain 拼乐高,LangGraph 画图纸。复杂 Agent 需要分支、循环、并行、人工审批和状态持久化,这些用线性链做不到。本文从状态图三要素出发,拆解 Annotation、Reducer、条件边、Checkpointer 与 Interrupt,带你从零手写一个可编排的多 Agent 工作流。

📑 目录

  • 为什么复杂 Agent 需要“图”?
  • 状态图三要素:State、Node、Edge
  • State 与 Annotation:Reducer 决定状态怎么变
  • 第一个图:两个节点,一条直线
  • 条件边:让 Agent 会“分岔”
  • 循环与重试:条件边指回上游
  • Checkpointer:让图“记得住”上次跑到哪
  • Interrupt:让人在环路中间“踩刹车”
  • 图的可视化:Mermaid 帮你画出来
  • 互动讨论

为什么复杂 Agent 需要“图”?

LangChain 像是一套搭好的乐高套装,按说明书拼就能跑;LangGraph 是给你底板和积木,自己画图纸、自己连线。

维度LangChainLangGraph
工作流形态线性链(pipe)网状图(节点 + 边)
控制流顺序执行分支、循环、并行
人工介入不支持原生 interrupt
状态持久化手动管理Checkpointer 自动快照
适用场景简单线性流程多 Agent 协作、复杂编排

多 Agent 为什么必要?

在单 Agent 架构下,所有工具的描述、每个功能的 prompt 都放在 system prompt 里。执行每个功能实际上只需要其中一部分 prompt,但整个 system prompt 每次都会被带上——token 浪费、消耗更高,更重要的是很多无关信息被填入,干扰 Agent 注意力,思考效率变低且容易出错。

多 Agent 的核心收益有三个:

原因说明
决策准确率高、token 消耗更低每个 Agent 只带必要的最少 Prompt,没有冗余信息干扰
并行思考和任务处理主管分派子任务,子 Agent 并行处理,整体效率更高
多角色互相讨论,纠错能力更强编程 Agent、测试 Agent、验证 Agent 各司其职

而多 Agent 之间的协作、路由、状态共享,正是 LangGraph 状态图要解决的问题。

状态图三要素:State、Node、Edge

要素本质职责
State共享数据结构在整个图中流动的上下文快照,节点读写它
Node普通函数接收状态,执行计算,返回增量更新
Edge路由机制决定下一个执行哪个节点

核心设计:节点之间不直接传数据,所有信息都经过 State。节点返回的是增量更新,不是完整状态替换,LangGraph 负责原子性合并。

运行时模型:Super-step

LangGraph 的运行时受 Google Pregel 启发,按 super-step(超步) 推进。每个超步内所有收到新消息的节点并行执行,节点执行完把增量更新作为消息沿边发送,下游节点在下一个超步被激活。没有活跃节点也没有消息在途时,图终止。

State 与 Annotation:Reducer 决定状态怎么变

用 Annotation.Root 声明根状态,每个 key 是一个字段,value 是该字段的通道定义:

javascript

import { Annotation, START, END, StateGraph } from '@langchain/langgraph';

const StateAnnotation = Annotation.Root({
    text: Annotation({
        reducer: (_prev, next) => next,  // 覆盖语义
        default: () => "",               // 默认值必须是函数
    })
});

Reducer 机制

签名:reducer(current, update) => newValue

  • current:当前已有状态值
  • update:节点返回的增量值
  • 返回值:合并后的最终值

关键认知:节点返回的是增量,不是完整状态。节点返回 { stepCount: 1 },意思是“这次贡献 1”,最终值由 reducer 决定(累加的话就是 current + 1)。

默认语义:没有自定义 reducer 的字段,默认走 LastValue,后写覆盖前写。

default 的坑

default 必须是函数,不是直接值:

javascript

default: () => 0,   // ✅ 正确
default: 0,         // ❌ 不符合约定

原因:对数组、对象等引用类型,每次初始化必须生成新引用,否则所有图实例共享同一个可变对象。

另一个坑:Annotation({ default: () => 0 }) 只传 default、不传 reducer,会命中 LastValue 分支,default 被直接忽略,初始值是 undefined。想让 default 生效,必须同时提供 reducer。

第一个图:两个节点,一条直线

javascript

import { Annotation, END, START, StateGraph } from '@langchain/langgraph';

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

// 函数就是节点
const step1 = (state) => ({ text: `${state.text} -> step1` });
const step2 = (state) => ({ text: `${state.text} -> step2` });

// 实例化图,编排节点和边
const graph = new StateGraph(StateAnnotation)
    .addNode("step1", step1)
    .addNode("step2", step2)
    .addEdge(START, "step1")
    .addEdge("step1", "step2")
    .addEdge("step2", END)
    .compile();

const result = await graph.invoke({ text: "hello" });
console.log(result);
// { text: 'hello -> step1 -> step2' }

构建流程

text

Annotation.Root 定义 State
  → new StateGraph(State)
  → .addNode("name", fn) × N
  → .addEdge(START, "firstNode")
  → .addEdge / .addConditionalEdges 连接各节点
  → .addEdge("lastNode", END)
  → .compile({ checkpointer?, store? })
  → .invoke(input, config) / .stream() / .batch()

关键点:

  • START / END 不是节点,是特殊常量字符串,代表图的入口和出口
  • compile() 返回的不是“流程图”,是可执行的 Runnable
  • addEdge 引用的节点名必须已经通过 addNode 注册过

条件边:让 Agent 会“分岔”

条件边根据路由函数的返回值,动态决定下一个执行哪个节点:

javascript

const StateAnnotation = Annotation.Root({
    query: Annotation({ reducer: (_prev, next) => next, default: () => "" }),
    route: Annotation({ reducer: (_prev, next) => next, default: () => "chat" }),
    answer: Annotation({ reducer: (_prev, next) => next, default: () => "" })
});

// 路由节点:判断是数学还是聊天
const router = (state) => {
    const isMath = /[+-*/]/.test(state.query);
    return { route: isMath ? "math" : "chat" };
};

const mathNode = (state) => {
    try {
        return { answer: String(eval(state.query)) };
    } catch {
        return { answer: "表达式无法计算" };
    }
};

const chatNode = (state) => ({ answer: `你说的是:${state.query}` });

const graph = new StateGraph(StateAnnotation)
    .addNode("router", router)
    .addNode("math", mathNode)
    .addNode("chat", chatNode)
    .addEdge(START, "router")
    .addConditionalEdges("router", (state) => state.route, {
        math: "math",
        chat: "chat"
    })
    .addEdge("math", END)
    .addEdge("chat", END)
    .compile();

await graph.invoke({ query: "你好" });   // 走 chat
await graph.invoke({ query: "1+2" });   // 走 math

条件边的执行时机

源节点执行完、返回值已通过 reducer 合并进 state 之后,路由函数才被调用。路由函数看到的是更新后的 state。

关键认知:路由函数不是节点。它不进入图、不更新状态、不被 checkpointer 持久化、不出现在图可视化里。它是一个纯函数,输入 state,输出字符串,执行完就结束。

路由函数返回值必须是映射表的 key

返回映射表里没有的字符串,运行时抛错。compile() 阶段不检查,因为返回值是动态的。

映射表可以省略,前提是路由函数直接返回节点名。但省略后 compile() 无法校验目标节点是否存在,拼写错误要到运行时才暴露,所以建议显式写。

循环与重试:条件边指回上游

条件边指回上游节点,就形成了循环:

javascript

const StateAnnotation = Annotation.Root({
    tries: Annotation({ reducer: (_prev, next) => next, defaultValue: 0 }),
    ok: Annotation({ reducer: (_prev, next) => next, default: () => false }),
    message: Annotation({ reducer: (_prev, next) => next, default: () => "" })
});

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

const graph = new StateGraph(StateAnnotation)
    .addNode("attempt", attempt)
    .addEdge(START, "attempt")
    .addConditionalEdges("attempt", (state) => state.ok ? "done" : "retry", {
        retry: "attempt",   // 指回上游,形成循环
        done: END
    })
    .compile();

await graph.invoke({ tries: 0 });

死循环防护

LangGraph 默认 recursionLimit: 25,超过抛 GraphRecursionError。生产环境应在路由函数里显式检查重试次数,不能只依赖兜底。

Command:节点内部路由 + 状态更新

Command 是节点返回值的特殊标记对象,让节点同时更新状态和指定下一步去哪:

javascript

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

return new Command({
    update: { riskLevel: "high" },   // 状态增量
    goto: "reject",                  // 下一个节点
});
维度条件边Command
路由决策位置外部路由函数节点函数内部
是否更新状态否,只路由是,路由同时更新状态
适用场景路由逻辑与节点业务解耦路由逻辑是节点业务的一部分

new 的意义:Command 是运行时类型标记,调度器靠 instanceof Command 识别它,普通对象 { update, goto } 不会被识别。

Send:并行扇出

Send 用于 Map-Reduce 式的并行分发,出现在条件边路由函数的返回值里:

javascript

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

.addConditionalEdges(START, (state) => {
    return state.topics.map((topic) => new Send("summarize", { topic }));
})

每个 Send 创建一个目标节点的独立实例,每个实例的输入状态 = 全局 state + Send.arg 的合并。所有实例并行执行,各自返回增量,最后走对应字段的 reducer 合并回全局 state。

Checkpointer:让图“记得住”上次跑到哪

Checkpointer 在每个 super-step 结束后,把整个 State 的完整快照存下来,加上执行元信息(当前在哪个节点、下一步去哪、checkpoint_id、parent_checkpoint_id)。

MemorySaver:内存持久化

javascript

import { MemorySaver } from '@langchain/langgraph';

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

// 一个应用会有多用户
const user1 = { configurable: { thread_id: "用户-小张" } };
const user2 = { configurable: { thread_id: "用户-小李" } };

await app.invoke({}, user1);  // 第 1 次
await app.invoke({}, user1);  // 第 2 次
await app.invoke({}, user1);  // 第 3 次
await app.invoke({}, user2);  // 另一个用户,从 0 开始

thread_id 隔离机制

一个 thread_id 对应一系列 checkpoint,不是一条记录。每次节点执行完、每个 super-step 结束,都会生成新 checkpoint,用 checkpoint_id 标识,通过 parent_checkpoint_id 串成链。

text

thread_id: "user-123"
  ├── checkpoint_1 (初始状态)
  ├── checkpoint_2 (节点A执行后)
  ├── checkpoint_3 (节点B执行后)
  └── checkpoint_4 (最新)

取“最新状态”就是取这条链的末端。回滚就是取中间的某个 checkpoint。

续跑机制

第二次调用做的是:加载最新快照 → 把本次新输入通过 reducer 合并进去 → 从快照记录的下一步节点继续执行。

不是“从快照原样继续”,新输入会走 reducer 合并。

持久化实现

实现存储适用
MemorySaver进程内存开发、测试,重启就丢
SqliteSaverSQLite 文件单机、小规模
PostgresSaverPostgreSQL生产、多实例

Checkpointer vs 记忆模块存储

维度记忆模块存储Checkpointer
存储内容消息列表完整 State 快照 + 执行元信息
读写方式应用层手动 addMessage运行时自动拦截、自动保存、自动恢复
粒度一条消息一个 super-step 的完整状态
用途对话历史断点续跑、时间旅行、人工介入、持久化

Interrupt:让人在环路中间“踩刹车”

interrupt 是节点内部调用的暂停函数,让图停在当前节点,把控制权交还给调用方,等人或外部系统给出输入后再从暂停点继续。

前提:必须配 checkpointer,因为暂停时要把当前 State 和暂停位置持久化。

完整示例

javascript

import {
    Annotation, END, START, StateGraph,
    Command, interrupt, MemorySaver
} 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() });

const config = { configurable: { thread_id: "interrupt-demo" } };

// 第一次调用:跑到 waitConfirm 节点,调 interrupt() 暂停
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();
rl.close();

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

暂停与恢复的机制

resume 的值会作为 interrupt() 的返回值,赋给 text。节点从 interrupt() 那一行继续往下跑。

Command.resume 是专门配合 interrupt 的字段,只在有中断暂停的情况下有意义。Command 的三个字段分工:update 更新状态、goto 路由、resume 恢复中断。

关键坑:interrupt 之前的代码会重跑

恢复时,节点函数从头重新执行,直到再次遇到 interrupt()。interrupt() 之前的副作用(写数据库、发请求)会执行两次。

javascript

async function review(state) {
    await sendEmail(state.request);   // ⚠️ 恢复时会再发一次
    const decision = interrupt({ question: "批准吗?" });
    return { approved: decision === "approve" };
}

正确做法:副作用放到 interrupt() 之后,或用幂等键去重。官方文档明确要求 interrupt 之前的代码必须能安全重跑。

其他坑

  • 同一节点多次 interrupt 要用 id 区分:interrupt({ q: "第一步" }, { id: "step-1" })
  • 恢复时 thread_id 必须一致,否则找不到暂停点
  • interrupt 不是边,不决定去哪,只是暂停

Interrupt vs Command

Commandinterrupt
位置节点返回值节点内部调用
作用更新状态 + 决定下一节点暂停,等外部输入
是否需要 checkpointer不需要必须
恢复方式不需要恢复new Command({ resume: value })
典型场景动态路由人工审批、外部 API 回调、HITL

两者常配合使用:节点里先 interrupt 等人审批,拿到结果后返回 Command 决定走哪条分支。

图的可视化:Mermaid 帮你画出来

LangGraph 提供了把编译后的图导出为 Mermaid 代码的 API:

javascript

const drawable = await graph.getGraphAsync();
const mermaid = drawable.drawMermaid({ withStyles: true });
console.log(mermaid);

输出类似:

text

%%{init: {'flowchart': {'curve': 'linear'}}}%%
graph TD;
	__start__([<p>__start__</p>]):::first
	step1(step1)
	step2(step2)
	__end__([<p>__end__</p>]):::last
	__start__ --> step1;
	step1 --> step2;
	step2 --> __end__;
	classDef first fill-opacity:0
	classDef last fill-opacity:0

把这段代码贴进支持 Mermaid 的渲染器(VS Code 的 Mermaid 插件、mermaid.live、Notion、GitHub Markdown)即可出图。

可视化能看到什么、看不到什么

能看到:编译后的静态图结构——节点、静态边、条件边的源和目标。

看不到:运行时状态,以及 Command 和 Send 的动态路由目标,因为那些是运行时才决定的。条件边画出来是虚线连到映射表里的所有可能目标,但哪条真的走了,图上看不出来。

调试建议:compile() 之后立刻 drawMermaid() 打印一次,能快速发现“边连错了”“孤立节点”“入口没连”这些问题。

互动讨论

💬 LangGraph 和 LangChain 到底是什么关系?

两者是不同抽象层级的互补。LangChain 是 Agent 框架,LangGraph 是底层的编排运行时。LangChain 1.0 的 createAgent 底层确实生成一个 LangGraph 状态图,但 LangGraph 可以完全独立于 LangChain 使用,不依赖它的模型抽象和工具封装。

💬 Reducer 的返回值到底是什么?

返回值是合并后的最终值。reducer 的职责是定义“如何合并”,返回的就是合并结果。节点返回的是增量,不是完整状态替换。

💬 为什么只写 default 不写 reducer,默认值会失效?

Annotation<T>({ default: () => 0 }) 只传 default、不传 reducer,会命中 LastValue 分支,default 被直接忽略,初始值是 undefined。想让 default 生效,必须同时提供 reducer。

💬 Checkpointer 存的是消息历史吗?

不是。Checkpointer 存的是完整 State 快照 + 执行元信息,不是只存消息列表。这是它能支持断点续跑、时间旅行、人工介入的根本原因。

💬 Interrupt 恢复时,节点会从哪里继续执行?

节点函数从头重新执行,直到再次遇到 interrupt()。interrupt() 之前的副作用会执行两次,所以必须保证 interrupt 之前的代码能安全重跑。