我在从零重学 LangGraph。第一天写了一整天代码,一行 LLM 调用都没有。
记录一下为什么这么干,以及顺手挖到的几个坑。
我之前的学法,和它的问题
我最早接触 LangGraph 是这样的:
agent = create_react_agent(model, tools)
agent.invoke({"messages": [("user", "北京天气怎么样")]})
跑通了,很爽。然后业务一复杂,问题就来了:
- State 为什么被覆盖了?消息列表为什么越滚越长?
- 挂了 checkpointer 为什么还是不记事?两个用户的对话为什么串味了?
- 两个并行节点写同一个字段,直接甩我一个
InvalidUpdateError? - 想在某个工具前面加一道人工审批,发现整个流程要重写?
我在这些问题上卡了很久,直到想明白一件事:这些没有一个是模型能力问题,全都是图的建模问题。
而 create_react_agent 恰好把图藏起来了。
我现在的判断是:LangGraph 的难点从来不在 Agent,在"图"。 Agent 只是图的一个特例——一张有环的图。
所以我给自己定了个规矩:先不接 LLM
LangGraph 的核心抽象是 State、Node、Edge、Checkpoint、Interrupt —— 没有一个词跟 LLM 有关。
它本质是一台带持久化的状态机执行引擎,拿掉模型照样成立。
而接了模型调试,代价是实打实的:
| 接 LLM | 不接 | |
|---|---|---|
| 单次跑完 | 几秒到几十秒 | 毫秒 |
| 结果 | 每次都不一样 | 完全确定 |
| 出错时 | 模型蠢?提示词烂?图写错?分不清 | 只可能是图写错 |
| 成本 | 花钱 / 撞限流 | 0 |
调试的第一原则是缩小变量。所以我打算前几篇全部用纯 Python 函数当节点,state 里只放数字和字符串。
等状态机搞明白了,把 LLM 塞进某个节点,大概只是把 def price(state) 换成 def chat(state) 的事。
我现在用的心智模型
图 = 一个共享的 State(字典)
+ 一串修改它的 Node(函数)
+ 规定谁接谁的 Edge(边)
跟 CI 的 pipeline、跟 Redux 的 store + reducer 是同一类东西。不神秘。
它的神秘感我觉得全部来自「中间过程被吞掉了」——所以我的 demo 把每一步都打印出来。
Demo:一条三节点的订单流水线
校验 → 计价 → 汇总,纯 Python,毫秒跑完:
class OrderState(TypedDict):
order_id: str
items: list[dict]
valid: bool
error: str
total: float
discount: float
summary: str
def price(state: OrderState) -> dict:
subtotal = sum(it["price"] * it["qty"] for it in state["items"])
discount = 30.0 if subtotal >= 200 else 0.0
return {"total": subtotal - discount, "discount": discount} # ← 只返回要改的键
builder = StateGraph(OrderState)
builder.add_node("validate", validate)
builder.add_node("price", price)
builder.add_node("summarize", summarize)
builder.add_edge(START, "validate")
builder.add_edge("validate", "price")
builder.add_edge("price", "summarize")
builder.add_edge("summarize", END)
app = builder.compile()
把每个节点的「进 / 出」打出来,看到三件我之前没意识到的事:
┌─ 节点 [validate]
│ 进来时看到的 state:{'order_id': 'SO-1001', 'items': [...]}
└─ 我返回的局部更新 :{'valid': True, 'error': ''}
┌─ 节点 [price]
│ 进来时看到的 state:{'order_id': 'SO-1001', 'items': [...], 'valid': True, 'error': ''}
└─ 我返回的局部更新 :{'total': 227.0, 'discount': 30.0}
-
State 是逐步长胖的。
validate进来时只有两个键 —— 声明在OrderState里的
total、summary此刻根本不存在,不是None,是没这个键。谁写谁才有。 - 每个节点只返回两三个键,不返回整个 state。合并是引擎干的活。
-
invoke的返回值是最终完整 state,不是最后一个节点的返回值。我一开始就搞错了这点。
让我印象最深的一点:节点契约
def node(state) -> dict: # 只返回「要修改的那几个键」
三条推论,第三条会咬人:
-
别自己改
state(state["total"] = ...)。合并是引擎的责任,节点只提交清单。 -
返回
None是合法的,意思是"我什么都不改"(只打日志的节点就该这样)。
代价:忘写return的 bug,引擎替你兜不住。 - 返回 State 里不存在的键 —— 静默丢弃,零报错。
第三条我实测了一下(langgraph 1.2.10)。写了个键名拼错的节点:
def typo(state):
return {"bb": 999} # 手滑,本来想写 b
# 结果:{'a': 1, 'b': 0} ← b 还是 0,没有任何报错、没有任何告警
我本来以为会抛 InvalidUpdateError。不会。 这大概是最难查的一类 bug:
「我明明赋值了啊」——键名拼错就是这个下场。
另外三个坑
| 坑 | 现象 |
|---|---|
| 输入少给了 state 里的键 |
invoke 不报错,等到某个节点读它才 KeyError(TypedDict 只是标注,运行时不校验) |
忘了 compile()
|
AttributeError: 'StateGraph' object has no attribute 'invoke' —— 报错完全不提示该 compile |
| 普通边是无条件的 | 我的 demo 里 validate 已判定订单非法,price 照样算了一遍钱。「失败就短路」得用条件边 |
第二个坑对应的分工我记成这样:StateGraph 是图纸,compile() 出来的才是运行时。
checkpointer、store、断点、缓存,全都是 compile 那一步挂上去的。
一个我自己也在想的问题:这例子值得用框架吗?
不值得。三个函数 for 循环调一遍,输出一字不差,还更短、更直观、零依赖。
我不打算给自己灌"框架就是先进"。真正的差别我觉得在这里:
| 需求 | 图版 | 裸函数版 |
|---|---|---|
| 看每一步中间态 |
stream 四种模式 |
自己 print |
| 流程停下来等人审批 | 一句 interrupt()
|
自己拆函数 + 存进度 |
| 挂了能从中间续跑 | 挂个 checkpointer
|
自己设计快照格式 |
| 回到第 2 步改个值重跑 | update_state |
基本没法做 |
| 两步并行 | fan-out 自动并行 | 手写 asyncio |
框架换来的不是"写得更短",而是「每一步都有名字、有边界、有快照」。
暂停、恢复、回溯、并行、可观测——这些能力全都建立在「节点是纯函数 + 状态集中管理」这条契约上。
我现在写的每一个 return {...},其实都是在为它们做准备。
所以我给自己的判断标准是:要不要上 LangGraph,看的不是"这是不是个 AI 应用",
而是"这条流程需不需要被中断和回看"。 不需要,for 循环就够了。
接下来想搞明白的
这个 demo 的 State 全是"覆盖"语义:谁写谁生效,后写的盖掉先写的。
可对话消息列表要的是累加 —— 每轮往后追加,而不是把上一轮盖掉。
这个「覆盖还是累加」的开关叫 Reducer,看起来是个高频坑,下一篇打算专门啃它。
我在从零重学 LangGraph,边学边把笔记整理出来,这是第 1 篇。
环境:Python 3.13 + LangGraph 1.2.10,文中所有输出都是实跑出来的。
有理解错的地方欢迎指出,也欢迎交流你踩过的坑。