Skip to content

Command 与 Send

要点

  • 前面几篇用条件边完成了大量路由逻辑
  • Command 让节点在更新状态的同时直接决定下一步
  • 官方语义里,Command 适合把「状态更新」和「动态跳转」放在同一个节点返回值里
  • Send 适合把一份内容创作任务拆成多份并行处理
  • 把前面的能力综合起来,做一个更接近实际的例子
  • Command 的 goto 会追加一条动态出边

内容

1. 条件边有一个不方便的地方

前面几篇用条件边完成了大量路由逻辑。

ReAct Agent 的工具循环、质量门控的重试循环,核心都是 addConditionalEdges

条件边能力上没问题,但用多了会发现一个不太方便的地方:路由逻辑和节点逻辑是分开的。

比如前面的重试循环:

typescript
// separate-logic.ts
import type { GraphNode, ConditionalEdgeRouter } from '@langchain/langgraph'

// 验证节点:检查草稿质量,把结果写入状态

const validate: GraphNode<typeof State> = (state) => {
  if (state.draft.length >= 30) {
    return { isValid: true, feedback: '' }
  }

  return { isValid: false, feedback: '草稿太短' }
}

// 路由函数:读取验证结果,决定下一步

const checkResult: ConditionalEdgeRouter<typeof State, 'generate'> = (
  state,
) => {
  if (state.isValid) return END

  if (state.retryCount >= 3) return END

  return 'generate'
}

validate 节点已经知道草稿合不合格了,但它没法直接说「下一步去哪」。它只能先把 isValid 写入状态,然后等条件边的路由函数再去读这个字段。

做同一个决策,代码分散在两个地方。

当图变复杂以后,这种分离会越来越明显:

  • 决策的上下文在节点里
  • 但路由的执行在条件边里
  • 两边要通过状态字段做间接通信

Command 就是用来解决这件事的:让节点直接说「我要更新这些状态,并且下一步走到那个节点」。

对照官方 Graph API,Command 可以先理解成一个节点返回的「动作包」:一部分是 update,告诉 LangGraph 要怎样合并 state;另一部分是 goto,告诉 LangGraph 下一步去哪里。它适合用在节点已经拿到足够上下文、可以同时做出业务判断和路由判断的地方。

Send 解决的是另一个问题:一条边不再只把同一份 state 传给下游,而是可以给多个下游节点分别发送不同输入。内容创作里常见的场景是「把一篇文章拆成多个小节并行生成」,每个小节节点拿到自己的标题和要求,最后再通过 reducer 把结果合并回来。

能力解决的问题内容创作里的例子
条件边节点外部根据 state 选路intent === 'review' 去审稿节点
Command节点内部更新 state 并决定下一步审核失败后写入反馈并跳回修改节点
Send给多个并行任务发送不同输入同时生成「背景 / 原理 / 实践」三个小节

2. Command:在节点里直接决定下一步去哪

Command 是一个类。节点不再返回普通的状态对象,而是返回一个 Command 实例。

typescript
// command-shape.ts
import { Command } from '@langchain/langgraph'

return new Command({
  update: { isValid: true }, // 状态更新,和节点正常返回的对象一样

  goto: 'output', // 下一步走到哪个节点
})

两个字段:

  • update:状态更新,和节点正常返回的状态对象一样
  • goto:下一步要走到的节点名

先看一个最简单的完整例子:

typescript
// command-basic.ts
import {
  StateGraph,
  StateSchema,
  START,
  END,
  Command,
} from '@langchain/langgraph'

import type { GraphNode } from '@langchain/langgraph'

import { z } from 'zod'

const State = new StateSchema({
  value: z.string().default(''),

  route: z.string().default(''),
})

// nodeA 根据输入决定下一步去哪

const nodeA: GraphNode<typeof State> = (state) => {
  if (state.value === 'urgent') {
    return new Command({
      update: { route: 'fast' },

      goto: 'fastTrack',
    })
  }

  return new Command({
    update: { route: 'normal' },

    goto: 'normalTrack',
  })
}

const fastTrack: GraphNode<typeof State> = (state) => {
  return { value: `${state.value} → 快速通道处理完成` }
}

const normalTrack: GraphNode<typeof State> = (state) => {
  return { value: `${state.value} → 普通通道处理完成` }
}

const graph = new StateGraph(State)

  // ends 声明 nodeA 可能会跳到哪些节点

  .addNode('nodeA', nodeA, { ends: ['fastTrack', 'normalTrack'] })

  .addNode('fastTrack', fastTrack)

  .addNode('normalTrack', normalTrack)

  .addEdge(START, 'nodeA')

  .addEdge('fastTrack', END)

  .addEdge('normalTrack', END)

  .compile()

const result = await graph.invoke({ value: 'urgent' })

console.log(result.value)

// → urgent → 快速通道处理完成

console.log(result.route)

// → fast

这里最关键的一行:

typescript
// addNode-ends.ts
.addNode('nodeA', nodeA, { ends: ['fastTrack', 'normalTrack'] })

当节点用 Command 做路由时,这篇里的写法都会通过 ends 告诉 LangGraph 这个节点可能会跳到哪些目标。因为 goto 是运行时才确定的,编译阶段先把候选目标声明出来,图才能顺利通过结构检查。

和条件边对比

同样的逻辑,用条件边需要三样东西:

  1. 节点函数——把决策结果写入状态
  2. 路由函数——读状态字段,返回节点名
  3. addConditionalEdges——把路由函数挂到边上

Command 只需要两样:

  1. 节点函数——在内部直接决定状态更新和下一步目标
  2. addNodeends 选项——声明可能的目标

逻辑更集中,代码也更短。

用 Command 重写重试循环

把第四篇的重试循环用 Command 改写。对比一下哪些代码可以省掉。

typescript
// command-retry.ts
import {
  StateGraph,
  StateSchema,
  START,
  END,
  Command,
} from '@langchain/langgraph'

import type { GraphNode } from '@langchain/langgraph'

import { z } from 'zod'

const State = new StateSchema({
  draft: z.string().default(''),

  retryCount: z.number().default(0),

  feedback: z.string().default(''),
})

// generate 不需要改,它只负责生成草稿

const generate: GraphNode<typeof State> = (state) => {
  if (state.retryCount === 0) {
    return { draft: 'LangGraph 是一个框架。', retryCount: 1 }
  }

  return {
    draft: `LangGraph 是一个基于图的 AI 工作流编排框架,支持状态管理、条件路由和持久化。(第 ${state.retryCount + 1} 版)`,

    retryCount: state.retryCount + 1,
  }
}

// validate 现在直接决定下一步去哪,不需要单独的路由函数了

const validate: GraphNode<typeof State> = (state) => {
  if (state.draft.length >= 30) {
    return new Command({
      update: { feedback: '' },

      goto: 'output',
    })
  }

  if (state.retryCount >= 3) {
    return new Command({
      update: { feedback: '已达最大重试次数' },

      goto: 'output',
    })
  }

  return new Command({
    update: {
      feedback: `草稿太短(${state.draft.length} 字),至少需要 30 字`,
    },

    goto: 'generate',
  })
}

const output: GraphNode<typeof State> = (state) => {
  return { draft: `最终结果:${state.draft}` }
}

const graph = new StateGraph(State)

  .addNode('generate', generate)

  // ends 声明 validate 可能跳到的所有目标

  .addNode('validate', validate, { ends: ['generate', 'output'] })

  .addNode('output', output)

  .addEdge(START, 'generate')

  .addEdge('generate', 'validate')

  .addEdge('output', END)

  .compile()

const result = await graph.invoke({})

console.log(result.draft)

// → 最终结果:LangGraph 是一个基于图的 AI 工作流编排框架...

和第四篇对比,省掉了:

  • isValid 状态字段——不需要了,validate 内部直接判断完就跳转
  • checkResult 路由函数——不需要了,路由逻辑在 validate 节点里
  • addConditionalEdges——不需要了,换成 addNodeends

注意 generate → validate 之间还是用的普通边。因为 generate 执行完一定走 validate,没有条件分支。只有 validate 的下一步不确定,所以只有它用了 Command

什么时候用 Command,什么时候用条件边

不需要一刀切,按这个思路选:

  • 路由只是简单地读一个布尔值或枚举字段 → 条件边就够了
  • 路由逻辑和节点逻辑紧密绑定,「算完就知道该走哪条路」 → Command 更自然
  • 需要在同一步里既更新状态又决定路由 → 用 Command 最顺手

两种方式可以在同一张图里混用。

3. Send:让同一个节点并行跑多份

Command 解决的是路由问题。Send 解决的是另一个问题:一个节点需要并行跑多份,每份拿到不同的输入。

最典型的场景是 map-reduce:

  • 先把一个任务拆成 N 份(map)
  • 每份并行处理
  • 最后把结果汇总(reduce)

比如用户给了三个主题,你想同时为三个主题生成摘要,而不是排队生成。

Send 的构造函数只有两个参数:

typescript
// send-shape.ts
import { Send } from '@langchain/langgraph'

new Send('targetNode', { topics: ['React'] })

//        ↑ 目标节点名    ↑ 传给这个节点的状态

每个 Send 实例代表「调用一次目标节点,传入这份特定的状态」。返回多个 Send,就是并行调用多次。

在条件边里使用 Send

Send 最基础的用法是在条件边的路由函数里返回一个 Send 数组:

typescript
// send-basic.ts
import {
  StateGraph,
  StateSchema,
  ReducedValue,
  Send,
  START,
  END,
} from '@langchain/langgraph'

import type { GraphNode } from '@langchain/langgraph'

import { z } from 'zod'

const appendStrings = (current: string[], update: string[]) => {
  return [...current, ...update]
}

const State = new StateSchema({
  topics: z.array(z.string()).default([]),

  // 多个并行节点都会写 summaries,所以必须用 reducer 追加

  summaries: new ReducedValue(
    z.array(z.string()).default([]),

    { reducer: appendStrings },
  ),
})

// 这个节点会被并行调用多次,每次拿到一个不同的 topic

const summarize: GraphNode<typeof State> = (state) => {
  const topic = state.topics[0]

  return {
    summaries: [`「${topic}」的摘要:这是一段关于 ${topic} 的分析...`],
  }
}

// 路由函数返回 Send 数组,触发并行扇出(fan-out)

// 这里的“扇出”是分布式系统里的常用说法,意思是把一份输入拆成多份子任务,同时分发给多个并行执行的分支

const fanOut = (state: typeof State.type) => {
  return state.topics.map((topic) => new Send('summarize', { topics: [topic] }))
}

const graph = new StateGraph(State)

  .addNode('summarize', summarize)

  .addConditionalEdges(START, fanOut)

  .addEdge('summarize', END)

  .compile()

const result = await graph.invoke({
  topics: ['React', 'LangGraph', 'Zustand'],
})

console.log(result.summaries)

// [

//   '「React」的摘要:这是一段关于 React 的分析...',

//   '「LangGraph」的摘要:这是一段关于 LangGraph 的分析...',

//   '「Zustand」的摘要:这是一段关于 Zustand 的分析...',

// ]

这里有三个要点。

第一,fanOut 不是返回一个节点名,而是返回一个 Send 数组。每个 Send 都指向同一个 summarize 节点,但传入的状态不同。LangGraph 会把它们并行执行。

第二,summaries 字段必须声明 reducer。因为三个并行节点都在往这个字段里写,如果没有 reducer,结果会互相覆盖,只留下最后一个。

第三,Send 的第二个参数是传给目标节点的状态。这里传的是 { topics: [topic] },只包含一个 topic。目标节点 summarize 拿到的 state.topics 就只有一个元素。

在 Command 的 goto 里使用 Send

Send 不只能在条件边里用。它也可以放在 Commandgoto 字段里,这样就能一步完成状态更新 + 并行扇出(fan-out,也就是把一份任务拆成多份后同时派发出去):

typescript
// command-send.ts
import {
  StateGraph,
  StateSchema,
  ReducedValue,
  Command,
  Send,
  START,
  END,
} from '@langchain/langgraph'

import type { GraphNode } from '@langchain/langgraph'

import { z } from 'zod'

const appendStrings = (current: string[], update: string[]) => {
  return [...current, ...update]
}

const State = new StateSchema({
  topics: z.array(z.string()).default([]),

  summaries: new ReducedValue(
    z.array(z.string()).default([]),

    { reducer: appendStrings },
  ),

  status: z.string().default(''),
})

// dispatcher 既更新状态,又触发并行扇出

const dispatcher: GraphNode<typeof State> = (state) => {
  return new Command({
    update: { status: `正在处理 ${state.topics.length} 个主题` },

    goto: state.topics.map(
      (topic) => new Send('summarize', { topics: [topic] }),
    ),
  })
}

const summarize: GraphNode<typeof State> = (state) => {
  const topic = state.topics[0]

  return {
    summaries: [`${topic} 分析完成`],
  }
}

const graph = new StateGraph(State)

  .addNode('dispatcher', dispatcher, { ends: ['summarize'] })

  .addNode('summarize', summarize)

  .addEdge(START, 'dispatcher')

  .addEdge('summarize', END)

  .compile()

const result = await graph.invoke({
  topics: ['性能优化', '错误处理', '状态管理'],
})

console.log(result.status)

// → 正在处理 3 个主题

console.log(result.summaries)

// → ['性能优化 分析完成', '错误处理 分析完成', '状态管理 分析完成']

这就是 Command + Send 的组合:一步完成状态更新 + 并行扇出。

4. 实战:多章节内容并行生成

把前面的能力综合起来,做一个更接近实际的例子。

场景:用户要求写一篇技术文章 → 解析出章节列表 → 并行生成每个章节草稿 → 汇总成一版完整文章。

typescript
// parallel-sections.ts
import {
  StateGraph,
  StateSchema,
  MessagesValue,
  ReducedValue,
  Command,
  Send,
  START,
  END,
} from '@langchain/langgraph'

import type { GraphNode } from '@langchain/langgraph'

import { z } from 'zod'

const appendStrings = (current: string[], update: string[]) => {
  return [...current, ...update]
}

const State = new StateSchema({
  messages: MessagesValue,

  sections: z.array(z.string()).default([]),

  sectionDrafts: new ReducedValue(
    z.array(z.string()).default([]),

    { reducer: appendStrings },
  ),
})

// 1. 解析节点:从用户消息里提取章节列表,然后扇出到生成节点

const parseSections: GraphNode<typeof State> = (state) => {
  const userMsg = state.messages.at(-1)?.content?.toString() ?? ''

  // 实际项目里这里会用 LLM 拆大纲,这里简化处理

  const knownSections = ['背景', '核心概念', '代码示例', '实践边界', '总结']

  const found = knownSections.filter((section) => userMsg.includes(section))

  if (found.length === 0) {
    // 没找到明确章节,直接给出提示

    return new Command({
      update: {
        messages: [{ role: 'assistant', content: '没有识别到要生成的章节。' }],
      },

      goto: END,
    })
  }

  // 找到了章节,更新章节列表 + 并行扇出生成

  return new Command({
    update: { sections: found },

    goto: found.map(
      (section) => new Send('writeSection', { sections: [section] }),
    ),
  })
}

// 2. 生成节点:写单个章节(会被并行调用多次)

const writeSection: GraphNode<typeof State> = (state) => {
  const section = state.sections[0]

  const data: Record<string, string> = {
    背景: '背景:从单 Agent 工具循环走到复杂业务流程时,状态、分支和恢复能力会变得重要。',

    核心概念: '核心概念:LangGraph 用节点、边和状态来描述 Agent 的执行过程。',

    代码示例:
      '代码示例:最小图通常包含 StateSchema、addNode、addEdge、compile 和 invoke。',

    实践边界:
      '实践边界:简单工具调用可以继续用 createAgent,复杂流程再引入 LangGraph。',

    总结: '总结:当流程需要分支、恢复和人工介入时,用图来组织会更清楚。',
  }

  return {
    sectionDrafts: [data[section] ?? `${section}:暂无草稿`],
  }
}

// 3. 汇总节点:把所有章节草稿组织成最终回复

const formatReport: GraphNode<typeof State> = (state) => {
  const report = state.sectionDrafts.join('\n\n')

  return {
    messages: [
      {
        role: 'assistant',

        content: `已生成 ${state.sections.length} 个章节草稿:\n\n${report}`,
      },
    ],
  }
}

const graph = new StateGraph(State)

  .addNode('parseSections', parseSections, { ends: ['writeSection'] })

  .addNode('writeSection', writeSection)

  .addNode('formatReport', formatReport)

  .addEdge(START, 'parseSections')

  .addEdge('writeSection', 'formatReport')

  .addEdge('formatReport', END)

  .compile()

const result = await graph.invoke({
  messages: [
    { role: 'user', content: '帮我写背景、核心概念和实践边界三个章节' },
  ],
})

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

// → 已生成 3 个章节草稿:

// →

// → 背景:从单 Agent 工具循环走到复杂业务流程时...

// → 核心概念:LangGraph 用节点、边和状态...

// → 实践边界:简单工具调用可以继续用 createAgent...

这张图的执行流程:

  1. parseSections 从用户消息里提取出「背景、核心概念、实践边界」三个章节
  2. Command 同时完成两件事:把章节列表写入状态 + 用 Send 扇出三个并行生成任务
  3. 三个 writeSection 并行执行,各自生成一个章节草稿,结果通过 reducer 汇总到 sectionDrafts
  4. formatReport 拿到全部草稿,拼成最终回复

如果没有 Send,你只能顺序写三个章节;如果没有 Command,你需要额外的条件边和状态字段来完成路由。两者组合在一起,代码更紧凑,语义也更清晰。

5. 使用注意

Command 的 goto 会追加一条动态出边

如果你同时给一个节点加了普通边和 Command 路由:

typescript
// gotcha-edge.ts
graph

  .addEdge('nodeA', 'nodeB') // 普通边

  .addNode('nodeA', nodeA, { ends: ['nodeC'] }) // nodeA 返回 Command({ goto: 'nodeC' })

nodeBnodeC 都会执行。Commandgoto 不会取消已有的普通边,它只是额外追加了一条动态边。所以,如果你打算把某个节点的出路完全交给 Command,就不要再给它保留普通出边。

ends 要一起写上

Command 做路由时,addNodeends 最好和节点一起声明。这样一来,图编译时能先知道这个节点可能跳到哪些目标;二来读代码时也能一眼看清这个节点的出口范围。

ends 的作用是告诉 LangGraph:这个节点可能会动态跳到哪些目标。图编译时需要这个信息来验证结构完整性和生成可视化。

Send 的并行结果需要 reducer

多个 Send 并行写同一个状态字段时,必须给这个字段声明 reducer(第三篇讲过的 ReducedValue)。没有 reducer 的话,多个并行写入会互相覆盖,最后只留下其中一个的值。

Command 和条件边可以混用

一张图里可以同时有用 Command 路由的节点和用条件边路由的节点。选哪种取决于那个节点的具体场景,不需要全图统一。

6. 总结

这一篇往前多走了一步:前面的条件边,解决的是“根据状态决定下一步去哪”;这里的 Command,解决的是“节点自己在更新状态的同时,直接决定下一步去哪”;Send 则是把“一份任务拆成多份并行去跑”这件事补上。

所以它们的关系不是替代,而是分工更细:

  • 简单条件分叉,条件边就够了
  • 节点里已经算完了,也知道下一步去哪,用 Command
  • 同一个节点要并行跑很多份,用 Send
  • 既要改状态又要并行扇出,就把 CommandSend 组合起来

下一篇讲 interrupt:暂停与恢复机制。到那时,图不只是“继续往下走”,还会在运行中停下来,等外部输入后再继续。

基于 MIT 协议开源