LangGraph.js 实战:从状态图到分支、循环、检查点与人工中断

26 阅读13分钟

LangGraph.js 实战:从状态图到分支、循环、检查点与人工中断

当一个 Agent 只需要完成“接收问题—调用模型—返回答案”时,一段线性代码往往就够了。但随着任务变复杂,我们很快会遇到更多控制需求:

  • 不同类型的问题要交给不同的处理节点;
  • 某一步失败后需要重试,而不是直接结束;
  • 同一个会话再次进入时,需要接着之前的状态运行;
  • 转账、删除等敏感操作执行前,需要暂停并等待人工确认;
  • 更复杂的场景还可能把任务拆给多个职责不同的 Agent。

这类流程已经不再是一条简单的调用链,而更像一张由节点和边组成的图。LangGraph 的作用,就是用“状态图”来表达这些流程。

本文不接入大模型,而是用几个足够小的 JavaScript 示例,依次讲清 LangGraph 最核心的五种能力:顺序执行、条件分支、循环重试、会话状态保存和人工中断。没有模型调用的干扰,我们反而能更清楚地看到图是怎样运转的。

一、为什么复杂 Agent 需要“图”

LangChain 提供模型调用、文档加载、文本切分、Embedding、向量存储、输出解析等基础能力。LangGraph 更关注另一个问题:这些能力应该以什么顺序执行,执行过程中怎样共享状态,又该如何分支、循环、暂停和恢复。

可以把二者的关系简单理解为:

  • LangChain 提供完成任务所需的基础模块;
  • LangGraph 负责把工作节点组织成一个可控制的状态化流程。

一个可工作的 Agent 也不只是 LLM 本身。可以把 LLM 看成负责理解与决策的“大脑”,把工具、MCP、RAG、Skill 等外部能力统称为它的运行支撑(Harness)。图编排负责决定哪个大脑在什么时候使用哪部分支撑能力:简单流程可以只有一个 Agent,复杂流程则可以组合多个 Agent,甚至让不同角色按任务选用不同模型。

图结构也是构建多 Agent 系统的重要基础。单 Agent 往往要携带所有工具说明和所有功能提示词,即使本轮任务只会用到其中一小部分。这会增加上下文开销,也会让无关信息干扰决策。拆分之后,每个 Agent 只保留完成自身职责所需的提示词和工具,主 Agent 再负责分派任务与汇总结果。

这种设计主要有三个价值:

  1. 缩小单个 Agent 的上下文范围。 每个角色只关注自己的任务,减少无关信息干扰。
  2. 允许并行处理。 主 Agent 可以把互不依赖的任务同时交给多个子 Agent。
  3. 便于交叉检查。 例如编程 Agent 负责实现,测试 Agent 负责测试,验证 Agent 再检查结果是否符合预期。

不过,多 Agent 并不是越多越好。它会增加模型调用次数和调度复杂度。是否拆分,应看任务能否形成边界清楚、上下文相对独立的职责。本文先不急着实现多 Agent,而是把它依赖的图编排基础打牢。

二、LangGraph 的四个基本概念

先看一条最简单的流程:

START → step1 → step2 → END

它包含四个基本概念。

1. State:贯穿整张图的状态

状态是节点之间传递的数据。前一个节点读取当前状态并返回更新,后一个节点会收到合并后的新状态。

2. Node:真正执行工作的节点

节点通常就是一个函数。函数接收当前状态,返回需要更新的部分状态。节点不必每次都返回完整对象。

3. Edge:决定节点之间怎样流转

普通边表示固定的下一步,条件边则根据当前状态动态选择下一步。边还可以指回前面的节点,从而形成循环。

4. START 与 END:图的入口和出口

START、END 是 LangGraph 提供的特殊节点。它们不处理业务,只负责标记流程从哪里开始、到哪里结束。

本文示例只需要安装 LangGraph:

pnpm add @langchain/langgraph

示例以 @langchain/langgraph 1.4.x 为基础,使用 ESM 模块语法,文件扩展名可以使用 .mjs。

三、第一张图:让两个节点顺序执行

下面实现一张只有两个工作节点的图。输入文本先经过 step1,再经过 step2。

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' }

Annotation.Root 在声明什么

Annotation.Root 定义整张图允许使用的状态字段。这里的状态只有一个 text:

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

每个字段可以配置两个关键部分:

  • default:调用方没有提供该字段时使用的默认值;
  • reducer:旧值与节点返回的新值应该怎样合并。

这里的 reducer 直接返回 next,所以节点返回的新文本会覆盖旧文本。虽然 step1 和 step2 的实现是在原文本后追加内容,但追加动作发生在节点函数里;站在 reducer 的角度看,仍然是用新字符串替换旧字符串。

如果状态字段是消息数组,reducer 也可以把前后两个数组合并。也就是说,节点负责产生“更新”,reducer 负责定义“更新如何进入总状态”。

节点返回的是状态更新,不是下一节点

step1 返回:

{
  text: "hello -> step1",
}

它表达的是对状态的更新,并没有决定下一步去哪里。真正的流转关系由边定义:

.addEdge(START, "step1")
.addEdge("step1", "step2")
.addEdge("step2", END)

把“做什么”和“接下来去哪”分开,是状态图非常重要的设计。节点可以专注业务,流程关系则集中留在图定义中。

compile() 与 invoke()

new StateGraph(...) 得到的是图构建器。添加完节点和边后,需要调用 compile() 得到可执行图,再用 invoke(initialState) 传入初始状态并运行。

因此,一次完整执行可以概括成:

初始状态
  ↓
step1 读取状态并返回更新
  ↓ reducer 合并
step2 读取新状态并返回更新
  ↓ reducer 合并
最终状态

四、把图画出来:用 Mermaid 检查结构

当节点越来越多,仅靠阅读链式调用很难快速确认图的走向。编译后的图可以生成 Mermaid 文本:

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

console.log(mermaid);

最小顺序图的核心结构如下:

graph TD;
    __start__([__start__]) --> step1;
    step1 --> step2;
    step2 --> __end__([__end__]);

withStyles: true 会在生成结果中附加样式定义。Mermaid 文本可以放进支持 Mermaid 的 Markdown 工具中渲染。即使发布平台不直接渲染,也可以在开发阶段用它核对节点、固定边和条件边是否符合预期。

五、条件分支:让图根据状态选择下一步

顺序图的下一步始终固定,但实际业务经常需要路由。例如,包含运算符的输入进入数学节点,其他输入进入普通对话节点:

                 ┌→ math ─→ END
START → router ──┤
                 └→ chat ─→ END

对应的状态包含输入、路由结果和最终答案:

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

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

console.log("result:", await graph.invoke({ query: "1+2" }));
console.log("result:", await graph.invoke({ query: "你好" }));

运行结果:

result: { query: '1+2', route: 'math', answer: '3' }
result: { query: '你好', route: 'chat', answer: '你说的是:你好' }

addConditionalEdges 的三个参数

这一段是条件分支的核心:

.addConditionalEdges(
  "router",
  (state) => state.route,
  {
    math: "math",
    chat: "chat",
  },
)

三个参数分别表示:

  1. 从哪个节点开始判断,这里是 router;
  2. 用什么函数得到分支键,这里读取 state.route;
  3. 分支键与目标节点怎样映射。

当 router 把 route 更新为 "math" 时,条件函数返回 "math",再通过映射进入同名的数学节点。普通文本则进入 chat。

路由节点只负责分类,不直接回答问题;工作节点只负责各自类型的处理。这种职责分离也是以后把不同任务交给不同 Agent 的基础。

这个演示中的 eval 为什么不能直接用于业务

eval() 会把字符串当作 JavaScript 代码执行。它不只会算 1+2,恶意字符串也可能访问运行环境或执行其他代码,因此不能用它处理用户提交的不可信内容。

另外,/[+\-*/]/ 只是判断字符串中是否出现运算符,并不能证明输入是合法的数学表达式。这里保留它,是为了把注意力集中在条件边的运行方式上,而不是实现一个表达式解析器。生产环境必须换成受约束的解析与计算方案。

六、循环:让节点在满足条件前不断重试

图与普通线性调用相比,一个很重要的区别是边可以指回已经执行过的节点。下面让 attempt 节点不断增加尝试次数,第三次成功后才结束:

START → attempt ──done──→ END
            ↑
            └──retry────┘
import {
  Annotation,
  END,
  START,
  StateGraph,
} from "@langchain/langgraph";

const StateAnnotation = Annotation.Root({
  tries: Annotation({
    reducer: (_prev, next) => next,
    default: () => 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;

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

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

最终结果是:

result: { tries: 3, ok: true, message: '第3次成功' }

执行过程并不复杂:

执行次数triesok分支
第一次1falseretry → attempt
第二次2falseretry → attempt
第三次3truedone → END

这里有一个很容易忽略的细节:状态定义中的字段名、节点读取的字段名和节点返回的字段名必须完全一致。例如,状态定义为 message,节点也必须返回 message;如果一处误写成 messaage,提示文本就不会进入预期的状态字段。

默认值配置也应写成:

default: () => 0

而不是把键名写成 defaultValue。状态字段较多时,统一命名比看起来更重要,因为条件边做出的决定完全依赖合并后的状态。

循环还必须拥有明确的退出条件。这个例子以 tries >= 3 为边界,所以一定会终止。如果退出条件永远不能满足,边就会不断回到 attempt。实际设计重试流程时,尝试次数应是状态的一部分,并设置清晰的成功或停止条件。

七、检查点:让同一个会话记住上一次状态

到目前为止,每次 invoke() 都可以看成一次独立运行。如果希望同一位用户再次调用时从上次状态继续,就需要 checkpointer,也就是检查点保存器。

下面用 MemorySaver 在内存中保存每个会话的访问次数:

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

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

const user1 = {
  configurable: {
    thread_id: "用户-小落",
  },
};

const user2 = {
  configurable: {
    thread_id: "用户-小月",
  },
};

console.log(await app.invoke({}, user1));
console.log(await app.invoke({}, user2));
console.log(await app.invoke({}, user1));
console.log(await app.invoke({}, user2));

输出如下:

{ visitCount: 1, message: '这是你在本会话第1次进入' }
{ visitCount: 1, message: '这是你在本会话第1次进入' }
{ visitCount: 2, message: '这是你在本会话第2次进入' }
{ visitCount: 2, message: '这是你在本会话第2次进入' }

thread_id 是会话状态的隔离键

MemorySaver 并不是把所有调用都合并到一起,而是通过配置中的 thread_id 区分会话:

{
  configurable: {
    thread_id: "用户-小落"
  }
}

小落和小月第一次进入时,读取到的都是各自会话的初始状态,所以计数都是 1。第二次使用相同的 thread_id 调用时,图会基于各自已经保存的状态继续执行,于是计数分别变成 2。

这里保存的是会话状态,thread_id 不一定必须等于用户 ID。同一个用户可以拥有多个会话,只要为不同会话分配不同的 thread_id 即可。

MemorySaver 适合演示,不等于数据库持久化

MemorySaver 把检查点放在当前进程内存中。它很适合本地演示和快速验证,但进程结束后,内存中的状态也会消失。如果业务要求重启后仍能恢复,就要把检查点保存到 SQLite、Redis 等持久化存储中。

检查点不仅能“记住访问次数”。Agent 的执行可能因为异常、暂停、授权等原因中断,保存状态后,后续才能沿着同一个会话继续运行。下一节的人工中断正好会用到它。

八、人工中断:暂停图,收到输入后继续执行

敏感操作不能只靠 Agent 自己决定。一个常见流程是先展示即将执行的动作,暂停等待用户输入,然后再恢复图:

START → showTransfer → waitConfirm → END
                            ↑
                        在这里暂停

LangGraph 使用 interrupt() 发出中断,使用 Command({ resume }) 携带人工输入恢复执行:

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

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();
console.log("你输入了:", line);
await rl.close();

const done = await graph.invoke(
  new Command({ resume: line }),
  config,
);

console.log("done", done);

输入“确认”后,可以看到如下过程:

待确认 {
  hint: '终端输入[确认]或者备注后回车,图才会继续执行',
  actionSummary: '向张三转账 $100'
}
> 确认
你输入了: 确认
done {
  actionSummary: '向张三转账 $100',
  userInput: '确认'
}

第一次 invoke():运行到中断点

图先经过 showTransfer,把操作摘要写进状态。进入 waitConfirm 后,interrupt() 抛出一个需要外部处理的中断值:

{
  hint: "终端输入[确认]或者备注后回车,图才会继续执行",
  actionSummary: state.actionSummary,
}

调用方可以从结果的 __interrupt__ 中取出这段信息,再显示给用户。此时图没有走到 END,而是等待恢复。

第二次 invoke():用 Command 恢复

终端读到用户输入后,代码没有重新传入普通初始状态,而是传入:

new Command({ resume: line })

这个值会成为 interrupt() 的返回结果,于是 waitConfirm 可以把它转换成字符串并写进 userInput,图随后到达 END。

暂停与恢复使用了同一个 config,也就是同一个 thread_id。同时,编译时配置了 MemorySaver。这两点共同保证恢复操作能够找到对应的中断状态,而不是启动一次无关的新会话。

暂停并不等于已经完成授权判断

这个示例展示的是中断机制:用户输入任何文本并回车,图都会恢复,然后把文本保存到 userInput。它并没有判断输入是否严格等于“确认”,也没有真正执行转账。

因此,若把它扩展成真实审批流程,还需要在恢复后验证输入,再决定批准、拒绝还是要求重新输入。不要因为流程中出现了人工输入,就把“有人回复了”和“操作已获批准”当成同一件事。

九、把五个示例连成一套思维模型

前面的代码从简单到复杂,其实是在逐步回答五个不同问题:

能力解决的问题核心 API
顺序执行工作节点按什么顺序运行addNode、addEdge
条件分支当前状态不同,下一步去哪里addConditionalEdges
循环重试条件不满足时如何再次执行指回原节点的条件边
状态保存下次调用怎样接着当前会话MemorySaver、thread_id
人工中断如何暂停并等待外部输入interrupt、Command

它们共享同一个核心模型:

节点读取 State
    ↓
节点返回部分更新
    ↓
Reducer 合并新旧状态
    ↓
普通边或条件边选择下一节点
    ↓
必要时保存、暂停或继续执行

掌握这个模型后,再把某个节点换成模型调用、工具调用、RAG 检索或子 Agent,本质上仍是在做同一件事:读取状态、完成职责、更新状态,再让图决定下一步。

十、几个容易踩坑的细节

最后集中检查几个会直接影响正确性的地方。

1. 状态字段名必须保持一致

状态声明、节点读取、节点返回、条件函数中的字段名必须一致。拼写偏差不会得到想要的状态,后续判断自然也可能出错。

2. 默认值使用 default 函数

正确形式是:

default: () => 0

不同类型的字段返回各自合适的初始值,例如字符串返回 "",布尔值返回 false。

配置键的拼写也必须准确:默认值使用 default,状态合并函数使用 reducer。如果把 reducer 误写成其他形式,配置的合并逻辑就不会按预期生效。

3. Reducer 决定的是状态合并方式

reducer: (_prev, next) => next

表示直接用新值覆盖旧值。不要把 reducer 和业务节点混为一谈:节点计算更新内容,reducer 只处理新旧值怎样合并。

4. 条件分支的返回值必须命中映射

条件函数返回 "math",映射对象中就必须存在 math。分支键、映射键和节点名称虽然可以写成一样,但它们承担的角色不同。

5. 循环必须能到达出口

循环边很强大,也最容易制造永不结束的流程。应把尝试次数放进状态,并保证某个条件能够路由到 END。

6. 不要对不可信输入使用 eval

eval 只是最小数学分支示例里的演示手段。它会执行任意 JavaScript 字符串,不能直接进入生产代码。

7. 恢复中断时必须使用同一个会话标识

暂停与恢复依赖检查点。恢复时换了 thread_id,就无法对应到原来的中断执行。

8. MemorySaver 的生命周期只是当前进程

它能展示检查点机制,却不能保证服务重启后仍有数据。需要长期保存时,应使用持久化存储。

总结

LangGraph 并不是简单地把几个函数串起来。它把 Agent 工作流拆成了三部分:

  • 用 State 描述共享数据;
  • 用 Node 描述每一步工作;
  • 用 Edge 描述固定流转、条件分支和循环。

在这套基础上,checkpointer 又让流程拥有跨调用延续的会话状态,interrupt 与 Command 则让程序可以在关键位置暂停,把决定权交回给人。

从最小顺序图开始,再逐步加入分支、循环、检查点和中断,是理解 LangGraph 最稳妥的路径。等这些控制能力清晰之后,无论节点内部接入大模型、检索系统、工具,还是把它扩展成多个 Agent 分工协作,整体结构都不会再显得神秘。