深入浅出 LangGraph —— 第3章:图的核心概念:节点、边与状态

开发板推荐:天空星STM32F407VET6开发板

超高性价比 STM32主控 | 超高主频 | 一板兼容百芯 | 比赛神器 | 沉金彩色丝印

📖 本章学习目标

  • ✅ 深刻理解节点(Node)、边(Edge)、状态(State)三者的关系
  • ✅ 掌握使用 Annotation 定义类型安全的自定义状态
  • ✅ 学会添加普通边、条件边和自环边
  • ✅ 理解图的编译与执行流程
  • ✅ 能够独立设计并实现一个多节点工作流图
  • ✅ 避免常见的状态管理和路由错误

一、为什么用"图"来建模 Agent

1、传统链式调用的局限

在 LangChain 早期,开发者使用 链(Chain) 来组织 LLM 调用。链是线性的——输入进去,依次经过每个步骤,输出出来。这在简单场景下没问题,但当业务逻辑复杂时,麻烦就来了:

  • ❌ 无法根据 LLM 的输出动态决定"下一步走哪"
  • ❌ 无法在某个步骤"等待人类审批"后继续
  • ❌ 无法在出错时跳回某个节点重试
  • ❌ 多 Agent 协作时的状态共享极其复杂

输入

步骤1

步骤2

步骤3

输出

链就像工厂的流水线,产品只能按固定顺序经过每个工位,无法根据产品质量决定是否需要返工或跳过某些工序。

2、图模型的优势

图(Graph)天然支持这些场景:节点表示"做什么",边表示"走哪里",状态表示"记住什么"。

维度链(Chain)图(Graph)
执行路径固定线性动态分支
循环支持❌ 不支持✅ 原生支持
状态管理手动传递自动共享
人机交互❌ 困难✅ 内置支持
可视化调试❌ 困难✅ 图结构清晰

需要搜索

直接回答

▶ START

节点A
分析问题

条件判断

节点B
搜索信息

节点C
生成回复

⏹ END

图就像城市的交通网络,你可以根据实时路况(状态)选择不同的路线(边),到达不同的目的地(节点)。


二、三大核心概念

1、状态(State)——图的"记忆"

状态是整个图的共享数据容器。你可以把它想象成一个白板——图中的每个节点都能读取白板上的内容,也能往白板上写新内容。

读取 State

更新 State

读取 State

更新 State

📋 State
{messages, count, ...}

节点A

节点B

关键特性:状态在节点之间自动传递和合并,你不需要手动把一个节点的输出传给下一个节点。

状态的生命周期:

  1. 初始化:调用 graph.invoke() 时提供初始状态
  2. 传递:每个节点接收当前完整的 State
  3. 更新:节点返回需要更新的字段
  4. 合并:LangGraph 使用 Reducer 合并新旧状态
  5. 持久化(可选):通过 Checkpointer 保存到数据库

2、节点(Node)——图的"工人"

节点是图中的处理单元,是一个纯函数(或异步函数):

节点函数签名:(state: State) => Partial<State> | Promise<Partial<State>>
  • 输入:当前完整的 State
  • 输出:需要更新的 State 字段(只需返回变化的部分)

节点就像流水线上的工人——拿到当前工件(State),加工后把变化的部分交回去。

节点的最佳实践:

好的节点设计:

  • 单一职责:每个节点只做一件事
  • 纯函数:相同的输入产生相同的输出
  • 无副作用:不修改外部变量,只操作 State
  • 快速执行:避免长时间阻塞

不好的节点设计:

  • 承担多个职责(既分析又搜索又回复)
  • 依赖全局变量或外部状态
  • 直接修改传入的 state 对象
  • 执行时间不可控(如无限循环)

3、边(Edge)——图的"道路"

决定节点执行完后"下一步去哪"。LangGraph 支持三种边:

边类型说明方法适用场景
普通边A 执行完永远去 BaddEdge(A, B)顺序执行的固定流程
条件边根据 State 动态决定去哪addConditionalEdges(A, routerFn)分支逻辑、动态路由
起止边连接内置的起点/终点addEdge('__start__', A)图的入口和出口

普通边

addEdge

节点A

节点B

条件边

return 'B'

return 'C'

节点A

routerFn

节点B

节点C

起止边

addEdge

addEdge

__start__

节点A

__end__


三、使用 Annotation 定义状态

1、内置注解:MessagesAnnotation

LangGraph 提供了开箱即用的消息状态注解:

import { MessagesAnnotation, StateGraph } from '@langchain/langgraph';

// MessagesAnnotation 等价于以下定义:
// { messages: BaseMessage[], 带自动追加(append)的 reducer }
const graph = new StateGraph(MessagesAnnotation);

MessagesAnnotation内置了 messages 字段,其 reducer 是"追加"语义:新消息追加到历史,而不是替换。 这是对话类 Agent 最常用的状态定义,无需手动定义,直接使用即可

2、自定义注解:Annotation.Root

当你需要除消息之外的自定义字段时,使用 Annotation.Root:

import { Annotation, StateGraph } from '@langchain/langgraph';

// 定义自定义状态
const AgentState = Annotation.Root({
  // 字符串字段:默认 reducer 是"覆盖"
  topic: Annotation<string>(),
  
  // 数字字段:自定义 reducer(累加)
  retryCount: Annotation<number>({
    reducer: (current, update) => current + update,
    default: () => 0,
  }),
  
  // 数组字段:自定义 reducer(追加)
  results: Annotation<string[]>({
    reducer: (current, update) => [...current, ...update],
    default: () => [],
  }),
});
  • Annotation.Root({}):创建状态定义,每个字段对应状态的一部分
  • Annotation<T>():简单字段,默认 reducer 是"用新值覆盖旧值"
  • reducer:自定义合并逻辑,(当前值, 新值) => 合并后的值
  • default:字段的初始值工厂函数,必须提供以避免 undefined

常见 Reducer 模式:

  1. 覆盖(默认):(curr, update) => update
  2. 追加:(curr, update) => [...curr, ...update]
  3. 累加:(curr, update) => curr + update
  4. 合并对象:(curr, update) => ({ ...curr, ...update })

3、访问状态类型

// 从注解中提取 TypeScript 类型,用于函数签名
type AgentStateType = typeof AgentState.State;

// 在节点函数中使用
async function myNode(state: AgentStateType) {
  console.log('当前话题:', state.topic);
  console.log('重试次数:', state.retryCount);
  
  return {
    retryCount: 1,  // 触发 reducer:retryCount += 1
    results: ['新结果'],  // 触发 reducer:追加到数组
  };
}

通过AgentState.State提取状态的 TypeScript 类型定义。节点执行后只需返回需要更新的字段,未返回的字段保持不变,其返回值经过 reducer 处理后才写入 State,TypeScript 会检查返回值的类型是否正确

4、组合内置和自定义状态

实际项目中,经常需要同时使用消息历史和自定义字段:

import { Annotation, MessagesAnnotation } from '@langchain/langgraph';

const CustomAgentState = Annotation.Root({
  // 继承 MessagesAnnotation 的消息管理能力
  ...MessagesAnnotation.spec,
  
  // 添加自定义字段
  userId: Annotation<string>(),
  sessionId: Annotation<string>(),
  metadata: Annotation<Record<string, any>>({
    reducer: (curr, update) => ({ ...curr, ...update }),
    default: () => ({}),
  }),
});

通过MessagesAnnotation.spec继承内置的消息状态, 这样既能享受消息自动追加,又能添加自定义字段,
metadata使用合并对象 Reducer,适合存储灵活的 KV 数据。


四、构建多节点工作流

步骤 1:设计工作流

我们以构建一个文章写作助手为例: 接受话题 → 生成大纲 → 根据大纲写作 → 输出结果

__start__

📝 生成大纲节点

✍️ 撰写文章节点

📊 统计字数节点

__end__

步骤 2:定义状态

import * as dotenv from 'dotenv';
dotenv.config();

import { Annotation, StateGraph } from '@langchain/langgraph';
import { ChatOpenAI } from '@langchain/openai';
import { HumanMessage } from '@langchain/core/messages';

// 定义state
const WriterState = Annotation.Root({
  topic: Annotation<string>(),           // 写作话题
  outline: Annotation<string>(),         // 文章大纲
  article: Annotation<string>(),         // 最终文章
  wordCount: Annotation<number>(),       // 字数统计
});

// 提取state类型定义
type WriterStateType = typeof WriterState.State;

// 创建模型
const model = new ChatOpenAI({ 
  model: 'gpt-4o-mini', 
  temperature: 0.8 
});

步骤 3:定义节点函数

// 节点1:生成大纲
async function generateOutline(state: WriterStateType) {
  const response = await model.invoke([
    new HumanMessage(
      `请为以下话题生成一篇文章的大纲(3-5个要点):\n话题:${state.topic}`
    ),
  ]);
  return { outline: response.content as string };
}

代码流程解读:
先读取 state.topic 获取写作主题,调用 LLM 生成大纲,最后返回 { outline: ... },只更新 outline 字段,其他字段(topic, article, wordCount)保持不变。

// 节点2:撰写文章
async function writeArticle(state: WriterStateType) {
  const response = await model.invoke([
    new HumanMessage(
      `请根据以下大纲,撰写一篇完整的文章:\n${state.outline}`
    ),
  ]);
  const article = response.content as string;
  return {
    article,
    wordCount: article.length,
  };
}

有了之前的节点生成的大纲(state.outline ),就可以再次调用 LLM 根据大纲撰写完整文章,同时返回 articlewordCount 两个字段的更新,LangGraph 会自动将这两个字段合并到 State 中。

// 节点3:统计字数(演示多节点协作)
async function calculateStats(state: WriterStateType) {
  const words = state.article.split(/\s+/).length;
  const chars = state.article.length;
  
  console.log(`📊 统计完成: ${words} 词, ${chars} 字符`);
  
  // 这里可以添加更多统计逻辑
  return {};  // 不更新任何字段,仅做日志记录
}

最终返回空对象 {},表示不更新 State, 这种节点适合做日志记录、监控埋点等副作用操作。虽然返回空对象,但节点仍会被执行。

步骤 4:组装图并运行

const writerGraph = new StateGraph(WriterState)
  .addNode('generateOutline', generateOutline)
  .addNode('writeArticle', writeArticle)
  .addNode('calculateStats', calculateStats)
  .addEdge('__start__', 'generateOutline')
  .addEdge('generateOutline', 'writeArticle')
  .addEdge('writeArticle', 'calculateStats')
  .addEdge('calculateStats', '__end__')
  .compile();

代码解读:

  • addNode + addEdge 链式调用:声明式地描述图的结构
  • 每个节点必须先注册(addNode),才能被边引用
  • __start____end__ 是 LangGraph 内置的特殊节点
  • compile():将图定义编译成可执行的 Runnable 对象,这一步非常关键,未编译的图无法执行

主运行函数:

async function main() {
  const result = await writerGraph.invoke({ 
    topic: 'TypeScript 5.0 新特性' 
  });
  
  console.log('📋 大纲:\n', result.outline);
  console.log('\n📄 文章:\n', result.article);
  console.log(`\n📊 字数:${result.wordCount}`);
}

main().catch(console.error);

代码解读:

  • invoke({ topic: '...' }):提供初始状态,启动图执行
  • 只提供 topic 字段,其他字段会使用 default 值或 undefined
  • 执行顺序:start → generateOutline → writeArticle → calculateStats → end
  • result 包含执行完成后的完整最终 State
  • .catch(console.error):捕获并打印异步错误

期望输出:

📊 统计完成: 856 词, 4523 字符

📋 大纲:
 1. TypeScript 5.0 概述
 2. 装饰器改进
 3. 枚举增强
 4. 性能优化
 5. 总结与展望

📄 文章:
 TypeScript 5.0 带来了许多令人兴奋的新特性...

📊 字数:4523 字

五、条件边:动态路由

1、概念

条件边允许根据当前 State 动态决定下一步走哪个节点:

需要搜索

直接回答

分析节点

路由函数

搜索节点

回答节点

__end__

2、实现条件边

const RouterState = Annotation.Root({
  question: Annotation<string>(),
  needsSearch: Annotation<boolean>(),
  answer: Annotation<string>(),
});

type RouterStateType = typeof RouterState.State;

// 路由函数:返回下一个节点的名称(字符串)
function routerFunction(state: RouterStateType): string {
  if (state.needsSearch) {
    return 'searchNode';
  }
  return 'answerNode';
}

关键点

  • 路由函数接收 State,返回"字符串"(下一节点名称)
  • 也可以返回字符串数组,实现同时触发多个节点(并行执行)
  • 路由函数是纯函数,不做实际处理,只做"导航"
  • 返回值必须与已注册的节点名完全一致,否则报错
const routerGraph = new StateGraph(RouterState)
  .addNode('analyzeNode', analyzeNode)
  .addNode('searchNode', searchNode)
  .addNode('answerNode', answerNode)
  .addEdge('__start__', 'analyzeNode')
  // 条件边:analyzeNode 执行后,调用 routerFunction 决定下一步
  .addConditionalEdges('analyzeNode', routerFunction, {
    searchNode: 'searchNode',
    answerNode: 'answerNode',
  })
  .addEdge('searchNode', 'answerNode')
  .addEdge('answerNode', '__end__')
  .compile();

代码解读:

  • addConditionalEdges参数:(源节点, 路由函数, 映射表)
  • 映射表将路由函数返回值映射到实际节点名(可省略,名称相同时)
  • searchNode → answerNode:搜索完后还是要回答
  • 如果路由函数返回的节点名不在映射表中,会抛出错误

3、高级用法:并行执行

路由函数可以返回字符串数组,触发多个节点并行执行,适合需要聚合多个数据源的场景:

function parallelRouter(state: RouterStateType): string[] {
  // 同时触发搜索和缓存查询
  return ['searchNode', 'cacheNode'];
}

const parallelGraph = new StateGraph(RouterState)
  .addNode('analyzeNode', analyzeNode)
  .addNode('searchNode', searchNode)
  .addNode('cacheNode', cacheNode)
  .addNode('mergeNode', mergeNode)
  .addEdge('__start__', 'analyzeNode')
  .addConditionalEdges('analyzeNode', parallelRouter)
  .addEdge('searchNode', 'mergeNode')
  .addEdge('cacheNode', 'mergeNode')
  .addEdge('mergeNode', '__end__')
  .compile();

六、最佳实践和踩坑指南

💡 实践 1:Reducer 的选择

❌ 错误:对消息使用默认覆盖 Reducer

// 错误:每次节点返回消息都会覆盖历史!
const BadState = Annotation.Root({
  messages: Annotation<BaseMessage[]>(), // 默认 reducer = 覆盖
});

✅ 正确:使用 messagesStateReducer 或 MessagesAnnotation

import { messagesStateReducer } from '@langchain/langgraph';

const GoodState = Annotation.Root({
  messages: Annotation<BaseMessage[]>({
    reducer: messagesStateReducer,  // 追加语义
    default: () => [],
  }),
});

原因:对话历史应当累积,而不是被每次新消息覆盖。

💡 实践 2:节点函数的纯度

❌ 不好的做法:节点依赖外部变量

let globalCounter = 0;

async function badNode(state: MyState) {
  globalCounter++;  // 副作用!难以测试和调试
  return { count: globalCounter };
}

✅ 推荐做法:节点是纯函数

async function goodNode(state: MyState) {
  // 只依赖 state,不修改外部变量
  return { count: state.count + 1 };
}

原因:纯函数易于测试、调试和并行执行。

💡 实践 3:状态的粒度控制

❌ 不好的做法:State 过于庞大

const BloatedState = Annotation.Root({
  rawHtmlContent: Annotation<string>(),     // 可能几百KB!
  fullDocumentText: Annotation<string>(),   // 大量文本
  embeddings: Annotation<number[][]>(),     // 向量数据
  // ...更多大字段
});

✅ 推荐做法:只存关键摘要

const LeanState = Annotation.Root({
  contentSummary: Annotation<string>(),     // 摘要
  documentRef: Annotation<string>(),        // 存 URL 或 ID 引用
  embeddingRef: Annotation<string>(),       // 向量存储的引用
});

原因:State 在检查点时会被序列化存储,过大的 State 会造成性能问题。

⚠️ 常见问题

问题现象解决方案
节点返回完整 State 而不是部分更新某些字段被意外重置为初始值节点只返回需要变化的字段
忘记 compile()graph.invoke is not a function所有 .addNode/.addEdge 调用后必须 .compile()
路由函数返回了不存在的节点名运行时报找不到节点路由函数的返回值必须与已注册的节点名完全一致
State 字段初始值未定义节点访问字段时报 undefined为每个自定义 Annotation 设置 default 工厂函数
直接修改 state 对象状态更新不生效或产生副作用永远返回新对象,不直接修改 state.xxx = value
Reducer 函数不纯随机行为、难以复现的 BugReducer 必须是纯函数,不能有副作用或随机性

📝 本章小结

核心知识点回顾

知识点关键要点应用场景
State全图共享的数据容器,通过 Annotation 定义所有图都需要
Node(state) => Partial<state> 的纯函数业务逻辑处理单元
普通边addEdge(A, B) 固定路径顺序执行的步骤
条件边addConditionalEdges 动态路由分支逻辑
Reducer控制 State 字段的合并方式消息追加、计数累加等
compile()将图定义编译为可执行对象所有图必须调用

🎯 动手练习

练习 1:设计三节点工作流

  • 目标:构建"问题分析→方案生成→结果优化"三节点图
  • 要求:
    1. 每个节点读取前节点的输出,逐步完善结果
    2. 使用自定义 State 追踪每个阶段的输出
    3. 添加字数统计功能
  • 验收标准:最终输出包含经过三轮优化的完整方案,且能查看每轮的中间结果

练习 2:实现动态分支

  • 目标:根据用户输入的语言(中文/英文),路由到不同的回复节点
  • 要求:
    1. 使用 addConditionalEdges 实现
    2. 创建语言检测函数(可用正则或简单关键词匹配)
    3. 中文节点和英文节点使用不同的 System Prompt
  • 验收标准:中文输入触发中文节点,英文触发英文节点,混合语言能正确识别主要语言

练习 3:自定义 Reducer

  • 目标:创建一个"只保留最新5条消息"的自定义 Reducer
  • 要求:
    1. 实现 reducer: (current, update) => [...current, ...update].slice(-5)
    2. 测试超过5条消息时的行为
    3. 验证旧消息是否被正确丢弃
  • 验收标准:超过5条消息时,旧消息自动被丢弃,State 中始终只有最近5条

练习 4:并行节点实验

  • 目标:实现一个并行搜索 Agent,同时查询多个数据源
  • 要求:
    1. 路由函数返回多个节点名称,触发并行执行
    2. 创建3个搜索节点(模拟不同数据源)
    3. 添加合并节点,汇总所有搜索结果
  • 验收标准:3个搜索节点并发执行,总耗时接近最慢的那个节点,而非三者之和

📚 延伸阅读


下一章:第4章 —— 状态管理深入:如何设计 State

开发板推荐:天空星STM32F407VET6开发板

超高性价比 STM32主控 | 超高主频 | 一板兼容百芯 | 比赛神器 | 沉金彩色丝印

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值