主题
概述
要点
- 前面 LangChain 章节已经讲到单 Agent 多工具、Middleware 和 Tracing
createAgent适合工具调用循环,但复杂业务会开始需要显式流程、结构化状态和恢复能力- LangGraph 用图(Graph)描述 Agent 执行流程,核心是节点、边和状态
- 条件边把模型推理和系统流程控制分开,让路由逻辑写回代码里
- LangGraph 不是替代 LangChain,而是在 LangChain 组件之上增加编排层
内容
1. LangChain 的 Agent 走到这里,哪些事变得吃力了
前面整个 LangChain 章节,我们从消息协议一路讲到了单 Agent 多工具、Middleware、Tracing。
一个 Agent 已经能连续调多个工具、根据中间结果决定下一步做什么,也能记录完整调用链路。很多问答、工具调用和轻量自动化场景,用 createAgent 就足够了。
但继续往复杂业务里走,会碰到几个更具体的问题。这里的问题不是「LangChain 不行」,而是 createAgent 默认把很多流程决策交给模型推理;当业务规则开始变多时,你会希望这些决策回到代码里。
1.1 执行流程只能往前走,不能清楚地回到某一步
LangChain 的 createAgent 本质上是一条工具调用循环:
txt
// create-agent-loop.txt
调模型 → 判断要不要调工具 → 调工具 → 回到模型 → 结束这个循环适合「模型自己决定下一步」的场景。但真实业务经常需要更明确的回退、重试和等待。
比如一个内容创作智能体收到一条任务:
txt
// backtrack-scene.txt
帮我写一篇介绍 LangGraph 的技术文章,先给大纲,再写正文,最后检查是否适合发布。Agent 生成完正文后,质量检查发现内容太像 API 摘要,没有讲清楚适用场景。这时候你希望它回到「补案例」这一步,而不是直接结束:
txt
这篇草稿缺少真实使用场景,我先补一个内容审核流程的例子,再重新检查是否可发布。在 createAgent 的循环里,你很难把「回到补案例节点」这件事写成明确流程。常见做法是把提示塞进工具返回值里,让模型自己理解下一步应该修改草稿。但这相当于用自然语言模拟控制流,稳定性取决于模型是否每次都理解对。
1.2 状态管理靠消息列表硬撑
LangChain Agent 的主要状态是消息列表。所有上下文、中间结果、决策依据,最终都会回到 messages 里。
简单场景下这没有问题。但当管线开始跟踪更多业务字段时,消息列表会变得不够直接。比如你可能同时需要知道:
- 当前创作流程走到了哪一步
- 已经生成过哪些标题和大纲版本
- 当前草稿是否通过风格检查
- 发布审核状态现在停在哪里
如果这些都藏在消息文本里,后面的节点要读取状态时,就只能从自然语言里提取或猜测。
更稳的做法,是有一份结构化状态对象。每个字段有明确类型,节点可以直接读写。比如 outlineVersions 就是数组,reviewStatus 就是枚举,retryCount 就是数字。
1.3 没有持久化,断了就没了
createAgent 跑完一轮就结束。中间状态通常留在当前调用上下文里,进程退出或请求结束以后,就需要你自己想办法保存。
如果 Agent 只处理一次性问答,这不是问题。但如果流程会跨很长时间,比如:
- 内容负责人隔几个小时才回来确认标题和发布渠道
- 长任务执行到一半,服务重启后要恢复
- 对话需要沿着同一个线程继续往下跑
只靠内存里的消息列表会很吃力。你需要一个机制,在图每一步执行后保存状态,并在下一次调用时恢复回来。
1.4 多步骤之间没有显式的业务流转关系
createAgent 内部的执行顺序主要由模型推理决定。模型觉得该调什么工具就调什么工具,觉得该结束就结束。
但很多业务规则不应该交给模型猜。比如:
- 事实核查必须在发布建议之前
- 内容负责人确认后才能进入发布节点
- 风格检查失败超过三次后必须交给人工改稿
- 大纲、正文和风险提示都完成后才能汇总结果
这些规则更适合写成代码里的流转关系,而不是藏在一段系统提示词里。
把这几类问题放在一起看,会得到一个比较清楚的边界:
| 复杂度上来以后需要什么 | createAgent 默认更偏向 | LangGraph 补上的能力 |
|---|---|---|
| 明确步骤顺序 | 模型推理决定下一步 | 节点和边显式定义流程 |
| 结构化业务状态 | 消息列表承载上下文 | StateSchema 定义状态字段 |
| 回退、重试、暂停 | 通过提示词引导模型 | 条件边、interrupt()、回边 |
| 跨请求恢复 | 需要自己保存 | Checkpointer 保存和恢复 |
| 多角色或多阶段协作 | 工具循环里继续堆逻辑 | 子图、多 Agent、并行节点 |
这就是 LangGraph 要解决的问题:当 Agent 不再只是一次模型调用或一条工具循环,而是一条可以分支、恢复、暂停和长期演进的业务流程时,用图来组织它。
2. LangGraph 的核心心智模型:节点 + 边 + 状态
LangGraph 用**图(Graph)**描述 Agent 的执行流程。
如果你有前端开发背景,可以先把它类比成一个状态机。图里最重要的是三件事:
- 节点(Node):一个处理步骤。它接收当前状态,执行逻辑,然后返回状态更新。
- 边(Edge):节点之间的连线。它定义「执行完 A 之后去 B」。
- 状态(State):所有节点共享的结构化对象。节点读取状态、返回更新,下一个节点拿到更新后的状态。
和 LangChain 的 createAgent 放在一起看,差别会更清楚:
| 维度 | createAgent(LangChain) | StateGraph(LangGraph) |
|---|---|---|
| 执行流程 | 隐式循环,由模型推理决定 | 显式定义节点和边,开发者控制流转 |
| 状态 | 主要是消息列表 | 结构化对象,每个字段有类型和更新规则 |
| 分支 | 靠模型理解提示词 | 条件边根据状态路由 |
| 回退与暂停 | 需要自己模拟 | 通过条件边、回边和 interrupt() 表达 |
| 持久化 | 需要自行处理 | Checkpointer 保存和恢复线程状态 |
| 适合场景 | 单 Agent 工具调用 | 多步骤、长流程、可恢复的 Agent 应用 |
画成图的话,前面那个「模型决定是否调用工具」的循环,在 LangGraph 里大概长这样:
txt
// graph-structure.txt
┌──────────┐
│ START │
└────┬─────┘
│
▼
┌───────────────┐
│ 调用模型 │◄──────────────┐
└───────┬───────┘ │
│ │
有工具调用? │
╱ ╲ │
是 否 │
│ │ │
▼ ▼ │
┌────────────┐ ┌────────┐ │
│ 执行工具 │ │ END │ │
└─────┬──────┘ └────────┘ │
│ │
└──────────────────────────────┘这个结构和 createAgent 内部做的事有相似之处:都是循环调模型和工具。差别在于,LangGraph 让这些步骤和流转关系变成显式结构。你可以看到它、修改它、给它加条件分支,也可以在某个节点上接入持久化、人工确认或容错路径。
3. 先跑一个最小的 Graph,感受「图」的运行方式
在深入细节之前,先用一个最小例子感受 LangGraph 的基本用法。这个例子不接 LLM,只保留四步:
- 定义状态。
- 添加节点。
- 连接边。
- 编译并运行。
3.1 安装
shellscript
// install.sh
yarn add @langchain/langgraph @langchain/core@langchain/langgraph 是 LangGraph 本体,@langchain/core 是 LangChain 的核心抽象层,里面有消息类型、工具接口等基础能力。
3.2 定义状态
LangGraph 的状态用 StateSchema 定义。每个字段可以是普通 Zod schema,也可以是 LangGraph 提供的特殊状态类型。
typescript
// first-graph-state.ts
import { StateSchema, MessagesValue } from '@langchain/langgraph'
import { z } from 'zod'
const MyState = new StateSchema({
// MessagesValue:专门用于消息列表的特殊类型
// 新消息会追加到列表末尾,而不是覆盖整个 messages
messages: MessagesValue,
// 普通 Zod schema:每次更新直接覆盖旧值
currentStep: z.string().default('init'),
})这里先记住两个概念:
MessagesValue:LangGraph 预置的消息列表类型。节点返回新消息时,新消息会追加到已有列表里。- 普通 Zod schema:比如
currentStep。节点返回新值时,旧值会被直接覆盖。
消息列表为什么要特殊处理?因为对话历史通常要保留下来。如果一个节点回复了「你好」,下一个节点再回复「再见」,我们希望最终状态里两条消息都在,而不是后一条覆盖前一条。
3.3 定义节点和边,构建图
typescript
// first-graph.ts
import {
StateGraph,
StateSchema,
MessagesValue,
START,
END,
} from '@langchain/langgraph'
import type { GraphNode } from '@langchain/langgraph'
import { z } from 'zod'
// 1. 定义状态
const MyState = new StateSchema({
messages: MessagesValue,
currentStep: z.string().default('init'),
})
// 2. 定义节点
// 每个节点接收当前状态,返回状态的部分更新
const greet: GraphNode<typeof MyState> = (state) => {
const userName = state.messages.at(-1)?.content ?? '朋友'
return {
messages: [{ role: 'assistant', content: `你好,${userName}!` }],
currentStep: 'greeted',
}
}
const farewell: GraphNode<typeof MyState> = () => {
return {
messages: [{ role: 'assistant', content: '再见,有问题随时来找我。' }],
currentStep: 'done',
}
}
// 3. 构建图
const graph = new StateGraph(MyState)
.addNode('greet', greet)
.addNode('farewell', farewell)
.addEdge(START, 'greet')
.addEdge('greet', 'farewell')
.addEdge('farewell', END)
.compile()
// 4. 运行
const result = await graph.invoke({
messages: [{ role: 'user', content: '小明' }],
})
console.log(result.currentStep)
// → 'done'
for (const msg of result.messages) {
console.log(`[${msg.getType()}]: ${msg.content}`)
}
// → [human]: 小明
// → [ai]: 你好,小明!
// → [ai]: 再见,有问题随时来找我。这里输出里的 human 和 ai,是运行时消息对象的类型名。我们在节点返回值里写的 role: 'user'、role: 'assistant',是更常见的输入对象写法。两种写法描述的是同一组消息,只是处在不同层。
这个例子故意简单,但它已经展示了 LangGraph 的核心流程:
| 步骤 | API | 作用 |
|---|---|---|
| 定义状态 | StateSchema | 声明图里有哪些数据、怎么更新 |
| 定义节点 | addNode | 每个节点读状态、做计算、返回更新 |
| 定义边 | addEdge | 说明节点之间怎么流转 |
| 编译运行 | compile + invoke | 把定义变成可执行的图 |
3.4 和 createAgent 的直观对比
如果用 LangChain 的 createAgent 做同样的事,大概会写成:
typescript
// langchain-way.ts
import { createAgent } from 'langchain'
const agent = createAgent({
model: 'openai:gpt-4.1-mini',
tools: [],
systemPrompt: '先打招呼,然后说再见。',
})这种写法把顺序放进了系统提示词。模型大多数时候会照做,但流程本身没有被代码表达出来。
而 LangGraph 的方式,顺序写在边里:
txt
START → greet → farewell → END这就是两者的第一个关键差别:当「先做什么、后做什么」本身就是业务规则时,图比提示词更适合承载这个规则。
4. 加上条件边:让图根据状态做决策
刚才的例子是一条直线,没有分支。现实中 Agent 的价值恰恰在于「根据情况走不同的路」。
LangGraph 用**条件边(Conditional Edge)**实现分支路由。下面这个例子里,节点先分析用户情绪,把结果写进 mood 字段;后面的路由函数再根据 mood 决定走哪条边。
typescript
// conditional-graph.ts
import {
StateGraph,
StateSchema,
MessagesValue,
START,
END,
} from '@langchain/langgraph'
import type { GraphNode, ConditionalEdgeRouter } from '@langchain/langgraph'
import { z } from 'zod'
const State = new StateSchema({
messages: MessagesValue,
mood: z.enum(['happy', 'sad', 'neutral']).default('neutral'),
})
const analyzeMood: GraphNode<typeof State> = (state) => {
const lastMsg = state.messages.at(-1)?.content?.toString() ?? ''
let mood: 'happy' | 'sad' | 'neutral' = 'neutral'
if (lastMsg.includes('开心') || lastMsg.includes('高兴')) mood = 'happy'
if (lastMsg.includes('难过') || lastMsg.includes('伤心')) mood = 'sad'
return { mood }
}
const happyReply: GraphNode<typeof State> = () => ({
messages: [
{ role: 'assistant', content: '很高兴听到你这么开心!继续保持好心情。' },
],
})
const sadReply: GraphNode<typeof State> = () => ({
messages: [{ role: 'assistant', content: '别难过,有什么我能帮你的吗?' }],
})
const neutralReply: GraphNode<typeof State> = () => ({
messages: [{ role: 'assistant', content: '你好,有什么可以帮你的?' }],
})
const moodRouter: ConditionalEdgeRouter<
typeof State,
'happyReply' | 'sadReply' | 'neutralReply'
> = (state) => {
switch (state.mood) {
case 'happy':
return 'happyReply'
case 'sad':
return 'sadReply'
default:
return 'neutralReply'
}
}
const graph = new StateGraph(State)
.addNode('analyzeMood', analyzeMood)
.addNode('happyReply', happyReply)
.addNode('sadReply', sadReply)
.addNode('neutralReply', neutralReply)
.addEdge(START, 'analyzeMood')
.addConditionalEdges('analyzeMood', moodRouter, [
'happyReply',
'sadReply',
'neutralReply',
])
.addEdge('happyReply', END)
.addEdge('sadReply', END)
.addEdge('neutralReply', END)
.compile()
const result = await graph.invoke({
messages: [{ role: 'user', content: '今天好开心啊' }],
})
console.log(result.messages.at(-1)?.content)
// → '很高兴听到你这么开心!继续保持好心情。'画出来就是这样:
txt
// conditional-flow.txt
┌──────────┐
│ START │
└────┬─────┘
│
▼
┌───────────────┐
│ analyzeMood │
└───────┬───────┘
│
mood 是什么?
╱ │ ╲
happy neutral sad
│ │ │
▼ ▼ ▼
┌──────┐ ┌───────┐ ┌──────┐
│happy │ │neutral│ │ sad │
│Reply │ │Reply │ │Reply │
└──┬───┘ └───┬───┘ └──┬───┘
│ │ │
└─────────┼────────┘
│
▼
┌──────┐
│ END │
└──────┘这里最值得注意的是:模型或节点可以参与「理解」,但路由逻辑写在代码里。
在这个例子里,analyzeMood 把判断结果写进 mood;moodRouter 根据 mood 做确定性路由。换成真实 Agent 时,analyzeMood 这一步可以由模型完成,但「happy 去 happyReply、sad 去 sadReply」这件事仍然由代码控制。
这就是 LangGraph 很重要的一层设计:把 AI 的非确定性推理和系统的确定性流程控制分开。 模型负责理解和生成,图负责流转和控制。
5. LangGraph 和 LangChain 的关系:不是替代,是分层
一个常见误解是:学了 LangGraph 就不需要 LangChain 了。
更准确的理解是:它们处在不同层。
- LangChain 提供组件:ChatModel、Tool、Prompt、OutputParser、Retriever。它解决的是「怎么和模型、工具、检索器交互」。
- LangGraph 提供编排:节点、边、状态、持久化、暂停恢复。它解决的是「多个步骤怎么组合成一条可控流程」。
在 LangGraph 的节点里,你照样会用 LangChain 的 ChatModel 调模型,用 LangChain 的 Tool 定义工具。LangGraph 不替代这些组件,它只是给你一种更强的方式来组织它们。
typescript
// langgraph-uses-langchain.ts
import { ChatOpenAI } from '@langchain/openai'
import { tool } from '@langchain/core/tools'
import {
StateGraph,
StateSchema,
MessagesValue,
START,
END,
} from '@langchain/langgraph'
import type { GraphNode } from '@langchain/langgraph'
import { z } from 'zod'
const model = new ChatOpenAI({ model: 'gpt-4.1-mini' })
const getWeather = tool(async ({ city }) => `${city}:明天小雨,17-22 度`, {
name: 'get_weather',
description: '查询天气',
schema: z.object({ city: z.string() }),
})
const modelWithTools = model.bindTools([getWeather])
const State = new StateSchema({
messages: MessagesValue,
})
const callModel: GraphNode<typeof State> = async (state) => {
const response = await modelWithTools.invoke(state.messages)
return { messages: [response] }
}
const graph = new StateGraph(State)
.addNode('callModel', callModel)
.addEdge(START, 'callModel')
.addEdge('callModel', END)
.compile()如果要做选择,可以先按这个表判断:
| 场景 | 更适合先用 | 原因 |
|---|---|---|
| 一次性问答、简单工具调用 | createAgent | 代码少,模型自主决定下一步即可 |
| 工具数量不多,流程不需要恢复 | createAgent | 没必要提前引入图结构 |
| 有明确分支、回退、重试 | LangGraph | 流转关系需要写成代码 |
| 需要人工审批、暂停后恢复 | LangGraph | interrupt() 和 Checkpointer 更贴近这类流程 |
| 多阶段、多角色、长期演进的 Agent 应用 | LangGraph | 状态、子图和多 Agent 协作更容易维护 |
所以更实用的判断不是「学了谁就不用谁」,而是先看你的 Agent 复杂度。简单工具循环继续用 createAgent;当你开始需要显式流程、结构化状态和可恢复执行,再把 LangGraph 接进来。
6. 收一下这一篇
这一篇先把 LangGraph 的轮廓搭起来。
前面 LangChain 里的单 Agent,在很多场景下已经够用。但只要开始碰到明确分支、结构化状态、持久化、暂停和回退,流程就不能继续只靠提示词和消息列表硬撑。LangGraph 做的事情,是把这部分流程控制拿回代码里。
从下一篇开始,就不再停在总览上了。我们会把 StateSchema、节点、Reducer、条件边这些部分一块一块拆开,先从最基础的 StateGraph 写法开始。