我第一次用 LangGraph 的时候,脑子里全是"这不就是把 LangChain 的链换个名字吗"的想法。等项目真做完,我收回这句话——图结构、状态管理、条件路由,这一套组合拳下来,写 Agent 工作流的思路彻底变了。如果你现在还在用 if-else 拼命编排多个 LLM 调用,或者被"多轮对话总丢上下文"折磨,这篇文章就是写给你的。
LangGraph 的核心价值用一句话说:以图的方式组织有状态的 Agent 工作流。它把每一次 LLM 调用、工具执行、条件判断都抽象成"节点"和"边",通过显式的状态管理让复杂流程可控、可回溯、可恢复。文章会从最基础的图模型讲到状态持久化,再穿插我这几个项目里踩过的真实大坑,希望能帮你少走半年弯路。
1. 为什么需要 LangGraph:从链式思维到图思维
1.1 先坦白:一条链式调用能走多远
在最早期的 LLM 应用里,"链式调用"确实够用。一次调用、解析结果、再放入下一次 prompt,这就像一个流水线,跑通一个固定流程没问题。我自己写过不少这样的脚本:先让模型生成大纲,再把大纲喂给下一个节点生成正文,最后再来一个节点做润色。整个流程顺序固定,没有分支,没有循环,代码也不复杂。
但真实的业务立刻会让这种模式露出马脚。最典型的是"工具选择"需求:Agent 需要先理解用户意图,决定是查天气、查数据库还是直接回答。如果你用链式结构硬写,每个分支都要单独写一条链,分支多了代码会膨胀到没法维护。更麻烦的是"循环"——比如 Agent 决定调用工具,拿到结果后需要再判断是否继续调用下一个工具,还是直接回复用户。这个"判断-执行-再判断"的过程在链式结构里几乎没法优雅表达,我见过有人用 while 循环加全局变量硬凑,跑一次正常,跑十次必出 bug。
这个痛点的根源在于:链式结构把"流程"和"状态"混在了一起。每一步的输出是下一步的输入,但中间过程没有任何显式的状态管理,一旦流程需要回退、需要条件分支、需要挂起恢复,代码的复杂度会指数级上升。
1.2 有状态 Agent 工作流到底解决什么
LangGraph 的解法是把工作流建模成"图"。节点代表动作,边代表转移,整个图共享一个 State 对象。这意味着每一个节点都可以读取和更新全局状态,边可以根据当前状态决定流向。这样的好处有三个。
第一个好处是分支和循环变成了图的一部分。条件边里写一个路由函数,返回"继续"还是"完成",图自动帮你处理后续流转。要循环就画一条"回到前面节点"的边,再也不用 while 循环了。
第二个好处是状态可见、可持久化。所有中间结果都记录在 State 里,你可以随时取出某一步的结果做日志、做审计,甚至把整个状态保存到外部存储,让程序重启后接着跑。这一点在多轮对话场景里价值巨大——对话的每个历史轮次都保存在状态中,调 LLM 时显式传入,不再依赖外部内存。
第三个好处是人和 AI 都能"理解"整个流程。图结构天然自带可视化,画出来就是一个流程拓扑,出了问题排查边界清晰。我曾在项目里把 LangGraph 的图结构直接打印给不懂技术的同事看,他们也能一眼理解"哪一步是查询、哪一步是判断、哪一步是回复"。
用一句话总结:链式结构适合"从 A 到 B"的直线任务,LangGraph 适合"根据情况在多个步骤之间跳转,并且过程中要持续记忆"的复杂任务。真正的 Agent 恰恰是后者。
2. 核心概念拆解:状态、节点、边与路由
2.1 状态(State):整个工作流的"共享记忆区"
LangGraph 里的 State 是一个贯穿全图的数据结构。它不会像普通函数那样在节点之间传一个"局部参数",而是全图共享同一个状态对象。你可以把它想象成一本公共笔记本:每个节点都能读取空白页,也都能往上写新内容,但写的内容会被接下来的节点看到。
定义 State 通常用 TypedDict,比如:
from typing import TypedDict, List, Annotated class AgentState(TypedDict): messages: List[str] # 对话消息历史 current_tool: str # 当前使用的工具 search_results: dict # 工具返回的搜索结果 final_answer: str # 最终回答第一次看到这个结构的读者可能会问:为什么要用 TypedDict 而不是直接定义一个类?原因在于 LangGraph 内部需要模式化地理解状态字段的类型,TypedDict 既轻量又能提供类型提示,在编译图时可以做静态检查,比 class 更直接。如果状态里某个字段需要"合并"而不是"覆盖",就配合 Reducer 使用——这是后话,第 4 章会详细说。
有一个关键点:状态不要塞太多无关字段。我见过有人把整个文件内容、所有中间缓存全往 State 里丢,结果序列化和持久化时性能直线下降。State 只放"跨节点必须共享"的数据,一次性局部数据用局部变量足够了。
2.2 节点(Node):最原子的执行单元
节点是执行具体动作的函数。它的签名字面上是"接收状态,返回状态更新"。在实际编码中,节点函数的参数会由框架自动解析——你需要哪个字段就声明哪个字段,LangGraph 会从当前状态里取出来传给你;返回值则会被合并回状态。
def call_llm(state: AgentState) -> dict: # 用 state["messages"] 构造 prompt response = llm.invoke(state["messages"]) return {"final_answer": response.content}写节点时最容易犯的错误是:在节点内部依赖"上一次调用的全局缓存"。比如在函数体外定义一个全局变量保存中间结果,第二次跑的时候读这个全局变量。这在小 Demo 里没问题,一旦并发执行或状态恢复到旧版本,全局变量就变成了隐形的定时炸弹。我在做并发的 Agent 服务时,就是因为一个全局缓存导致两个会话互相串了数据,查了很久才定位到。
所以节点必须是"纯函数":所有输入输出走 State,不在节点外部留下任何可变的全局状态。这是 LangGraph 实践的第一条铁律。
2.3 边(Edge)与条件路由:让流程拥有"红绿灯"
LangGraph 有两种边:普通边和条件边。普通边表示"上一个节点执行完,无条件走到下一个节点",这跟链式结构一样。条件边则需要一个路由函数:这个函数读当前状态,返回一个值,框架根据返回值匹配不同的目标节点。
def route_after_tool(state: AgentState) -> str: if state.get("need_more_tool"): return "tool_node" return "final_node"然后构建图时:
graph.add_conditional_edges("agent_node", route_after_tool, { "tool_node": "tool_node", "final_node": "final_node", })这里有个容易混淆的细节:路由函数的名字和返回值都只是"标识",真正决定走向的是第三个参数——从标识到实际节点名的映射。我最初写的时候把路由函数返回值直接当节点名用,结果图编译通过,运行时报 key 错误,后来才发现映射表的存在。
条件路由是 Agent 工作流的灵魂。它让 LLM 的"判断"变成了图的分支,也让"调用工具-看结果-再判断"这个循环有了落地的载体。
2.4 图的编译与运行机制
当节点和边定义好后,把它们加入 StateGraph,最后编译成 App:
from langgraph.graph import StateGraph, START, END builder = StateGraph(AgentState) builder.add_node("agent_node", agent_node) builder.add_node("tool_node", tool_node) builder.add_edge(START, "agent_node") builder.add_edge("tool_node", "agent_node") # 工具执行完回到 agent 再判断 builder.add_conditional_edges("agent_node", route_after_tool, {...}) builder.add_edge("final_node", END) app = builder.compile()编译这一步会做很多事情:校验所有边的目标节点是否存在、检查状态字段类型是否匹配、建立内部执行调度结构。所以如果图定义有问题,大概率在 compile 阶段就会报错,而不是等到运行时——这一点对调试非常友好。
运行时,每次执行app.invoke(input_state)就是一次完整的图遍历,直到走到 END。LangGraph 内部会维护一个待执行任务队列,按拓扑顺序调度节点。值得注意的是,同一个节点可以被多次执行(比如循环场景),LangGraph 不会做去重,因为它认为每一次执行都是独立的任务单元。
3. 从零搭建一个可运行的多工具 Agent
3.1 环境和依赖准备
先用pip install langgraph安装核心库。如果你的项目里还需要调用模型,建议同时安装langchain-openai或你实际用到的模型适配包。我不太建议一次性装整个 langchain 全家桶,用哪个模块装哪个模块就行,能少不少依赖冲突。
我当前的开发环境是一个普通云虚拟机,Python 3.10,内存 4G。LangGraph 本身不重,但如果你要跑本地模型,4G 内存会非常吃紧,建议把 LLM 调用放到远端推理服务上,只把 LangGraph 作为编排层。
3.2 定义状态、工具和节点函数
我们做一个能查天气、能查城市信息的 Agent,目的是展示"工具循环"的完整流程,代码尽量精简。
from typing import TypedDict, List, Annotated from langgraph.graph import StateGraph, START, END from langgraph.prebuilt import ToolNode from langchain_core.tools import tool class AgentState(TypedDict): messages: Annotated[List[dict], "append"] # 用 reducer 追加而非覆盖 next_action: str final_answer: str # 定义两个简单工具 @tool def get_weather(city: str) -> str: """查询指定城市的天气""" # 实际项目里这里调用天气 API,demo 直接返回固定串 return f"{city} 今天晴,温度 22 到 28 度" @tool def get_city_info(city: str) -> str: """查询指定城市的简介""" return f"{city} 是一座历史悠久的城市,人口约 500 万"注意我在 messages 字段上用了Annotated[List[dict], "append"],这里的"append"是 LangGraph 预定义的一个 Reducer 标识,作用是:当节点返回{"messages": [新消息]}时,不是整体覆盖旧 list,而是把新元素追加到旧 list 后面。这个机制对多轮对话至关重要,第 4 章再展开。
3.3 实现 Agent 节点和路由函数
Agent 节点负责决定"要不要调用工具、调用哪个工具、还是直接给出最终回答"。我这里为了清晰,做了一个简化版:用 LLM 的 JSON 输出做工具选择,然后返回一个 next_action 字段。
from langchain_openai import ChatOpenAI llm = ChatOpenAI(model="gpt-4o-mini", temperature=0) tools_by_name = { "get_weather": get_weather, "get_city_info": get_city_info, } def agent_node(state: AgentState): system_prompt = ( "你是智能助手。请根据用户的问题决定下一步行动:" "如果需要查询天气调用 get_weather,需要了解城市简介调用 get_city_info," "如果可以直接回答则直接给出答案,不要调用工具。" "输出 JSON 格式:{\"action\": \"tool_name 或 final\", \"thought\": \"...\"}" ) messages = state["messages"] + [{"role": "system", "content": system_prompt}] response = llm.invoke(messages) # 实际项目中这里需要 robust 的 JSON 解析,demo 从简 parsed = json.loads(response.content) action = parsed.get("action", "final") if action == "final": return {"next_action": "final", "messages": [{"role": "assistant", "content": "直接回答"}]} elif action in tools_by_name: return {"next_action": action, "messages": [{"role": "assistant", "content": f"需要调用 {action} 工具"}]} else: return {"next_action": "final", "messages": [{"role": "assistant", "content": "无法理解,直接回答"}]} def route_after_agent(state: AgentState) -> str: if state.get("next_action") == "final": return "final" else: return "tool"这里我刻意保留了 JSON 解析的"天真写法",因为你实际项目里很可能会遇到模型输出不严格的问题。经验做法是:用PydanticOutputParser或者让模型只输出纯 JSON 并用json.loads加 try-except 兜底,兜底失败就默认走 final 分支,防止整个工作流卡死。
3.4 组装图并加入工具节点
把前面定义的东西组装起来。工具节点我直接用了 LangGraph 预置的ToolNode,它可以从 tools 列表构建,自动接收上一步选中的工具参数并执行。
tools = [get_weather, get_city_info] tool_node = ToolNode(tools) builder = StateGraph(AgentState) builder.add_node("agent", agent_node) builder.add_node("tool", tool_node) builder.add_edge(START, "agent") builder.add_conditional_edges("agent", route_after_agent, { "tool": "tool", "final": "final" }) builder.add_edge("tool", "agent") # 工具执行完回到 agent,再判断 builder.add_edge("final", END) app = builder.compile()这一步值得把builder.add_edge("tool", "agent")单独拎出来强调:这看似简单的一行,就是"Agent 循环调用工具"的实现核心。工具执行的结果会写回 State(ToolNode 默认把工具输出追加到 messages 里),然后回到 agent 节点,agent 根据新状态再判断是否继续调用工具。
流程走一遍就明白了:
- 用户输入"广州天气如何"。
- agent 节点判断需要调用工具,返回 next_action="get_weather"。
- 条件路由把流程导向 tool 节点。
- tool 节点执行工具,结果写回 State。
- 边回到 agent,agent 看到工具结果后,判断现在可以回答了,返回 next_action="final"。
- 流程走向 final 节点,结束。
这个过程看起来和"函数调用"差不多,但借着 StateGraph,我们获得的是请求级别的状态隔离、可持久化、可中间干预。这正是上生产环境时需要的能力。
3.5 编译执行和结果验证
def run_agent(user_input: str) -> str: initial_state = { "messages": [{"role": "user", "content": user_input}], "next_action": "", "final_answer": "", } result = app.invoke(initial_state) return result["messages"][-1]["content"]跑几个用例看看:
- 输入"广州天气怎么样",正常情况下会输出天气信息。
- 输入"介绍一下北京这座城市",会走 get_city_info。
- 输入"你好",模型应该能直接回答而不调用工具。
我在本地跑的时候遇到过一个问题:模型出于习惯会对"你好"也返回一个工具调用,原因是 prompt 里的工具描述太泛,模型误解成"任何时候都可以提供城市信息"。解决方法是把 Agent 节点的 prompt 改严格:"只有当用户明确询问天气或城市简介时才调用工具,闲聊直接回复。"调完这个问题基本消失。
4. 状态持久化:如何让 Agent 记住每一次对话
4.1 单次执行模式的局限性
第 3 章的 Demo 每次invoke都是独立执行的,上一次的状态不会保留到下一次。这在多轮对话场景下是致命的——用户说"那深圳呢",如果 Agent 不记得之前聊的是"广东的天气",根本没法理解"那"指的是什么。
你可能会说:把整个 messages 历史存在内存里,下次 invoke 时传进去不就好了?这确实能解决一部分问题,但一旦服务重启、多实例部署、需要会话回滚时,内存存储会变成最不可靠的环节。LangGraph 的解法是把状态持久化抽象成一个 Checkpointer 接口,把"读取历史状态"和"保存当前状态"交给外部存储实现。
4.2 用 Checkpointer 实现会话级记忆
from langgraph.checkpoint.memory import MemorySaver checkpointer = MemorySaver() app = builder.compile(checkpointer=checkpointer) # 执行时需要传入 thread_id,用于区分不同会话 config = {"configurable": {"thread_id": "session_001"}} result = app.invoke(initial_state, config=config)当带 Checkpointer 时,LangGraph 会把每次执行后的状态保存到 Checkpointer 里,key 是 thread_id。下一次用同样的 thread_id invoke 时,框架会自动加载之前的状态,作为本次执行的初始状态。这意味着你不需要手动维护消息历史,整个 State 自动延续。
我在项目里最初用的是内存版 MemorySaver 做测试,验证没问题后才换成了数据库版的持久化。这里强烈建议:任何要上线的项目,不要用 MemorySaver。它只在内存里存状态,重启全丢。生产环境至少要用支持持久化存储的 Checkpointer 实现,比如基于文件或数据库的。实际选型我后面会细说。
4.3 Reducers:状态字段的合并策略
为什么 messages 字段要用Annotated[List[dict], "append"]?因为 LangGraph 的默认行为是"新值覆盖旧值"。如果节点返回{"messages": [new_msg]},直接覆盖的话,历史消息全没了,多轮上下文自然无从谈起。
Reducer 在这里的作用是定义"新旧值如何合并"。"append"只是最简单的一种,LangGraph 还允许你传一个自定义函数:
def merge_messages(old_list: list, new_list: list) -> list: return old_list + new_list # 自定义合并逻辑 messages: Annotated[List[dict], merge_messages]这个自定义机制非常灵活,我曾经在某个项目里用它实现"去重"——如果新消息内容和最后一条旧消息完全一样,那就不追加。这能避免某些模型在循环里反复输出相同内容的尴尬情况。
Reducers 的使用有一个细节:字段级 Reducer 只对该字段生效,不影响其他字段。所以你可以让 messages 追加、让 next_action 覆盖、让某个统计字段累加,各管各的,互不干扰。这也是为什么 LangGraph 的状态管理比普通 dict 强很多的原因。
4.4 多轮交互的完整流程改造
改造后的多轮逻辑非常清爽:
def chat_with_thread(thread_id: str, user_input: str) -> str: config = {"configurable": {"thread_id": thread_id}} # invoke 时会自动加载该 thread 的历史状态 result = app.invoke( {"messages": [{"role": "user", "content": user_input}]}, config=config, ) return result["messages"][-1]["content"]注意这里有个关键点:你传给 invoke 的输入会作为"增量"和已加载的历史状态合并。也就是说,每次只需要传"这一轮的新消息",不用手动把整个历史拼接一遍。LangGraph 靠 Reducer 自己做合并。
我自己第一版改造时没搞懂这个机制,傻傻地把历史消息全手动塞进每次 invoke 的输入里,结果刷新状态时出现了重复消息——历史被追加了两次。后来才意识到:带 Checkpointer 后,历史已经在状态里了,输入只需要传增量。
这个 API 设计初看有点绕,但实际用几天就会发现它的好处:无状态的服务配合外部 Checkpointer,天然支持横向扩展,任何一台实例处理同一个 thread_id 都能无缝衔接,因为状态在共享存储里,不在进程内存里。
5. 实战中的坑:排查方法论与调试技巧
5.1 常见报错速查表
报错 "Node x is not present in graph"
原因:add_edge或add_conditional_edges引用了不存在的节点名。我十次里有八次是把节点名拼错,还有两次是 add_node 的顺序写在 add_edge 之后还不小心写错了字。解决:检查所有字符串是否严格一致,建议统一用常量定义节点名。
报错 "ValueError: Expected an input with a value for 'messages'"
原因:状态里有必须字段,但初始输入没传。比如 State 定义了messages: List[str]而没有默认值或 Reducer,invoke 时没给这个字段就会报错。解决:要么给字段设置默认初始值,要么在 invoke 输入里显式传一个空 list。推荐后者,更明确。
报错 "Invalid state schema: field 'msg' is not a valid type"
原因:State 里的字段类型写错了,常见于Annotated的写法问题。比如Annotated[List[dict], "append"]的"append"如果不是预注册的字符串,LangGraph 可能不认识。解决:自定义 Reducer 就写成函数,预带回话就用"append"或其他官方映射字符串。自定义 Reducer 作为字符串引用时,请用langgraph.graph.MESSAGE_APPENDER这类常量替代硬编码,避免版本更新 API 变动。
报错 "Cannot return multiple values for unknown state keys"
原因:节点返回的 dict 里出现了 State 没定义的 key。某次我在节点里顺手返回了一个{"debug_temp": 123},State 里没这个字段,编译没拦,运行时直接报错。解决:要么把需要的字段都定义进 State,要么节点返回时做一层过滤。好消息是这类报错会指明具体的 key 名,定位不困难。
| 现象 | 可能原因 | 快速排查方法 |
|---|---|---|
| 图编译失败 | 节点名拼写不一致 | 打印 builder.get_graph().nodes |
| 运行时节点不执行 | 路由函数返回了不在映射里的值 | 在路由函数里加日志输出返回值 |
| 对话历史重复 | 手动拼了历史又用了 Checkpointer | 只传增量消息 |
| 并发环境下状态串台 | 用了全局缓存/内存状态 | 检查节点里是否有全局变量引用 |
| 工具多次重复调用 | LLM 没判断出"已经拿到结果" | 检查工具结果是否注入到了最新 messages |
5.2 状态字段冲突和覆盖问题
这是我在实际项目中踩得最深的一个坑。有个字段parsed_result,我在两个节点里都用到,一个节点更新它,另一个节点读取它。看起来没问题,但并发场景下两个节点同时更新同一个字段,后写的覆盖先写的,数据就丢了。
解决思路分两种:如果两个节点本来就应该是"保鲜兄弟",就一定要有先后依赖关系,或者在 Reducer 里做合并。如果两个节点互不依赖但都要更新同一字段,把字段拆成两个独立字段是更清晰的方案。状态字段的粒度和依赖关系,要在设计图的时候就想清楚,而不是靠运行时的红黑树去补救。
5.3 复杂图的并发与超时控制
LangGraph 从设计上支持节点级别的并行执行——如果两个节点之间没有依赖关系,它们可以在同一层级并行跑。但真实项目里,并行意味着要小心状态写入冲突和外部 API 限流。
我在一个 RAG 项目里遇到过这种情况:两个节点同时调用同一个搜索服务,服务端 QPS 限流直接报 429,导致整个工作流失败。解决办法是给工具节点做统一的限速包装,或者调整图结构,让并发节点变成串行节点。大多数情况下我建议默认不追求并行,Agent 工作流的瓶颈往往在 LLM 调用和非业务逻辑上,串行能省掉大量状态心智负担。
超时控制上,LangGraph 可以给节点设置超时时间,框架到点会中断执行。但更稳妥的做法是在你的工具函数内部就做好超时处理,比如用asyncio.wait_for或 requests 的 timeout 参数。因为 LangGraph 层面的超时是"中断整个任务",工具跟外部的连接可能已经建立,资源回收容易不彻底。
5.4 可视化调试:让流程自己"说话"
LangGraph 提供了直接把图画出来的能力:app.get_graph().draw_mermaid()能输出 Mermaid 格式的图。但实际项目里我更爱用"日志+状态快照"来排查。
做法很简单:写一个全局的回调或在每个节点入口打印当前状态的关键字段。状态快照比任何流程图都能说明问题——你可以直观看到某个节点执行前 messages 有几条、next_action 是什么、工具结果有没有正确注入。排查过一轮真实 bug 之后你会发现,80% 的问题都出在"状态里的数据和你想的不一样",而不是"图的结构不对"。
另外,LangGraph 官方还提供了调试模式,在 compile 时加debug=True(部分版本支持),可以输出每一步的执行明细。我在调试复杂循环时开过一次,输出量非常大,但对理解内部调度确实有奇效。生产环境记得关掉,否则日志会爆炸。
6. 上生产前的几个忠告
6.1 图的粒度设计:节点粗了糊,细了碎
到底一个节点应该多大?我的经验是:节点内部保持单一职责。比如"处理用户输入""调用工具""校验工具结果"这些是独立的节点,不要全部塞到一个大节点里。但也没必要为了拆而拆——如果一个节点内部只有三行代码且只被调用一次,拆出来纯粹增加复杂度。
判断节点粒度的唯一标准是:将来这个步骤会不会需要单独的复跑、单独的日志、单独的替换实现。会,就拆粗一点;不会,就合到相邻节点里。项目迭代三个月后你会发现,实现细节一直在变,但图的骨架换得很少。骨架稳了,后续需求再怎么变都只是往图里加节点。
6.2 与外部系统集成时的边界感
Agent 不是万能的,它应该只负责"决策和编排",不负责"实现细节"。我在一个业务项目里把"写数据库"这个动作直接放在 agent 节点里,用 LLM 生成的 SQL 去执行,上线第一周就出过一次更新错数据的事故。后来改成了:LLM 只输出一个结构化的操作意图,由后端的固定代码去执行数据库操作,数据库权限也只能访问有限的表。这个改动大大降低了出事的概率。
集成的边界感可以看成"人机权责边界":LLM 负责读意图、选路径、决定调用哪个工具,但最终对数据产生副作用的地方,用确定性代码去把关。这不光是安全问题,也是调试问题——确定性代码出错的规律性更强、更好排查。
6.3 测试与回归策略
LangGraph 项目的测试和普通 Python 项目的测试有一个显著不同:你不仅要测"函数结果对不对",还要测"状态流转对不对"。我最基本的做法是做一个 golden set,每个 case 是一组(输入消息序列、期望最终答案关键词、期望调用哪些工具),然后跑一遍完整流程,比对结果。
对状态流转的测试可以用一个取巧的方式:mock 掉所有的 LLM 调用,让它固定输出 JSON 指令。这样整个图的流转就是确定性的:输入 A 必然走工具分支,输入 B 必然走 final 分支。mock 掉 LLM 的测试虽然犠牲了"真实性",但换来的是"可重复性",能让 CI 在几分钟内跑完整个图的所有分支。真实的 LLM 行为测试放到单独的分级测试里,一天跑一次,不用卡在每一次提交上。
6.4 关于状态设计的一点个人体会
项目做回到后面,我最大的体会是:写 LangGraph 和图结构本身不难,难的是想明白"你的 Agent 到底需要什么状态、怎么流转、怎么恢复"。很多问题(多轮对话丢上下文、工具选错、流程混乱)本质上都不是框架的锅,而是状态设计没想清楚。
我在每一次新建图之前,都会先在纸上画出每个节点的输入输出,再标出哪些信息和后续节点有关。这个"纸上状态设计"花不了十分钟,但能让后面所有开发顺非常多。LangGraph 把状态从"隐性全局变量"提升到了"显式可设计的一等公民",你要做的就是把设计这一步真正重视起来。
最后分享一个小技巧:复杂 Agent 启动时,先跑一个最小图验证 LLM 返回结构,再逐步加节点和边。我见过太多人一上来就画了一个十个节点的宏伟蓝图,跑通第一个节点就花了一天。先用三节点跑通,再迭代加分支,整个过程会愉快非常多。