Day 05 / 共 60 天 · 阶段 1 入门与心智
State 与 TypedDict:一个类如何变成一排通道
D03 说过"构造 StateGraph(State) 那一刻,字段就被解析成通道了"。今天就把这台"翻译机"拆开:_get_channels 怎么遍历你 TypedDict 的每个字段、_get_channel 怎么在四种可能里选出正确的通道类型、Annotated[list, reducer] 里的 reducer 又是怎么靠数参数个数被嗅出来的。看完你会明白:State schema 不是"运行时的数据",而是"编译前的一张字段→通道映射表"。
📍 阶段 1「入门与心智」(D01-06) · 你在第 5 天
D01 全景分包→
D02 跑通第一个图→
D03 StateGraph→
D04 节点与边→
D05 State/TypedDict→
D06 对话图+小结
💡 一句话锚点
你写的
class State(TypedDict) 是"图纸的字段说明书"。LangGraph 不直接用这个类存运行数据,而是在建图时把它逐字段翻译成通道(channel)——每个字段配一个"这个字段该怎么合并"的小对象。字段没写 reducer → LastValue(新值覆盖旧值);写了 Annotated[类型, 函数] → 用你的函数合并。State schema 的全部意义,就是产出这张 {字段名 → 通道} 表。L01
痛点:为什么不能直接拿 TypedDict 当状态用?
🤔 痛点你自然会想:状态就是个
dict,节点返回 {"x": 5} 直接更新进去不就行了?但问题来了——如果两个并行节点同时返回 {"messages": [...]},谁覆盖谁?如果某个字段本意是"累加"(消息越来越多)而不是"覆盖",光靠 dict.update 根本表达不了。LangGraph 需要为每个字段单独记住"合并规矩",而普通 TypedDict 只有类型、没有合并规矩。💡 本质:把"字段的合并规矩"编码进类型标注里LangGraph 借用 Python 的
Annotated[类型, 元数据] 语法——类型给人和类型检查器看,元数据(reducer 函数或通道对象)给 LangGraph 看。建图时它读出这些元数据,为每个字段造一个对应的通道。没标注的字段就默认 LastValue(覆盖)。所以 State 类本质是"把合并规矩塞进字段标注"的载体。大白话普通
dict 只知道"x 是个 int";LangGraph 的通道还额外知道"x 该怎么更新——是覆盖,还是累加,还是自定义"。今天就是看它怎么从你的类里读出这份"怎么更新"。L02
入口 _add_schema:解析 + 登记 + 查重
D03 见过 __init__ 一上来就调 _add_schema(state_schema)。它的完整逻辑:
libs/langgraph/langgraph/graph/state.py:342-372
def _add_schema(self, schema, /, allow_managed=True):
if schema not in self.schemas: # 同一个 schema 只解析一次
_warn_invalid_state_schema(schema) # ① 校验是不是合法 schema
channels, managed, type_hints = _get_channels(schema) # ② 核心翻译
if managed and not allow_managed: # ③ input/output 不许有托管字段
raise ValueError(f"Invalid managed channels ...")
self.schemas[schema] = {**channels, **managed} # ④ 缓存这个 schema 的解析结果
for key, channel in channels.items():
if key in self.channels:
if self.channels[key] != channel:
if isinstance(channel, LastValue):
pass # 同名但新的是 LastValue → 容忍
else:
raise ValueError(f"Channel '{key}' already exists ...")
else:
self.channels[key] = channel # ⑤ 汇总进全局 channels
schema not in self.schemas解析结果按 schema 类缓存。state/input/output 若指向同一个类,只翻译一次。_get_channels(schema)★真正的翻译在这(L03)。返回三样:普通通道字典、托管值字典、原始类型提示。allow_managed=FalseD11 会讲:输入/输出 schema 里不许出现引擎托管字段,这里用参数卡死。self.channels[key] = channel把各 schema 解析出的通道合并到全局 self.channels。这就是"图里所有字段的通道总表"。LastValue 冲突放行D03 讲过的"宽进严出":同名字段若新来的是最普通的 LastValue,不覆盖已有的更具体通道。L03
_get_channels:把一个类拆成一排字段
翻译机的主函数。它先处理"没有字段的裸类型",再逐字段调 _get_channel:
libs/langgraph/langgraph/graph/state.py:1801-1821
def _get_channels(schema):
if not hasattr(schema, "__annotations__"): # 没字段(如裸 int/list)
return ({"__root__": _get_channel("__root__", schema, allow_managed=False)}, {}, {})
type_hints = get_type_hints(schema, include_extras=True) # ★带上 Annotated 的元数据
all_keys = {
name: _get_channel(name, typ) # 每个字段翻译成一个通道
for name, typ in type_hints.items()
if name != "__slots__"
}
return (
{k: v for k, v in all_keys.items() if isinstance(v, BaseChannel)}, # 普通通道
{k: v for k, v in all_keys.items() if is_managed_value(v)}, # 托管值
type_hints,
)
没有 __annotations__你可以把整个状态设成一个裸类型(如 StateGraph(int)),这时造一个特殊字段 __root__ 包住它。这就是 D03 见过的 "__root__" 由来。include_extras=True★关键参数!默认的 get_type_hints 会丢掉 Annotated 的元数据(只留类型)。加上它才能拿到 reducer。少了这个参数整个 reducer 机制就废了。逐字段 _get_channel字典推导,把每个 (字段名, 类型标注) 交给 _get_channel 决定它变成哪种通道。分成 channels / managed翻译结果分两类:普通通道(BaseChannel)和引擎托管值(如 RemainingSteps,D17 会见)。分开返回。图注:schema 的产物是一张字段→通道映射表,不同字段可以有完全不同的合并语义。
L04
_get_channel:四选一决定通道类型
单个字段的通道类型,靠这个"四选一"函数拍板:
libs/langgraph/langgraph/graph/state.py:1836-1859
def _get_channel(name, annotation, *, allow_managed=True):
# 先剥掉 Required / NotRequired 外壳
if hasattr(annotation, "__origin__") and annotation.__origin__ in (Required, NotRequired):
annotation = annotation.__args__[0]
if manager := _is_field_managed_value(name, annotation): # ① 是托管值吗?
if allow_managed: return manager
else: raise ValueError(...)
elif channel := _is_field_channel(annotation): # ② 标注里直接给了通道对象吗?
channel.key = name
return channel
elif channel := _is_field_binop(annotation): # ③ 标注里给了 reducer 函数吗?
channel.key = name
return channel
fallback = LastValue(annotation) # ④ 都不是 → 默认覆盖写
fallback.key = name
return fallback
剥 Required/NotRequiredTypedDict 里 NotRequired[int] 这种可选标记先脱壳,只看里面的真实类型。① 托管值如 RemainingSteps(D17)——引擎自己算的值,不用你写。② 直接给通道你写 Annotated[int, EphemeralValue]——标注里明确指定通道类,直接用。③ 给 reducer 函数你写 Annotated[list, add_messages]——最后一个元数据是可调用的合并函数,包成 BinaryOperatorAggregate(L05 细看)。④ fallback = LastValue★最常见分支:字段只写了普通类型(x: int),没有任何合并元数据 → 默认 LastValue(新值覆盖旧值)。顺序即优先级:托管值 > 显式通道 > reducer 函数 > 默认 LastValue。这个 if-elif 链的顺序不能乱——它决定了当一个标注同时符合多个条件时听谁的。
图注:if-elif 短路匹配,顺序即优先级。绝大多数普通字段落到最右边的 LastValue。
L05
怎么"嗅"出 reducer:数参数个数
最精妙的一段——它怎么判断 Annotated 最后那个东西是不是一个合法 reducer?答案是数它接受几个位置参数:
libs/langgraph/langgraph/graph/state.py:1890-1908
def _is_field_binop(typ):
if hasattr(typ, "__metadata__"): # 是 Annotated 才有 __metadata__
meta = typ.__metadata__
if len(meta) >= 1 and callable(meta[-1]): # 最后一个元数据可调用
sig = signature(meta[-1])
params = list(sig.parameters.values())
if sum(p.kind in (p.POSITIONAL_ONLY, p.POSITIONAL_OR_KEYWORD)
for p in params) == 2: # ★恰好 2 个位置参数
return BinaryOperatorAggregate(typ, meta[-1])
else:
raise ValueError(f"Invalid reducer signature. Expected (a, b) -> c. Got {sig}")
return None
__metadata__Annotated[list, add] 会把 (add,) 存到类型的 __metadata__ 里。没有这个属性 → 不是 Annotated → 返回 None。callable(meta[-1])取最后一个元数据,必须是"可调用的"(函数/lambda)才可能是 reducer。恰好 2 个位置参数★reducer 的约定签名是 (旧值, 新值) -> 合并值。这里用 signature 数它有几个位置参数,必须恰好 2 个。这就是 reducer"长什么样"的运行时定义。不是 2 个就 raise边界:你写了个 3 参数的函数当 reducer?直接报错 Expected (a, b) -> c,不让你在运行时才炸。BinaryOperatorAggregate包成"二元累加通道"——它会拿旧值和新值反复调你的函数。add_messages、operator.add 都走这条路(D07-09 深入)。对比 _is_field_channel(直接给通道对象/类的情况):
libs/langgraph/langgraph/graph/state.py:1862-1887(节选)
def _is_field_channel(typ):
if hasattr(typ, "__metadata__"):
for item in typ.__metadata__:
if isinstance(item, BaseChannel): # 元数据是"通道实例" → 直接用
return item
elif isclass(item) and issubclass(item, BaseChannel): # 是"通道类" → 实例化
return item(typ.__origin__ if hasattr(typ, "__origin__") else typ)
return None
📝 真实值走一遍
msgs: Annotated[list, add_messages] → __metadata__=(add_messages,),add_messages 可调用、签名 (left, right) 恰好 2 个位置参 → 造出 BinaryOperatorAggregate(list, add_messages)。x: int → 没有 __metadata__,三个探测函数全返回 None → fallback 到 LastValue(int)。flag: Annotated[int, EphemeralValue] → _is_field_channel 命中"通道类"分支 → EphemeralValue(int)(用完即弃,不持久化,D31)。
L06
三种合法 schema:TypedDict / dataclass / Pydantic
State schema 不止 TypedDict。建图时有个校验函数会提醒你别传错东西:
libs/langgraph/langgraph/graph/state.py:111-120
def _warn_invalid_state_schema(schema):
if isinstance(schema, type): # 是个类 → OK
return
if typing.get_args(schema): # 是 Annotated[...] 之类带参数的 → OK
return
warnings.warn(
f"Invalid state_schema: {schema}. Expected a type or Annotated[type, reducer]. ...")
| schema 形态 | 写法 | 特点 |
|---|---|---|
TypedDict | class S(TypedDict): x: int | 最常用,轻量,运行时就是普通 dict |
dataclass | @dataclass class S: x: int | 属性访问 state.x,读取时被 coerce 成实例(见 D04 mapper) |
Pydantic BaseModel | class S(BaseModel): x: int | ★带校验!每次读状态都会 validate(D12 专讲) |
| 裸类型 | StateGraph(int) | 整个状态就一个值,包成 __root__ 通道 |
大白话不管你用哪种,最终都经过
_get_channels 变成"字段→通道"表。区别只在节点拿到的 state 长啥样:TypedDict 拿到 dict,dataclass/Pydantic 拿到对象实例(靠 D04 学过的 mapper 转换)。L07
两处设计取舍 + 一处边界
🎨 设计取舍①:为什么用
Annotated 塞 reducer,而不是单独一个配置字典?
朴素做法:让你在 StateGraph(State, reducers={"msgs": add}) 里单独传合并规矩。源码选择把 reducer 写进字段类型标注(Annotated[list, add])。好处:字段的"类型"和"合并规矩"写在同一行、离得最近,一眼看全;且 Annotated 对类型检查器透明(IDE 仍认为 msgs 是 list)。代价:语法对新手有点怪("这方括号里第二个东西是啥?"),且必须记得用 get_type_hints(..., include_extras=True) 才读得到——少个参数就全失效。🎨 设计取舍②:为什么靠"数参数个数"识别 reducer,而不要求显式包一层?
源码判断"是不是 reducer"的标准是可调用且恰好 2 个位置参数(
_is_field_binop)。好处:你能直接用标准库现成的 operator.add、自己的 lambda x,y: y,不用为了当 reducer 去继承或包装什么。代价:约定很脆——写成 3 参数、或用了带默认值的奇怪签名,就被判定非法(虽然会明确报错)。这是"鸭子类型的便利 vs 显式接口的稳妥"之间的取舍,LangGraph 选了便利+早报错。⚠️ 边界:并行节点写同一个"覆盖字段"会报错如果两个并行节点在同一超步都写了一个
LastValue(无 reducer)字段,LangGraph 无法决定"听谁的",会抛 InvalidUpdateError(INVALID_CONCURRENT_GRAPH_UPDATE)。本质:LastValue 的语义是"只能有一个写入者",并行两个写入者违反了它。解法是给该字段配一个能"合并多个写入"的 reducer(如 operator.add 累加、add_messages)。所以"要不要 reducer"不只是风格问题——只要某字段可能被并行写,就必须给它 reducer。这条边界 D07/D10 会再展开。L08
今日小结 + 动手 + 明日预告
🧠 今天你应该能回答
- State schema 的产物是什么?(一张
{字段名 → 通道}映射表,存进 self.channels) - 没写 reducer 的字段默认是什么通道?(
LastValue,新值覆盖旧值) - _get_channel 的"四选一"顺序?(托管值 → 显式通道 → reducer 函数 → 默认 LastValue)
- LangGraph 怎么判断一个东西是 reducer?(可调用 + 恰好 2 个位置参数)
- 为什么解析必须用
get_type_hints(..., include_extras=True)?(否则丢掉 Annotated 元数据,reducer 全失效) - 合法 schema 有哪几种?(TypedDict / dataclass / Pydantic BaseModel / 裸类型)
- 什么情况下字段"必须"有 reducer?(可能被并行节点同时写入时)
✋ 10 分钟动手
# 1. 看解析入口 _add_schema
sed -n '342,372p' libs/langgraph/langgraph/graph/state.py
# 2. 看主翻译 _get_channels(注意 include_extras=True)
sed -n '1801,1821p' libs/langgraph/langgraph/graph/state.py
# 3. 看四选一 _get_channel + reducer 嗅探
sed -n '1836,1908p' libs/langgraph/langgraph/graph/state.py
# 4. 亲眼看不同字段变成不同通道
python3 -c "
from langgraph.graph import StateGraph
from typing_extensions import TypedDict, Annotated
import operator
class S(TypedDict):
x: int
msgs: Annotated[list, operator.add]
g = StateGraph(S)
for k,v in g.channels.items(): print(k, '->', type(v).__name__)
"
💡 明日预告 · Day 06阶段 1 收官!明天 D06 不引入新机制,而是把 D01-05 串成一张能跑的对话图:用现成的
MessagesState(graph/message.py:372)、加一个模型节点、一条条件边形成循环,然后回头用"通道触发"心智模型完整讲一遍它是怎么转起来的——给入门阶段画个句号。