节点与边:一个函数如何变成"工位"
昨天见到 add_node 把你的函数打包成 StateNodeSpec、编译后又变成 PregelNode。今天把这两层彻底拆开:graph/_node.py 里节点函数允许长成 9 种签名(要不要 config / writer / store / runtime),引擎靠 检查你函数的参数名和类型 决定塞什么进去;边则被拆成两种,编译期退化成"谁触发谁"的通道订阅。看完你会明白——所谓"节点"就是一份规格单,所谓"边"就是一次通道写入。
StateNodeSpec(一张记着"函数+重试+超时+能跳哪"的规格单,见 _node.py);编译后它变成 PregelNode("被某通道触发→读若干通道→跑函数→写若干通道");而你写的那个 Python 函数,只是被塞进规格单里的 runnable 而已。今天就把这三副面孔对齐。痛点:节点函数写法太多,引擎怎么统一调?
def n(state)、def n(state, config)、def n(state, *, writer)、def n(state, *, store)、def n(state, *, runtime)……引擎在运行期只有一个 state 要喂,它怎么知道你这个函数还想要 config?还想要 store?多传了会报"多余参数",少传了会报"缺参数"。这个"按需投喂"的难题,就是今天 _node.py 那一堆 Protocol 和 _runnable.py 的注入逻辑在解决的事。_node.py 用一串 Protocol(类型协议)把"合法的节点长相"穷举出来,这是给类型检查器(IDE)看的;真正运行时,RunnableCallable 用 inspect.signature 读你的函数参数,只有你签名里出现且类型对得上的那些参数,才会被注入。就像自助餐——菜(config/writer/store/runtime)都备着,你盘子里有哪个格子,才给你盛哪个。def node(state) 和 def node(state, *, store) 都能跑——区别只在引擎运行时多塞不塞一个 store 给你。_node.py:9 种节点签名,全被 Protocol 穷举
整个 _node.py 只有 95 行,前 80 行几乎全是"节点长相"的类型声明。看开头几个 Protocol:
class _Node(Protocol[NodeInputT_contra]):
def __call__(self, state: NodeInputT_contra) -> Any: ...
class _NodeWithConfig(Protocol[NodeInputT_contra]):
def __call__(self, state: NodeInputT_contra, config: RunnableConfig) -> Any: ...
class _NodeWithWriter(Protocol[NodeInputT_contra]):
def __call__(self, state: NodeInputT_contra, *, writer: StreamWriter) -> Any: ...
class _NodeWithStore(Protocol[NodeInputT_contra]):
def __call__(self, state: NodeInputT_contra, *, store: BaseStore) -> Any: ...
后面还有 config+writer、config+store、config+writer+store、以及带 runtime 的组合,最后用一个 TypeAlias 把它们全"或"起来:
StateNode: TypeAlias = (
_Node[NodeInputT]
| _NodeWithConfig[NodeInputT]
| _NodeWithWriter[NodeInputT]
| _NodeWithStore[NodeInputT]
| _NodeWithWriterStore[NodeInputT]
| _NodeWithConfigWriter[NodeInputT]
| _NodeWithConfigStore[NodeInputT]
| _NodeWithConfigWriterStore[NodeInputT]
| _NodeWithRuntime[NodeInputT, ContextT]
| Runnable[NodeInputT, Any]
)
ProtocolPython 的"结构化类型":不要求你继承谁,只要你的函数"长得像"这个 __call__,类型检查器就认。所以你的普通函数天然满足。NodeInputT_contra输入类型(逆变),就是节点第一个参数 state 的类型。逆变让"接受更宽状态的函数"也能用在更窄的位置——类型细节,用时无感。*, writer / store注意这些是仅关键字参数(星号后面)。这不是随便写的——L05 会看到引擎只在特定 kind 的参数上注入,仅关键字是推荐形态。| Runnable[...]最后一支:你也可以直接传一个 LangChain Runnable 当节点。所以节点不一定是函数。Protocol 是纯给 IDE / mypy 用的——让你在写 def node(state, *, store) 时,编辑器知道这是合法节点、并给 store 正确补全。运行时并不遍历它们(运行时靠 L05 的签名反射)。这是"类型层"和"运行层"各干各的。StateNodeSpec:节点的"规格单"长这样
_node.py 真正装数据的只有末尾这个 dataclass——昨天 add_node 打包出来的就是它:
@dataclass(slots=True)
class StateNodeSpec(Generic[NodeInputT, ContextT]):
runnable: StateNode[NodeInputT, ContextT]
metadata: dict[str, Any] | None
input_schema: type[NodeInputT]
retry_policy: RetryPolicy | Sequence[RetryPolicy] | None
cache_policy: CachePolicy | None
is_error_handler: bool = False
error_handler_node: str | None = None
ends: tuple[str, ...] | dict[str, str] | None = EMPTY_SEQ
defer: bool = False
timeout: TimeoutPolicy | None = None
| 字段 | 装什么(大白话) | 哪天深入 |
|---|---|---|
runnable | 你写的那个函数(已被包成 Runnable)——节点的"本体" | 本讲 |
input_schema | 这个节点读状态时用的 schema(默认=图的 state_schema) | D05/D11 |
retry_policy | 失败重试策略 | D25/D52 |
cache_policy | 结果缓存策略 | D51 |
ends | 声明"这个节点跑完可能跳去哪"(画图/Command 用) | D15 |
defer | 延迟到本次运行快结束才执行(汇合场景) | D21 |
timeout | 单次执行超时 | D25 |
error_handler_node | 这个节点出错时转交给哪个处理节点 | D52 |
可注入的参数清单:KWARGS_CONFIG_KEYS
引擎到底认哪些"特殊参数名"?答案是一张硬编码表 KWARGS_CONFIG_KEYS,每一项 = (参数名, 允许的类型, 从哪取值, 默认值):
KWARGS_CONFIG_KEYS = (
("config", (RunnableConfig, "RunnableConfig", Optional[RunnableConfig], ...),
"N/A", inspect.Parameter.empty),
("writer", (StreamWriter, "StreamWriter", inspect.Parameter.empty),
"stream_writer", lambda _: None),
("store", (BaseStore, "BaseStore", inspect.Parameter.empty),
"store", inspect.Parameter.empty),
("store", (Optional[BaseStore], "Optional[BaseStore]"),
"store", None), # ← store 可选版,默认 None
("previous",(ANY_TYPE,), "previous", inspect.Parameter.empty),
("runtime", (ANY_TYPE,), "N/A", inspect.Parameter.empty),
("error", (NodeError, "NodeError"), "N/A", None),
)
参数名 + 类型都要对不是光看名字!config 必须标成 RunnableConfig,store 必须标成 BaseStore,否则不注入。名字对但类型不对,会被跳过(config 还会 warn 提醒你标错了)。"store" 出现两次一个要求 BaseStore(无默认,必须有 store 才行),一个要求 Optional[BaseStore](默认 None)。这样你写 store: BaseStore | None = None 时即使没配 store 也不炸。runtime / previous / errorD59 的 Runtime、函数式 API 的 previous、错误处理器的 NodeError——都走同一套注入机制,用时再展开。"N/A"config 和 runtime 不从 runtime 属性取(它们直接注入),所以取值来源标 N/A。db 参数指望自动注入——那得靠 runtime.context(D59)。这张表就是"节点能白拿到的东西"的官方清单。按签名自动注入:inspect + 反射
包装函数的 RunnableCallable 在初始化时就读一遍你的签名,决定"这个函数接受哪些特殊参数":
self.func_accepts: dict[str, tuple[str, Any]] = {}
params = inspect.signature(func or afunc).parameters # 读你的参数表
for kw, typ, runtime_key, default in KWARGS_CONFIG_KEYS:
p = params.get(kw)
if p is None or p.kind not in VALID_KINDS: # 没这个参数 → 跳过
continue
if typ != (ANY_TYPE,) and p.annotation not in typ: # 类型不匹配 → 跳过
if kw == "config" and p.annotation != inspect.Parameter.empty:
warnings.warn("The 'config' parameter should be typed as ...")
continue
self.func_accepts[kw] = (runtime_key, default) # ✓ 记下"要注入这个"
运行时(每次调用节点)再按这张 func_accepts 把值填进 kwargs:
runtime = config.get(CONF, {}).get(CONFIG_KEY_RUNTIME)
for kw, (runtime_key, default) in self.func_accepts.items():
if kw in kwargs: # 调用方已给 → 不覆盖
continue
kw_value = MISSING
if kw == "config": kw_value = config
elif kw == "error": kw_value = config...get(CONFIG_KEY_NODE_ERROR, MISSING)
elif runtime:
if kw == "runtime": kw_value = runtime
else: kw_value = getattr(runtime, runtime_key) # 从 Runtime 取
if kw_value is MISSING:
if default is inspect.Parameter.empty:
raise ValueError(f"Missing required config key '{runtime_key}' ...")
kw_value = default
kwargs[kw] = kw_value
VALID_KINDS只有 POSITIONAL_OR_KEYWORD 和 KEYWORD_ONLY 两种参数会被注入(_runnable.py:227)。所以 *args、**kwargs 这类不算——这也是为什么协议里用 *, writer 仅关键字写法。初始化时算一次签名反射是建对象时做一次,结果缓存进 func_accepts。运行时不再反射(反射慢),只查这个小字典。这是典型的"预计算换运行时性能"。缺必需值就 raise如果你写了 store: BaseStore(无默认)但 compile 时没传 store,运行到这里 default is empty → 直接报错,告诉你少配了 store。def node(state, *, store: BaseStore, writer: StreamWriter): ...。初始化:反射发现
store(类型对) 和 writer(类型对) → func_accepts = {"store": (...), "writer": (...)};config/runtime 没写 → 不在表里。运行时:引擎调
node(state, store=<真实store>, writer=<真实writer>),没多塞 config。你按需要写,引擎按需要给。
边:从两个集合,到"通道订阅"
回顾 D03:普通边进 edges,汇合边进 waiting_edges。取用时有个属性把两者拍平成统一的 (起, 终):
@property
def _all_edges(self) -> set[tuple[str, str]]:
return self.edges | {
(start, end) for starts, end in self.waiting_edges for start in starts
}
编译时 attach_edge 把"边"翻译成"往通道写"。看普通边(单起点):
def attach_edge(self, starts, end):
if isinstance(starts, str):
if end != END:
self.nodes[starts].writers.append(
ChannelWrite(
(ChannelWriteEntry(_CHANNEL_BRANCH_TO.format(end), None),)
)
)
再看节点自己被 attach 时(state.py:1518-1521),它的触发器 triggers 正是"通往我的那个分支通道":
self.nodes[key] = PregelNode(
triggers=[branch_channel], # 被 branch:to:key 通道触发就跑
channels=("__root__" if is_single_input else input_channels),
...
bound=node.runnable, # 你的函数在这
)
_CHANNEL_BRANCH_TO.format(end)每个目标节点 end 都有一个专属"分支到达通道"(名字像 branch:to:end)。边 = 让起点节点"写"这个通道A→B 这条边,落地成"A 的 writers 里加一条:写 branch:to:B 通道"。触发 = 让终点节点"订阅"这个通道B 的 triggers=[branch:to:B]。于是 A 一写这个通道,B 就被唤醒。汇合边→NamedBarrierValue多起点时(state.py:1546-1561)建一个 join:A+B:C 屏障通道,A、B 都写它,凑齐才触发 C。这就是 D03 说的"同步屏障",D32 精讲。两处设计取舍 + 一处边界
add_node 里写 needs=["store","config"] 显式声明。源码选择读你的函数签名自动判断。好处:写节点像写普通函数,想要 store 就在参数里加 store: BaseStore,不用在两个地方(函数签名 + add_node)重复声明,天然"单一事实来源"。代价:类型标注必须写对(store 不标 BaseStore 就不给你),这也是 KWARGS_CONFIG_KEYS 对 config 类型标错会 warn 的原因——用魔法就得容忍魔法失灵时要给提示。POSITIONAL_OR_KEYWORD / KEYWORD_ONLY 两种参数会被注入(VALID_KINDS)。如果你把 store 放进 **kwargs、或用了不匹配的类型标注(如 store: dict),引擎会静默跳过——你的 store 永远是没被注入的默认值,且不报错。排查这类"参数一直是 None"的问题,第一步就是检查参数类型标注是否精确等于 BaseStore / RunnableConfig。推荐写法:config: RunnableConfig、store: BaseStore、writer: StreamWriter、runtime: Runtime[Ctx],都放在 *, 后面。今日小结 + 动手 + 明日预告
🧠 今天你应该能回答
- 一个节点有哪三副面孔?(你的函数 → StateNodeSpec 规格单 → PregelNode 运行单元)
- _node.py 里那一堆 Protocol 是干嘛的?(给 IDE/类型检查器穷举合法节点长相,运行时不用它们)
- StateNodeSpec 存了哪些信息?(runnable/input_schema/retry/cache/ends/defer/timeout…)
- 引擎怎么知道该给我的函数注入 config/store?(初始化时 inspect 签名,名字+类型都对才注入)
- 可被注入的参数有哪些?(config/writer/store/previous/runtime/error,见 KWARGS_CONFIG_KEYS)
- 边在编译后变成了什么?(起点节点写 branch:to:目标 通道,目标节点订阅该通道触发)
- 为什么 store 参数明明写了却一直是 None?(类型没标成 BaseStore,被静默跳过)
✋ 10 分钟动手
# 1. 通读 _node.py:9 种节点协议 + StateNodeSpec
sed -n '16,96p' libs/langgraph/langgraph/graph/_node.py
# 2. 看可注入参数清单
sed -n '147,208p' libs/langgraph/langgraph/_internal/_runnable.py
# 3. 看签名反射如何决定 func_accepts
sed -n '317,343p' libs/langgraph/langgraph/_internal/_runnable.py
# 4. 亲手验证"按需注入":给函数加 config 参数看是否被塞
python3 -c "
from langgraph.graph import StateGraph, START, END
from typing_extensions import TypedDict
class S(TypedDict): x:int
def n(state, config): return {'x': state['x']+1} # 要 config
g=StateGraph(S); g.add_node('A', n); g.add_edge(START,'A'); g.add_edge('A',END)
print(g.compile().invoke({'x':1})) # config 会被自动注入
"
# 5. 看边如何编译成通道写/订阅
sed -n '1537,1561p' libs/langgraph/langgraph/graph/state.py
input_schema、状态字段被解析成 channel。明天 D05 就钻进 graph/state.py 的 schema 解析:_get_channels / _get_channel 怎么把你的 TypedDict 一个字段一个字段翻译成通道,Annotated[list, reducer] 里的 reducer 又是怎么被"嗅"出来的。