先说结论:如果你还在用一层while循环加一堆if-else拼装大模型调用,LangGraph 绝对是值得你花一个周末认真研究的东西。它不是又一个套壳框架,而是把"智能体"这件事从"写流程"升级成了"画状态图",让复杂的多步推理、工具调用、人工介入、状态回溯都有了清晰的落点。这篇文章我不打算念文档,而是从实际构建智能体的角度,把 LangGraph 的核心设计、关键陷阱和一套能直接跑通的实践方案拆开讲清楚。
我自己是在一个客服工单自动分类的项目里第一次大规模用上 LangGraph 的。之前用纯代码编排,十来个工具函数、七八个判断分支,代码已经乱到改一个逻辑要全局搜索三遍。换成 LangGraph 之后,图的结构、状态的流动、每一步的输入输出都变得肉眼可见,排查问题从"猜"变成了"看"。这篇文章适合两类人:一类是刚接触 Agent 开发、想找个靠谱框架起步的;另一类是已经被各种编排代码折磨过、想找个更优雅方案的。不管你是哪种,跟着这篇文章走一遍,你会有种"原来 Agent 还能这么写"的感觉。
1. 这一框架解决的,是写 Agent 最恶心的那部分
1.1 手写 ReAct 循环的痛点
我们先回忆一下不用框架时,一个带工具调用的 Agent 长什么样。核心逻辑通常是这样:把用户问题塞给大模型,大模型可能返回一个工具调用请求,代码解析这个请求、执行对应的工具函数、把结果拼回去再丢给大模型,如此循环,直到大模型说"我不需要工具了,直接回答"。
听起来很简单对吧?但一旦工具数量超过五个,分支逻辑开始变多,这个循环就会失控。你需要手动维护一个messages列表,每次迭代都要小心翼翼地追加消息;你需要判断工具调用是参数格式错误还是业务逻辑错误;你还需要处理"工具执行超时了怎么办"、"同一轮调用里多个工具并行执行的结果怎么合并"这类边角问题。更别提如果你还想支持"用户中途打断、修改参数、然后继续"这种交互,手写代码的复杂度会直接爆炸。
我用过一个很典型的方案:用一个大字典保存整个对话状态,每次循环结束后手动更新这个字典。前两周还好,到了第三周,这个字典里有十几个键,每个键都有好几层嵌套结构,我根本记不清哪一层需要在哪一步更新。出现 Bug 的时候,调试器里看那个巨大的字典,人都是懵的。
1.2 StateGraph 和普通流程图的核心区别
LangGraph 的核心思路不是给你一个更高级的循环,而是让你用"图"来描述整个 Agent 的行为。一个图由节点(Node)和边(Edge)构成。节点是你希望执行的一步操作,比如"调用大模型"、"执行搜索工具"、"生成最终回答";边定义了节点之间的流转关系。
但这里有个关键区别:普通流程图是"数据流"驱动,数据从一个节点流到下一个节点;LangGraph 的 StateGraph 是"状态"驱动。什么意思?整个图共享一个状态对象,这个对象会随着节点的执行不断更新。每个节点读取这个状态、做一些事情、然后把更新后的状态返回。图的下一个节点看到的,是更新后的同一个状态,而不是上一个节点的输出。
这个设计带来的直接好处是:节点之间不需要关心彼此的输入输出签名,只要约定好状态里有什么字段就行。你可以随时插入一个新的节点,只要它操作的状态字段和相邻节点兼容,整个图依然能跑。这在迭代开发中太重要了。我经常是先画一个三步的图跑通,然后再加一个"意图判断"节点插在最前面,整个过程不需要改动原有节点的一行业务代码。
如果打个比方,普通代码编排就像一条流水线,每个工位只处理自己的零件,零件形态变了整条线就得改;LangGraph 则像一块共享的白板,每个角色(节点)都往白板上写自己的内容,下一个角色只要看白板上有什么就能继续干活。白板本身(状态)是贯穿始终的。
2. 核心概念逐层拆解:节点、边、状态和 Reducer
2.1 状态是你唯一需要操心的数据
在 LangGraph 里,状态(State)本质上就是一个数据结构。官方文档里通常用 TypedDict 来定义,但实际项目中我个人更推荐用dataclass,因为当你需要定义复杂的嵌套结构,或者要给状态字段写默认值、写辅助方法时,dataclass的灵活性高得多。
一个典型的研究助手状态可能长这样:
from dataclasses import dataclass, field @dataclass class ResearchState: topic: str = "" # 当前研究主题 plan: list = field(default_factory=list) # 研究计划步骤 search_results: dict = field(default_factory=dict) # 每个计划步骤对应的搜索结果 report: str = "" # 最终研究报告 max_steps: int = 5 # 最大搜索轮次 current_step: int = 0 # 当前执行到第几步每个节点函数接收一个state参数,返回一个字典。返回的字典就是你要更新的状态字段。要注意的是:你只需要返回你想更新的字段,不需要返回全部状态。LangGraph 会把你返回的部分更新和原有状态做合并。
这个合并背后的逻辑,就是 Reducer。
2.2 Reducer:多节点写入同一状态的合并规则
这是 LangGraph 和普通 DAG 框架最不一样、也最容易踩坑的地方。默认情况下,如果有两个节点都要往状态里同一个字段写东西,后执行的节点会把先执行节点的结果整个覆盖掉。这是绝大多数新手的第一个 Bug 来源:我以为节点 A 往search_results里写了三个关键词的结果,节点 B 又往里写了一个关键词,结果回头一看,search_results里只剩节点 B 写的那一个。
解决办法是给状态字段指定 Reducer。Reducer 是一个函数,它在节点返回新值和当前状态里的旧值之间执行合并逻辑。最常用的是operator.add,它的作用是:如果字段的旧值和新值都是列表,就把它们拼接起来。
用dataclass定义带 Reducer 的状态,需要用到Annotated类型:
from typing import Annotated, TypedDict import operator class ResearchState(TypedDict): topic: str search_results: Annotated[list, operator.add] # 每次写入自动追加,而不是覆盖我实际项目里几乎给所有列表字段都配了operator.add,因为绝大多数场景下你希望的是"收集所有节点的产出",而不是"用最后一个节点的产出"。如果你的合并逻辑更复杂,比如要去重,或者要按照某个字段聚合,那就写一个自定义函数传进去。Reducer 函数签名是(old_value, new_value) -> merged_value,灵活度很高。
2.3 条件边和 Command:控制流的分岔口
有了状态和节点,接下来需要决定"下一步去哪"。LangGraph 有两种边:普通边和条件边。普通边就是固定流转,比如节点 A 结束后必然去节点 B。条件边则是在节点执行完后,根据状态内容动态决定下一个节点是谁。
条件边的核心是一个路由函数。这个函数接收当前状态,返回一个字符串,这个字符串必须是一个已注册的节点名或边的关键字。看个例子:
def should_continue(state: ResearchState): if state["current_step"] >= state["max_steps"]: return "generate_report" # 达到上限,去生成报告 return "search_tool" # 没达到上限,继续搜索返回的是下一步要去哪个节点的名字。LangGraph 会拿着这个返回值去匹配图中定义好的条件边映射。
这里有一个进阶玩法值得了解:Command对象。在一些复杂场景下,你可能希望一个节点同时在"更新状态"和"指定下一步跳转"两件事上拥有一票否决权。用Command可以做到:
from langgraph.types import Command from typing import Literal def my_node(state: ResearchState) -> Command[Literal["search", "end"]]: # 做点事情 if state["something"]: return Command( update={"important_flag": True}, # 先更新状态字段 goto="search" # 再强制指定下一步 ) return Command(update={"important_flag": False}, goto="end")这种写法比条件边更直接,适合那种"路由决策和状态更新强耦合"的场景。但我个人建议能不用就不用,条件边直观、可调试性强,Command 用得多了图的可读性会变差。
3. 从零构建一个可用的研究助手智能体
前面原理讲了不少,这一节我们动手写一个能真正跑起来的东西。需求很明确:用户输入一个主题,智能体自动拆解研究问题、多次调用搜索工具收集信息、最后生成一份结构化的研究报告。
3.1 先想清楚需求,再画图
动手写代码之前,我建议你先在纸上画一下这个 Agent 的行为。我的设计是这样的:
- 入口节点
plan_research:接收用户主题,调用大模型生成研究问题列表,写入状态的plan字段。 - 条件节点
should_search:检查current_step是否达到max_steps,决定下一步是去搜索还是去写报告。 - 执行节点
search_tool:根据当前步骤对应的研究问题,调用搜索工具,把结果存入search_results。 - 汇总节点
summarize:把最新搜索结果和大模型已有消息拼起来,让大模型做一轮信息提取,更新消息历史。 - 终止节点
generate_report:拿全部研究结果生成报告。
这里面search_tool执行完后,会通过Command主动把current_step加一,然后无条件回到should_search,形成循环。这个"搜索 -> 判断 -> 搜索"的环路,就是研究型 Agent 的核心节律。
3.2 节点函数怎么写才不会踩坑
每个节点就是一个普通的 Python 函数,签名统一是(state) -> dict或(state) -> Command。我直接贴一个实际可用的版本,重点看节点之间怎么通过状态协作。
from langchain_openai import ChatOpenAI from langchain_core.messages import HumanMessage, SystemMessage from langchain_community.tools import DuckDuckGoSearchRun llm = ChatOpenAI(model="gpt-4o-mini", temperature=0.2) search = DuckDuckGoSearchRun() PLAN_PROMPT = """你是研究规划助手。请把用户的研究主题拆解为最多3个具体搜索问题,每个问题单独一行。只输出问题本身。""" def plan_research(state: ResearchState): resp = llm.invoke([SystemMessage(content=PLAN_PROMPT), HumanMessage(content=state["topic"])]) questions = [line.strip() for line in resp.content.splitlines() if line.strip()] return {"plan": questions, "current_step": 0} def search_tool(state: ResearchState) -> Command[Literal["should_continue"]]: step = state["current_step"] query = state["plan"][step] if step < len(state["plan"]) else state["topic"] result = search.run(query) return Command( update={ "search_results": {query: result}, # 注意这里返回一个单元素字典 "current_step": step + 1 }, goto="should_continue" )这里有个细节:search_results字段是一个Annotated[list, operator.add]类型的列表,但我在search_tool里返回的是一个字典。这会不会类型冲突?不会。LangGraph 的operator.add处理的是"字段级别的追加合并",如果字段值是字典,那每次写入会整体替换?答案是:会。所以这里我建议把search_results定义成Annotated[list, operator.add],然后每次写入的是一个单元素列表,而不是字典。
正确写法:
class ResearchState(TypedDict): topic: str plan: Annotated[list, operator.add] search_results: Annotated[list, operator.add] # 存 {"query": result} 字典的列表 report: str max_steps: int current_step: int然后:
def search_tool(state: ResearchState) -> Command[Literal["should_continue"]]: step = state["current_step"] query = state["plan"][step] if step < len(state["plan"]) else state["topic"] result = search.run(query) return Command( update={ "search_results": [{"query": query, "result": result}], "current_step": step + 1 }, goto="should_continue" )summarize节点会读取search_results列表的最后一个元素,把查询和结果包装成一条消息追加到messages列表里,然后让大模型提取要点。
3.3 编译运行:图怎么变成可以调用的对象
节点定义好之后,就是构建图:
from langgraph.graph import StateGraph, START, END from typing import Literal def should_continue(state: ResearchState) -> Literal["search_tool", "generate_report"]: if state["current_step"] >= state["max_steps"]: return "generate_report" return "search_tool" builder = StateGraph(ResearchState) builder.add_node("plan_research", plan_research) builder.add_node("search_tool", search_tool) builder.add_node("summarize", summarize) builder.add_node("generate_report", generate_report) builder.add_edge(START, "plan_research") builder.add_edge("plan_research", "search_tool") builder.add_edge("search_tool", "should_continue") # 条件节点作为边先注册 builder.add_conditional_edges("search_tool", should_continue) builder.add_edge("generate_report", END) graph = builder.compile()注意add_conditional_edges的写法:第一个参数是"从哪个节点出发",第二个参数是路由函数。意思是:当search_tool节点执行完后,调用should_continue函数,根据返回值决定跳转到search_tool(继续循环)还是generate_report(结束)。
调用也很简单:
result = graph.invoke({"topic": "LangGraph 状态管理最佳实践"}, config={"recursion_limit": 20}) print(result["report"])invoke的第一个参数是初始状态,LangGraph 会自动补全其他字段。config里的recursion_limit是递归深度上限,循环图里这个参数特别重要,不设置的话跑几轮可能就报超出限制的错误。
整个流程跑下来,你会发现图内部每个节点的输入输出都清晰得像看动画:状态一步步累积,搜索结果一条条追加,最终报告基于完整上下文生成。这种可观测性,是纯代码编排很难做到的。
4. 持久化、人工介入和部署中的实际经验
4.1 Checkpointer:让 Agent 真正有"记忆"
LangGraph 有一个概念叫 Checkpointer,作用是把每一步执行完的状态保存下来。有了它,Agent 就有了两样能力:断点续跑和对话历史管理。
断点续跑的意思是:如果图中某一步抛了异常,或者你主动设置了中断点,图执行到那里会暂停,检查一下状态,修完再继续。这在生产环境太重要了。我之前做客服工单分类,偶尔会遇到工具 API 超时,没有 Checkpointer 的话整个会话就得重新跑一遍,用户等得暴躁。有了 Checkpointer,超时之后我可以从失败节点重新执行,前面已经干完的活不白干。
对话历史管理更直白:每次graph.invoke之后,图的状态会存到 Checkpointer 里。下一次用户发消息,你只需要带着同一个thread_id去invoke,图就自动从上次结束的状态继续,而不是从头开始。
from langgraph.checkpoint.memory import InMemorySaver checkpointer = InMemorySaver() graph = builder.compile(checkpointer=checkpointer) config = {"configurable": {"thread_id": "user-123"}} graph.invoke({"topic": "LangGraph"}, config=config) # 用户追加问题 graph.invoke({"topic": "顺便说说 Checkpointer 怎么用"}, config=config)不过InMemorySaver只适合单进程本地调试。多实例部署时,多个服务进程各自持有内存状态,同一个用户的请求打到不同实例上就乱了。生产环境建议用数据库类型的 Checkpointer,比如PostgresSaver或SqliteSaver。使用方式和内存版几乎一样,只是初始化时多传一个连接参数。
4.2 人机协同节点:interrupt 的玩法
研究助手还好,但如果你构建的是一个需要人类审批或输入的 Agent,比如"帮我写邮件,发送前让我看一下",纯自动流转就不够了。LangGraph 提供了一个interrupt函数,可以在节点内部暂停图的执行,等待外部输入。
from langgraph.types import interrupt def review_node(state): message = f"请确认是否发送邮件,收件人:{state['recipient']}" decision = interrupt({"message": message}) if decision == "approve": return {"approved": True} return {"approved": False}图跑到interrupt时,会抛出一个GraphInterrupt异常,但状态已经保存在 Checkpointer 里。你的应用捕获这个异常后,可以把interrupt返回的message发给前端,等用户点了"同意"或"拒绝"之后,再用Command(resume=...)从暂停处继续。
from langgraph.types import Command graph.invoke(Command(resume="approve"), config=config)这个机制我一开始觉得挺绕,后来发现它本质上就是"把人的决策也当作一个外部工具调用",和工具调用的流程天然统一。特别适合审批流、人工复核、敏感操作确认这类场景。
4.3 部署时的常见坑
部署 LangGraph 应用,最典型的坑是 Checkpointer 类型选错。内测时候用InMemorySaver一切都好,一上多实例就出现"用户请求串线"的诡异问题。排查了半天,发现是没有跨实例共享状态。换成PostgresSaver后就好了。所以我的建议是:项目第一天就按生产环境设计,直接用数据库 Checkpointer,省得后面返工。
另一个坑是 LangGraph 的流式输出。它支持stream_mode参数,可以是"values"(每次状态变更都流式输出)、"updates"(只输出节点产生的更新),或者自定义模式。前端要展示"Agent 正在做什么",用"updates"最合适。但注意一点:stream_mode和interrupt配合时,中断信息会出现在流的末尾,格式还不太一样,需要单独处理。
5. 常见问题与排查技巧实录
5.1 状态怎么改都不生效?Reducer 写错了
这是出现频率最高的问题,没有之一。绝大多数"为什么我更新了状态但下一个节点看到的是旧值"的困惑,都是因为字段没配 Reducer,被后一个节点的返回值整个覆盖了。排查思路很固定:先看这个字段是不是被多个节点写了。如果是,就把字段类型改成Annotated[list, operator.add]或者自定义合并逻辑。
还有一个常见的变体:operator.add配在TypedDict字段上,但字段初始值是字典或字符串,节点返回的值也是字典或字符串,那合并逻辑就是把两个字典做加法运算,直接报TypeError。检查状态定义和节点返回类型的一致性,是排查这类问题最快的路径。
5.2 条件边返回了不存在的路由名
这个问题我等会儿用一个速查表来汇总。条件边的路由函数返回值必须要能匹配一个已注册的节点名。如果你写了个拼写错误,LangGraph 会抛出类似Node not found的异常。注意看报错信息,它通常会明确指出找不到哪个节点。这种错基本是粗心造成的,但有时也会因为重构改名后忘了更新条件边而出现。我的建议是给路由函数的返回值加上Literal类型标注,让 IDE 帮你在写代码时就检查出来,别等到运行时才暴露。
5.3 循环死循环,图停不下来
循环图是 LangGraph 的强项,也最容易出事。当你配置了递归限制 (recursion_limit)超限时,LangGraph 会抛GraphRecursionError,告诉你执行步数超出了限制值。解决死循环有几种思路:一是设置recursion_limit,这是兜底;二是像我的current_step一样,在状态里维护一个"步数计数器",到点自动退出;三是更优雅一点,在should_continue里判断"最近几轮搜索结果是否有明显增益",没有就提前收敛。
实际项目中我把第三种方法用在了客服工单分类上:如果最近两轮工具调用返回的内容和已有关键信息高度重合,就认为"信息够用了",直接跳去生成最终回答。这个策略可以省掉很多不必要的 token 消耗。
5.4 Checkpointer 相关的诡异问题
用数据库 Checkpointer 时,有时候会遇到corrupted state之类的报错。常见原因是状态对象里有不可序列化的字段,比如某些自定义类的实例。LangGraph 在把状态存到数据库前会做序列化,如果你往状态里塞了 Python 对象,而这类没有实现序列化协议,就会出现问题。
解决办法是把状态里的复杂对象转成 JSON 兼容结构再存。我通常在状态里只放str、dict、list这类基础类型,真正需要复杂对象时,先把它们序列化成字符串存进状态,用时再反序列化。丑是丑了点,但绝对稳。
还有一个容易忽视的点:thread_id一定要保证唯一。我之前在测试环境随便生成 UUID,结果一个用户开几个会话,历史记录互相串场。
下面把几种高频问题整理成一张速查表,方便你排查时快速定位:
| 问题现象 | 排查方向 | 解决方案 |
|---|---|---|
| 状态字段被后节点覆盖 | 检查字段是否被多节点写入 | 给字段配 Reducer(如 operator.add) |
| 条件边报节点不存在 | 检查路由函数返回值 | 加 Literal 类型标注,核对节点名 |
| 图执行步数超限 | 检查循环退出条件 | 设置 recursion_limit + 添加步数计数 |
| 状态存储报序列化错误 | 检查状态里是否有复杂对象 | 转成 JSON 兼容结构 |
| 多实例下会话串线 | 检查 Checkpointer 类型 | 生产环境改用数据库 Checkpointer |
| interrupt 后无法恢复 | 检查是否用了同一个 thread_id | 确保 config 里 thread_id 不变 |
5.5 调试技巧:画图之前先想状态
最后一个建议,也是最想强调的一点:先用一个下午想清楚状态,再动手写节点。LangGraph 的代码组织方式决定了,你的状态设计就是整个应用的数据库设计。字段拆得越细,后面加功能越容易;字段定义越模糊,后面到处都在为"这个字段到底存了什么"而吵架。
我踩过比较大的坑,是把"中间过程"和"最终结果"混在同一个字段里。比如用search_results既存搜索结果,又存总结后的要点,结果读取方根本分不清当前拿到的是原始内容还是加工过的内容。正确的做法是分成两个字段:raw_results存原始搜索,summarized_points存提炼过的要点。各节点职责清晰,后面加缓存、加日志也都方便。
写 Code 时,每个节点函数开头和结尾各打印一下state的关键字段,这个习惯在很多次排障里救过我。
写在最后的一点个人体会
LangGraph 真正让我觉得"顺手"的,是"图是数据、数据是图"这种统一模型。状态贯穿整个流程,节点只关心自己该读什么、写什么,图结构就是可执行的架构文档。运行时你看一眼图的流转日志,就知道 Agent 每一步在做什么、为什么这么走,而不是面对一大坨调用链抓瞎。
当然,它也不是万能的。如果你的 Agent 只有两三个步骤、没有循环、不需要人机交互,那你用手写代码反而更快。LangGraph 解决的是"复杂的确定性流程编排"问题,没有这个复杂度,用它就是杀鸡用牛刀。
如果你打算深入,建议从官方文档里那个ReAct agent示例开始,跑通之后试着把里面的工具换成一个真实 API。等你体会到"换个工具只需要加一个节点、不需要动主流程"的时候,你就会明白为什么一个图状编排框架能成为新一代智能体构建的主流选择。