Day 04 / 共 60 天 · 阶段 1 入门与心智

节点与边:一个函数如何变成"工位"

昨天见到 add_node 把你的函数打包成 StateNodeSpec、编译后又变成 PregelNode。今天把这两层彻底拆开:graph/_node.py 里节点函数允许长成 9 种签名(要不要 config / writer / store / runtime),引擎靠 检查你函数的参数名和类型 决定塞什么进去;边则被拆成两种,编译期退化成"谁触发谁"的通道订阅。看完你会明白——所谓"节点"就是一份规格单,所谓"边"就是一次通道写入。

📍 阶段 1「入门与心智」(D01-06) · 你在第 4 天
D01 全景分包 D02 跑通第一个图 D03 StateGraph D04 节点与边 D05 State/TypedDict D06 对话图+小结
💡 一句话锚点 一个节点在 LangGraph 里有三副面孔:搭图时它是 StateNodeSpec(一张记着"函数+重试+超时+能跳哪"的规格单,见 _node.py);编译后它变成 PregelNode("被某通道触发→读若干通道→跑函数→写若干通道");而你写的那个 Python 函数,只是被塞进规格单里的 runnable 而已。今天就把这三副面孔对齐。
L01

痛点:节点函数写法太多,引擎怎么统一调?

🤔 痛点你可能见过这些五花八门的节点写法: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)看的;真正运行时,RunnableCallableinspect.signature 读你的函数参数,只有你签名里出现且类型对得上的那些参数,才会被注入。就像自助餐——菜(config/writer/store/runtime)都备着,你盘子里有哪个格子,才给你盛哪个。
大白话你只管按需要写参数,引擎"看菜下饭"。所以 def node(state)def node(state, *, store) 都能跑——区别只在引擎运行时多塞不塞一个 store 给你。
L02

_node.py:9 种节点签名,全被 Protocol 穷举

整个 _node.py 只有 95 行,前 80 行几乎全是"节点长相"的类型声明。看开头几个 Protocol

libs/langgraph/langgraph/graph/_node.py:16-35
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 把它们全"或"起来:

libs/langgraph/langgraph/graph/_node.py:70-81
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 的签名反射)。这是"类型层"和"运行层"各干各的。
L03

StateNodeSpec:节点的"规格单"长这样

_node.py 真正装数据的只有末尾这个 dataclass——昨天 add_node 打包出来的就是它:

libs/langgraph/langgraph/graph/_node.py:84-96
@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
数据结构:一个节点的"三副面孔" ① 你的函数 def node(state, *, store): return {"x": ...} 普通 Python 可调用 ② StateNodeSpec runnable / input_schema retry / timeout / ends 搭图期:一张规格单 ③ PregelNode triggers / channels bound / writers 编译后:可运行单元 add_node compile → attach_node 同一个节点,三个阶段三种形态 ①→② 是"登记信息",②→③ 是"翻译成通道触发模型" 你写的函数原封不动躺在 spec.runnable → PregelNode.bound 里
图注:函数不变,外面套的"壳"随阶段升级。今天重点是 ① 和 ②,③ 留到阶段 4 精讲。
L04

可注入的参数清单:KWARGS_CONFIG_KEYS

引擎到底认哪些"特殊参数名"?答案是一张硬编码表 KWARGS_CONFIG_KEYS,每一项 = (参数名, 允许的类型, 从哪取值, 默认值):

libs/langgraph/langgraph/_internal/_runnable.py:147-208(节选)
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 必须标成 RunnableConfigstore 必须标成 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。
💡 这就是"约定的边界"LangGraph 支持注入的参数不是无限的,就这张表列的这几个(config/writer/store/previous/runtime/error)。你不能随便加个 db 参数指望自动注入——那得靠 runtime.context(D59)。这张表就是"节点能白拿到的东西"的官方清单。
L05

按签名自动注入:inspect + 反射

包装函数的 RunnableCallable初始化时就读一遍你的签名,决定"这个函数接受哪些特殊参数":

libs/langgraph/langgraph/_internal/_runnable.py:317-343
        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:

libs/langgraph/langgraph/_internal/_runnable.py:373-398
        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_KEYWORDKEYWORD_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。你按需要写,引擎按需要给。
L06

边:从两个集合,到"通道订阅"

回顾 D03:普通边进 edges,汇合边进 waiting_edges。取用时有个属性把两者拍平成统一的 (起, 终)

libs/langgraph/langgraph/graph/state.py:336-340
    @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 把"边"翻译成"往通道写"。看普通边(单起点):

libs/langgraph/langgraph/graph/state.py:1537-1545
    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 精讲。
控制流:边 A→B 编译成"写通道 / 订阅通道" 节点 A 通道 branch:to:B 节点 B writers 写入 triggers 订阅 "A 跑完 → 写 branch:to:B → B 被唤醒" 图里没有"边"这个运行时对象,只有"谁写谁订阅"的通道关系 这套"通道触发"正是 Pregel(阶段 4)的地基
图注:边在运行期"消失"了,化成节点之间通过通道的读写关系。
L07

两处设计取舍 + 一处边界

🎨 设计取舍①:为什么"注入"用签名反射,而不是让用户显式声明要什么? 朴素做法:让你在 add_node 里写 needs=["store","config"] 显式声明。源码选择读你的函数签名自动判断好处:写节点像写普通函数,想要 store 就在参数里加 store: BaseStore,不用在两个地方(函数签名 + add_node)重复声明,天然"单一事实来源"。代价:类型标注必须写对(store 不标 BaseStore 就不给你),这也是 KWARGS_CONFIG_KEYS 对 config 类型标错会 warn 的原因——用魔法就得容忍魔法失灵时要给提示。
🎨 设计取舍②:为什么把"边"编译成通道读写,而不是保留一张邻接表去调度? 朴素做法:运行时维护"A→B、A→C"邻接表,A 完成后查表找后继。源码把边彻底翻译成通道的写/订阅,运行期没有"边"这个概念。好处:普通边、汇合边、条件边、Send 动态扇出——全部统一成"往某个通道写值",调度器只需要看"哪些通道更新了→唤醒哪些订阅者",一套机制吃下所有路由形态(这正是 Pregel/BSP 的优雅之处)。代价:调试时"边"不直观,得理解通道模型才看得懂运行日志——这也是本教程阶段 4 要专门啃 Pregel 的原因。
⚠️ 边界:仅关键字之外的写法可能"静默不注入"只有 POSITIONAL_OR_KEYWORD / KEYWORD_ONLY 两种参数会被注入(VALID_KINDS)。如果你把 store 放进 **kwargs、或用了不匹配的类型标注(如 store: dict),引擎会静默跳过——你的 store 永远是没被注入的默认值,且不报错。排查这类"参数一直是 None"的问题,第一步就是检查参数类型标注是否精确等于 BaseStore / RunnableConfig。推荐写法:config: RunnableConfigstore: BaseStorewriter: StreamWriterruntime: Runtime[Ctx],都放在 *, 后面。
L08

今日小结 + 动手 + 明日预告

🧠 今天你应该能回答

  • 一个节点有哪三副面孔?(你的函数 → 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
💡 明日预告 · Day 05今天多次提到 input_schema、状态字段被解析成 channel。明天 D05 就钻进 graph/state.py 的 schema 解析:_get_channels / _get_channel 怎么把你的 TypedDict 一个字段一个字段翻译成通道,Annotated[list, reducer] 里的 reducer 又是怎么被"嗅"出来的。
← Day 03 · StateGraph 基础 Day 05 · State 与 TypedDict →