📖 本章学习目标
- ✅ 深刻理解节点(Node)、边(Edge)、状态(State)三者的关系
- ✅ 掌握使用
Annotation定义类型安全的自定义状态- ✅ 学会添加普通边、条件边和自环边
- ✅ 理解图的编译与执行流程
- ✅ 能够独立设计并实现一个多节点工作流图
- ✅ 避免常见的状态管理和路由错误
一、为什么用"图"来建模 Agent
1、传统链式调用的局限
在 LangChain 早期,开发者使用 链(Chain) 来组织 LLM 调用。链是线性的——输入进去,依次经过每个步骤,输出出来。这在简单场景下没问题,但当业务逻辑复杂时,麻烦就来了:
- ❌ 无法根据 LLM 的输出动态决定"下一步走哪"
- ❌ 无法在某个步骤"等待人类审批"后继续
- ❌ 无法在出错时跳回某个节点重试
- ❌ 多 Agent 协作时的状态共享极其复杂
链就像工厂的流水线,产品只能按固定顺序经过每个工位,无法根据产品质量决定是否需要返工或跳过某些工序。
2、图模型的优势
图(Graph)天然支持这些场景:节点表示"做什么",边表示"走哪里",状态表示"记住什么"。
| 维度 | 链(Chain) | 图(Graph) |
|---|---|---|
| 执行路径 | 固定线性 | 动态分支 |
| 循环支持 | ❌ 不支持 | ✅ 原生支持 |
| 状态管理 | 手动传递 | 自动共享 |
| 人机交互 | ❌ 困难 | ✅ 内置支持 |
| 可视化调试 | ❌ 困难 | ✅ 图结构清晰 |
图就像城市的交通网络,你可以根据实时路况(状态)选择不同的路线(边),到达不同的目的地(节点)。
二、三大核心概念
1、状态(State)——图的"记忆"
状态是整个图的共享数据容器。你可以把它想象成一个白板——图中的每个节点都能读取白板上的内容,也能往白板上写新内容。
关键特性:状态在节点之间自动传递和合并,你不需要手动把一个节点的输出传给下一个节点。
状态的生命周期:
- 初始化:调用
graph.invoke()时提供初始状态 - 传递:每个节点接收当前完整的 State
- 更新:节点返回需要更新的字段
- 合并:LangGraph 使用 Reducer 合并新旧状态
- 持久化(可选):通过 Checkpointer 保存到数据库
2、节点(Node)——图的"工人"
节点是图中的处理单元,是一个纯函数(或异步函数):
节点函数签名:(state: State) => Partial<State> | Promise<Partial<State>>
- 输入:当前完整的 State
- 输出:需要更新的 State 字段(只需返回变化的部分)
节点就像流水线上的工人——拿到当前工件(State),加工后把变化的部分交回去。
节点的最佳实践:
✅ 好的节点设计:
- 单一职责:每个节点只做一件事
- 纯函数:相同的输入产生相同的输出
- 无副作用:不修改外部变量,只操作 State
- 快速执行:避免长时间阻塞
❌ 不好的节点设计:
- 承担多个职责(既分析又搜索又回复)
- 依赖全局变量或外部状态
- 直接修改传入的 state 对象
- 执行时间不可控(如无限循环)
3、边(Edge)——图的"道路"
边决定节点执行完后"下一步去哪"。LangGraph 支持三种边:
| 边类型 | 说明 | 方法 | 适用场景 |
|---|---|---|---|
| 普通边 | A 执行完永远去 B | addEdge(A, B) | 顺序执行的固定流程 |
| 条件边 | 根据 State 动态决定去哪 | addConditionalEdges(A, routerFn) | 分支逻辑、动态路由 |
| 起止边 | 连接内置的起点/终点 | addEdge('__start__', A) | 图的入口和出口 |
三、使用 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 模式:
- 覆盖(默认):
(curr, update) => update - 追加:
(curr, update) => [...curr, ...update] - 累加:
(curr, update) => curr + update - 合并对象:
(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:设计工作流
我们以构建一个文章写作助手为例: 接受话题 → 生成大纲 → 根据大纲写作 → 输出结果 。
步骤 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 根据大纲撰写完整文章,同时返回 article 和 wordCount 两个字段的更新,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 动态决定下一步走哪个节点:
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 函数不纯 | 随机行为、难以复现的 Bug | Reducer 必须是纯函数,不能有副作用或随机性 |
📝 本章小结
核心知识点回顾
| 知识点 | 关键要点 | 应用场景 |
|---|---|---|
| State | 全图共享的数据容器,通过 Annotation 定义 | 所有图都需要 |
| Node | (state) => Partial<state> 的纯函数 | 业务逻辑处理单元 |
| 普通边 | addEdge(A, B) 固定路径 | 顺序执行的步骤 |
| 条件边 | addConditionalEdges 动态路由 | 分支逻辑 |
| Reducer | 控制 State 字段的合并方式 | 消息追加、计数累加等 |
| compile() | 将图定义编译为可执行对象 | 所有图必须调用 |
🎯 动手练习
练习 1:设计三节点工作流
- 目标:构建"问题分析→方案生成→结果优化"三节点图
- 要求:
- 每个节点读取前节点的输出,逐步完善结果
- 使用自定义 State 追踪每个阶段的输出
- 添加字数统计功能
- 验收标准:最终输出包含经过三轮优化的完整方案,且能查看每轮的中间结果
练习 2:实现动态分支
- 目标:根据用户输入的语言(中文/英文),路由到不同的回复节点
- 要求:
- 使用
addConditionalEdges实现 - 创建语言检测函数(可用正则或简单关键词匹配)
- 中文节点和英文节点使用不同的 System Prompt
- 使用
- 验收标准:中文输入触发中文节点,英文触发英文节点,混合语言能正确识别主要语言
练习 3:自定义 Reducer
- 目标:创建一个"只保留最新5条消息"的自定义 Reducer
- 要求:
- 实现
reducer: (current, update) => [...current, ...update].slice(-5) - 测试超过5条消息时的行为
- 验证旧消息是否被正确丢弃
- 实现
- 验收标准:超过5条消息时,旧消息自动被丢弃,State 中始终只有最近5条
练习 4:并行节点实验
- 目标:实现一个并行搜索 Agent,同时查询多个数据源
- 要求:
- 路由函数返回多个节点名称,触发并行执行
- 创建3个搜索节点(模拟不同数据源)
- 添加合并节点,汇总所有搜索结果
- 验收标准:3个搜索节点并发执行,总耗时接近最慢的那个节点,而非三者之和
📚 延伸阅读
下一章:第4章 —— 状态管理深入:如何设计 State

980

被折叠的 条评论
为什么被折叠?



