Chapter 01 · 核心概念
六块积木:把状态图逐个建起来
上一章 index 给了状态图的心智地图——这章把每块积木逐个建起来。六个概念按依赖排开:先有图模型(StateGraph),再有图里流动的数据(State + reducer),再有改数据的函数(Node),再有决定走向的边(Edge),然后把图编译成能跑的东西(compile),最后给它装上记忆(checkpointer)。每节配场景走查,让你读到陌生 LangGraph 代码时能直接命名"这是 reducer 在合并"、"这是条件边在分支"、"这是 thread_id 在续跑"。基于 LangGraph v1.0(2025-10-22 发布),写于 2026-06;下文代码用于演示概念分界,未在本机执行。
本章你要建立的心智模型
- StateGraph 是图模型:节点 + 边 + 一份共享 state;链是直线,图能分支、能回边
- State 用 TypedDict 定义;每个字段的 reducer 决定"并发/多次更新如何合并"——不写 reducer 默认覆盖
- Node 是一个函数:读 state、返回"部分更新 dict",框架按 reducer 把它合并回 state
- Edge 分普通边(固定下一步)与条件边(按函数返回值动态路由)——条件边是图能分支与回边的根
1.1StateGraph · 图模型
StateGraph 是图模型:一组节点、一组边、一份所有节点共享的 state(直译"状态图"——把整个流程画成图,节点是步骤,边是走向)。
LCEL 的 | 管道只能表达"A 接 B 接 C"——一条直线。但 agent 的真实控制流不是直线:LLM 要决定下一步走哪个工具(分支)、工具失败要重试(回边)、判断没收敛要再想一轮(循环)。这些用直线管道写出来,要么塞满 if/else 嵌套,要么靠外层 while 循环手动驱动。StateGraph 把"步骤"和"走向"分离成节点与边两套独立声明——走向可以指回任何节点,于是循环、分支、回边都成了图上一条普通的边。
把 StateGraph 想成状态机图(state machine):节点是状态,边是转移。类比失效之处:经典状态机一次只在一个状态;LangGraph 的图能在同一步让多个节点并发激活(fan-out),再用 reducer 把它们的输出汇合。它更接近 Pregel 那种"一批节点同时算、算完同步一次"的图计算模型——02 章会展开这层底层机制。
文档说"图能有环、链不能",但为什么链不能?LCEL 的 | 在构造期就把对象拼成一条静态的 RunnableSequence——拼接顺序即执行顺序,运行时无法回头,因为"下一个是谁"早被位置固定死了。StateGraph 把"下一个是谁"从位置里解放出来,交给边(尤其是条件边里的函数)在运行时决定。代价:图必须显式 .compile() 做一次拓扑校验,且环必须靠某条边给出"何时跳出",否则就是死循环——这是链永远不会遇到的一类失败。
场景走查 · 一条最小三节点图
from typing import TypedDict
from langgraph.graph import StateGraph, START, END
# State:这张图里所有节点都能读、都能写的一份共享数据
class State(TypedDict):
topic: str
draft: str
def write_draft(state: State) -> dict:
return {"draft": f"关于 {state['topic']} 的初稿"}
def polish(state: State) -> dict:
return {"draft": state["draft"] + "(已润色)"}
# 1) 建图模型,告诉它 state 的形状
builder = StateGraph(State)
# 2) 注册节点:名字 -> 函数
builder.add_node("write", write_draft)
builder.add_node("polish", polish)
# 3) 连边:START 是入口虚拟节点,END 是出口虚拟节点
builder.add_edge(START, "write")
builder.add_edge("write", "polish")
builder.add_edge("polish", END)
# 4) 编译成可执行对象(§1.5 细讲)
graph = builder.compile()
print(graph.invoke({"topic": "向量数据库", "draft": ""}))
# {'topic': '向量数据库', 'draft': '关于 向量数据库 的初稿(已润色)'}
逐行解读(代码 → 概念,不是代码 → 语法)
StateGraph(State):图模型的构造从"声明 state 形状"开始——图必须先知道节点之间传的是什么,才能为每个字段挂合并逻辑(§1.2)。这一行就是 StateGraph 区别于 LCEL 的起点:LCEL 不持有共享 state,数据只在管道里单向流过。add_node("write", write_draft):节点是"名字 → 函数"的登记。名字(字符串)是后面连边时的唯一引用——边连的是名字,不是函数对象。add_edge(START, "write"):START和END是两个内置的虚拟节点,标记图的入口与出口。它们不是你写的函数,只是"从哪进、到哪算结束"的锚点。- 这张图是直线:write → polish → END,没有分支也没有环——和 LCEL 管道等价。图的威力要到条件边(§1.4)才显现;此处先把"节点 + 边 + 共享 state"这三件套立住。
把 add_edge("polish", END) 改成 add_edge("polish", "write")(让 polish 指回 write),编译时会报错吗?运行时会怎样?
展开答案(先停 10 秒再点)
编译不报错——图允许有环,这正是它区别于链的地方。但运行时会无限循环:write → polish → write → polish …… 没有任何边给出"跳出"条件。最终触发 GraphRecursionError(默认递归上限 25 步)。
设计洞察:环本身合法,但"何时跳出环"必须由条件边(§1.4)显式给出——比如 polish 后接一条条件边,判断质量够了就走 END、不够才回 write。普通边连成的环一定是死循环;这是"图能循环"这件事的隐含契约。
与下一个概念的关系:图模型立住了,但那份"共享 state"到底怎么被多个节点安全地读写?答案藏在每个字段的 reducer 里。下一节看 State 与 reducer。
1.2State 与 reducer · 共享数据的合并协议
State 用 TypedDict 定义字段;每个字段可挂一个 reducer(合并函数),决定"老值 + 新值 → 合并后的值"——不挂 reducer 时默认是覆盖。
裸 dict 的合并是"后写覆盖"。聊天 agent 里 state 有个 messages 列表,每个节点都想往里追加一条消息——若用覆盖,第二个节点的写入会把第一个整段抹掉,对话历史只剩最后一条。reducer 把"该追加还是该覆盖"从节点代码里抽出来,提升为字段级的一次性声明:messages 字段挂上 add_messages,框架就知道用"追加/按 id 更新"来合,而不是用 = 覆盖。这一个协议同时撑起并发汇合(fan-in)、checkpoint 续跑、人工修改历史——它们用的是同一套字段级合并逻辑。
reducer 像 Python 的 functools.reduce——把一串值用同一个二元函数折叠成一个。类比失效之处:functools.reduce 折叠一个序列、只有一个函数;LangGraph 是每个字段一个 reducer,且折叠的两端固定是"state 里的当前值"和"节点刚返回的新值"。它更像 Redux 的 reducer,但 Redux 全局一棵 reducer 树,LangGraph 按字段分发。
文档教你"用 Annotated[list, add] 让列表追加",但机制是:节点从不直接改 state,它只返回一个部分更新 dict;框架拿着 (state 里的旧值, dict 里的新值) 这对值,去查该字段注册的 reducer,调用 reducer(old, new) 得到的结果才写回 state。所以不写 reducer 的字段,框架用的是隐式的"取新值"reducer——即覆盖。代价就在并发:两个节点同一步都给同一个无 reducer 字段写值,框架拿到两个"新值"却没有合并函数能把它们并成一个,于是直接抛 InvalidUpdateError,而不是任选一个悄悄盖掉。
场景走查 · 三种字段,三种合并行为
from typing import Annotated, TypedDict
from operator import add
from langgraph.graph import StateGraph, START, END
from langgraph.graph.message import add_messages
from langchain_core.messages import AnyMessage, HumanMessage, AIMessage
class State(TypedDict):
# 字段 A:无 reducer -> 默认覆盖。返回 {"user_id": "u2"} 会抹掉旧值。
user_id: str
# 字段 B:reducer = operator.add -> 列表拼接。返回 {"events": ["x"]} 被追加。
events: Annotated[list[str], add]
# 字段 C:reducer = add_messages -> 按 message.id upsert(新 id 追加、同 id 重写)。
messages: Annotated[list[AnyMessage], add_messages]
def node_alpha(state: State) -> dict:
return {
"user_id": "u_alpha", # 覆盖
"events": ["alpha"], # 追加
"messages": [AIMessage(content="from alpha", id="m1")],
}
def node_beta(state: State) -> dict:
return {
"user_id": "u_beta", # 又覆盖
"events": ["beta"], # 又追加
"messages": [AIMessage(content="from beta", id="m2")], # 新 id -> 追加
}
graph = (
StateGraph(State)
.add_node("alpha", node_alpha)
.add_node("beta", node_beta)
.add_edge(START, "alpha")
.add_edge("alpha", "beta")
.add_edge("beta", END)
.compile()
)
initial = {"user_id": "u0", "events": [], "messages": [HumanMessage("hi", id="h1")]}
final = graph.invoke(initial)
print(final["user_id"]) # u_beta <- 默认 reducer 覆盖
print(final["events"]) # ['alpha', 'beta'] <- add 拼接
print([m.content for m in final["messages"]])
# ['hi', 'from alpha', 'from beta'] <- add_messages 按 id 追加
逐行解读
user_id: str:没有Annotated→ 走默认 reducer → 后写覆盖。这是 reducer 协议的"零标注"形式,final["user_id"]等于最后一个写它的节点的值。Annotated[list[str], add]:把operator.add注册为字段的合并函数。任意满足(old, new) -> merged的可调用对象都能放进Annotated的第二个位置——add作用在列表上就是拼接。Annotated[list[AnyMessage], add_messages]:用 LangGraph 内置的消息 reducer。它不是简单 append——是按message.id做 upsert:新 id 追加、同 id 用新内容替换旧的。这里 alpha 写id="m1"、beta 写id="m2",两个不同 id 都追加,所以最终三条都在。- 同一次运行里三种行为并存:合并语义是按字段定的,不是按节点、不是按整张图。读 state 定义时,看每个字段有没有
Annotated、挂的是什么函数,就能预判它会被覆盖还是被累积。
add_messages 按 id upsert——新 id 追加、同 id 替换。人工"编辑历史消息"靠的就是"复用同 id 重写"这条机制。把 node_alpha 和 node_beta 改成并行分支(都从 START 出发、同一步执行),且 user_id 仍然没有 reducer,运行时会发生什么?
展开答案(先停 10 秒再点)
抛 InvalidUpdateError: At key 'user_id': Can receive only one value per step. Use an Annotated key to handle multiple values. ——两个并发节点在同一步都写 user_id,默认 reducer(覆盖)无法决定保哪个,框架拒绝静默合并、直接报错。
设计洞察:LangGraph 不"猜"。它把"没声明 reducer 又并发写"判为程序员的错误,而不是用任意策略悄悄合并掉一个。修复路径就是给该字段加 Annotated[..., 某 reducer],明确告诉框架并发写该怎么合。"加 Annotated"是绝大多数并发崩溃的标准修法。
与下一个概念的关系:reducer 是被动的合并协议;主动产出"新值"的是 Node。下一节看 Node 的契约——它返回什么、不该做什么。
1.3Node · 读 state、返回部分更新的函数
Node 是一个函数,入参是当前 state,返回一个"部分更新 dict"——框架按各字段的 reducer 把这个 dict 合并回 state。
agent 的每一步都是一团副作用:调 LLM、调工具、查数据库、写日志。若把这些副作用直接散在主循环里,控制流和业务逻辑就缠成一团,无法单独测试、无法被框架托管。Node 给副作用定了一个极简契约——把它包成"读 state、返回部分更新 dict"的函数。节点不关心自己被谁调用、调用几次、是否并发;调度、合并、持久化、tracing 全由框架接管。契约简到几乎不像框架,这正是节点能跟 LCEL 的 Runnable、裸 Python 函数、第三方 agent 自由互换的原因——任何"吃 dict 吐 dict"的东西都能当节点。
Node 像 React reducer 里的 action handler:拿到当前状态,返回"该改什么",而不直接改状态本身。类比失效之处:React 的 reducer 返回完整新状态;LangGraph 的节点只返回想改的那几个字段(部分 dict),没返回的字段原样保留。这是个省事的约定——也是个陷阱来源(见下方预测题)。
文档说"节点返回 dict 来更新 state",但更深一层是:返回 dict 的 key 必须是 State 里声明过的字段,否则那个 key 被静默忽略——不报错。框架拿返回 dict 跟 state 做的不是"替换整个 state",而是"对 dict 里出现的每个已知字段,各自走一遍 reducer"。两个推论:其一,节点返回 {} 完全合法,表示"这步什么都不改";其二,节点不该原地修改入参 state——框架靠"返回值里出现了哪些字段"来决定更新谁,原地改了入参却没返回对应字段,那次修改就丢了。
场景走查 · 同步、async、返回 Command 三种形态
from typing import TypedDict, Annotated
from operator import add
from langgraph.graph import StateGraph, START, END
from langgraph.types import Command
class S(TypedDict):
counter: int # 默认 reducer:覆盖
events: Annotated[list[str], add]
# 形态 1:同步节点——读 state、返回部分更新 dict
def sync_step(state: S) -> dict:
return {"counter": state["counter"] + 1, "events": ["sync"]}
# 形态 2:async 节点——契约相同,只是函数本身 async;适合 await LLM / HTTP
async def async_step(state: S) -> dict:
# result = await some_llm_call(...)
return {"events": ["async"]}
# 形态 3:返回 Command——同时"更新 state"和"声明下一步去哪"
def step_then_route(state: S) -> Command:
return Command(
update={"counter": state["counter"] * 2, "events": ["routed"]},
goto="finalize", # 直接指定下一节点,省掉一条 add_edge
)
def finalize(state: S) -> dict:
return {"events": ["done"]}
graph = (
StateGraph(S)
.add_node("sync_step", sync_step)
.add_node("async_step", async_step)
.add_node("step_then_route", step_then_route)
.add_node("finalize", finalize)
.add_edge(START, "sync_step")
.add_edge("sync_step", "async_step")
.add_edge("async_step", "step_then_route")
# step_then_route 用 Command.goto 跳到 finalize——无需 add_edge
.add_edge("finalize", END)
.compile()
)
逐行解读
- 形态 1(
sync_step):最常见的节点——读 state、返回部分更新 dict。返回的两个 key(counter、events)都在 State 里声明过,所以都生效;若多写一个{"foobar": 1},foobar会被静默丢弃。 - 形态 2(
async_step):同样的契约,函数本身是async def。同一张图里 sync 与 async 节点能并存,但要真正并发,必须用graph.ainvoke()驱动;用同步的graph.invoke()会把 async 节点也顺序跑完,并发优势消失。 - 形态 3(
step_then_route):返回Command而非普通 dict。它做两件事——update=部分照常走 reducer 折叠,goto=直接指定下一节点名。这让"先改 state 再决定走向"在一个节点里一次完成,很多场景下可省掉一条独立的条件边。
把 sync_step 写成 state["counter"] += 1; return state(原地改入参、再返回整个 state),counter 会被更新吗?
展开答案(先停 10 秒再点)
结果是静默不生效,且不报错。counter 没有 reducer,走默认"取新值"。但你原地改的是框架传进来的那个 state 对象本身,再把它整体返回——框架比对时,counter 字段的"新值"等于它已经持有的那个被就地改过的值,"取新值"取到的是同一份引用,更新效果被吞掉,看起来像没改。
修复:永远返回新的部分 dict,return {"counter": state["counter"] + 1},绝不原地 mutate 入参。这条是节点契约里最隐蔽的一条——代码不报错,行为却不对,调起来费劲。
与下一个概念的关系:Node 负责"产出新值",但"这个节点跑完该走哪个节点"由谁决定?是 Edge。下一节看 Edge 的两种形态——这是图能分支与回边的根。
1.4Edge · 普通边与条件边
Edge 决定"一个节点跑完,下一个跑谁":普通边(add_edge)固定下一个节点;条件边(add_conditional_edges)调一个函数读 state、按返回值动态选下一个节点。
静态直连(A 永远接 B)只够表达固定流程。真实 agent 要"LLM 说该查工具就去查、说够了就结束"——下一步是哪个节点,运行时才知道,取决于 state 的内容。条件边把"选下一步"交给一个普通 Python 函数:函数读 state、返回一个节点名字符串,图就跳到那个节点。因为这个函数能返回任意已存在的节点名(包括上游节点),分支、循环、回边全都成立——这就是为什么说"条件边是图能分支与回边的根"。普通边连不出环,条件边能。
普通边像代码里的顺序执行(一条接一条),条件边像 if/elif/else 或 match——按当前数据选分支。类比失效之处:if 的分支目标在写代码时就定死;条件边的"分支目标集合"是图里所有节点名,函数运行时返回哪个就跳哪个,甚至可以由 LLM 现场决定返回值。它更像一个"以节点名为值的 goto",只是被约束在图的节点集合内。
条件边的函数返回值,校验发生在运行时而非编译时——因为框架无法在编译期穷举一个 Python 函数(甚至内部调 LLM)会返回的全部值。add_conditional_edges 的第三个参数(一个 dict 或 list,把返回值映射到节点名)主要供可视化和静态分析,并不收紧运行时校验:函数返回了一个不在图里的节点名,要到那一步才抛错。推论:把 LLM 接到条件边上时,必须做防御性收口——对 LLM 的输出 .strip().lower()、用白名单过滤、给一个兜底分支(比如 fallback 到 END),否则模型一句话跑偏就让整张图在运行时崩。
场景走查 · 普通边 + 条件边连成一个带循环的 agent
from typing import TypedDict, Annotated
from operator import add
from langgraph.graph import StateGraph, START, END
class State(TypedDict):
question: str
attempts: int
answer: str
log: Annotated[list[str], add]
def think(state: State) -> dict:
n = state["attempts"] + 1
# 假装第 2 次才想出满意答案
answer = "satisfactory" if n >= 2 else "draft"
return {"attempts": n, "answer": answer, "log": [f"think#{n}->{answer}"]}
def respond(state: State) -> dict:
return {"log": ["responded"]}
# 条件函数:读 state,返回下一节点的名字(字符串)
def route_after_think(state: State) -> str:
if state["answer"] == "satisfactory":
return "respond" # 满意 -> 去 respond
if state["attempts"] >= 3:
return "respond" # 兜底:试够 3 次也收手,避免死循环
return "think" # 不满意且没超限 -> 回到 think(这就是回边)
graph = (
StateGraph(State)
.add_node("think", think)
.add_node("respond", respond)
# (1) 普通边:入口固定接 think
.add_edge(START, "think")
# (2) 条件边:think 跑完调 route_after_think,按返回值跳转
# 第三参数是"返回值 -> 节点"映射,供可视化;不收紧运行时校验
.add_conditional_edges("think", route_after_think,
{"think": "think", "respond": "respond"})
# (3) 普通边:respond 跑完结束
.add_edge("respond", END)
.compile()
)
print(graph.invoke({"question": "Q", "attempts": 0, "answer": "", "log": []})["log"])
# ['think#1->draft', 'think#2->satisfactory', 'responded']
# think 跑了两次(第一次回边重来),第二次满意才走 respond
逐行解读
- (1)
add_edge(START, "think"):普通边——固定走向、无判断。读代码时遇到add_edge,约等于"无脑直连下一个"。 - (2)
add_conditional_edges("think", route_after_think, {...}):think 跑完后调route_after_think(state),它读 state 返回一个节点名字符串,图就跳过去。第三个参数{"think": "think", "respond": "respond"}把"会出现的返回值"映射到节点,仅供画图与静态检查——运行时真正生效的是函数实际返回的那个字符串。 - (3) 回边
return "think":条件函数返回上游节点名"think",于是控制流跳回 think——这就是"环"。环成立的前提是函数里有一个会变 false 的退出条件(这里是answer == "satisfactory"或attempts >= 3),否则就是死循环。 - 兜底分支
attempts >= 3:把"试够 N 次也收手"显式写进条件函数。这是把循环交给条件边时的标准纪律——永远留一条通往END或终态的边,别指望模型每次都给出能退出的返回值。
think 的那条朱红回边,把图变成了循环——而能否跳出循环,完全取决于路由函数里是否有一个终会成立的退出条件。普通边(黑线)永远画不出这条回边。条件函数 route_after_think 某次返回字符串 "reflect"(图里并没有叫 reflect 的节点),会在什么时候、以什么方式失败?
展开答案(先停 10 秒再点)
在运行到那一步时抛错(ValueError,提示找不到名为 reflect 的节点),而不是在 compile() 时。原因:框架无法在编译期预知一个 Python 函数会返回什么——尤其当函数内部调 LLM 时返回值完全是运行时产物。第三参数的映射 {"think": ..., "respond": ...} 只用于可视化和静态提示,不会在编译期把 "reflect" 拦下。
设计洞察:这正是"把 LLM 当路由器"必须防御的点。标准做法:路由函数内对模型输出做白名单校验 + 兜底(命中白名单才返回对应节点名,否则返回 END 或一个安全的 fallback 节点),把"模型跑偏"挡在图崩溃之前。
与下一个概念的关系:State、Node、Edge 到此都还只是声明——一堆登记好的节点和边。把它们变成一个真能 .invoke() 的对象,是 compile() 的工作。下一节看编译与执行。
1.5compile 与执行 · 把图变成 Runnable
builder.compile() 把声明好的图编译成一个可执行对象,它实现了 LangChain 的 Runnable 接口("Runnable"=统一的调用接口),于是直接复用 LCEL 的 .invoke() / .stream() / .batch()。
"声明图"和"执行图"被刻意分成两个阶段。StateGraph 阶段你可以随意增删节点和边——这是图的可变期;.compile() 是一道闸门:图被冻结,框架在这一刻做拓扑校验(有没有指向不存在节点的边、入口出口是否连通)、注入 checkpointer、并把整张图包成一个 Runnable。包成 Runnable 这一步是关键——它让一张 LangGraph 图能像任何 LCEL 组件一样被 | 进更大的管道,也能被另一张图当成一个节点嵌进去(子图)。前期可变、编译时冻结,是"易写"与"易部署"分而治之的经典工程动作。
文档说"compile 后才能 invoke",但更深一层:编译产物之所以能 .stream() / .batch() / .ainvoke(),不是 LangGraph 各写一遍,而是因为它实现了 Runnable 这个统一接口——这些方法是接口自带的(参见上一份 LangChain 教程的核心论点)。所以"图能流式输出"和"一条 LCEL 链能流式输出"是同一套机制的两个实例。代价:编译是有成本的一次性动作,不该在每次请求里重复 compile();正确姿势是模块加载时编译一次、长期复用同一个编译产物。
.compile() 最值得看的参数是 checkpointer=(→ §1.6)。看到 .compile(checkpointer=...)——这张图是有状态的,能续跑、能暂停恢复;只看到光秃秃的 .compile()——它是无状态的,每次 invoke 都从头开始、跑完即忘。这一个参数的有无,就把"一次性工具"和"带记忆的 agent"区分开了。
场景走查 · 编译产物就是一个 Runnable
from typing import TypedDict, Annotated
from operator import add
from langgraph.graph import StateGraph, START, END
class S(TypedDict):
x: int
trace: Annotated[list[str], add]
def double(state: S) -> dict:
return {"x": state["x"] * 2, "trace": ["double"]}
builder = StateGraph(S)
builder.add_node("double", double)
builder.add_edge(START, "double")
builder.add_edge("double", END)
graph = builder.compile() # <- 冻结 + 拓扑校验 + 包成 Runnable
# 1) invoke:跑一次,拿最终 state
print(graph.invoke({"x": 3, "trace": []}))
# {'x': 6, 'trace': ['double']}
# 2) batch:一批输入并行跑——Runnable 接口自带,图没额外写
print(graph.batch([{"x": 1, "trace": []}, {"x": 10, "trace": []}]))
# [{'x': 2, 'trace': ['double']}, {'x': 20, 'trace': ['double']}]
# 3) stream:按步产出中间过程——同样来自 Runnable 接口
for chunk in graph.stream({"x": 5, "trace": []}):
print(chunk)
# {'double': {'x': 10, 'trace': ['double']}} <- 每个节点完成时吐一块
逐行解读
graph = builder.compile():这一行之后builder的结构被冻结,graph是一个全新对象——编译产物。再往builder加节点不会影响已编译的graph。graph.invoke(...):跑完整张图、返回最终 state。这是和 LCEL 链一模一样的方法名——因为两者都实现了 Runnable。graph.batch([...]):一批输入并行跑,每个输入独立得到一份最终 state。图本身没写过 batch 逻辑,能力来自 Runnable 接口。graph.stream(...):不等整张图跑完,每个节点完成时就吐出一块更新(默认stream_mode="updates",键是节点名)。这让"边跑边看 agent 在干什么"落了地;流式的多种模式在 02 章展开。
在请求处理函数里每次都执行 graph = builder.compile() 再 graph.invoke(...),功能上对吗?有什么代价?
展开答案(先停 10 秒再点)
功能上对——结果不会错。但代价是每次请求都白白重做一遍拓扑校验和 Runnable 封装,是纯浪费的开销;若编译时还注入了 checkpointer,每次新建编译产物还会让你拿不到稳定的状态句柄。
修复:把 compile() 放在模块加载期执行一次,得到的 graph 在所有请求间复用。"声明期可变、编译一次、长期复用"——编译是闸门,不是每请求都做的步骤。
与下一个概念的关系:默认编译出的图是无状态的——跑完即忘。要让它记住上一次、能续跑、能暂停恢复,得在 compile() 时装上 checkpointer。下一节看记忆是怎么来的。
1.6checkpointer 与 thread_id · 记忆与续跑
在 compile(checkpointer=...) 装上 checkpointer("检查点存档器"),每步把 state 存档;invoke 时传 config={"configurable": {"thread_id": ...}},同一个 thread_id 就能续上上一次的进度。
对话记忆、暂停后人工介入再恢复、崩溃后从最近一步重跑、回看历史做时间旅行——这四件事在传统 agent 框架里要分别造四套机制。LangGraph 把它们塌缩成一个原语:每步存一个 checkpoint,按 thread_id 寻址。thread_id 是这套记忆的唯一寻址 key——同一个 thread_id 看到同一段历史并在其上累积;不同 thread_id 互相隔离、互不可见。"会话""用户""session"全是 thread_id 之上的应用层约定,框架本身只认 thread_id 这一个 key。
checkpointer + thread_id 像游戏的存档槽:每打完一关自动存一次(每步一个 checkpoint),下次用同一个存档槽(同一 thread_id)进游戏就接着上次玩。类比失效之处:游戏存档通常只留最新或几个手动档;LangGraph 默认把每一步都留成可寻址的 checkpoint,于是能"回到任意一关重来"(时间旅行 / 重放分歧),而不只是读最近一档。
文档说"checkpointer 保存 state",但要害是第二次 invoke 时初始输入会被忽略:checkpointer 已存有该 thread_id 的 state,新的 invoke 不是"用新输入重置 state",而是"在最近 checkpoint 之上继续累积"。所以同一个 thread_id 反复用初始值 {"count": 0} invoke,count 不会每次回到 0——它续着涨。代价/约束:thread_id 是唯一的隔离边界,没有第二道。这意味着 thread_id 选错(比如硬编码成同一个),不同用户的历史会串到一起;它必须当多租户系统里的租户 key 来认真对待,而不是一个随手的环境标签。
场景走查 · 同 thread_id 续跑 vs 新 thread_id 重置
from typing import TypedDict, Annotated
from operator import add
from langgraph.graph import StateGraph, START, END
from langgraph.checkpoint.memory import InMemorySaver # v1.0 名字;旧名 MemorySaver
class S(TypedDict):
count: int
log: Annotated[list[str], add]
def step(state: S) -> dict:
return {"count": state["count"] + 1, "log": [f"count={state['count']}"]}
graph = (
StateGraph(S)
.add_node("step", step)
.add_edge(START, "step")
.add_edge("step", END)
.compile(checkpointer=InMemorySaver()) # <- 装上 checkpointer,图变有状态
)
cfg_alice = {"configurable": {"thread_id": "alice"}}
cfg_bob = {"configurable": {"thread_id": "bob"}}
# Alice 连跑三次——同一 thread_id,state 在 checkpoint 上累积
graph.invoke({"count": 0, "log": []}, cfg_alice) # count -> 1
graph.invoke({"count": 0, "log": []}, cfg_alice) # 初始 count=0 被忽略!续到 2
graph.invoke({"count": 0, "log": []}, cfg_alice) # 续到 3
print(graph.get_state(cfg_alice).values["count"]) # 3
# Bob 第一次跑——不同 thread_id,从零开始,与 Alice 互不可见
graph.invoke({"count": 0, "log": []}, cfg_bob)
print(graph.get_state(cfg_bob).values["count"]) # 1
# 时间旅行:倒序列出 Alice 的所有 checkpoint
for snap in graph.get_state_history(cfg_alice):
print(snap.config["configurable"]["checkpoint_id"], snap.values["count"])
逐行解读
InMemorySaver:进程内的 dict 存档,重启即丢。名字里有 "memory" 不代表持久化——生产要换成SqliteSaver(单机/嵌入)或PostgresSaver(多实例共享)。把内存存档器带进生产,重启后所有会话记忆归零。compile(checkpointer=InMemorySaver()):这一个参数把图从无状态切成有状态。没有它,后面的get_state/get_state_history都无处可查。- 连续三次
invoke(..., cfg_alice):第二次起,传进去的初始{"count": 0}被忽略——checkpointer 已有 state,新 invoke 在最近 checkpoint 之上续,所以 count 是 1→2→3,不是每次都 1。这是最常让人困惑的一点:代码看着像每次从 0 开始,实际在累积。 get_state_history(cfg_alice):倒序列出该 thread_id 的全部 checkpoint。人工编辑历史、重放某个分叉点、调试"它当时看到的 state 是什么"全靠它——这是"每步都存"换来的能力。
生产里把 thread_id 硬编码成 "default"(所有请求都用它),会出现什么现象?
展开答案(先停 10 秒再点)
所有用户共享同一段历史:Alice 的对话会出现在 Bob 的下一次回复里,count 这类累积字段被全体用户一起越叠越高。因为 thread_id 是唯一的隔离边界,硬编码成一个值等于把所有人塞进同一条时间轴。
修复:按隔离粒度构造 thread_id,例如 thread_id = f"{user_id}:{session_id}",每开一次新会话生成新的 session_id。把 thread_id 当多租户系统的租户 key 对待——它不是"环境标签",是隔离边界本身。
把六块积木接上:StateGraph 给出图模型,State + reducer 定义共享数据怎么合并,Node 产出部分更新,Edge(尤其条件边)决定走向并让循环成立,compile() 把声明冻结成 Runnable,checkpointer + thread_id 再给它装上记忆与续跑。02 章会钻进这套积木的运行引擎——为什么并发节点共享一个 checkpoint、super-step 到底是什么、Pregel 怎么调度。
§本章 self-check
先合上教程,把你能想到的答案写在纸上或编辑器里。 写完再点开答案对照——直接点开等于把这一节当再读一遍。
- 用一句话说清"链是直线、图能回边"背后的机制差异:为什么 LCEL 管道运行时无法回头,而 StateGraph 能?
- 一个 state 字段写成
Annotated[list[str], add]和写成list[str](无 Annotated),在"两个并发节点同时写它"这一情形下,行为分别是什么? - 节点原地修改入参 state(
state["x"] += 1)再返回整个 state,为什么x没有 reducer 时更新会丢失?该怎么写才对? - (设计题)要做一个"客服 agent,支持多用户并发会话、每个会话独立记忆、且 LLM 决定何时转人工"——说明你会怎么用本章的六块积木搭:哪个概念负责隔离不同用户、哪个负责让 LLM 决定走向、哪个负责"转人工"后还能恢复现场。
答案(先做完再展开)
- LCEL 的
|在构造期就把组件拼成一条静态序列,"下一个是谁"由位置固定,运行时没有"重新选下一步"的机制;StateGraph 把"下一个是谁"交给边(尤其条件边里的函数)在运行时按 state 决定,函数可以返回上游节点名,于是能回边、循环。 Annotated[list[str], add]:两个并发节点的写入被 reducer(add)拼接成一个列表,两份内容都保留。list[str]无 Annotated:走默认覆盖 reducer,框架在同一步收到两个值却没有合并函数,抛InvalidUpdateError,而非任选一个。- 框架靠"返回值里出现了哪些字段、各自走一遍 reducer"来更新 state。
x无 reducer 走"取新值";原地改入参再整体返回时,x的"新值"就是被就地改过的那个同一引用,"取新值"取到的与当前持有的相同,更新被吞。正确写法:返回新的部分 dict{"x": state["x"] + 1},不 mutate 入参。 - 参考思路:thread_id(§1.6)按
user:session构造,承担多用户/多会话的隔离;条件边(§1.4)的路由函数读 state(或 LLM 输出)决定走"继续自动回复"还是"转人工"节点,并做白名单兜底;checkpointer(§1.6)让"转人工"时把当前 state 存档,人工处理后用同一 thread_id 续跑即可恢复现场;State + reducer 里messages用add_messages累积对话;Node 包住每一步副作用;整体用 StateGraph 组装、compile(checkpointer=...)冻结。判分点:能把"隔离→thread_id、动态走向→条件边、可恢复→checkpointer"三条对应清楚,而不是只罗列概念。
两个并发分支各写一半,再在汇合节点拼起来——但其中一个分支偶尔写一个"未声明字段"
设计一张图:从 START 同时扇出到 branch_a 和 branch_b(两者并发),各自往 results(Annotated[list, add])写一条,再汇合到 merge 节点读取 results。现在给 branch_b 故意返回一个 State 里没声明的字段 {"results": ["b"], "debug_note": "hi"}。问:debug_note 会进 state 吗?merge 读 state["results"] 会看到几条?如果把 results 的 Annotated[list, add] 去掉,这张图还能编译吗、能运行吗?
提示(卡住再展开)
三个落点分别踩在三节上:未声明字段 → §1.3 的"返回 dict 里未声明的 key 被静默忽略";并发写同一字段 → §1.2 的"有 reducer 则合并、无 reducer 则 InvalidUpdateError";"能编译吗 vs 能运行吗" → §1.4/§1.5 的"拓扑校验在编译期、并发写冲突在运行期"。先分别答这三问,再合起来。