Day 18 / 共 60 天 · 阶段 3 控制流

START / END 与入口:两个"虚拟节点"的真相

你一直在写 add_edge(START, "model")add_edge("x", END),但 START / END 到底是什么?它们不是真节点,而是 constants.py 里两个特殊字符串常量。今天把它们看穿:为什么用 sys.intern 定义、set_entry_point 只是 add_edge(START, key) 的一层糖、add_edge 怎么用几行护栏禁止"END 当起点/START 当终点"、以及 compilevalidate 如何靠"START 必须出现在某条边的起点"来判定图有没有入口。阶段 3 收官。

📍 阶段 3「控制流」(D13-18) · 你在第 18 天(收官)
D13 条件边 D14 Branch 路由 D15 Command D16 Send 扇出 D17 递归上限 D18 START/END
💡 一句话锚点 STARTEND 是两个保留字符串"__start__" / "__end__"),代表图的"虚拟入口"和"虚拟出口"。add_edge(START, "A") 的意思是"图一开始就把输入送给 A";add_edge("A", END) 意思是"到 A 就结束"。它们不对应你写的任何函数,只是让"入口/出口"能用和普通节点一样的"边"来表达——不需要为入口出口发明新语法。
L01

痛点:入口和出口,凭什么也用"边"表达?

🤔 痛点一张图要能跑,必须回答两件事:数据从哪个节点进(入口)、到哪算跑完(出口)。最直白的设计是给建造器加两个专门方法/属性存"入口节点名""出口节点名"。但这样一来,"入口→A"这种关系就和"A→B"这种普通边用了两套机制,调度器得分别处理,代码分叉。
💡 本质:用两个"虚拟节点"把入口/出口也变成普通的边LangGraph 造了两个特殊名字 STARTEND 冒充节点。于是"入口"= 一条从 START 出发的边,"出口"= 一条指向 END 的边。整张图从头到尾只有一种关系——边。调度器、校验器、可视化都只需处理"边",不用为入口出口开小灶。这是"用一个巧妙的抽象消灭特例"的经典手法。
大白话START 就像"传送带的入料口"、END 像"出料口"。它们不是机器(节点),但画在图上就能让"从哪进、到哪出"用连线(边)表达,和机器之间的连线一模一样。
L02

constants.py:两个 sys.intern 字符串

它们的定义,简单到只有两行有效代码:

libs/langgraph/langgraph/constants.py:28-31
END = sys.intern("__end__")
"""The last (maybe virtual) node in graph-style Pregel."""
START = sys.intern("__start__")
"""The first (maybe virtual) node in graph-style Pregel."""

同文件还定义了两个流式相关标签,也用 sys.intern

libs/langgraph/langgraph/constants.py:24-27
TAG_NOSTREAM = sys.intern("nostream")
TAG_HIDDEN = sys.intern("langsmith:hidden")
"__start__" / "__end__"就是普通字符串,只是用双下划线包裹表示"保留、别当普通节点名用"。这也是 D03 为什么 add_node("__start__", ...) 会被拒(保留名)。
sys.intern(...)★把字符串"驻留"到解释器的字符串池,保证同一内容全程是同一个对象。好处:判断 key == START / key is START 时可以走极快的指针相等,而不是逐字符比较。图运行时这种判断非常频繁,intern 是实打实的性能优化。
docstring 里的 "maybe virtual"官方注释自己都说 START/END 是"(可能是)虚拟节点"——它们在编译后会变成真实的 PregelNode(START)或被特殊处理(END),但概念上是虚拟的入口/出口。
💡 为什么值得用 intern?因为这两个常量在整个引擎里被成千上万次拿来做相等判断(每个节点 attach、每次路由都要判"是不是 START/END")。intern 让这些判断退化成指针比较,是"小改动、全局收益"的典型微优化。
L03

set_entry_point / set_finish_point:全是语法糖

你可能见过两种写入口的写法。它们其实是同一件事

libs/langgraph/langgraph/graph/state.py:1066-1077
    def set_entry_point(self, key: str) -> Self:
        """Specifies the first node to be called in the graph.
        Equivalent to calling `add_edge(START, key)`."""
        return self.add_edge(START, key)                # ★就是加一条 START→key 的边
libs/langgraph/langgraph/graph/state.py:1103-1114
    def set_finish_point(self, key: str) -> Self:
        """Marks a node as a finish point of the graph."""
        return self.add_edge(key, END)                  # ★就是加一条 key→END 的边

连"条件入口"也是一层糖——包在 add_conditional_edges(START, ...) 上:

libs/langgraph/langgraph/graph/state.py:1079-1101(节选)
    def set_conditional_entry_point(self, path, path_map=None) -> Self:
        return self.add_conditional_edges(START, path, path_map)
set_entry_point(k) = add_edge(START, k)完全等价。用哪个纯看你喜好。set_entry_point 语义更"读得懂",add_edge(START, k) 更统一。
set_finish_point(k) = add_edge(k, END)同理。注意"finish point"不代表"图里唯一的结束"——你可以有多条通往 END 的边、也可以让条件边返回 END。
为什么保留这些糖方法历史 + 可读性。老代码和教程里 set_entry_point 很常见;新代码倾向直接 add_edge(START, ...)(少一个要记的 API)。
数据结构:START/END 就是 edges 里的普通元组 self.edges : set[tuple[str, str]] ("__start__", "A") ("A", "B") ("B", "__end__") 入口边、普通边、出口边 —— 全是同一个集合里的元组 validate 只需检查 "__start__" 有没有作为某元组的第 0 项出现
图注:没有单独的"入口/出口"字段,START/END 混在 edges 里,靠字符串值区分。
🍼 一句话不用纠结"该用 set_entry_point 还是 add_edge(START,...)"——它们生成的图纸一模一样(都是往 self.edges 加一条边)。
L04

add_edge 的方向护栏

既然 START/END 是"节点",就得防止你把它们用反。add_edge 开头几行就是这些护栏:

libs/langgraph/langgraph/graph/state.py:938-953
        if isinstance(start_key, str):
            if start_key == END:
                raise ValueError("END cannot be a start node")     # END 不能当起点
            if end_key == START:
                raise ValueError("START cannot be an end node")     # START 不能当终点
            ...
            self.edges.add((start_key, end_key))                    # 普通边进 edges
            return self

汇合边(多起点)那一支也有对称的校验,还多查"节点必须先 add_node":

libs/langgraph/langgraph/graph/state.py:956-966
        for start in start_key:
            if start == END:
                raise ValueError("END cannot be a start node")
            if start not in self.nodes:
                raise ValueError(f"Need to add_node `{start}` first")   # 起点得是已注册节点
        if end_key == START:
            raise ValueError("START cannot be an end node")
        if end_key != END and end_key not in self.nodes:
            raise ValueError(f"Need to add_node `{end_key}` first")
        self.waiting_edges.add((tuple(start_key), end_key))
END 不能当起点到了 END 就是终点,后面没有下一步。add_edge(END, "x") 直接报错。
START 不能当终点入口之前没有上一步。add_edge("x", START) 直接报错。
汇合边起点必须已注册多起点 add_edge(["A","B"], "C") 时,A、B 必须先 add_node。注意 单起点普通边不做这个检查——它允许边先于节点添加(顺序更宽松),最终由 compile 的 validate 兜底。
END 例外终点是 END 时不要求"已 add_node"(END 本就不是你注册的节点)。
护栏分两道add_edge 这里是"即时护栏"(明显方向错误当场拦);真正的"图整体连通性"校验留给 compile 时的 validate(L05)。分层校验让"当场能判的当场判、需要全图信息的最后判"。
L05

validate:靠"START 是否为某边起点"判入口

compile 会调 validate。它先把所有边的起点汇成 all_sources,再检查 START 在不在里面:

libs/langgraph/langgraph/graph/state.py:1116-1132(节选)
    def validate(self, interrupt=None):
        all_sources = {src for src, _ in self._all_edges}       # 所有边的起点集合
        for start, branches in self.branches.items():
            all_sources.add(start)                              # 条件边的源也算
        for name, spec in self.nodes.items():
            if spec.ends:
                all_sources.add(name)
        # validate sources
        for source in all_sources:
            if source not in self.nodes and source != START:
                raise ValueError(f"Found edge starting at unknown node '{source}'")
        if START not in all_sources:                            # ★没有任何边从 START 出发
            raise ValueError(
                "Graph must have an entrypoint: add at least one edge from START to another node")

出口侧则校验所有边的终点都是已知节点或 END:

libs/langgraph/langgraph/graph/state.py:1134-1155(节选)
        all_targets = {end for _, end in self._all_edges}
        ...
        for target in all_targets:
            if target not in self.nodes and target != END:
                raise ValueError(f"Found edge ending at unknown node `{target}`")
        ...
        self.compiled = True
        return self
_all_edgesD04 学过的属性:把普通边 + 汇合边拍平成统一的 (起,终) 集合。这里用它取所有起点/终点。
START not in all_sources → 报错★"图必须有入口"的判定就这一句:只要没有任何边以 START 为起点,说明你没设入口,直接报错并教你 add at least one edge from START
起点/终点必须已知边指向一个没 add_node 的名字?在这里被抓出来(unknown node)。这就是 D03 说的"结构错误集中在 compile 时爆出"。
不强制必须有 END注意:源码没有"必须有边指向 END"的检查。图可以没有显式 END 边——只要它能靠递归上限(D17)或中断停下。但没入口是硬错误。
self.compiled = Truevalidate 通过后置 compiled 标记(D03 那个"编译后再改结构不生效"的开关)。
控制流:validate 的两道检查 ✓ 合法图 START → A → B → END START 在 all_sources 里 所有终点是已知节点/END ✗ 报错的图 A → B(忘了连 START) → "must have an entrypoint" X → typo(未注册节点) → "unknown node"
图注:入口缺失是最常见的新手错误,validate 用一句 START not in all_sources 就兜住。
L06

两处设计取舍 + 一处边界

🎨 设计取舍①:为什么把入口/出口做成"虚拟节点",而不是单独的属性? 朴素做法:建造器上加 entry_point / finish_points 属性。LangGraph 选择用 START/END 两个虚拟节点 + 普通边表达。好处:全图只有"边"一种关系——校验、可视化、Pregel 调度、条件边返回 END……全部复用同一套机制,没有特例分支;甚至"条件入口"也自然地变成 add_conditional_edges(START, ...)代价:START/END 是"看起来像节点、其实不是节点"的特殊存在,新手会疑惑"它对应哪个函数"——需要像今天这样点破"它们只是保留字符串"。用一点认知成本,换整套机制的统一,划算。
🎨 设计取舍②:为什么"入口缺失"是硬错误,"出口缺失"却不是? validate 强制 START 必须是某条边起点,却不要求必须有边通向 END。为什么:没有入口的图根本无从开始——这是逻辑上不可能运行的死图,必须拦。而"没有显式 END"是合法的:带环的 Agent 常常靠"条件边动态返回 END""递归上限(D17)""人在环中断"来停止,不一定有静态的 x→END 边。强制要求 END 边反而会误伤这些合理形态。所以两者松紧不同,是按"是否逻辑上必须"来定的。
⚠️ 边界:单起点 add_edge 不检查节点是否存在,靠 compile 兜底L04 看到:单起点普通边 add_edge("A", "B") 时,源码检查 A/B 是否已 add_node(只有汇合边和某些路径才检查)。这意味着你可以先连边、后加节点,顺序很自由——但代价是拼错节点名不会当场报错,要等到 compile 的 validate 才抛 "unknown node"。如果你 add_edge(START, "modle")(把 model 拼错了),搭图时静悄悄,compile 时才炸。排查"unknown node"报错时,第一嫌疑就是边里的节点名和 add_node 的名字对不上(含大小写、下划线)。
L07

今日小结 + 阶段 3 回顾 + 明日预告

🧠 今天你应该能回答

  • START / END 本质是什么?(constants.py"__start__"/"__end__" 两个保留字符串)
  • 为什么用 sys.intern?(字符串驻留,让频繁的相等判断走指针比较,提速)
  • set_entry_point 和 add_edge(START, k) 的关系?(前者就是后者的语法糖,完全等价)
  • add_edge 有哪些方向护栏?(END 不能当起点、START 不能当终点)
  • validate 怎么判断图有没有入口?(START not in all_sources 就报 "must have an entrypoint")
  • 为什么入口缺失报错、出口缺失不报错?(没入口逻辑上无法运行;没显式 END 可靠条件边/上限/中断停止)
  • 拼错节点名什么时候才报错?(单起点边不当场查,等 compile 的 validate 抛 unknown node)

🗺️ 阶段 3「控制流」五天回顾

一句话
D13/14条件边:路由函数决定下一步去哪,编译成对 branch 通道的动态写入
D15Command:节点直接返回"更新+跳转",跨节点/跨子图控制
D16Send:动态扇出,一个节点派生 N 个并行任务(map-reduce)
D17递归上限:超步计数 step/stop 兜底,防死循环,GraphRecursionError
D18START/END 虚拟节点:把入口出口也统一成"边",validate 校验入口
🍼 阶段 3 直觉控制流的一切——分支、跳转、扇出、循环、入口出口——在 LangGraph 里都被统一成"往通道写值 / 订阅通道触发"。理解这句,你就为阶段 4 深入 Pregel 引擎做好了准备。
💡 明日预告 · Day 19(进入阶段 4 深水区)阶段 1-3 你已把"图怎么搭、状态怎么流、控制怎么走"全部看穿。明天起进入最硬核的阶段 4「Pregel 执行引擎」:D19 讲 BSP/超步模型——为什么图要一"超步"一"超步"地跑、pregel/__init__.pyPregel 类总览,把前面反复提到的"通道触发""超步"正式讲透。
← Day 17 · 循环与递归上限 Day 19 · Pregel / BSP 模型 →