写到 Agent 工程化的第三篇,绕不开一个现实问题:节点一多、一并行,状态就开始互相踩。检索节点刚塞进去的文档被重试节点整份覆盖,打分结果在两个分支里各写各的,日志对完根本说不清哪一步的结果进了最终上下文。
常规做法是自己维护一个全局 dict 或类属性,靠约定"只写自己负责的 key"。问题在于:增量合并要手写、并发顺序不可控、出错后想从中间某步重跑更是无从下手,约定一多就没人记得全。
本文分享一套可直接落地的状态流水线方案:类型化 State + 声明式 Reducer + 条件边路由 + SQLite 检查点,四件套串起来就能得到一条能并行、能归约、能回放的图执行链路。
一、状态失控在哪:全局变量模式的三宗罪
先把病灶钉死:多节点共享一份可变状态时,最常见的三种翻车现场如下。
- 写覆盖:后执行的节点返回整份 state,前一个节点辛苦查到的工具结果被直接抹掉。
- 并发竞争:并行分支同时读改写同一个 dict,最终值取决于调度顺序,复现不了。
- 无法回放:状态散落在内存变量里,线上出错时不能从某个中间节点重跑一次。
三种常见承载方式的差别,一张表就能看清:
| 方式 | 并行安全 | 增量合并 | 断点回放 | 代码侵入 |
|---|---|---|---|---|
| 全局 dict / 类属性 | 否 | 手写 | 否 | 低 |
| payload 沿链路传递 | 部分 | 手写 | 部分 | 中 |
| StateGraph + Reducer | 是 | 声明式 | 是 | 中 |
**核心结论:**状态不是一个"大家共用的变量",而是一份"每个节点只能追加、系统负责归约、随时可回放的账本"。

二、状态声明:Annotated 类型里藏着 Reducer
状态怎么定义决定后面一切:LangGraph 的 State 就是一个 TypedDict,真正的机关写在类型注解上。
python
import operator
from typing import Annotated, TypedDict
from langgraph.graph import StateGraph, START, END
class AgentState(TypedDict):
question: str # 无 Reducer:后者覆盖前者
docs: Annotated[list[str], operator.add] # 有 Reducer:多分支结果自动拼接
score: Annotated[float, max] # 自定义 Reducer:保留最高分
Annotated[类型, 归约函数] 就是状态字段的更新协议,框架在节点返回后按它合并,业务代码不用自己写 merge。
| 字段写法 | 更新语义 | 适用场景 |
|---|---|---|
question: str |
Last-write-wins 覆盖 | 输入参数、配置 |
Annotated[list, operator.add] |
逐次追加 | 文档、消息、工具调用记录 |
Annotated[T, custom_fn] |
自定义归约 | 计分、集合去重、布尔标志 |
**核心结论:**把"谁负责合并"从节点里挪到类型声明里,节点才能只关心自己的业务。
三、节点与普通边:函数签名就是状态契约
节点的写法可以压到最简:接收 state,返回增量 dict,其余交给框架。
python
def retrieve(state: AgentState) -> dict:
return {"docs": [f"doc-{state['question']}"]}
def grade(state: AgentState) -> dict:
return {"score": max(state["score"], 0.8)}
g = StateGraph(AgentState)
g.add_node("retrieve", retrieve)
g.add_node("grade", grade)
g.add_edge(START, "retrieve")
g.add_edge("retrieve", "grade")
g.add_edge("grade", END)
app = g.compile()
print(app.invoke({"question": "顺序消息为何乱", "docs": [], "score": 0.0}))
- 只返回增量:节点返回的 key 才会参与合并,没写的字段保持原样,天然避免覆盖。
- 只连显式边 :
add_edge定义了唯一执行顺序,同一父节点下多条出边即为并行扇出。
执行链路:节点返回增量 → Reducer 归约 → 写回共享 State → 下游节点读到合并后的值。
**核心结论:**节点的入参类型和返回 dict 就是它与系统的全部契约,契约之外的字段它碰不到。
四、条件边:把分支判断从节点里剥出来
分支不该藏在 if 里,交给返回目标 key 的路由函数更可测:
python
from langgraph.graph import END
def router(state: AgentState) -> str:
if not state["docs"]:
return "retrieve" # 没检索到就重试
return "grade"
g.add_conditional_edges(
"retrieve",
router,
{"retrieve": "retrieve", "grade": "grade", "END": END},
)
路由返回值与图行为一一对应:
| 路由返回 | 后续节点 | 典型场景 |
|---|---|---|
"grade" |
进入打分节点 | 拿到文档,正常推进 |
"retrieve" |
回到检索节点 | 命中为空,换关键词重试 |
"END" |
结束并输出 | 重试次数耗尽或已达标 |
- 路由纯函数 :
router只读 state、不改 state,可以脱离图单独写单测。 - 映射表显式声明 :
{...}里列全所有出口,漏写会在编译期直接报错,而不是运行时静默走偏。

**核心结论:**把 if 从节点搬到边,状态更新与流程控制彻底解耦,重试逻辑改一行映射即可。
五、Reducer 取舍:追加、去重、覆盖各有代价
三种最常用的归约写法,选错了会出现重复结果或结果丢失。
python
import operator
def merge_set(old: set, new: set) -> set:
return old | new # 集合并集:天然去重
# 1) 追加:并行分支的 docs 会拼成一条长列表
docs: Annotated[list[str], operator.add]
# 2) 去重合并:适合 tag、已访问 URL
tags: Annotated[set[str], merge_set]
# 3) 覆盖:无 Reducer 字段,只给配置类参数用
question: str
- 问题:
operator.add对 list 只会无脑拼接,重试十次就留下十份重复文档,上下文被撑爆。 - 治理:节点内按
doc_id先去重,或干脆把字段类型换成set+merge_set归约。 - 问题:给计分字段用默认覆盖,两个并行分支的结果必然丢一个。
- 治理:用
max、sum这类自定义 Reducer,让"谁该赢"写进类型而不是写进运气。
**核心结论:**Reducer 是状态的合并策略表,先想清楚"多份结果该合成什么",再决定字段类型。
六、检查点与回放:把状态落库随时续跑
compile 时挂上 checkpointer,每步状态都会落盘,断点续跑只需要一行。
python
from langgraph.checkpoint.sqlite import SqliteSaver
with SqliteSaver.from_conn_string("agent.db") as memory:
app = g.compile(checkpointer=memory)
cfg = {"configurable": {"thread_id": "case-001"}}
app.invoke({"question": "顺序消息为何乱", "docs": [], "score": 0.0}, cfg)
# 出错后沿用上一次快照,只继续没跑完的节点
app.invoke(None, cfg)
snap = app.get_state(cfg)
print(snap.values["docs"], snap.next) # 看当前状态与待执行节点
thread_id是会话主键:同一个 id 共享一条状态链,换 id 就是全新会话。invoke(None, cfg)表示续跑 :框架读取最近一条 checkpoint,从snap.next指出的节点继续。- 排查顺序 :先看
snap.next定位卡在哪,再看snap.values判断是数据问题还是路由问题。
**核心结论:**状态一旦落库,调试从"重跑整个链路"变成"重放一个节点",线上排障成本直接降一个量级。

结语
这一篇把状态管理拆成了四个可独立验证的动作:用 TypedDict + Annotated 声明字段协议,用节点返回增量、普通边定顺序、条件边做路由,用 Reducer 决定多份结果如何归约,最后用 SQLite 检查点把每一步状态固化下来。四步合起来,Agent 从"跑一次看运气"变成"每一步可观察、可单测、可重放",后面接人工审批、多 Agent 协作时也能直接复用这套状态底座。
状态定义清楚了,图就只是一张能被随时重放的调度表。