我第一次听说 LangGraph 的时候,心里是有点抵触的。“又一层封装、又一个概念要学”,而且它跟 LangChain 长得太像,总担心是一套抽象过度的新玩具。但后来被一个“多轮工具调用”的需求折磨过之后,我发现 LangGraph 真正解决的其实不是“多一层封装”的问题,而是把流程控制从业务代码里拆了出来,让你能用图的方式明确描述“什么时候调模型、什么时候调工具、什么时候结束”。这篇文章就按照我实际用起来的路径走一遍:从定义模型能力开始,到设计状态、搭节点,最后把图编译跑起来,顺便说说官方文档里没写透的坑。
以前我用链式方式写过一个“资料检索+报告整理”的小工具:先用模型生成检索词,然后调用检索接口,再把结果丢给模型写总结。前两版还好,等需求变成“检索失败要换方向重试”和“某些问题必须多检索几轮才能回答”之后,代码里开始出现各种 if/else、循环标记和临时变量,最后主流程函数里塞了四个回调函数,一个小改动就能牵动全局。那时候我就意识到,链式调用适合“直线型”流程,一旦流程出现分支、循环、回退,它的表达力就不够了。
1. 为什么是LangGraph:从链式编排到状态图
1.1 链式调用撑不住的分支、循环和状态
具体来说,链式写法有三个痛点。第一个是状态传递:链式写法通常用一个 dict 或自定义上下文对象传递中间结果,节点多了以后,谁改了什么字段基本靠猜,出问题只能打断点慢慢查。第二个是分支控制:要加一个“如果结果不完整就再检索一轮”的逻辑,链式就得写在处理函数里,用 if 判断然后重新调用前面的函数,等于把调用栈变成手写栈。第三个是中间状态不可观测:你只能在最终结果里看输出,中间模型返回了什么、工具返回了什么,基本都是黑盒。这三个问题,LangGraph 正是用“图 + 状态”来解决的。
我自己早期写多轮检索时最崩溃的一个场景是:模型调完工具之后,需要把工具结果再拼回 prompt,然后再调用一次模型。这个逻辑本身不难,难的是“工具结果拼在哪”“上一轮模型说了什么”“这轮模型会不会继续调工具”这些状态必须一路跟着传。链式写法里这些变量通常散落在各个函数参数里,参数一多,调用关系就乱。而 LangGraph 的状态机制恰好把“大家共用一块黑板”这件事变成了框架级能力,不再靠人肉传参。
1.2 状态图的心智模型:节点、边、黑板
LangGraph 的核心抽象很简单:一张有向图。节点是函数,边是流转方向,状态是节点之间共享的一块“黑板”。每个节点执行完毕后把结果写到黑板上,下一个节点从黑板读数据,这就是一次完整的流转。它看起来很像生产线上的工序:工件从 A 工位流到 B 工位,每一步都有人往工位上贴标签,你随时能检查工件目前是什么状态。
我常用一个比喻来理解:不要把它当“框架”,把它当“工作流引擎”。你的业务逻辑被拆成若干个纯函数,每个纯函数只负责“读状态、算逻辑、返回状态更新”;而“下一步该去哪个节点”完全由边决定。普通边是固定的 A→B,条件边则是根据状态内容动态选择去向,比如“如果上一步说需要检索,就去检索节点;如果说可以回答了,就走到结束”。这种心智模型一建立起来,复杂流程的设计难度会下降一大截。
从技术实现上看,StateGraph 会在每次运行时维护一份状态对象,状态对象的字段类型和更新方式都由你定义。这里有个很容易被忽略的设计:LangGraph 里的状态不是“全局变量”,而是“按节点传递的快照”。每个节点的返回值只是“更新声明”,不会直接修改外部对象,这个约束保证了多节点流程可以回放、可以断点恢复,也为后面讲到的持久化打下了基础。理解了这一点,后面看中断和续跑功能就能秒懂。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 环境准备与核心概念:状态、节点、模型怎么凑到一起
2.1 安装与版本确认
先做环境准备。用 pip 安装即可:
bash复制pip install langgraph
如果你的项目已经用了 LangChain 的模型接口,一般不需要额外装太多,因为 langgraph 会依赖 langchain-core。实际开发中我建议再装一个 langchain 本体,不为别的,只是为了用 HumanMessage、AIMessage、ToolMessage 这些标准消息类型,统一消息格式在后续调试时非常方便。
装好以后先确认版本,避免照着旧教程踩坑:
python复制import langgraph
print(langgraph.__version__)
LangGraph 早期 API 变化比较快,我见过不少人在 0.1.x、0.2.x、0.3.x 之间切换时遇到语法对不上的问题。比如早期版本用 StateGraph + set_entry_point,新版本更推荐 add_edge(START, "节点名");再比如条件边从 conditional_edges 字段改成了 add_conditional_edges 方法。遇到这类差异,最快的办法是看本地安装版本的源码 docstring,而不是网上旧博客。另外,如果你想用绘图功能里的 draw_mermaid 系列,还需要装 pygraphviz 或 Pillow,不过这不是入门刚需,可以后面再说。
2.2 状态定义:用 TypedDict 约定数据类型
LangGraph 要求显式定义状态结构,最常用的是 TypedDict。举个例子:
python复制from typing import TypedDict, Annotated
class AgentState(TypedDict):
messages: Annotated[list, "add_messages"]
rounds: int
这里的 messages 字段很有意思:它用了 Annotated 标注了一个缩减函数 "add_messages",实际代码里是 from langgraph.graph.message import add_messages。它的意思是:当节点返回新的消息时,不覆盖旧消息,而是把新消息累加到消息列表后面。这个“累加”和“覆盖”的差别,是整个 LangGraph 里最容易踩坑的地方。
普通字段(比如 rounds)默认是“覆盖式更新”:节点返回 {"rounds": 2},状态里的 rounds 就会变成 2。而 Annotated[list, add_messages] 字段是“累积式更新”:节点返回一条新消息,状态里的 messages 会在原列表末尾追加这条消息。为什么这两个都重要?因为 agent 对话场景里消息历史必须累积,否则多轮对话会互相丢失;而计数、当前步骤名这类字段,通常只需要保留最新值。把这个规则记清楚,能帮你避免一多半的“状态被吞了”的诡异问题。
2.3 模型定义:LangGraph 没有 Model 类型,重点是节点函数
标题里写“从模型定义到图运行”,很多人会误以为 LangGraph 要求你指定某个模型类。其实 LangGraph 本身并不关心你用的哪个模型,也不要求模型必须接入 LangChain。它的节点就是普通的 Python 函数,类型签名统一是 (state) -> dict。所谓“模型定义”,在实践里其实是“把模型调用封装成一个节点函数”,也就是定义这个节点面对输入状态时,要如何调用模型、如何解析模型输出、如何把结果写回状态。
这样设计的好处是模型和流程解耦。你既可以直接在节点里调用项目已有的模型 SDK,也可以用 LangChain 模型接口。甚至可以先用一个假函数模拟模型输出,把整张图的流转逻辑先验证一遍,再替换成真实模型。这对我来说是入门最快的路径:图结构的正确性和模型能力是两件事,别混在一起调试。
当然,如果你打算让模型“自己决定下一步动作”,那模型的选择还是会影响实现的。比如有些模型支持“工具调用”协议,可以直接返回结构化 tool_calls;有些模型不支持,你就只能靠提示词让模型输出 JSON,再用解析和兜底逻辑处理。下面实操部分我会用“提示词 + JSON 输出”的方式,因为这个方式不依赖特定模型能力,最适合用来理解图的流转。
3. 实操:从模型定义到图运行的完整案例
3.1 定义状态和模型节点
我做一个极简的“研究助手”工作流。它的行为是:用户提一个问题,模型判断是需要检索还是可以直接回答;如果需要检索,就进入模拟检索节点;检索完回到模型节点,再判断一次;直到模型认为可以直接回答,流程结束。这个流程虽小,但覆盖了节点、普通边、条件边、循环和终止,是理解 LangGraph 最好的最小闭环。
先定状态:
python复制from typing import TypedDict, Annotated, Literal
from langgraph.graph.message import add_messages
class ResearchState(TypedDict):
messages: Annotated[list, add_messages] # 消息历史,累积追加
topic: str # 当前主题,直接覆盖
rounds: int # 检索轮数,覆盖
next_action: Literal["search", "answer"] # 模型决定的下一个动作
然后定义“模型节点”。我这里用一个 call_llm 函数作为项目模型调用的统一入口,它的内部实现可以是你们项目的模型封装。为了演示,我假设模型会返回一段 JSON:
python复制import json
def call_llm(system_prompt: str, user_content: str) -> str:
# 这里是你们项目的模型调用,返回字符串
# 示例里直接返回一个固定的 JSON,方便你跑通全流程
return '{"action": "answer", "answer": "状态图由节点、边和状态组成。"}'
模型节点函数负责拼 prompt、调用模型、解析 JSON,然后把结果写回状态:
python复制def medical_agent(state: ResearchState):
system_prompt = (
"你是研究助手。如果用户问题需要查资料才能回答,"
'请输出 {"action": "search", "query": "检索关键词"};'
'如果已有信息足够,请输出 {"action": "answer", "answer": "你的回答"}。'
)
last_user = state["messages"][-1].content
raw = call_llm(system_prompt, last_user)
try:
parsed = json.loads(raw)
except json.JSONDecodeError:
parsed = {"action": "answer", "answer": "模型输出解析失败,我直接退出。"}
if parsed["action"] == "search":
return {
"messages": [AIMessage(content=f"我需要检索:{parsed['query']}")],
"next_action": "search",
}
return {
"messages": [AIMessage(content=parsed["answer"])],
"next_action": "answer",
}
这里有几个细节值得说明。第一,节点函数返回值并不是“新的完整状态”,而是“要对当前状态做的增量更新”。没返回的字段保持不变,返回的字段按前面讲的规则覆盖或累积。第二,我这里让模型节点同时输出了消息和 next_action,这两个字段一个走累积一个走覆盖,刚好验证上一节说的两种更新语义。第三,把模型输出解析失败当成“直接回答”处理,是我在生产里养成的习惯:与其让流程卡死,不如让模型退化为保守的答句,宁可答得泛一点,也不能阻塞整个工作流。
3.2 搭图:节点、普通边和条件边
工具节点我用一个模拟检索器,不接真实搜索,只在本地数据里找答案:
python复制def search_node(state: ResearchState):
demo_db = {"langgraph": "LangGraph 是用于构建有状态 agent 的图编排框架。"}
last_msg = state["messages"][-1].content
keyword = last_msg.replace("我需要检索:", "").strip()
answer = demo_db.get(keyword, f"没有找到关于 {keyword} 的资料。")
return {
"messages": [ToolMessage(content=answer, name="demo_search")],
"rounds": state["rounds"] + 1,
}
这里用 ToolMessage 表示工具执行结果,和 AIMessage、HumanMessage 一样都是标准消息类型。需要提醒的是,ToolMessage 在真实工具调用协议里还有个 tool_call_id 用于关联模型发起的某次工具调用,我这里简化掉了,因为模型并不通过 tool_calls 协议发起工具调用,你只要把消息文本写对就行。
接下来是搭图。先创建 StateGraph,再逐个添加节点和边:
python复制from langgraph.graph import StateGraph, START, END
def build_graph():
graph = StateGraph(ResearchState)
graph.add_node("agent", medical_agent)
graph.add_node("search", search_node)
graph.add_edge(START, "agent")
graph.add_conditional_edges(
"agent",
lambda state: state["next_action"],
{"search": "search", "answer": END},
)
graph.add_edge("search", "agent")
return graph
app = build_graph().compile()
这段是全文最核心的逻辑,我展开讲一下。add_edge(START, "agent") 表示图从 agent 节点开始执行。add_conditional_edges 是条件边:agent 节点执行完后,框架会调用第二个参数(一个函数)拿到一个字符串值,然后拿这个值去第三个参数的字典里查路由。如果返回 "search",就跳到 search 节点;如果返回 "answer",就跳到 END 结束。注意这个函数的返回值必须在映射字典的键集合里,否则会抛异常。
最后 add_edge("search", "agent") 让检索节点执行完之后,无条件回到 agent 节点,形成循环。如果 agent 又返回 search,会继续轮回,直到返回 answer 才跳出。这里还没有做最大轮数限制,属于一个隐患,我在第 4 节会专门讲循环失控的问题。编译这步会做图合法性校验,比如所有节点是否都有出口、条件边的映射键是否合法等,有错会直接报出来,所以你在开发阶段完全可以大胆编译。
3.3 编译与运行:invoke 的完整调用
图编译后得到一个可执行对象,通常叫 app。运行入口是 invoke 或 stream,两者区别是后者可以按事件流返回中间每一步的结果,前者只返回最终状态。先看看最简单的 invoke 用法:
python复制from langchain_core.messages import HumanMessage
result = app.invoke({
"messages": [HumanMessage(content="LangGraph 是干什么的?")],
"topic": "langgraph",
"rounds": 0,
"next_action": "search",
})
print(result["messages"][-1].content)
这里输入端口的结构必须和状态字段一一对应。LangGraph 会把你传入的 dict 作为初始状态,然后从 START 节点开始执行。最终返回的是执行结束后的完整状态,里面包含了每一步累积下来的消息列表。如果你只想看模型最后的回答,取 result["messages"][-1].content 即可;如果想看中间检索结果,直接打印整个 messages 列表就行,这正是图相对于链式写法的一大优势——中间产物都在状态里,随取随用。
关于 recursion_limit,它控制整个图的“步数”上限,LangGraph 内部默认值是 25,但不同版本可能不一样。如果你的 agent 逻辑里循环次数可能很多,显式传一个合理上限会更稳妥:
python复制result = app.invoke(
{"messages": [HumanMessage(content="LangGraph 是干什么的?")]},
config={"recursion_limit": 10, "configurable": {"thread_id": "demo-1"}},
)
configurable 里的 thread_id 跟记忆功能强相关,后面讲 checkpointer 时会用到。现在只记一个概念:invoke 的 config 参数用于控制运行时的执行策略,而不是修改图结构。图结构是编译期定死的,运行时只负责“按图执行 + 按配置限制”,这种编译期/运行期分离的思路也是 LangGraph 设计得比较干净的地方。
3.4 流式输出:观察每一步的状态变化
当你需要在前端展示“agent 正在思考”“正在检索”“正在回答”这类过程时,用 stream 会比 invoke 体验好得多。stream 会按节点执行顺序产生一个个事件:
python复制inputs = {"messages": [HumanMessage(content="LangGraph 是干什么的?")], "topic": "langgraph", "rounds": 0}
for event in app.stream(inputs, stream_mode="updates"):
# event 形如 {"agent": {"messages": [AIMessage...], "next_action": "search"}}
for node_name, update in event.items():
print(f"节点 {node_name} 更新:{update}")
stream_mode 可以传 "updates"、"values" 等不同粒度。"updates" 只会返回每个节点对状态的增量更新,适合拿去做流程展示;"values" 会返回每一步的完整状态快照,适合调试观察。用习惯了之后,我几乎不再用 print 调试流程,直接把 stream 挂到内部日志里,每个节点做了什么都一目了然。
需要说明的是,LangGraph 还支持 astream_events,它的粒度更细,能探查到节点内部甚至模型层面的 token 级输出。但入门阶段不要被这些高级模式带偏,先把 invoke 和 stream 用熟,理解节点、边、状态三者关系,比追求各种花式 stream 重要得多。
4. 常见问题与排查技巧实录
4.1 消息历史被覆盖:Annotated 与 add_messages 的坑
我在一开始写 agent 节点时特别容易犯的错,是直接在函数里让 state["messages"] = state["messages"] + [new_msg],然后在返回值里带一个 "messages": [...]。如果不加 Annotated[list, add_messages],每次节点返回新消息都会把旧消息整个覆盖掉,多轮对话就只剩下最后一轮的消息。排查办法很简单:在 stream_mode="values" 里看每一步的完整状态,如果 messages 长度没按预期增长,基本就是缩减规则没写对。
Annotated 的写法一定不能漏。我见过有人把 Annotated[list, add_messages] 写成了 Annotated[list, "add_messages"](带引号),在新版本里字符串标注也能被解析,但不够稳妥,最好引入真正的函数对象。如果用的是 from langgraph.graph.message import add_messages,它会根据底层消息类型自动合并,并且能处理消息去重,比自己手写 state["messages"] + [...] 要可靠得多。
4.2 条件路由值和映射字典对不上
add_conditional_edges 是 LangGraph 里面最容易“报莫名其妙错误”的地方。报错通常长这样:ValueError: Expected return value of conditional edges to be...,或者 Node not found。本质原因就是节点函数返回的 next_action 值不在路由字典的键里,框架不知道该往哪走。比如你返回了 "search",但字典键写成了 "Search",大小写不一致;又比如模型解析 JSON 时 key 带了空格,正好避开了你的字典键。
我常用的排查三板斧:第一,在路由函数里加一行 print(state["next_action"]);第二,在模型节点里对解析结果做一次白名单校验,不在 {"search", "answer"} 里的值一律改成 "answer";第三,看报错信息里提到的是哪个节点,然后把映射字典的键都打出来对比。宁可让节点函数里多做一次兜底,也不要让一条非法路由值直接触发异常,这在生产环境的 agent 里尤为重要。
4.3 循环失控:recursion_limit 与硬性截断
有循环的图,最怕模型一直认为“需要检索”。我第一次跑的时候,看到日志里 agent 节点反复进入 search,次数一度突破 25,接着 LangGraph 抛出递归深度超限的异常。解决办法有两层。第一层是运行时限制,在 invoke 的 config 里设置 recursion_limit,它确实能阻止无限循环,但代价是流程直接报错,对用户不友好。第二层是逻辑限制,也是我更推荐的:给状态加一个 rounds 字段,在模型节点的 prompt 里告诉模型“你已经检索了 N 轮,最多检索 3 轮;若仍无法回答,请基于已有信息给出答案”,然后在节点函数里检查 rounds >= 3 时强制把 next_action 设为 "answer"。
这种硬性截断比依赖框架限制更可靠。因为 recursion_limit 只是一个执行步数上限,它不知道你的业务允许几轮;而业务上“最多检索 3 轮”是需求本身的一部分,必须写进状态和节点逻辑里。实际项目里我把轮数上限、超时限制、预算都放在状态里,由节点函数自行检查,框架只负责兜底。
4.4 图的可视化与日志调试
LangGraph 自带可视化,调试阶段非常好用。最简单的:
python复制app.get_graph().print_ascii()
在 Jupyter 里可以输出 ASCII 图,能直观看到节点和边的连接。想看得更清楚,可以用 draw_mermaid() 或 draw_mermaid_png() 生成图,前者输出 Mermaid 文本,后者直接出图。我第一次看到自己的 agent 图时,才发现字段名起得不直观导致边看着很乱,于是重新给节点取了动词型名称,比如 medical_agent、search_node,整张图的可读性立刻上来了。
除可视化外,建议在节点函数开头加一行结构化日志。不要用 print,直接用 Python 的 logging,带上节点名、状态摘要。流程出错时,日志顺序就是节点的执行顺序,配合 stream_mode="values" 的状态快照,绝大多数问题都能快速定位。这也是 LangGraph 相对其他隐式控制流框架的最大优点:流程的每一步都有迹可查。
| 现象 | 常见原因 | 排查/解法 |
|---|---|---|
| 消息历史只剩最后一轮 | 状态字段没有用 add_messages | 改用 Annotated[list, add_messages] |
| 条件边报节点找不到 | 路由函数返回值不在映射字典键内 | 打印路由值,做白名单兜底 |
| 无限循环触发超限 | 没有轮数硬截断 | 状态加 rounds,prompt 加轮数约束 |
| 节点更新不生效 | 返回值字段名和状态字段拼写不一致 | 检查返回值 dict 的 key |
5. 进阶扩展:记忆、人工介入与多 agent 结构
5.1 checkpointer:给图装上记忆
前面演示的图是无状态执行:每次 invoke 都是全新的状态,消息历史不会保留。要支持真正的多轮对话,LangGraph 官方方案是 checkpointer。启动时把它传入 compile:
python复制from langgraph.checkpoint.memory import MemorySaver
memory = MemorySaver()
app_with_memory = build_graph().compile(checkpointer=memory)
然后每次调用时在 config 里带同一个 thread_id:
python复制config = {"configurable": {"thread_id": "user-001"}}
result1 = app_with_memory.invoke({"messages": [HumanMessage(content="先介绍一下状态图。")]}, config=config)
result2 = app_with_memory.invoke({"messages": [HumanMessage(content="再举个例子。")]}, config=config)
此时第二次调用会把第一次的完整状态作为起点,messages 历史自动叠加,这就实现了多轮记忆。thread_id 本质上是一条“会话记忆”的钥匙,不同 thread_id 之间互不干扰。这里要提醒:MemorySaver 是基于内存的,进程一重启就没了,生产环境建议用支持持久化的 saver,把 checkpoint 落盘,避免丢记忆。
5.2 中断与人工确认:interrupt_before
带人工确认的 agent 是业务里非常常见的需求。比如模型认为要执行某个操作前,希望先问一下用户“是否同意”。LangGraph 的作法是在编译时指定中断位置:
python复制app = build_graph().compile(
checkpointer=memory,
interrupt_before=["search"], # 进入 search 节点之前暂停
)
这样执行到 search 节点前会暂停,invoke 返回一个未完成的中间状态。你可以在前端把状态展示给用户,等用户确认后再用同一个 thread_id 继续执行。这个能力特别适合“agent 生成操作建议,人工确认后执行”的审批流。我认为它是 LangGraph 在生产项目里最有价值的功能之一,因为它把以前需要手写的“暂停-恢复”逻辑直接内建到了图中。
5.3 子图封装与并行节点的扩展思路
当业务流程变大,所有节点堆在一张图里会越来越难维护。LangGraph 支持把一张图作为一个节点嵌入另一张图,也就是子图。这正好对应了软件工程里的“函数拆函数”思想:先搭一个内部的小工作流,再把整张小图作为一个节点接到大图里。子图有自己的状态定义,在大图里通过节点函数触发,父子图之间通过 state 字段传递数据。
另一个实用能力是并行节点。如果流程图里有几个互不依赖的步骤,可以并发执行。不过并行会带来状态合并的复杂度,比如多个并行节点同时更新同一个字段时,需要做一些额外约定。我的建议是:刚上手别碰并行,先用顺序结构跑通业务;确认状态设计和节点边界合理后,再考虑用并行提升吞吐。很多团队第一步就把图画得太复杂,结果后面每一步排查都很难受,这在我的经验里是最常见的项目翻车原因。
我个人在实际操作中的体会是:LangGraph 的曲线并不陡,真正陡的是“状态更新规则”和“条件路由”这两个坎。建议你拿到手先别急着接真实模型,用一个模拟函数把图跑通,观察状态每一步的变化,再换成模型。等你能在纸上画出“节点-边-状态”的草图,并对每一步会发生什么心里有数,LangGraph 这个工具才算是真正上手了。之后再去碰记忆、中断、子图这些东西,会发现一切都顺理成章。
