☰
LangGraph核心模块拆解:状态机与条件分支构建AI Agent工作流
2026/10/16 7:02:19 网站建设 项目流程

如果你最近在折腾AI Agent,大概率绕不开LangGraph这个名字。我第一次看到它时,心里想的是:又来了一个花架子框架?但把它拆开以后发现,它做的事情其实特别朴实——把你脑子里的“流程状态机”变成可以运行的代码。和普通链式调用不一样,LangGraph把每一步都当成节点,把节点间的跳转规则当成边,状态则交给一个全局对象管理。也就是说,你可以在任意一步停下来查看状态、改变方向、甚至回溯重跑。

这篇文章我打算直接从核心模块入手,用一个完整的小项目把代码讲透。适合已经跑过LangChain、对Agent有点概念、但还没系统看过LangGraph的读者。我先保证你看完能上手写一个带条件分支和状态记忆的图,然后我们再聊持久化、人工介入这些进阶玩法。看完以后,你至少不会再觉得它是个黑盒。

1. LangGraph到底在解决什么问题

1.1 从普通链式调用到有状态编排

先想想以前我们怎么写AI流程。最朴素的做法是链式调用:

llm.invoke(prompt1) -> LLM结果 -> 拼到prompt2 -> llm.invoke(prompt2) -> ...

这种写法的问题在于:流程是固定的、线性的,一旦中间需要根据结果改变路径,代码就变得非常难维护。比如“如果用户情绪是愤怒,就转人工,否则自动回复”,你会发现自己写了一堆if else,还把每一步的临时变量散落在各处。

LangGraph把这个事情抽象成一张图。每个处理步骤是一个节点,节点之间的跳转关系是边。所有的中间结果都写进一个全局状态对象里,谁都能读,谁都可以改。这样一来:

  • 流程可以用图的方式直接画出来,和产品/团队交流成本低。
  • 分支、循环、回退都是图的拓扑关系,而不是散落的逻辑。
  • 运行过程中状态可持久化,相当于给工作流加了“存档”。

我用一个生活化的类比:普通流程就是一条固定传送带,零件从A到B到C,中间没有岔路;LangGraph是一个带分拣口的流水线,传送带可以变轨、可以回流,每个包裹上的标签就是状态,系统靠标签决定下一个该去哪。这个类比我一直跟别人讲,基本都能秒懂。

1.2 核心设计理念:节点、边、状态机

LangGraph的底层核心就三个概念:

  • State(状态):一个数据结构,保存整个流程运行时需要的信息。
  • Node(节点):一个Python函数,接收State,返回对State的增量更新。
  • Edge(边):从哪个节点走到哪个节点的规则。

其中最容易忽略的是State。它不是普通的dict,而是带类型的、可叠加更新的对象。LangGraph允许每个节点只返回需要修改的字段,剩下的字段自动保留。这个设计让代码非常干净,你不需要手动传递一堆上下文。

再往下走,条件边(Conditional Edges)让图有“智能”。一个节点返回结果后,图会根据状态里的某个字段,决定下一步走哪条边。我觉得理解了这三样东西,外加一个编译过程,langgraph的核心就已经拿下一大半了。

2. 核心模块逐个拆解,附代码直击

2.1 StateGraph:整个工作流的骨架

StateGraph是LangGraph的主类,所有模块都围绕它组织。先看一段最基础的代码:

from typing import TypedDict from langgraph.graph import StateGraph class MyState(TypedDict): messages: list user_name: str def node_a(state: MyState) -> dict: # 读取state,返回需要更新的字段 return {"user_name": "张三"} workflow = StateGraph(MyState) workflow.add_node("a", node_a) workflow.set_entry_point("a") workflow.set_finish_point("a") app = workflow.compile()

这里有几个关键点:

  • StateGraph的泛型参数是状态结构,不是节点,很多新手看示例会误以为StateGraph(MyState)是传入某个内置类型。
  • add_node给节点起名字,名字可以随意,但建议用有业务含义的词。
  • set_entry_point和set_finish_point是出入口。不能只有start没有end,否则编译时会直接报错。
  • compile()返回一个可调用的对象,它才是真正用来执行工作流的入口。

我实测下来,StateGraph本身没什么魔法,它做的事情就是把节点和边校验一遍,生成一个可执行的编译图。真正决定行为的是State和节点里的逻辑。

2.2 State:不可变状态与Reducer

State定义的是整张图的“公共记忆”。LangGraph推荐用TypedDict定义,好处是IDE能自动补全,字段结构也非常直观。不过麻烦的地方在于更新规则。

默认情况下,如果两个节点都返回同一个字段,后执行的节点会覆盖先执行的值。这通常在链式流程里没问题,但如果你希望“累加”或者“合并”消息列表,就要用到Reducer。

from typing import Annotated, TypedDict from langgraph.graph import add_messages class ChatState(TypedDict): messages: Annotated[list, add_messages] user_id: str

Annotated的含义是:这个字段的更新不走默认覆盖,而是走add_messages这个函数。add_messages会把新的消息追加到原有列表末尾,而不是整体替换。

我开发时遇到过一个问题:在一个并行节点里,两个分支都返回messages字段,如果没有Reducer,状态就会变成最后执行分支的消息,前一个分支的结果直接丢掉了。后来我统一给列表字段加上Reducer,才彻底解决。所以建议所有列表类型的字段都显式配置Reducer,这不会带来额外成本,但能少踩很多坑。

2.3 Node与Edge:运算单元和路线规则

节点函数是LangGraph里的最小运算单元。它接收当前State,返回一个字典,字典里的字段会被合并到State中。核心写法如下:

def process(state: MyState) -> dict: new_messages = state["messages"] + [{"role": "assistant", "content": "处理完成"}] return {"messages": new_messages}

注意:节点函数不要返回None,也不要返回一个包含所有字段的大字典。最佳实践是只返回你真正改动过的字段,这样代码更清晰,也能减少潜在的冲突。

边的类型分两种:

  • 普通边:从一个节点无条件走到另一个节点。
  • 条件边:根据状态或路由函数的输出,动态决定下一步。

普通边的代码:

workflow.add_edge("a", "b") workflow.set_entry_point("a") workflow.set_finish_point("b")

如果流程是线性的,这种写法非常简单。但如果分支多了,我建议不要每个分支都手动连边,而是用条件边集中管理。

2.4 Conditional Edges:让流程自己拿主意

条件边是LangGraph里最体现“智能”的部分。它的路由函数不是直接返回下一个节点的名字,而是返回一个“键值”,再把键值映射到节点。这样可以避免路由函数和具体节点名耦合。

看一个示例:

def route_after_check(state: MyState) -> str: if state["sentiment"] == "negative": return "human" return "auto_reply" workflow.add_conditional_edges( "classify", route_after_check, { "human": "human_handler", "auto_reply": "auto_reply_node", } )

这个写法的好处是,route_after_check只返回业务含义的字符串,比如“human”“auto_reply”;具体哪个节点处理这个字符串,由后面的字典统一管理。以后改节点名字只需要改映射,不需要动路由函数。

我一开始习惯直接在路由函数里返回节点名,比如return "human_handler"。短期没问题,但图上节点一多,路由函数反复修改,很容易不小心把名字写错。后来全部改成语义化返回,维护成本低很多。

条件边完全可以实现循环效果:让一个节点处理完以后,路由回更早的节点。这就解决了“如果LLM输出不合法就重试”这类需求。要注意的是,循环必须设置一个最大次数,否则可能无限跑下去。

3. 一个完整的可复现示例:智能客服工单处理

3.1 需求场景与状态设计

我拿一个常见的场景来说:智能客服工单分类与回复。用户提交一条问题,系统先做意图和情绪识别,如果用户情绪负面或者问题属于售后类,就转人工;否则直接用自动回复模板。

这个场景很适合用LangGraph演示,因为它同时用到了条件分支和状态持久化。我们定义状态如下:

from typing import Annotated, TypedDict from langgraph.graph import add_messages class TicketState(TypedDict): messages: Annotated[list, add_messages] category: str sentiment: str need_human: bool ticket_id: str final_answer: str
  • messages:保存对话原文,用add_messages做累积。
  • category:问题分类,比如“技术咨询”“售后”“建议”。
  • sentiment:情绪标签。
  • need_human:是否转人工,由分类节点计算后写入。
  • ticket_id:工单号,用来模拟业务标识。
  • final_answer:最终回复内容。

状态里既有简单字段又有列表字段,这样能同时展示Reducer和覆盖更新的区别。设计状态的时候,我的经验是先写下流程里所有节点需要读写的字段,再开始写节点函数,不要边写边加。

3.2 节点函数与分支逻辑实现

先做几个工具函数,模拟LLM的输出:

def classify_and_analyze(state: TicketState) -> dict: user_text = state["messages"][-1]["content"] # 实际项目里这里会调用LLM或专门的分类模型 if "退款" in user_text or "投诉" in user_text: category = "售后" sentiment = "negative" elif "怎么用" in user_text or "安装" in user_text: category = "技术咨询" sentiment = "neutral" else: category = "其他" sentiment = "positive" need_human = category == "售后" or sentiment == "negative" return { "category": category, "sentiment": sentiment, "need_human": need_human, }

这个节点读取状态里的最后一条消息,给出分类、情绪和是否转人工的判断。返回的字段会被写入全局状态,供后面的条件边使用。为了让演示简单,我没有真正调大模型,只用了关键词判断;改成LLM调用时,逻辑是一样的,只是返回之前加一个llm.invoke()。

接着写两个下游节点:一个是自动回复,一个是转人工。

def auto_reply(state: TicketState) -> dict: reply = f"【{state['category']}】您好,这是一条自动回复。您的工单号是: {state['ticket_id']}" return {"final_answer": reply, "messages": [{"role": "assistant", "content": reply}]} def human_handle(state: TicketState) -> dict: reply = f"【{state['category']}】已将工单 {state['ticket_id']} 转给人工客服,请稍候。" return {"final_answer": reply, "messages": [{"role": "assistant", "content": reply}]}

自动回复和人工接待的逻辑都很简单,就是读取状态、生成回复、把回复写回状态。注意消息字段用了Reducer,所以回传消息时用列表追加语义;如果没用Reducer,这条回传语句会覆盖之前的messages,那就出问题了。

条件路由函数:

def decide_next(state: TicketState) -> str: if state["need_human"]: return "human" return "auto"

3.3 编译与调用全流程

把节点和图组合起来:

from langgraph.graph import StateGraph workflow = StateGraph(TicketState) workflow.add_node("classify", classify_and_analyze) workflow.add_node("auto_reply", auto_reply) workflow.add_node("human_handle", human_handle) workflow.set_entry_point("classify") workflow.add_conditional_edges( "classify", decide_next, { "human": "human_handle", "auto": "auto_reply", } ) workflow.add_edge("auto_reply", "__end__") workflow.add_edge("human_handle", "__end__") app = workflow.compile()

注意__end__是一个特殊节点名称,表示流程结束。LangGraph要求从set_entry_point进入的每个分支最终都要能到达__end__,否则编译或运行时可能报错。如果你不想显式连终点边,也可以用set_finish_point("auto_reply")指定。

调用它:

initial_state = { "messages": [{"role": "user", "content": "我要退款,你们的服务太差了"}], "ticket_id": "T20250001", } result = app.invoke(initial_state) print(result["final_answer"])

运行后会走到human_handle分支,因为消息里包含“退款”和“投诉”,sentiment被标记为negative,need_human为True。

参数说明:invoke会返回最终完整状态,而不是只返回输出。所以你可以直接result["final_answer"],也能看到category、sentiment等中间字段。这个特性很实用,调试时可以直接把result打出来看整条链路的状态变化,省去到处打日志的麻烦。

4. 进阶玩法:持久化、人工介入与并行

4.1 Checkpointer:断点续跑和多轮对话

前面那个例子,图运行完状态就丢了。现实场景里我们往往需要保留状态:比如支持多轮对话、允许某次运行中断后再继续。LangGraph用Checkpointer解决这个问题。

from langgraph.checkpoint.memory import MemorySaver checkpointer = MemorySaver() app = workflow.compile(checkpointer=checkpointer) config = {"configurable": {"thread_id": "thread-1"}} result1 = app.invoke(initial_state, config) result2 = app.invoke({"messages": [{"role": "user", "content": "那具体多久能解决?"}]}, config)

关键就两点:

  • 在compile时传入checkpointer。
  • 每次invoke都带上同一个thread_id。

带thread_id之后,LangGraph会把每一次运行的状态快照保存在检查点里。第二次invoke时,它能从同一个thread的旧状态继续,而不是从零开始。我在做多轮客服对话时,这个功能几乎是必需的,否则每轮都要把历史消息手动拼进去。

MemorySaver只适合本地调试,进程一重启数据就没了。生产环境建议用数据库类的持久化实现,LangGraph也提供了基于PostgreSQL等适配器的checkpointer,只是我不推荐在初学阶段就把存储搞复杂,先用内存版跑通,再换存储实现。

4.2 人工介入:interrupt与resume

客服系统里有一种经典需求:自动流程走到某一步时停下来,等人工确认或填写信息,然后继续跑。LangGraph的interrupt机制专门做这件事。

from langgraph.types import interrupt def human_approve(state: TicketState) -> dict: approval = interrupt({ "type": "需要人工确认", "content": state["messages"][-1]["content"], }) return {"need_human": approval.get("approved", False)}

当图执行到这个节点时,遇到interrupt会抛出一个中断信号,把运行状态保存下来,然后暂停。之后你通过外部程序把用户的确认结果传回同一个thread,图就会从刚才中断的地方继续执行。

app.invoke(initial_state, config) # 图执行到interrupt时暂停 resume_command = {"approved": True} app.invoke(Command(resume=resume_command), config)

这个概念理解成“工作流自动暂停等人工回话”就好。它的价值在于把人工确认这个动作变成了图里的一个节点,而不是靠外部回调绕过核心流程。我第一次用这个能力时很兴奋,因为它直接把我以前的“sleep+轮询数据库”方案替换掉了。

4.3 并行节点与Map-Reduce模式

处理多个工单或者同时跑多个检索任务时,线性执行效率太低。LangGraph支持通过Send API动态创建并行分支。

from langgraph.constants import Send def continue_to_support_agents(state: TicketState): return [ Send("agent_worker", {"ticket_id": t["id"], "description": t["desc"]}) for t in state["sub_tickets"] ] workflow.add_conditional_edges( "dispatch", continue_to_support_agents, ["agent_worker"] )

Send的含义不是调用一个节点,而是把传入的数据作为子状态,启动一个新的“agent_worker”节点实例。每个子任务可以独立运行,也可以有自己的内部图路径。

我做批量工单分派时,还会配合Map-Reduce模式:先把一批任务分发给多个worker节点,各跑各的,最后用一个“汇总”节点把结果合并回总状态。这里的汇总节点跟普通节点没什么区别,只是需要处理列表类型的返回结果。注意要给列表字段配好Reducer,不然多个并行分支返回时会互相覆盖。

并行不是越多越好,特别是每个worker都要调LLM时,并发太高会导致接口限流。我的习惯是先限制并发数,比如一次最多处理5个子任务,再逐步调大。

5. 踩坑实录与排查技巧

5.1 高频报错与对策

我在本地和项目里跑LangGraph时,遇到过不少问题,挑几个典型的列出来:

报错或现象原因解决办法
TypeError: 'NoneType' object is not iterable节点函数返回了None,或字段被覆盖成了None确保节点函数始终返回dict,没有更新就不要返回这个字段
并行结果互相覆盖列表字段没有配置Reducer给列表字段加Annotated[list, add_messages],或者自定义merge函数
ValueError: Expected node 'xxx' not found条件边映射里写了不存在的节点名检查add_conditional_edges第三个参数的所有目标是否执行过add_node
多轮对话历史不增长第二次invoke没带thread_id,或没配checkpointer编译时传checkpointer,invoke时带上thread_id
图执行卡死条件边形成无终止条件的循环在循环路径上增加次数计数器,达到上限强制结束
Invalid update返回了State里不存在的字段检查节点函数返回的key是否都定义在State中

5.2 几个建议和最佳实践

先说说状态设计。State字段越少越好,不要把大段临时内容塞进State。有些中间计算结果只在当前节点用,那就不要写到State里,用节点内部局部变量处理。不然调试时会有大量干扰噪声,而且还容易触发Reducer冲突。

再说节点函数风格。我建议所有节点函数都写成“纯函数”式,不对State做隐式修改,只返回更新。这样单元测试也很好写,你用假State调一个节点,断言返回的dict对不对就行。我一直把节点当独立函数测试,最后再组合成图,整体bug率明显下降。

还有条件边路由函数务必保持轻量。它会跟随每次状态更新被调用,如果里面有重IO或慢计算,整个流程会被拖慢。需要复杂判断时,应提前在一个普通节点里计算好结果,把结果放在State里,路由函数只做读操作。

最后,关于调试日志。LangGraph运行时的图信息,可以把这个打开:

from langgraph.pregel import PregelOptions options = PregelOptions(debug=True) result = app.invoke(initial_state, config=options)

实测能看到每个节点的输入状态输出状态,比自己在节点里print干净得多。不过生产环境不建议一直开着,日志量有点大。

我在实际项目中有一个小习惯:每个节点函数内部都会记录一句日志,格式是“当前节点名+输入状态关键字段+输出状态关键字段”。刚开始觉得麻烦,后来排查线上问题全靠这些日志去还原链路。LangGraph的优势就在于状态流清晰,日志能把每一步的状态变化串起来,定位问题非常快。

再提一个容易忽略的细节:LangGraph的State字段命名不要用太通用的单词,比如result、data,因为多个节点都可能返回这些字段,一旦字段冲突,排查成本很高。我吃过一次亏:一个节点的data是列表,另一个节点的data是字符串,运行时报错后查了半天才明白是字段被覆盖了。现在我都用带有业务含义的字段名,比如sub_ticket_list、final_reply_text。

如果你只是想在单机脚本里快速验证逻辑,用MemorySaver就够了。但如果你准备把这个东西接到一个真实服务里,一定要提前想好checkpointer与业务系统的关系。每个thread_id对应一段独立会话,所以thread_id最好用业务侧的会话ID或用户ID,这样即使重启服务,也能通过同样的thread_id恢复上下文。

LangGraph不是一个需要背很多API才能上手的框架,它的核心就是“状态在哪、节点干什么、边怎么走”。把这三点搞清楚,剩下都是技巧问题。希望这篇拆解能让你少走一点弯路,至少遇到报错时,能第一时间去查State的定义和Reducer配置,而不是对着整个框架发呆。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询