用 LangGraph 搭智能体工作流,教程大多只教你怎么画节点、连边,跑到一半服务重启、状态丢了,才发现是 checkpoint 没配对。这篇不展开图怎么画,专讲让工作流「能停下来、能恢复」的持久化,以及几个最常踩的坑。

判断直接放开头:LangGraph 上生产的第一个门槛不是图,而是 checkpoint 有没有用上一个能扛重启的存储。

StateGraph 与 checkpoint

LangGraph 的核心抽象是 StateGraph:一个节点集合加一个共享状态,节点之间通过状态传递消息。默认情况下这个状态只活在内存里,一次 invoke 结束就没了。要让它跨调用、跨进程存活,得在 compile 时挂一个 checkpointer——每个 super-step 之后,checkpointer 把整份状态快照存下来。

存下来的快照不是简单覆盖,它是一条带父指针的链表,get_state_history() 往回走就能翻出每一步的历史,这也是时间旅行回退的基础。所以 checkpointer 不只是「记忆」,还是「可回退」的前提。

thread_id

挂上 checkpointer 之后,最常犯的第一个错是忘了传 thread_id。thread_id 是状态的隔离键,同一个 thread_id 的多次调用共享一份状态。不传,每次 invoke 都开一条新线程,模型根本不记得上一轮说过什么。

from langgraph.graph import StateGraph, MessagesState, START, END
from langgraph.checkpoint.postgres import PostgresSaver

def chatbot(state: MessagesState):
    return {"messages": state["messages"]}  # 示意:实际调 LLM

graph = StateGraph(MessagesState)
graph.add_node("chatbot", chatbot)
graph.add_edge(START, "chatbot")
graph.add_edge("chatbot", END)

checkpointer = PostgresSaver.from_conn_string("postgresql://.../langgraph")
checkpointer.setup()          # 建表,只跑一次
app = graph.compile(checkpointer=checkpointer)

config = {"configurable": {"thread_id": "user-42"}}
app.invoke({"messages": [("user", "我喜欢 Python")]}, config)
app.invoke({"messages": [("user", "那我的爱好是什么")]}, config)  # 能答对,因为状态还在

注意 setup() 这一步,很多人忘了,第一次 invoke 直接报「表不存在」。PostgresSaver 不会自己建表,得你先调 setup()。

还有个容易踩的:两个不同的图共用了同一个 thread_id,会把彼此的 state 读串,字段形状对不上就报一堆莫名其妙的错。不同图之间,thread_id 加个前缀隔离,比如 chat-42、wf-42。

MemorySaver 与 PostgresSaver

第二个高频坑是把 MemorySaver 留在生产里。MemorySaver 是进程内存,服务器一重启、一扩容,所有会话历史清零,用户上下文全丢。它只适合本地调试和单测。生产环境换 PostgresSaver、SqliteSaver 这类能落盘的实现,代价几乎为零,但少踩一次「线上用户突然失忆」的事故。

还有个变体坑:RedisSaver 有浅层版本(ShallowRedisSaver),它只存最后一份快照,get_state_history() 只能取到一条,做不了时间旅行回退。需要历史或回放就用完整版 RedisSaver。

interrupt 与人工介入

人工介入是 LangGraph 的招牌,靠 interrupt() 实现:图跑到某个节点停下来等输入。坑在于 interrupt() 的实现是抛一个内部控制信号,你不能用 try/except 把它包起来「吞掉」,否则图会卡死或者直接跳过等待。

恢复时要用 Command 对象传值,而不是把普通 dict 当输入再 invoke 一遍——后者会被当成一次全新输入,图从头重跑。这是「恢复」和「重来」的区别,很多人在这一步栽跟头。

状态膨胀

持久化是有代价的:每个 super-step 存一份完整快照,状态越大,快照越贵。一个 200 条消息的会话,跑 50 步就是 200 条消息存了 50 份。两个办法:临时数据用 UntrackedValue 标记,不进快照;消息列表定期裁剪,只保留近 N 条。别让 checkpoint 变成隐形的存储账单。

值不值得上 LangGraph

如果你的工作流就是一条直线,几个节点顺序执行,没有分支、没有并行、没有人工卡点,那用 LangGraph 是杀鸡用牛刀,直接写函数串起来更清楚。LangGraph 值钱的地方是显式分支、可恢复的持久化、以及出问题能回退到任意一步重跑。先想清楚要不要这些,再决定上不上。