Skip to content

StateGraph 基础

要点

  • 上一篇我们用一个最小的例子感受了 LangGraph 的基本流程:定义状态 → 添加节点 → 连接边 → 编译运行
  • StateGraph 可以先理解成一张「会携带状态往前走」的流程图
  • 状态是一个具体的、有类型的共享对象
  • 节点是图里的处理单元:读取状态,执行逻辑,返回局部更新
  • 节点定义了「做什么」,边定义了「做完以后去哪」
  • compile() 把图定义转化为可运行实例,invoke() 传入初始状态并触发执行

内容

1. 上一篇看到了全貌,这一篇开始动手

上一篇我们用一个最小的例子感受了 LangGraph 的基本流程:定义状态 → 添加节点 → 连接边 → 编译运行。

但那个例子跳过了很多细节。

这一篇把 StateGraph 的基础能力拆开来讲,目标是让你能独立构建一个包含多个节点、结构化状态、正确流转的图。

我重新对照了 LangGraph 官方的 Graph API、Quickstart 和 StateGraph API Reference。官方对 StateGraph 的定义比较克制:它是一类「节点通过读取和写入共享 state 来通信」的图;节点接收已定义的 State,返回 Partial<State>;每个状态字段可以选择配置 reducer,用来决定多个更新怎样合并。

把这句话换成更容易上手的说法:

StateGraph 是一张会执行的流程图。State 像一张工单,节点像处理工单的岗位,边像流转规则,reducer 则决定同一个字段收到新内容时是覆盖、追加还是累加。

先把这四个对象的位置放稳,后面的 API 会容易很多。

对象负责什么在代码里通常长什么样
State保存这轮图执行里的共享数据new StateSchema({ messages, intent })
Node读取状态,执行一步处理,返回局部更新const node: GraphNode<typeof State> = (state) => ({ ... })
Edge决定节点之间的流转关系.addEdge('a', 'b') / .addConditionalEdges(...)
Reducer决定字段更新时怎么合并MessagesValue / new ReducedValue(...)

下面先从最核心的问题开始:状态是什么,怎么定义。

2. 状态:所有节点共享的数据容器

在 LangGraph 里,状态是一个具体的、有类型的对象。它在所有节点之间共享:每个节点读取当前状态,执行自己的逻辑,然后返回状态的部分更新。

StateSchema 来定义状态的结构。

这里可以先记住一个原则:State 里只放「这轮流程会继续往下用」的数据。比如内容创作智能体里的用户消息、当前意图、草稿、审核结果,适合放在 State 里;用户 ID、团队 ID、运行环境这类外部背景,更适合放到运行时配置或后面会讲的 runtime context 里。

2.1 最简单的状态:一个消息列表

绝大多数 AI 应用的状态里都有一个消息列表。LangGraph 为此提供了专门的类型 MessagesValue

typescript
// simple-state.ts
import { StateSchema, MessagesValue } from '@langchain/langgraph'

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

MessagesValue 不是一个普通的数组类型。它内置了一个合并逻辑(LangGraph 里叫 reducer):当节点返回新消息时,新消息通常会追加到已有列表末尾,而不是替换掉整个列表。官方文档还补充了一个细节:如果新消息带有已有消息的 ID,MessagesValue 会按 ID 更新对应消息,而不是简单重复追加。

举个例子说明这个行为:

typescript
// messages-append.ts
// 假设当前状态是:

// { messages: [{ role: 'user', content: '你好' }] }

// 节点返回:

return {
  messages: [{ role: 'assistant', content: '你好!有什么可以帮你的?' }],
}

// 更新后的状态变成:

// {

//   messages: [

//     { role: 'user', content: '你好' },

//     { role: 'assistant', content: '你好!有什么可以帮你的?' }    ← 追加

//   ]

// }

如果没有这个合并逻辑,节点返回的新消息会直接覆盖掉之前所有的消息历史。对话上下文会在状态层面断掉,后面的节点也读不到前面发生过什么。

MessagesValue 还会自动做消息反序列化——你传入 { role: 'user', content: '...' } 这样的普通对象,它会自动转换成 LangChain 的 HumanMessage 实例。所以你在节点里可以用 LangChain 的消息类型方法(比如 msg.getType()),也可以直接传普通对象,两种都行。

2.2 加上普通字段

除了消息列表,你通常还需要其他状态字段来跟踪业务信息。普通的 Zod schema 字段遵循最简单的更新规则:每次赋值直接覆盖旧值。 官方文档把它称为默认 reducer:忽略旧值,用节点返回的新值替换对应字段。

typescript
// state-with-fields.ts
import { StateSchema, MessagesValue } from '@langchain/langgraph'

import { z } from 'zod'

const State = new StateSchema({
  // 消息列表:追加合并

  messages: MessagesValue,

  // 普通字段:直接覆盖

  currentStep: z.string().default('init'),

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

  userId: z.string().optional(),
})

这里 .default() 的作用是设定初始值。当你第一次调用 graph.invoke() 时,如果没有传入 currentStep,它的值就是 'init'

几种更新行为的对比:

字段类型节点返回值更新行为适合存什么
MessagesValue新消息数组追加到已有列表对话历史
普通 Zod schema新值直接覆盖旧值当前步骤、计数器、标志位
ReducedValue新增量按 reducer 合并日志、计数、并行结果

NOTE

下一篇会继续往下讲 ReducedValue,也就是普通字段在不想直接覆盖时,应该怎么声明自己的合并方式。

3. 节点:读取状态、执行逻辑、返回更新

节点是图里的处理单元。每个节点就是一个函数,签名可以先理解成这样:

typescript
// node-signature.ts
// 接收当前状态,返回状态的部分更新

;(state: State) => Partial<State>

「部分更新」是关键。 节点不需要返回完整的状态对象,只需要返回你想修改的字段。没有返回的字段保持不变。

这也是 StateGraph 和普通函数流水线不太一样的地方。普通流水线里,函数 A 的返回值经常就是函数 B 的完整输入;StateGraph 里,节点返回的是「对共享状态的更新」。LangGraph 会把这份更新按字段规则合并回 State,再把新的 State 交给后续节点。

3.1 用 GraphNode 约束类型

LangGraph 提供了 GraphNode 类型,帮你把节点函数的输入输出类型和状态定义关联起来:

typescript
// graphnode-type.ts
import {
  StateGraph,
  StateSchema,
  MessagesValue,
  START,
  END,
} from '@langchain/langgraph'

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

import { z } from 'zod'

const State = new StateSchema({
  messages: MessagesValue,

  currentStep: z.string().default('init'),
})

// 用 GraphNode<typeof State> 约束节点函数的类型

const myNode: GraphNode<typeof State> = (state) => {
  // state.messages —— 类型自动推导为消息数组

  // state.currentStep —— 类型自动推导为 string

  return {
    currentStep: 'processed',

    // 没有返回 messages,所以 messages 保持不变
  }
}

GraphNode<typeof State> 做了两件事:

  1. state 参数的类型被自动推导成你定义的状态结构
  2. 返回值被约束为状态的部分更新——你只能返回状态里存在的字段

如果你拼错了字段名(比如写成 currentStpe),TypeScript 会直接报错。

3.2 节点可以是同步的,也可以是异步的

大多数实际场景里,节点需要调用外部服务(模型、数据库、API),所以通常是异步的:

typescript
// async-node.ts
// 异步节点:调用外部模型

// 这里假设 model 是前面创建好的 ChatOpenAI 实例

const callModel: GraphNode<typeof State> = async (state) => {
  const response = await model.invoke(state.messages)

  return {
    messages: [response],

    currentStep: 'model-called',
  }
}

// 同步节点:纯粹的数据处理

const checkResult: GraphNode<typeof State> = (state) => {
  const lastMsg = state.messages.at(-1)

  return {
    currentStep: lastMsg ? 'has-response' : 'no-response',
  }
}

LangGraph 会自动处理 Promise,你不需要做任何额外配置。

3.3 节点的第二个参数:config

节点函数还有一个可选的第二个参数 config,用来接收运行时的配置信息:

typescript
// node-config.ts
const myNode: GraphNode<typeof State> = (state, config) => {
  // config.configurable 里可以拿到调用时传入的自定义参数

  const userId = config?.configurable?.user_id

  console.log('当前用户:', userId)

  return { currentStep: 'done' }
}

// 调用时传入 configurable

await graph.invoke(
  { messages: [{ role: 'user', content: '你好' }] },

  { configurable: { user_id: 'user_2048' } },
)

config 的典型用途:传递用户 ID、会话 ID、环境标识这些不属于状态但节点需要知道的信息。它不会被写入状态,也不会成为 StateSchema 的一部分。后面讲运行时上下文时,还会把 configcontextstate 的边界再拆开。

4. 边:定义节点之间的流转关系

节点定义了「做什么」,边定义了「做完以后去哪」。

如果把 State 当作工单,节点就是处理工单的岗位,边就是工单从一个岗位流到下一个岗位的规则。固定边适合确定流程,条件边适合根据当前状态选择下一步。

4.1 普通边:确定性的流转

typescript
// normal-edges.ts
import { START, END } from '@langchain/langgraph'

// 假设 nodeA、nodeB、nodeC 是前面定义好的节点函数

const graph = new StateGraph(State)

  .addNode('nodeA', nodeA)

  .addNode('nodeB', nodeB)

  .addNode('nodeC', nodeC)

  // 普通边:固定的流转路径

  .addEdge(START, 'nodeA') // 入口 → A

  .addEdge('nodeA', 'nodeB') // A → B

  .addEdge('nodeB', 'nodeC') // B → C

  .addEdge('nodeC', END) // C → 结束

  .compile()

STARTEND 是 LangGraph 的两个特殊节点:

  • START:图的入口。addEdge(START, 'nodeA') 表示「收到输入后,第一个执行 nodeA」
  • END:图的终点。addEdge('nodeC', END) 表示「nodeC 执行完后,整个图结束,返回最终状态」

普通边是无条件的——A 执行完一定去 B,没有「如果……就……」。

4.2 条件边:根据状态决定走哪条路

条件边是 LangGraph 很关键的一层能力。上一篇已经展示过一个情绪路由的例子,这里用更简洁的方式回顾核心 API:

typescript
// conditional-edges.ts
import type { ConditionalEdgeRouter } from '@langchain/langgraph'

// 路由函数:接收当前状态,返回下一个节点的名称

const router: ConditionalEdgeRouter<typeof State, 'nodeB' | 'nodeC'> = (
  state,
) => {
  if (state.currentStep === 'need-more') {
    return 'nodeB'
  }

  return 'nodeC'
}

const graph = new StateGraph(State)

  .addNode('nodeA', nodeA)

  .addNode('nodeB', nodeB)

  .addNode('nodeC', nodeC)

  .addEdge(START, 'nodeA')

  // 条件边:nodeA 执行完后,调用 router 函数决定去哪

  .addConditionalEdges('nodeA', router, ['nodeB', 'nodeC'])

  .addEdge('nodeB', END)

  .addEdge('nodeC', END)

  .compile()

addConditionalEdges 的三个参数:

  1. 源节点名称:从哪个节点出发
  2. 路由函数:接收当前状态,返回目标节点名称(或 END
  3. 可能的目标列表:声明这条条件边可能去哪些节点(帮助 LangGraph 做图结构校验)

路由函数也可以返回 END,表示直接结束:

typescript
// router-end.ts
const router: ConditionalEdgeRouter<typeof State, 'retry'> = (state) => {
  if (state.retryCount >= 3) return END // 重试超过 3 次就结束

  return 'retry'
}

NOTE

条件边的详细用法会在第 4 篇单独展开。这里先知道基本语法即可。

5. 编译与运行:从定义到执行

5.1 compile():把定义转化为可运行的图

所有的 addNodeaddEdge 都是在定义图的结构。调用 compile() 之后,LangGraph 会把这份定义变成可运行对象。你可以把这一步理解成「把流程图装配成执行计划」。

根据官方 API reference,StateGraph 在添加节点和边之后必须先调用 .compile(),然后才能使用这张图。

调用 compile() 时,LangGraph 会:

  1. 检查图的结构是否合法(比如有没有孤立节点、有没有从 START 到不了的节点)
  2. 生成一个可执行的 CompiledGraph 实例
typescript
// compile.ts
// 这只是定义,还不能运行

const builder = new StateGraph(State)

  .addNode('greet', greet)

  .addEdge(START, 'greet')

  .addEdge('greet', END)

// 编译后才能运行

const graph = builder.compile()

你必须在调用 invoke 之前调用 compile。 直接在 builder 上调用 invoke 会报错。

5.2 invoke():传入初始状态,运行图

typescript
// invoke.ts
const result = await graph.invoke({
  messages: [{ role: 'user', content: '你好' }],
})

invoke 的参数就是初始状态。LangGraph 会:

  1. 把初始状态和 StateSchema 的默认值合并
  2. 从 START 开始,按边的定义依次执行节点
  3. 每个节点的返回值会更新状态
  4. 到达 END 后,返回最终状态

返回值 result 就是经过所有节点处理后的完整状态对象。

5.3 传入运行时配置

invoke 的第二个参数是配置对象,可以传自定义参数给节点:

typescript
// invoke-config.ts
const result = await graph.invoke(
  // 第一个参数:初始状态

  { messages: [{ role: 'user', content: '你好' }] },

  // 第二个参数:运行时配置

  {
    configurable: {
      user_id: 'user_2048',

      session_id: 'session_001',
    },
  },
)

节点通过 config.configurable 就能拿到这些值(前面 3.3 节已经展示过)。

6. 完整示例:一个内容创作智能体的路由图

把前面的知识点串起来,构建一个稍微有点业务含义的图。

场景:用户发来内容创作需求 → 判断当前要做大纲、草稿还是审核 → 进入对应节点 → 返回结果。

这个例子故意不接真实 LLM。原因是这一篇先讲 StateGraph 的骨架:状态怎么更新、节点怎么流转、条件边怎么选路。如果这里直接接模型,读者很容易把注意力放到 prompt 和模型输出上,反而看不清图本身的执行规则。

typescript
// complete-example.ts
import {
  StateGraph,
  StateSchema,
  MessagesValue,
  START,
  END,
} from '@langchain/langgraph'

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

import { z } from 'zod'

// 1. 定义状态

const State = new StateSchema({
  messages: MessagesValue,

  intent: z.enum(['outline', 'draft', 'review', 'unknown']).default('unknown'),

  handled: z.boolean().default(false),
})

// 2. 定义节点

// 意图识别:从用户消息中判断意图

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

  let intent: 'outline' | 'draft' | 'review' | 'unknown' = 'unknown'

  if (lastMsg.includes('大纲') || lastMsg.includes('结构')) {
    intent = 'outline'
  } else if (lastMsg.includes('草稿') || lastMsg.includes('正文')) {
    intent = 'draft'
  } else if (lastMsg.includes('审核') || lastMsg.includes('检查')) {
    intent = 'review'
  }

  return { intent }
}

// 处理大纲生成

const handleOutline: GraphNode<typeof State> = (_state) => {
  return {
    messages: [
      {
        role: 'assistant',
        content: '我先给这篇文章拆一个三段式大纲:背景、核心机制、实践边界。',
      },
    ],

    handled: true,
  }
}

// 处理草稿生成

const handleDraft: GraphNode<typeof State> = (_state) => {
  return {
    messages: [
      {
        role: 'assistant',
        content:
          '我会基于当前大纲生成第一版草稿,并保留后续审核需要的状态字段。',
      },
    ],

    handled: true,
  }
}

// 处理内容审核

const handleReview: GraphNode<typeof State> = (_state) => {
  return {
    messages: [
      {
        role: 'assistant',
        content: '我会检查草稿是否有事实风险、结构断层和发布前需要补充的案例。',
      },
    ],

    handled: true,
  }
}

// 兜底处理

const handleFallback: GraphNode<typeof State> = (_state) => {
  return {
    messages: [
      {
        role: 'assistant',
        content: '我不太确定你想说什么,能再说清楚一点吗?',
      },
    ],

    handled: true,
  }
}

// 3. 路由函数

const intentRouter: ConditionalEdgeRouter<
  typeof State,
  'handleOutline' | 'handleDraft' | 'handleReview' | 'handleFallback'
> = (state) => {
  switch (state.intent) {
    case 'outline':
      return 'handleOutline'

    case 'draft':
      return 'handleDraft'

    case 'review':
      return 'handleReview'

    default:
      return 'handleFallback'
  }
}

// 4. 构建图

const graph = new StateGraph(State)

  .addNode('classify', classify)

  .addNode('handleOutline', handleOutline)

  .addNode('handleDraft', handleDraft)

  .addNode('handleReview', handleReview)

  .addNode('handleFallback', handleFallback)

  .addEdge(START, 'classify')

  .addConditionalEdges('classify', intentRouter, [
    'handleOutline',
    'handleDraft',
    'handleReview',
    'handleFallback',
  ])

  .addEdge('handleOutline', END)

  .addEdge('handleDraft', END)

  .addEdge('handleReview', END)

  .addEdge('handleFallback', END)

  .compile()

// 5. 运行

const result = await graph.invoke({
  messages: [{ role: 'user', content: '帮我先出一版 LangGraph 文章大纲' }],
})

console.log('意图:', result.intent)

// → 意图: outline

console.log('已处理:', result.handled)

// → 已处理: true

for (const msg of result.messages) {
  console.log(`[${msg.getType()}]: ${msg.content}`)
}

// → [human]: 帮我先出一版 LangGraph 文章大纲

// → [ai]: 我先给这篇文章拆一个三段式大纲:背景、核心机制、实践边界。

这个例子虽然没有接入真实 LLM,但它展示了 LangGraph 最重要的几个能力:

结构化的状态——intenthandled 是明确的、有类型的字段,不是塞在消息文本里的模糊信息。你可以直接读取 result.intent 来判断当前创作阶段,不需要从自然语言里去「猜」。

显式的流程控制——意图识别之后走哪条路,是代码里的 switch 决定的。这意味着你可以把关键业务流程留在代码里控制,而不是全部交给模型自由发挥。

节点职责单一——每个节点只做一件事:classify 只管识别创作阶段,handleOutline 只管生成大纲,handleReview 只管审核提示。这让每个节点都可以独立测试和替换。

7. 图的执行模型:消息传递

最后补充一个理解 LangGraph 行为的关键概念。

LangGraph 的底层执行模型受 Pregel 启发,可以用消息传递(Message Passing)来理解。这里的「消息」指的是节点之间传递的状态更新,不要和聊天消息里的 messages 字段混在一起。

执行过程是这样的:

  1. 所有节点初始状态为「不活跃」
  2. 从 START 出发,第一个节点收到初始状态,变为「活跃」
  3. 节点执行完毕后,把状态更新「发送」给下一个节点(通过边的定义)
  4. 下一个节点收到更新后变为「活跃」,开始执行
  5. 重复这个过程,直到到达 END

如果一个节点后面接了多条普通边,而且这些节点之间没有依赖关系,LangGraph 会在后续的超级步骤(super-step)里并行推进它们。

条件边的执行方式不同。它会先运行路由函数,再根据返回值决定下一步只走哪一条路,而不是把所有候选分支一起推进。

这也解释了为什么 reducer 很重要:并行节点可能在同一个超级步骤里更新同一个字段。如果这个字段没有 reducer,LangGraph 只能按默认规则覆盖;如果你希望收集多份结果,就要提前声明「这些更新应该怎样合并」。

8. 总结

这一篇覆盖了 StateGraph 的基础骨架:

  1. 状态StateSchema 定义,MessagesValue 追加合并,普通 Zod 字段直接覆盖
  2. 节点是接收状态、返回部分更新的函数,用 GraphNode<typeof State> 约束类型
  3. addEdge 定义固定流转,用 addConditionalEdges 定义条件分支
  4. STARTEND 标记图的入口和出口
  5. compile() 把定义转化为可执行的图,invoke() 传入初始状态运行

下一篇继续往下讲 Reducer。到那时,状态字段不只是“直接覆盖”,还可以按追加、累加、去重这些不同语义来更新。

基于 MIT 协议开源