AI使用笔记 12

用图的方式理解 LangGraph:从 addNode、addEdge 到多 Agent 协同

刚接触 LangGraph 的人,多半会被它那套"图"的术语拦住:节点、边、状态、超级步、条件路由……名词一个接一个。其实把这些词拆开看,它的核心想法很朴素——把一个 agent 的工作流画成一张图,节点负责干活,边负责告诉流程下一步去哪。这篇文章把 addNodeaddEdge、条件边、多 Agent Supervisor 模式这几个关键概念串起来讲清楚,看完你应该能动手搭出自己的第一张图。

先把三个词记住:State、Nodes、Edges

LangGraph 对自己的定位讲得很直白:它把 agent 工作流建模成图,而你用三样东西来定义 agent 的行为——StateNodesEdges。官方那句被反复引用的话说得最精炼:

nodes do the work, edges tell what to do next.

翻译过来就是:节点做事,边决定下一步做什么。State 则是贯穿全图的共享上下文,所有节点都对着它读写。官方对 State 的定义是:它由图的 schema 以及一组 reducer 函数组成,reducer 决定了状态更新如何被合并进去。这意味着状态不是被某个节点独占的局部变量,而是整张图累积共享的"黑板"。

底层执行机制上,LangGraph 用的是消息传递(message passing),受 Google 的 Pregel 系统启发。执行按离散的 super-step 推进。这里有个容易记混的点,值得一开始就说清楚:

同一个 super-step 内的节点是并行执行的;顺序执行的节点则分属不同的 super-step。

把 super-step 想成"一轮迭代"就好理解了——同一轮里能并行跑的节点归在一起,跨轮的节点自然得排队。

addNode 和 addEdge 各管什么

理解了三件套,再看两个最常用的 API。addNode 注册一个可执行节点,addEdge 定义节点之间的流转关系。一个回答"图里有什么人",一个回答"这些人按什么顺序接力"。

维度 addNode addEdge
作用 注册一个可执行节点(agent / 函数) 定义节点之间的流转关系
类比 招聘员工,告诉公司"有哪些人" 制定流程,告诉员工"下一步找谁"
参数 addNode("名称", 函数) addEdge("来源节点", "目标节点")
调用时是否执行 否,只是注册 否,只是定义结构
链式调用 支持,返回 builder 自身 支持,返回 builder 自身

官方给的最小示例长这样:

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

const graph = new StateGraph(StateAnnotation)
  .addNode("agent", agentNode)      // 注册节点:定义"有什么"
  .addNode("tools", toolsNode)      // 注册节点:定义"有什么"
  .addEdge(START, "agent")          // 定义边:从哪开始
  .addConditionalEdges("agent", routeAgent)  // 条件边:动态路由
  .addEdge("tools", "agent")        // tools 做完回 agent
  .compile();                       // 编译图(必须步骤)

末尾那句 .compile() 不是装饰,官方明确说过:图必须编译之后才能用。编译这一步会做一些基础的结构检查(比如有没有孤儿节点),也是你挂载 checkpointers、breakpoints 这类运行时参数的地方。

执行顺序的真相:配置阶段 vs 运行阶段

很多人第一次写 LangGraph 会踩一个坑:以为 addNodeaddEdge 按链式调用的书写顺序执行。其实不是。这两个调用都只在做"注册"和"定义结构"的事,真正跑节点逻辑发生在 .invoke(state).stream(state) 的时候。三阶段的差别如下:

阶段 代码 实际行为
配置阶段 .addNode() / .addEdge() 只是注册 / 定义,完全不执行
编译阶段 .compile() 验证图结构合法性,不执行节点逻辑
运行阶段 .invoke(state) / .stream(state) 真正按边的定义顺序执行节点函数

为了说明"执行顺序只由边决定",可以对比下面两种写法。第一种按直觉顺序写,第二种把边和节点的注册顺序打乱,但执行结果完全一样:

javascript
// 写法1:按注册顺序
builder.addNode("A", fnA)
  .addNode("B", fnB)
  .addEdge(START, "A")
  .addEdge("A", "B");

// 写法2:打乱顺序,执行结果不变
builder.addEdge("A", "B")      // 先定义边
  .addNode("B", fnB)           // 后注册节点
  .addEdge(START, "A")
  .addNode("A", fnA);

两种写法的执行路径都是 START → A → B → END,因为边才是顺序的唯一裁判。记住这点,后面看别人写的图就不会被链式调用的排版带偏。

边的三种类型与一个易踩的坑

LangGraph 把边分成几种关键类型:Normal Edge、Conditional Edge,以及 Entry Point 和 Conditional Entry Point 这类入口边。日常用得最多的是前两种。

**Normal Edge(普通边)**用于固定流转——A 做完一定去 B,没有任何条件分支:

javascript
graph.addEdge("node_a", "node_b");  // A 做完总是去 B

**Conditional Edge(条件边)**用于需要动态决策的场景。它接受一个节点名和一个路由函数:节点执行完之后,调用路由函数,由它返回下一个该去哪。路由函数同样接收当前 state,返回值默认就被当作目标节点名(或节点名列表):

javascript
// routing_function 接收当前 state,返回下一个节点名
graph.addConditionalEdges("node_a", routingFunction);

// 可选:提供路径映射字典,把返回值显式映射到节点
graph.addConditionalEdges("node_a", routingFunction, {
  true: "node_b",
  false: "node_c"
});

这里有一条容易被忽略的约束,官方专门提醒过:对同一个节点,要么用普通边做静态路由,要么用条件边或 Command 做动态路由,不要混用。原因是两条路径都可能被执行,会让图的行为变得很难推理。简单说,一个节点要么"铁定去某处",要么"看情况决定",别让它同时具备两种身份。

需要快速判断该用哪种边时,可以套下面这几条:

text
固定流程(A 做完一定做 B)         → addEdge("A", "B")
需要决策(A 做完可能去 B/C/D)     → addConditionalEdges("A", router, {...})
入口点                            → addEdge(START, "A")
结束点                            → addEdge("A", END)

多 Agent 怎么搭:Supervisor 模式

单 agent 能做的事有限,真正复杂的任务往往要多个 agent 协同。LangGraph 官方和社区实践中常见三种多 Agent 拓扑:

拓扑 结构 适用场景 缺点
Network 每个 agent 可以调用其他任意 agent 真正的点对点协作;较少见 组合路由决策复杂,难评估和调试
Supervisor 一个 Supervisor 路由到 N 个 Worker,Worker 完成后回 Supervisor 约 90% 的真实团队:一个调度器带多个专家 Supervisor 思考负担过重时会成瓶颈
Hierarchical Supervisor 上面还有 Supervisor 大团队里有子团队(比如"研究部"有自己的内部调度) 成本翻倍,约 8 个专家以上才值得

对大多数场景来说,Supervisor 模式是首选:结构清晰、容易调试,一个调度中心配几个分工明确的专家 agent,刚好对应现实中"一个项目经理带几个工种"的协作方式。

它的架构大致是这样:

text
                    ┌─────────┐
                    │  START  │
                    └────┬────┘
                         ▼
              ┌─────────────────┐
              │   Supervisor    │ ← 调度中心:分析状态,决定下一步
              │  (循环执行)      │
              └────────┬────────┘
                       │
         ┌─────────────┼─────────────┐
         ▼             ▼             ▼
    ┌─────────┐  ┌─────────┐  ┌─────────┐
    │Researcher│  │  Coder  │  │ Writer  │
    │(信息检索)│  │(代码生成)│  │(文案撰写)│
    └────┬────┘  └────┬────┘  └────┬────┘
         │            │            │
         └────────────┴────────────┘
                       │
                       ▼
              ┌─────────────────┐
              │   Supervisor    │ ← 检查完成度,继续调度或结束
              └────────┬────────┘
                       ▼
                    ┌─────────┐
                    │   END   │
                    └─────────┘

核心循环很紧凑:Supervisor 执行 → 决定路由 → 子 agent 执行 → 回到 Supervisor。每一轮子 agent 把结果写回 state,Supervisor 再根据最新状态判断是该继续派活还是收尾。

路由决策怎么写

Supervisor 的"决定下一步"通常交给一个 LLM 来做。下面这段代码展示了典型的写法:Supervisor 节点拼好 prompt 调模型,拿到决策后做一道白名单校验,再写回 state.next;另一个独立的 router 函数只负责读 state.next 并返回目标节点名。

javascript
// Supervisor 节点:LLM 动态决策
async function supervisorNode(state) {
  const prompt = `根据当前任务状态,决定下一步:
  任务: ${state.task}
  已完成: ${JSON.stringify(state.results)}

  可选: researcher / coder / writer / finish
  请返回一个名称。`;

  const response = await model.invoke(prompt);
  const decision = response.content.trim().toLowerCase();

  // 安全校验:防止 LLM 输出异常
  const validAgents = ["researcher", "coder", "writer", "finish"];
  const next = validAgents.includes(decision) ? decision : "finish";

  return { next };  // 这个 next 会被 router 函数读取
}

// 路由函数:读取 state.next,返回目标节点名
function router(state) {
  return state.next;  // 返回 "researcher" / "coder" / "writer" / "finish"
}

那道白名单校验不是多余的。LLM 偶尔会返回多余的文字、大小写不一致甚至幻觉出一个不存在的节点名,直接拿去路由轻则报错、重则跑飞。用 validAgents 兜个底,遇到异常输出就回落到 finish,是最省心的做法。

把上面这些拼成一张完整的图:

javascript
const builder = new StateGraph(StateAnnotation)
  // 注册所有 Agent 节点
  .addNode("supervisor", supervisorNode)
  .addNode("researcher", researcherNode)
  .addNode("coder", coderNode)
  .addNode("writer", writerNode)

  // 定义入口
  .addEdge(START, "supervisor")

  // Supervisor 的条件路由(动态决策)
  .addConditionalEdges("supervisor", router, {
    researcher: "researcher",
    coder: "coder",
    writer: "writer",
    finish: END,
  })

  // 子 Agent 完成后返回 Supervisor(关键!)
  .addEdge("researcher", "supervisor")
  .addEdge("coder", "supervisor")
  .addEdge("writer", "supervisor");

注意最后三行普通边——它们是 Supervisor 模式能循环起来的关键。子 agent 做完不直接结束,而是回到 supervisor,由调度中心决定是再派一个专家还是收工。少了这几行,图跑一轮就停了。

跟着一次任务走一遍执行过程

光看代码还不够直观,拿一个具体任务跟着图走一遍就清楚了。假设用户给的任务是"写一个爬虫并生成文档",整张图的执行轨迹是这样的:

text
用户任务: "写一个爬虫并生成文档"

Step 1: START → supervisor
        Supervisor 分析: 需要技术研究 → 返回 { next: "researcher" }

Step 2: supervisor → researcher (条件路由)
        Researcher 执行: 研究爬虫技术 → 返回研究结果
        researcher → supervisor (普通边,自动返回)

Step 3: supervisor (再次执行)
        Supervisor 分析: 研究完成,需要代码 → 返回 { next: "coder" }

Step 4: supervisor → coder (条件路由)
        Coder 执行: 编写爬虫代码 → 返回代码
        coder → supervisor (普通边,自动返回)

Step 5: supervisor (再次执行)
        Supervisor 分析: 代码完成,需要文档 → 返回 { next: "writer" }

Step 6: supervisor → writer (条件路由)
        Writer 执行: 撰写使用文档 → 返回文档
        writer → supervisor (普通边,自动返回)

Step 7: supervisor (再次执行)
        Supervisor 分析: 全部完成 → 返回 { next: "finish" }

Step 8: supervisor → END (条件路由)
        流程结束

留意 Step 3、5、7:每次子 agent 回来,Supervisor 都会重新执行一次。这正是 Supervisor 模式"循环"的体现——它不是一次性派完所有活,而是每完成一步就回来重新评估。也正因为这样,state 的累积共享才显得重要:researcher 写进 state 的研究结果,coder 和 writer 后续都能读到,不必自己重复研究一遍。

几条值得记牢的要点

把前面的内容收一收,有几条结论值得你动手时贴在显示器边上:

  • addNode 定义"有什么",addEdge 定义"怎么走",addConditionalEdges 是"智能地怎么走"——前两个只注册不执行,顺序由边说了算。
  • 图必须 .compile() 才能用,这是硬性步骤,跳过会直接报错。
  • 同一个节点别把普通边和条件边混用,两条路径都可能执行,调试起来会很痛。
  • Supervisor 模式的循环靠两件事撑起来:子 agent 用普通边回到 supervisor,supervisor 每次都被重新执行。
  • state 是全图共享的累积上下文,靠 reducer 合并更新,别把它当某个节点的局部变量用。
  • LLM 做路由决策时,记得加白名单校验兜底,别把模型输出直接当节点名。

把这些记住,搭一张能跑的多 Agent 图其实没那么难。难的部分 LangGraph 已经替你抽象成了边和节点,你要做的,主要是想清楚"有哪些活、按什么顺序干、什么时候算完"——这恰好也是你平时安排一件复杂事情时会想的问题。

感谢阅读,如果这篇文章对你有帮助,欢迎继续浏览同栏目内容。

返回 AI使用笔记