做 AI Agent 的同行,一定对 LangChain 那套生态很熟了。但 LangChain 早期的链式表达式有个绕不过去的坎:流程是线性的,A 完了必走 B,B 完了必走 C,中间想"看情况再说"就很拧巴。实际做 Agent 时,"看情况再说"才是常态——模型得判断这句话要不要调工具,工具返回后得决定是继续调还是直接回答,调错了还得能返回来重试。这些控制流写死在链式表达式里,要么疯狂堆 if-else,要么就把流程变成没人敢动的毛线球。
LangGraph 的解法是把执行流程做成"图模型"。节点 Node 是一个个处理单元,边 Edge 定义它们怎么连接,所有节点共享一个 State 状态对象。模型思考是一个节点,工具执行是一个节点,条件判断是边上的路由规则。每一步都显式、可观测、可回放。这篇文章我会从图模型的设计逻辑讲起,手写一个带工具调用的 Agent,再用 FastAPI 把它暴露成真实可用的服务。适合刚上手 LangGraph 的同学,也适合已经写了几个 demo 但没想清楚内部到底怎么跑的朋友。
1. LangGraph 为什么要把 Agent 建模成图
以 LangChain 的 LCEL 为例,它把 Prompt、模型、输出解析器用管道符串起来:
python复制chain = prompt | llm | output_parser
这在"输入一条、输出一条"的场景里非常优雅,比如翻译、摘要、提取结构化信息。可一旦遇到"模型输出里带了工具调用,我们要去执行工具,再把工具结果交回给模型",链条就断了——你需要一个能回头继续执行的循环。LCEL 也能靠 .map() 或者自定义 Runnable 硬解,但写出来既难读又难调试。每一步到底走到哪儿了,完全要靠自己的日志去猜。
图模型就不一样。它把流程里的每个动作都拆成独立节点,节点之间的跳转关系用边来描述,边可以是无条件跳转,也可以是带路由判断的条件跳转。这本质上就是把你脑中的流程图"翻译"成代码:有判断的地方用条件边,有重复执行的地方让边形成环,需要停下来的地方指向 END。
具体到 LangGraph,就是几个核心东西。首先是一张 StateGraph,它是图的骨架,你往里 add_node 添加节点、add_edge 连接边。其次是 State,一个全局共享的数据结构,每个节点执行完会把更新的内容写回 State,下一个节点从 State 里读最新数据。再有就是 END 这个特殊节点,图跑到这里就结束了一轮执行。
我见过很多第一次接触 LangGraph 的同学,上来就套用编程语言的思路去理解 Node 和 Edge,结果卡住了。其实你可以把它想成一个"车间流水线":State 是车间里贴着的一张共享工单,每个工位的工人(Node)看完工单干自己的活,干完了把结果补写到工单上;至于下一个去哪个工位,既可以固定(Edge),也可以看工单当前内容决定(Conditional Edge)。理解了这个模型,后面所有 API 细节都是顺水推舟。
用图模型组织 Agent 带来的收益是结构性的。第一是可观测性:每次节点调用、每次状态更新都能被框架记录下来,配合日志或可视化工具,你一眼就能看到 Agent 在哪个节点反复横跳。第二是可控制性:你可以给任意节点设置中断,执行到那里停住,等人确认后再继续,这就是 human-in-the-loop 的基础。第三是可恢复性:引入 checkpointer 之后,图的状态可以持久化,进程重启后还能从上次的进度继续跑。
这几条恰恰是生产级 Agent 和实验室 demo 的分水岭。如果你只是想让模型调一个 API,那不需要图模型;但如果你的 Agent 要面对多轮对话、多步骤工具编排、异常重试、人工审批这些真实业务约束,图模型几乎就是为这些场景量身的。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 图模型的三个核心部件:State、Node、Edge
2.1 State:所有节点共享的数据容器
LangGraph 的 State 不要求是一个类,直接定义成 TypedDict 最简单,当然你也能用 Pydantic BaseModel,好处是字段校验。我平时用 TypedDict 居多,因为 Agent 场景里消息列表本身就会校验,没必要再包一层。
python复制from typing import Annotated, TypedDict
import operator
class AgentState(TypedDict):
messages: Annotated[list, operator.add]
这个定义很值得细品:messages 字段没有用普通的 list,而是包了 Annotated[list, operator.add]。在 LangGraph 里,State 的每个字段都有自己的"合并规则"(reducer)。不加 reducer 时,默认规则是"后写覆盖",也就是说节点 A 往字段里写了一个值,节点 B 再写,字段就只剩 B 的值了。而 operator.add 是说:新写入的内容追加到原列表后面,而不是覆盖它。
为什么需要这个机制?因为一个 Agent 图里有多个节点会往 messages 里写消息:模型节点写 AIMessage,工具节点写 ToolMessage,如果每次都覆盖,前面写的内容就全没了,多轮对话根本没法做。用 operator.add 把消息不断追加,整个对话历史才得以累积。这个"覆盖 vs 追加"的取舍,是 LangGraph 初学者最容易踩的第一个坑,我在第四章还会展开讲。
2.2 Node:图里的处理单元
Node 就是一个普通的函数(或 async 函数),它接收当前 State 作为入参,返回一个字典。字典里的键就是 State 里那些字段,表示"我想更新这些内容"。LangGraph 会拿着返回值按 reducer 规则合并进全局 State。
python复制def call_model(state: AgentState) -> dict:
response = llm.invoke(state["messages"])
return {"messages": [response]}
没有魔法,没有复杂的类继承。关键是函数要足够单一:一个节点最好只做一件事。模型思考放一个节点,工具执行放一个节点,数据库写操作放一个节点。不要图省事把几步塞进一个大节点——那样确实能跑,但你会失去图模型带来的可观测性和调度能力,等于把图退化成了脚本。
我写节点的时候还会遵守一条规则:节点函数内部不改 State 的内容,只返回增量更新。这样做的好处是逻辑可复用、可测试——你直接传一个假 State 进去,看它返回什么,根本不用跑整个图。
2.3 Edge 与 Conditional Edge:照着条件走流程
Edge 分两种。普通边好理解,就是从节点 A 无条件走到节点 B:
python复制builder.add_edge("tools", "agent")
条件边是图模型的灵魂。它给节点挂一个路由函数,路由函数读当前 State,返回一个字符串 key,图再根据这个 key 走对应的边。
python复制def route_after_agent(state: AgentState) -> str:
last_message = state["messages"][-1]
if getattr(last_message, "tool_calls", None):
return "tools"
return "end"
builder.add_conditional_edge(
"agent",
route_after_agent,
{"tools": "tools", "end": END},
)
这里路由函数返回的不是目标节点本身,而是映射字典里的一个 key。为什么用字符串 key 而不是直接返回节点名?我第一次写的时候就直接返回节点名,后来改图时发现所有字符串散落得到处都是,特别难受。用 key + 映射字典,图和路由逻辑就解耦了,以后给节点改名只需要动映射。另外这种 key 的方式也更接近状态机/流程图设计,跟白板上的方框连线一一对应,跟产品讲流程的时候就靠它了。
条件边可以指向任何一个节点,也可以指向 END。"走完结束"也是一种条件分支。实际上,判定结束条件本身就是 Agent 里最核心的逻辑:什么时候该继续调用工具,什么时候该收手回答用户,全靠这条边。
3. 实操:手写一个会调工具的 Agent,再用 FastAPI 接出去
3.1 先搭环境:装哪些包、各自干什么
bash复制pip install "langgraph>=0.2" "langchain-core" "langchain-openai"
pip install "fastapi" "uvicorn"
langgraph 是主角,提供 StateGraph 全套 API。langchain-core 提供 @tool 装饰器、Message 类型这些基础物。langchain-openai 是我们演示用的模型提供方,它走的是 OpenAI 兼容接口,你完全可以把模型换成 deepseek、qwen,或者本地起的 OpenAI 兼容服务。fastapi 和 uvicorn 是最后暴露 HTTP 服务用的。
顺手说一句,我这边演示用的模型需要支持工具调用(function calling),目前主流商用模型都支持。如果你用的是特别小或者老旧的模型,bind_tools 之后模型可能吐不出来规范的 tool_calls,那就是模型能力问题,不是 LangGraph 的问题。
3.2 定义状态、工具与节点
先把状态定义好,然后定义两个常用的工具:一个查天气,一个算算式。
python复制import operator
from typing import Annotated, TypedDict
from langchain_core.messages import HumanMessage, ToolMessage
from langchain_core.tools import tool
from langchain_openai import ChatOpenAI
class AgentState(TypedDict):
messages: Annotated[list, operator.add]
@tool
def get_weather(city: str) -> str:
"""查询指定城市当前的天气情况。"""
return f"当前{city}晴,气温26摄氏度,微风。"
@tool
def quick_calc(expression: str) -> str:
"""计算简单的四则运算表达式,比如 '3*4+2'。"""
# 注意:生产环境不要直接用 eval,建议换成 ast 白名单解析
return str(eval(expression))
tools = [get_weather, quick_calc]
llm = ChatOpenAI(model="gpt-4o-mini", temperature=0)
llm_with_tools = llm.bind_tools(tools)
然后是两个节点函数。call_agent 把当前历史消息交给模型,模型如果判断要调工具,返回的 AIMessage 里就会带上 tool_calls 字段。call_tools 读取这个字段,逐个执行对应工具,然后构造 ToolMessage 返回。
python复制def call_agent(state: AgentState) -> dict:
response = llm_with_tools.invoke(state["messages"])
return {"messages": [response]}
def call_tools(state: AgentState) -> dict:
last_message = state["messages"][-1]
results = []
for call in last_message.tool_calls:
fn = {t.name: t for t in tools}[call["name"]]
result = fn.invoke(call["args"])
results.append(
ToolMessage(content=result, tool_call_id=call["id"])
)
return {"messages": results}
这里有个容易忽略的细节:ToolMessage 必须带上 tool_call_id,而且这个 id 要跟 tool_calls 里的 id 一致。模型是靠这个 id 把工具结果和之前的调用请求对应起来的,对不上就会报错。我在第四章会专门说这个坑。
3.3 拼图:把节点、边、条件边串起来
现在进入最关键的一步。其实下面的代码就是整套图模型的核心,理解了这个,LangGraph 就算入门了。
python复制from langgraph.graph import START, END, StateGraph
def route_after_agent(state: AgentState) -> str:
last_message = state["messages"][-1]
if getattr(last_message, "tool_calls", None):
return "tools"
return "end"
builder = StateGraph(AgentState)
builder.add_node("agent", call_agent)
builder.add_node("tools", call_tools)
builder.add_edge(START, "agent")
builder.add_conditional_edge(
"agent",
route_after_agent,
{"tools": "tools", "end": END},
)
builder.add_edge("tools", "agent")
一条条来看。START 是入口节点,新建的图自动就有。add_edge(START, "agent") 表示一进图先跑 agent 节点。add_conditional_edge 挂在 agent 节点上:模型回复里带了工具调用,就走 tools 节点;没带,说明模型准备直接回答用户,就走到 END,这一轮结束。add_edge("tools", "agent") 是关键的环:工具执行完,把结果作为新消息追加进状态,然后再回去让模型看结果、决定下一步。
所以这个图的执行轨迹是:START → agent → (有tool_calls) tools → agent → (有tool_calls) tools → ... → agent → (无tool_calls) END。循环的次数完全由模型和流程控制,这正是 Agent 多步推理的形态。
3.4 编译与执行:图是怎么跑起来的
builder.compile() 会把图"固化"成可执行对象。为什么要编译这一步?一是校验:图里有没有孤立节点、条件边的映射有没有指向不存在的节点,都在编译时暴露出来。二是生成内部的执行编排逻辑,LangGraph 会根据节点之间的依赖和状态通道信息做调度。
python复制graph = builder.compile()
result = graph.invoke(
{"messages": [HumanMessage(content="14*27等于多少?")]},
config={"recursion_limit": 10},
)
print(result["messages"][-1].content)
invoke 的入参是初始 State,只要提供 messages 里的第一条用户消息即可。执行完,result["messages"] 里会包含整个过程中产生的所有消息,最后一条就是模型给出的最终回复。
如果给助手换一个需要多次工具调用的问题,你会发现它真的会在 agent 和 tools 之间来回跳好几次,直到模型觉得信息够了、不再产生新的 tool_calls,才走到 END。这种"边问边干、边干边问"的能力,链式管线很难给你。
3.5 用 FastAPI 把 Agent 暴露成 HTTP 服务
命令行跑通之后,下一步就是接真实业务。我会用 FastAPI 起一个 /chat 接口,并把 LangGraph 的 checkpointer 接上,让接口天然支持多轮会话。
python复制from fastapi import FastAPI
from langgraph.checkpoint.memory import MemorySaver
from pydantic import BaseModel
agent_graph = builder.compile(checkpointer=MemorySaver())
server = FastAPI()
class ChatBody(BaseModel):
user_input: str
thread_id: str = "default"
@server.post("/chat")
async def chat(body: ChatBody):
config = {"configurable": {"thread_id": body.thread_id}}
result = await agent_graph.ainvoke(
{"messages": [HumanMessage(content=body.user_input)]},
config=config,
)
return {"reply": result["messages"][-1].content}
这里有两个关键点。第一,为什么用 ainvoke?FastAPI 的接口是异步的,LangGraph 的编译对象同时支持同步和异步调用,在 Web 环境里坚持用同步 invoke 容易把事件循环卡死,尤其模型调用本身是 IO 密集操作。第二,为什么带 thread_id?checkpointer 会按 thread_id 隔离会话状态。同一个 id 连续发消息,Agent 能记住上下文;不同 id 互不干扰。这对多用户线上服务是必需品,不能省略。
如果你还想做打字机效果,可以用 astream_events 拿 token 级流式输出,配合 FastAPI 的 StreamingResponse 返回 SSE。我自己的经验是:先跑通非流式,再加流式,一步到位容易在事件过滤和异步上下文上翻车。
启动方式也顺手提一下:uvicorn main:server --host 0.0.0.0 --port 8000,然后 curl -X POST http://localhost:8000/chat -d '{"user_input":"上海今天热不热?","thread_id":"u-001"}' 就能测了。
4. 实战排坑:状态、循环、流式输出与持久化
4.1 状态字段被覆盖:Reducer 的正确用法
先说最普遍的错误。很多人定义 State 时图省事:
python复制class BadState(TypedDict):
messages: list
这样定义,第一次节点返回 {"messages": [...]} 没问题,第二次某个节点再 return,messages 字段就被整体覆盖了,前面的消息全部丢光。表现就是"模型仿佛失忆"或者"多轮后消息越来越多但上下文错乱"。
正确做法是用 Annotated[list, operator.add],把默认"覆盖"语义改成"追加"。如果你的合并逻辑更复杂,比如要对列表去重、按时间排序,可以自定义 reducer 函数:
python复制def unique_append(current: list, new: list) -> list:
# 去重逻辑
return current + new
reducer 接收两个参数,第一个是当前字段值,第二个是节点返回的更新值,返回合并后的新值即可。它本质上就是状态通道(channel)的个性化规则,理解了它,就理解了 LangGraph 状态管理的另一半。
4.2 多跳循环导致 RecursionError
Agent 图里最常见的运行时错误就是递归超限。LangGraph 对图的执行深度有默认限制(通常在 25 左右,视版本而定),超过会直接报 RecursionError 或类似错误。深层原因通常是模型一直不满足退出条件,不断生成 tool_calls,比如工具本身抛异常但被吞掉,返回的 ErrorMessage 又诱导模型继续调用。
两个习惯能救命。第一,在 config 里显式调大限制:config={"recursion_limit": 50}。第二,也是更重要的,在路由函数里加"熔断"逻辑,防止死循环跑满整个限制:
python复制def route_after_agent(state: AgentState) -> str:
last_message = state["messages"][-1]
if getattr(last_message, "tool_calls", None) and len(state["messages"]) < 10:
return "tools"
return "end"
不过这里要注意,如果你开了 checkpointer 做多轮会话,state["messages"] 会包含历史所有消息,用长度做熔断会误伤。这时候建议在 State 里单独加一个计数器字段,用自定义 reducer 递增,并在每轮用户请求开始前重置。计数器方案稍复杂,但可控性最好。
4.3 ToolMessage 的 tool_call_id 对不上
工具调用报错里,频次最高的估计就是工具结果的消息 ID 对不上。LangChain 的模型接口要求:模型调用工具后,你回传的 ToolMessage 必须带 tool_call_id,且要跟 AIMessage 的 tool_calls 列表里对应调用的 id 完全一致。我最初简化实现时图省事,随便传了个空字符串,后果是模型服务返回 400 或者悄然忽略结果,图直接卡死。
排查方法很简单:在 call_tools 里打印 last_message.tool_calls 的 id,再打印 ToolMessage(tool_call_id=...),两边比对即可。还有一个衍生坑:如果工具执行抛异常,不要把异常直接往外抛,否则整个图会中断。正确做法是 try/except 包住工具调用,失败时也返回一条 ToolMessage,内容写清楚"工具执行失败:xxx",让模型自己判断怎么恢复。
python复制try:
result = fn.invoke(call["args"])
except Exception as exc:
result = f"工具执行失败: {exc}"
results.append(ToolMessage(content=result, tool_call_id=call["id"]))
这样图不会因为单个工具故障而全盘崩溃,模型还能根据失败信息换策略重试,这才是 Agent 该有的容错性。
4.4 流式输出不按预期工作
想给前端加打字机效果,LangGraph 给了两条路:astream 按节点粒度给状态更新,astream_events 给底层事件流。很多人在 astream_events 里拿不到 token 级内容,基本是三个原因。
一是忘了传 version="v2",旧的事件格式和新的过滤语法不一致。二是事件名写错,要过滤的是 on_chat_model_stream,不是 on_chain_stream。三是在同步代码里混用异步,比如 async for 里调了同步的 response.invoke。我的建议是:先用 async for chunk in graph.astream(...) 把节点级输出跑通,确认整个链路是异步的,再上 astream_events 做 token 级精细化。
流式还有一个容易被忽略的性能点:每次事件回调都会触发 Python 侧的上下文切换,如果 version="v2" 后你不过滤事件就全量监听,开销会明显变大。生产里最好只订阅 on_chat_model_stream 和少数关键节点事件。
4.5 Checkpointer 重启后丢失会话
MemorySaver 是最方便的 checkpointer,但它的数据存在内存里,服务一重启,所有会话记忆全部清空。demo 无所谓,生产环境就不行了。换持久的存储方案,LangGraph 提供了 SqliteSaver、PostgresSaver 等接口,接入方式基本一致:
python复制from langgraph.checkpoint.sqlite import SqliteSaver
with SqliteSaver.from_conn_string("checkpoints.sqlite") as saver:
agent_graph = builder.compile(checkpointer=saver)
换 checkpointer 之后,多轮会话的恢复逻辑就可以真正落到数据库里。注意一个运维细节:checkpoint 里保存的是完整消息历史,可能包含用户隐私,上生产前要评估存储加密和访问权限。
我个人在线上服务里的做法是:会话级记忆走 checkpointer,业务级字段仍然落业务库;不在图状态里塞一堆与对话无关的大对象,否则每次保存 checkpoint 的成本会越来越高。
4.6 图结构设计的几个反模式
最后聊几个我见过、也自己犯过的设计问题。
第一个,是把所有逻辑塞进一个大节点。一个节点里又调模型又调工具又写数据库,图是画出来了,但实际退化成脚本,中间哪一步失败都无法精确定位。图模型的价值就在"看得见每一步",千万别亲手把它毁掉。
第二个,是条件边的 key 不维护。路由函数返回的字符串,如果在映射字典里没对应,LangGraph 编译时可能不报错,运行时才炸。所以映射字典和路由函数最好放在一起维护,不要让它们跨文件散落。
第三个,是在节点内部直接调用另一个图的 invoke。这种做法会让执行流变得极难追踪,中断恢复、并发调度全都会受牵连。跨图协作应该用"把另一个图作为子图节点嵌入"这种官方支持的方式,而不是手工嵌套调用。
第四个,是不区分"会话级状态"和"单轮执行状态"。所有东西都堆在一个 State 里,很快字段就会失控。我的习惯是消息历史、用户身份这些长时间口径放 State,单轮临时的中间结果放节点内部变量,不污染共享状态。
我实际做完这个项目之后最大的感受是:LangGraph 的图模型并没有发明太多新概念,它只是把状态机、工作流引擎这些软件工程里被验证了几十年的东西重新搬到了 LLM Agent 领域。正因为它不"神秘",所以它可观测、可调试、可回滚。你会更清楚地看到模型在哪个节点上跑偏,工具结果在哪个环节被吞掉,而不是像以前那样对着 AgentExecutor 的抽象日志干瞪眼。
如果你刚开始接触,我建议先别急着上 FastAPI、上持久化,就把一个小图跑通,打印每一步的消息变化,亲手改两条条件边,感受一下"图"和"脚本"的差别。等手感来了,再去把 checkpointer、流式输出、人工审批这些生产化能力一个个接进来。这个过程不会太长,但每一步踩的坑,后面都能省回来。
