1.别急着调模型:LangGraph 的本体是一台状态机

我在从零重学 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}
  1. State 是逐步长胖的。 validate 进来时只有两个键 —— 声明在 OrderState 里的
    totalsummary 此刻根本不存在,不是 None,是没这个键。谁写谁才有。
  2. 每个节点只返回两三个键,不返回整个 state。合并是引擎干的活。
  3. invoke 的返回值是最终完整 state,不是最后一个节点的返回值。我一开始就搞错了这点。

让我印象最深的一点:节点契约

def node(state) -> dict:   # 只返回「要修改的那几个键」

三条推论,第三条会咬人:

  • 别自己改 statestate["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() 出来的才是运行时。
checkpointerstore、断点、缓存,全都是 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,文中所有输出都是实跑出来的。
有理解错的地方欢迎指出,也欢迎交流你踩过的坑。

©著作权归作者所有,转载或内容合作请联系作者
【社区内容提示】社区部分内容疑似由AI辅助生成,浏览时请结合常识与多方信息审慎甄别。
平台声明:文章内容(如有图片或视频亦包括在内)由作者上传并发布,文章内容仅代表作者本人观点,简书系信息发布平台,仅提供信息存储服务。

友情链接更多精彩内容