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 当终点"、以及 compile 时 validate 如何靠"START 必须出现在某条边的起点"来判定图有没有入口。阶段 3 收官。
📍 阶段 3「控制流」(D13-18) · 你在第 18 天(收官)
D13 条件边→
D14 Branch 路由→
D15 Command→
D16 Send 扇出→
D17 递归上限→
D18 START/END
💡 一句话锚点
START 和 END 是两个保留字符串("__start__" / "__end__"),代表图的"虚拟入口"和"虚拟出口"。add_edge(START, "A") 的意思是"图一开始就把输入送给 A";add_edge("A", END) 意思是"到 A 就结束"。它们不对应你写的任何函数,只是让"入口/出口"能用和普通节点一样的"边"来表达——不需要为入口出口发明新语法。L01
痛点:入口和出口,凭什么也用"边"表达?
🤔 痛点一张图要能跑,必须回答两件事:数据从哪个节点进(入口)、到哪算跑完(出口)。最直白的设计是给建造器加两个专门方法/属性存"入口节点名""出口节点名"。但这样一来,"入口→A"这种关系就和"A→B"这种普通边用了两套机制,调度器得分别处理,代码分叉。
💡 本质:用两个"虚拟节点"把入口/出口也变成普通的边LangGraph 造了两个特殊名字
START、END 冒充节点。于是"入口"= 一条从 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 里,靠字符串值区分。
🍼 一句话不用纠结"该用 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 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 通道的动态写入 |
| D15 | Command:节点直接返回"更新+跳转",跨节点/跨子图控制 |
| D16 | Send:动态扇出,一个节点派生 N 个并行任务(map-reduce) |
| D17 | 递归上限:超步计数 step/stop 兜底,防死循环,GraphRecursionError |
| D18 | START/END 虚拟节点:把入口出口也统一成"边",validate 校验入口 |
🍼 阶段 3 直觉控制流的一切——分支、跳转、扇出、循环、入口出口——在 LangGraph 里都被统一成"往通道写值 / 订阅通道触发"。理解这句,你就为阶段 4 深入 Pregel 引擎做好了准备。
💡 明日预告 · Day 19(进入阶段 4 深水区)阶段 1-3 你已把"图怎么搭、状态怎么流、控制怎么走"全部看穿。明天起进入最硬核的阶段 4「Pregel 执行引擎」:D19 讲 BSP/超步模型——为什么图要一"超步"一"超步"地跑、
pregel/__init__.py 的 Pregel 类总览,把前面反复提到的"通道触发""超步"正式讲透。