Nodejs也能写Agent - 16.LangGraph篇 - 条件分支与循环

上一篇我们把 State 和 Graph API 摸熟了:StateSchema、reducer、addNode / addEdge / addSequence。但这会儿图还是「画好就顺着走」------从 STARTEND 一条道走到黑。

真正的 Agent 味道不在这儿。模型说「我要调天气工具」,你就得去 tools;工具跑完,还得绕回模型再想一遍;模型终于给出人话答案,才该停。固定边 addEdge 画不出这种「自己决定下一步」的戏。

这一篇我们讲三样东西:addConditionalEdges 条件边 、经典的 ReAct 环(agent ↔ tools) ,以及节点里用 Command 动态跳转。写完之后,你的图才算真正「转起来」。

老规矩,本文 API 均以官网最新文档核对过(Graph API overviewUse the graph APIWorkflows and agents)。网上不少教程仍在教 Annotation.Root / MessagesAnnotation、手写 tools 节点------能跑,但是 Legacy / 啰嗦版;本篇主路径跟官网走 StateSchema + ToolNode

一、固定边的天花板

addEdge("a", "b") 的含义很死板:a 跑完,下一步永远是 b。对翻译→校验→摘要这种预定流程够用;但 Agent 要的是:

场景 固定边做不到什么 条件边 / 循环怎么解
根据状态选路径 画两条边会两边都走 addConditionalEdges 按 State 选一条
Tool calling 循环 没法「有调用就去执行、没有就结束」 agent → 条件路由 → tools → 再回 agent
模型边算边跳 路由逻辑和节点更新拆开写很散 节点直接 return new Command({ goto, update })

可以把它想成红绿灯路口:固定边是「永远直行」;条件边是「看信号灯决定直行还是转弯」;循环则是「转完一圈还能再回来」。
#mermaid-svg-5dP2xl3Obow7vAM5{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;fill:#333;}@keyframes edge-animation-frame{from{stroke-dashoffset:0;}}@keyframes dash{to{stroke-dashoffset:0;}}#mermaid-svg-5dP2xl3Obow7vAM5 .edge-animation-slow{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 50s linear infinite;stroke-linecap:round;}#mermaid-svg-5dP2xl3Obow7vAM5 .edge-animation-fast{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 20s linear infinite;stroke-linecap:round;}#mermaid-svg-5dP2xl3Obow7vAM5 .error-icon{fill:#552222;}#mermaid-svg-5dP2xl3Obow7vAM5 .error-text{fill:#552222;stroke:#552222;}#mermaid-svg-5dP2xl3Obow7vAM5 .edge-thickness-normal{stroke-width:1px;}#mermaid-svg-5dP2xl3Obow7vAM5 .edge-thickness-thick{stroke-width:3.5px;}#mermaid-svg-5dP2xl3Obow7vAM5 .edge-pattern-solid{stroke-dasharray:0;}#mermaid-svg-5dP2xl3Obow7vAM5 .edge-thickness-invisible{stroke-width:0;fill:none;}#mermaid-svg-5dP2xl3Obow7vAM5 .edge-pattern-dashed{stroke-dasharray:3;}#mermaid-svg-5dP2xl3Obow7vAM5 .edge-pattern-dotted{stroke-dasharray:2;}#mermaid-svg-5dP2xl3Obow7vAM5 .marker{fill:#333333;stroke:#333333;}#mermaid-svg-5dP2xl3Obow7vAM5 .marker.cross{stroke:#333333;}#mermaid-svg-5dP2xl3Obow7vAM5 svg{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;}#mermaid-svg-5dP2xl3Obow7vAM5 p{margin:0;}#mermaid-svg-5dP2xl3Obow7vAM5 .label{font-family:"trebuchet ms",verdana,arial,sans-serif;color:#333;}#mermaid-svg-5dP2xl3Obow7vAM5 .cluster-label text{fill:#333;}#mermaid-svg-5dP2xl3Obow7vAM5 .cluster-label span{color:#333;}#mermaid-svg-5dP2xl3Obow7vAM5 .cluster-label span p{background-color:transparent;}#mermaid-svg-5dP2xl3Obow7vAM5 .label text,#mermaid-svg-5dP2xl3Obow7vAM5 span{fill:#333;color:#333;}#mermaid-svg-5dP2xl3Obow7vAM5 .node rect,#mermaid-svg-5dP2xl3Obow7vAM5 .node circle,#mermaid-svg-5dP2xl3Obow7vAM5 .node ellipse,#mermaid-svg-5dP2xl3Obow7vAM5 .node polygon,#mermaid-svg-5dP2xl3Obow7vAM5 .node path{fill:#ECECFF;stroke:#9370DB;stroke-width:1px;}#mermaid-svg-5dP2xl3Obow7vAM5 .rough-node .label text,#mermaid-svg-5dP2xl3Obow7vAM5 .node .label text,#mermaid-svg-5dP2xl3Obow7vAM5 .image-shape .label,#mermaid-svg-5dP2xl3Obow7vAM5 .icon-shape .label{text-anchor:middle;}#mermaid-svg-5dP2xl3Obow7vAM5 .node .katex path{fill:#000;stroke:#000;stroke-width:1px;}#mermaid-svg-5dP2xl3Obow7vAM5 .rough-node .label,#mermaid-svg-5dP2xl3Obow7vAM5 .node .label,#mermaid-svg-5dP2xl3Obow7vAM5 .image-shape .label,#mermaid-svg-5dP2xl3Obow7vAM5 .icon-shape .label{text-align:center;}#mermaid-svg-5dP2xl3Obow7vAM5 .node.clickable{cursor:pointer;}#mermaid-svg-5dP2xl3Obow7vAM5 .root .anchor path{fill:#333333!important;stroke-width:0;stroke:#333333;}#mermaid-svg-5dP2xl3Obow7vAM5 .arrowheadPath{fill:#333333;}#mermaid-svg-5dP2xl3Obow7vAM5 .edgePath .path{stroke:#333333;stroke-width:2.0px;}#mermaid-svg-5dP2xl3Obow7vAM5 .flowchart-link{stroke:#333333;fill:none;}#mermaid-svg-5dP2xl3Obow7vAM5 .edgeLabel{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-5dP2xl3Obow7vAM5 .edgeLabel p{background-color:rgba(232,232,232, 0.8);}#mermaid-svg-5dP2xl3Obow7vAM5 .edgeLabel rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-5dP2xl3Obow7vAM5 .labelBkg{background-color:rgba(232, 232, 232, 0.5);}#mermaid-svg-5dP2xl3Obow7vAM5 .cluster rect{fill:#ffffde;stroke:#aaaa33;stroke-width:1px;}#mermaid-svg-5dP2xl3Obow7vAM5 .cluster text{fill:#333;}#mermaid-svg-5dP2xl3Obow7vAM5 .cluster span{color:#333;}#mermaid-svg-5dP2xl3Obow7vAM5 div.mermaidTooltip{position:absolute;text-align:center;max-width:200px;padding:2px;font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:12px;background:hsl(80, 100%, 96.2745098039%);border:1px solid #aaaa33;border-radius:2px;pointer-events:none;z-index:100;}#mermaid-svg-5dP2xl3Obow7vAM5 .flowchartTitleText{text-anchor:middle;font-size:18px;fill:#333;}#mermaid-svg-5dP2xl3Obow7vAM5 rect.text{fill:none;stroke-width:0;}#mermaid-svg-5dP2xl3Obow7vAM5 .icon-shape,#mermaid-svg-5dP2xl3Obow7vAM5 .image-shape{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-5dP2xl3Obow7vAM5 .icon-shape p,#mermaid-svg-5dP2xl3Obow7vAM5 .image-shape p{background-color:rgba(232,232,232, 0.8);padding:2px;}#mermaid-svg-5dP2xl3Obow7vAM5 .icon-shape .label rect,#mermaid-svg-5dP2xl3Obow7vAM5 .image-shape .label rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-5dP2xl3Obow7vAM5 .label-icon{display:inline-block;height:1em;overflow:visible;vertical-align:-0.125em;}#mermaid-svg-5dP2xl3Obow7vAM5 .node .label-icon path{fill:currentColor;stroke:revert;stroke-width:revert;}#mermaid-svg-5dP2xl3Obow7vAM5 :root{--mermaid-font-family:"trebuchet ms",verdana,arial,sans-serif;} 是

START
agent
有 tool_calls?
tools
END

二、addConditionalEdges:动态选下一跳

签名长这样:

typescript 复制代码
graph.addConditionalEdges(source, routingFn, pathMap?);
参数 说明
source 刚跑完的那个源节点名(也可以是 START
routingFn `(state) => string
pathMap 可选。把 routingFn 的返回值映射到真实节点名;也可以直接传目标节点名数组

官网还给了类型别名 ConditionalEdgeRouter,TypeScript 里写上它,返回值写错节点名编辑器会提醒你:

typescript 复制代码
import {
  StateGraph,
  StateSchema,
  GraphNode,
  ConditionalEdgeRouter,
  START,
  END,
} from "@langchain/langgraph";
import * as z from "zod";

const State = new StateSchema({
  which: z.string(),
  result: z.string().default(""),
});

const routeAfterA: ConditionalEdgeRouter<typeof State, "b" | "c"> = (state) => {
  return state.which === "b" ? "b" : "c";
};

const graph = new StateGraph(State)
  .addNode("a", (state) => ({ which: "c" }))
  .addNode("b", () => ({ result: "B" }))
  .addNode("c", () => ({ result: "C" }))
  .addEdge(START, "a")
  .addConditionalEdges("a", routeAfterA) // 不传 pathMap:直接返回节点名
  .addEdge("b", END)
  .addEdge("c", END)
  .compile();

pathMap 的两种写法

Record 形式------routingFn 返回「逻辑标签」,再映射到真实节点:

typescript 复制代码
.addConditionalEdges("check", routeFn, {
  process: "process",
  skip: "skip",
})

数组形式 ------官网 ReAct 示例更爱这种,声明「允许去哪些终点」,routingFn 直接返回节点名或 END

typescript 复制代码
.addConditionalEdges("llmCall", shouldContinue, ["toolNode", END])

不传 pathMap 时,routingFn 必须直接返回 已注册的节点名或 END。返回幽灵名字,compile 不一定报错,跑起来才 Node not found------属实闷坑。

从 START 也能条件分支

入口不一定死连某一个节点:

typescript 复制代码
graph.addConditionalEdges(START, (state) =>
  state.mode === "fast" ? "fastPath" : "fullPath"
);

另外:routingFn 还可以返回 字符串数组,一次 fan-out 到多个节点(map-reduce 那套)。本篇先把「选一条路」和「ReAct 环」吃透;扇出留给后面进阶篇。

三、示例 1:简单条件分支

根据输入长短走不同处理节点------把「路口红绿灯」落到代码上:

typescript 复制代码
import {
  StateGraph,
  StateSchema,
  GraphNode,
  ConditionalEdgeRouter,
  START,
  END,
} from "@langchain/langgraph";
import * as z from "zod";

const State = new StateSchema({
  input: z.string(),
  flag: z.boolean().default(false),
  result: z.string().default(""),
});

const checkNode: GraphNode<typeof State> = async (state) => {
  return { flag: state.input.length > 10 };
};

const processNode: GraphNode<typeof State> = async (state) => {
  return { result: `已处理:${state.input.toUpperCase()}` };
};

const skipNode: GraphNode<typeof State> = async (state) => {
  return { result: `直接返回:${state.input}` };
};

const routeFn: ConditionalEdgeRouter<typeof State, "process" | "skip"> = (
  state
) => {
  return state.flag ? "process" : "skip";
};

const graph = new StateGraph(State)
  .addNode("check", checkNode)
  .addNode("process", processNode)
  .addNode("skip", skipNode)
  .addEdge(START, "check")
  .addConditionalEdges("check", routeFn, {
    process: "process",
    skip: "skip",
  })
  .addEdge("process", END)
  .addEdge("skip", END)
  .compile();

const out1 = await graph.invoke({ input: "hello" });
// → result: "直接返回:hello"

const out2 = await graph.invoke({ input: "this is a long input string" });
// → result: "已处理:THIS IS A LONG INPUT STRING"

流程很直白:check 只负责把判断结果写进 State;真正「往哪走」交给条件边。节点保持「只干一件事」,路由逻辑独立------后面改策略时不用把节点函数撕开重写。

四、ReAct 循环:Agent 自己转起来

ReAct = Reasoning + Acting:模型思考 → 决定要不要调工具 → 执行工具 → 再思考......直到给出最终答案。这是绝大多数 Tool-calling Agent 的骨架。

四个职责:

  1. agent / llmCall 节点llm.bindTools(tools) 调模型,往 messages 里塞一条 AIMessage(可能带 tool_calls
  2. routing 函数 :看最后一条消息有没有 tool_calls
  3. tools 节点 :执行工具,回写 ToolMessage
  4. tools → agent 的固定边:形成环,直到模型不再要工具

终止条件:最后一条 AIMessage 没有 tool_calls,routing 返回 END

主推 ToolNode,别再人人手写一遍工具循环。 官网 Workflows and agents 的 Agent 示例就是 new ToolNode(tools)------它自带并行执行、错误处理,工具若返回 Command 也能自动往上冒泡。手写当然可以,但你必须自己保证:返回的是 ToolMessage,且 tool_call_id 和模型发出的 call id 对得上。

完整可跑示例(本地 Ollama + qwen2.5:7b):

typescript 复制代码
import {
  StateGraph,
  StateSchema,
  MessagesValue,
  GraphNode,
  ConditionalEdgeRouter,
  START,
  END,
} from "@langchain/langgraph";
import { ToolNode } from "@langchain/langgraph/prebuilt";
import { ChatOllama } from "@langchain/ollama";
import { tool } from "@langchain/core/tools";
import { HumanMessage } from "@langchain/core/messages";
import * as z from "zod";

const getWeather = tool(
  async ({ city }: { city: string }) => `${city}:晴,25°C`,
  {
    name: "get_weather",
    description: "查询城市天气",
    schema: z.object({ city: z.string().describe("城市名") }),
  }
);

const tools = [getWeather];

const llm = new ChatOllama({ model: "qwen2.5:7b", temperature: 0 });
const llmWithTools = llm.bindTools(tools);

const State = new StateSchema({
  messages: MessagesValue,
});

const agentNode: GraphNode<typeof State> = async (state) => {
  const response = await llmWithTools.invoke(state.messages);
  return { messages: [response] }; // MessagesValue → append,只返回增量
};

const toolNode = new ToolNode(tools);

const shouldContinue: ConditionalEdgeRouter<typeof State, "tools"> = (state) => {
  const lastMsg = state.messages.at(-1);
  if (lastMsg?.tool_calls?.length) return "tools";
  return END;
};

const graph = new StateGraph(State)
  .addNode("agent", agentNode)
  .addNode("tools", toolNode)
  .addEdge(START, "agent")
  .addConditionalEdges("agent", shouldContinue, ["tools", END])
  .addEdge("tools", "agent") // 环:工具跑完回到模型
  .compile();

const result = await graph.invoke({
  messages: [new HumanMessage("北京天气怎么样?")],
});

console.log(result.messages.at(-1)?.content);

跑「北京天气怎么样?」时,大致会经历:

复制代码
HumanMessage
  → AIMessage(tool_calls: get_weather)
  → ToolMessage("北京:晴,25°C")
  → AIMessage("北京今天晴,约 25°C......")  // 无 tool_calls → END

这就是 Agent 的心跳。链式 pipe 做不漂亮的事,在图里就是两条边 + 一个路由函数。

循环别死扛:recursionLimit

万一模型抽风,每一次都还要再调工具,环会一直转。官网提供 recursionLimit (按 super-step 计数),超限抛 GraphRecursionError

typescript 复制代码
import { GraphRecursionError } from "@langchain/langgraph";

try {
  await graph.invoke(
    { messages: [new HumanMessage("......")] },
    { recursionLimit: 25 } // 默认一般够用;调试死循环时可故意调小
  );
} catch (error) {
  if (error instanceof GraphRecursionError) {
    console.error("图递归超限,检查终止条件或模型是否一直产出 tool_calls");
  } else {
    throw error;
  }
}

生产里还可以在 State 里加个 stepCount 自己拦一层;但最低限度,先把 recursionLimit 当安全带系上。

五、Command:节点里同时更新状态 + 跳转

条件边的分工是「节点只改 State,边负责选路」。有时你想在 同一个节点函数里 既写更新、又指定下一跳------官网为此提供了 Command

typescript 复制代码
import {
  StateGraph,
  StateSchema,
  GraphNode,
  Command,
  START,
  END,
} from "@langchain/langgraph";
import * as z from "zod";

const State = new StateSchema({
  foo: z.string(),
});

// 第二个泛参声明这个节点可能 goto 哪些终点(配合 ends)
const nodeA: GraphNode<typeof State, "nodeB" | "nodeC"> = (state) => {
  const value = Math.random() > 0.5 ? "b" : "c";
  const goto = value === "b" ? "nodeB" : "nodeC";

  return new Command({
    update: { foo: value }, // 等价于普通节点返回的 Partial Update
    goto,                   // 等价于条件边的路由结果
  });
};

const nodeB: GraphNode<typeof State> = (state) => ({ foo: state.foo + "B" });
const nodeC: GraphNode<typeof State> = (state) => ({ foo: state.foo + "C" });

const graph = new StateGraph(State)
  .addNode("nodeA", nodeA, { ends: ["nodeB", "nodeC"] }) // ⚠️ 必写
  .addNode("nodeB", nodeB)
  .addNode("nodeC", nodeC)
  .addEdge(START, "nodeA")
  // 注意:nodeA 没有 addConditionalEdges,也没有 addEdge 指出边
  .compile();

为什么必须写 ends

LangGraph 要画图、做静态分析,得知道「这个节点运行时可能跳到哪」。Command.goto 是动态的,不声明 ends,框架看不到潜在边------渲染和图校验都会缺一段。

条件边 vs Command:怎么选?

你想做的事 用谁
只根据 State 选下一跳,节点本身不掺和路由 addConditionalEdges
同一个节点 既要 update 又要决定去哪 Command({ update, goto })
从外部恢复被 interrupt 打断的图 Command({ resume })(下一章 Checkpointer)

铁律:同一节点不要把 Command.gotoaddEdge 混着用。 官网写得很明白------Command 加的是动态边 ,静态 addEdge 照样会执行。结果就是:你 gotomyOtherNode,同时又 addEdge("nodeA", "nodeB")两边都会跑 。每个节点:要么交给边路由,要么交给 Command,二选一。

Command 的四种字段(本篇先认门)

字段 作用
update 写入 State(跟节点直接返回 Partial 一样)
goto 下一跳节点名(或多个 / Send
graph 子图里跳到父图时用(Command.PARENT
resume 配合 interrupt 恢复执行------留给下一章

Command 重写「有 tool_calls 就去 tools」也可以,但多数 Agent 用条件边更清晰:agent 节点专心调模型,路由函数一眼能看懂终止条件。Command 更适合「校验失败立刻打回某个节点」这类强耦合逻辑。

常见坑

  1. routingFn 返回不存在的节点名 :compile 可能放你一马,运行时报 Node not found。节点名字符串跟 addNode 第一个参数必须逐字一致。
  2. pathMap 的 key 和返回值对不上 :routing 返回 "Process",map 里写的是 "process"------看起来像卡住,其实是路由迷路了。
  3. 无限循环没安全带 :模型一直产出 tool_calls 就会空转。设 recursionLimit,必要时在 State 里加步数上限。
  4. 手写 tools 节点忘了 ToolMessage / tool_call_id :模型协议要求「带 tool_calls 的 AIMessage 后面必须跟对应 ToolMessage」。缺一个,下一轮模型或接口会报错。能上 ToolNode 就上。
  5. Command 忘了 ends :跳转「能跑」不代表图画得对;渲染和校验会缺边。养成 addNode(name, fn, { ends: [...] }) 的习惯。
  6. Command.goto 又叠了 addEdge:两条出路同时触发,状态更新和日志都会乱成一锅粥。路由方式认准一种。
  7. 被旧教程带去 Annotation.Root / MessagesAnnotation :本系列统一 StateSchema + MessagesValue。看到 Annotation 知道是 Legacy 即可,新代码别再开倒车。

到这里,Agent 已经能在进程里自己转圈了。但一关进程,State 全没;想多轮对话、中间停下来让人审批,还缺点关键基建------Checkpointer

相关推荐
狗头大军之江苏分军24 分钟前
《潮水漫过十七岁》开学了
后端
苏三说技术1 小时前
如何看待GPT-6在UP主众测中碾压夺冠?它是现在最强大模型吗?
后端
mldong1 小时前
一份 JSON,一条能跑的审批流:把报销流程送上工作流引擎
后端·架构
wno7041 小时前
Spring Boot WebFlux增删改查
java·spring boot·后端
Captaincc1 小时前
AI用量v0.1.11更新发布 新增 jusage doctor 诊断指令 托盘展示token 和余额 新增 AutoClaw 支持
前端·后端·vibecoding
aramae2 小时前
模拟实现strlen()函数 (C语言)
c语言·开发语言·后端
计算机魔术师3 小时前
德国Wiki被黑后两周,OpenAI终于把模型失控的账本摊开了
前端
kyriewen3 小时前
我让 AI 当面试官面了我一轮:第 3 个追问我就卡住了(附 10 道追问清单)
前端·面试·ai编程
IT_陈寒3 小时前
Python的GIL把我坑惨了,多线程跑得比单线程还慢
前端·人工智能·后端
分支预测失败4 小时前
RISC-V 时间子系统深度专题:mtime 访问路径、SBI 定时器与 Linux tickless 协同
后端