Day 14 / 共 60 天 · 阶段3 控制流
Branch 路由源码走读
Day 13 我们看到条件边被打包成一个 BranchSpec 存进 branches。今天把 graph/_branch.py 整个拆开:一条 BranchSpec 从"死数据"到"运行时真能拨道岔",中间经过 from_path → run → _route → _finish 四步。这是理解 LangGraph 一切分支/扇出的根基。
📍 你在 60 天里的位置(阶段3 · 控制流 D13-18)
D13 边→
D14 Branch→
D15 Command→
D16 Send→
D17 递归上限→
D18 START/END
💡 一句话锚定今天(延续地铁扳道员)
Day 13 我们把"扳道员"招进来了(
BranchSpec 存进 branches)。今天看这个扳道员上岗后完整的一次拨道动作:① from_path=入职时先摸清"这人能识别哪几条线"(解析路径映射);② run=给他配一部对讲机(writer,用来通知调度中心"我拨去哪条线了");③ _route=列车到岔口,他看货(读 state)→ 掏出判断(调路由函数);④ _finish=把判断结果翻译成真实线路号、并对着对讲机喊出去。四步走完,道岔真的拨动了。L01
从"存储"到"运行":BranchSpec 的一生
🤔 痛点:路由函数返回一个字符串,凭什么图就动了?你写的路由函数就是普通的
def route(state) -> str,返回 "tools"。可 LangGraph 是"通道 + 超步"的世界(阶段 4),根本没有"goto 某函数"这种东西——它只认"往某个通道写值来触发订阅它的节点"。那么一个返回字符串的函数,是怎么被翻译成"往通道写值"的?💡 本质:Branch 是"路由函数"和"通道写入"之间的适配器
BranchSpec 把你朴素的路由函数包装成一个特殊节点:这个节点运行时先调你的函数拿到返回值,再把返回值翻译成一串"往 branch:to:目标节点 通道写入"的动作。于是"函数返回 tools"就等价于"往触发 tools 的通道写了个信号",图自然就动了。今天全程都在拆这个"翻译 + 写入"过程。| 方法 | 位置 | 干什么 | 何时调 |
|---|---|---|---|
from_path | _branch.py:88 | 把 path+path_map 规整成 BranchSpec | 建图时(add_conditional_edges 内) |
run | _branch.py:122 | 把 spec 包成"带写入能力"的可执行节点 | 编译时 |
_route | _branch.py:146 | 读状态、调路由函数 | 运行时(每次到源节点后) |
_finish | _branch.py:192 | 把返回值翻译成目标、写通道 | _route 内部收尾 |
L02
from_path:三种方式认出"能去哪些节点"
建图时 add_conditional_edges 调了 BranchSpec.from_path。它最烧脑的一段,是努力搞清楚"这条分支到底可能去哪几个节点"(ends)。_branch.py:88:
# graph/_branch.py:88
@classmethod
def from_path(cls, path, path_map, infer_schema=False) -> BranchSpec:
path_map_: dict[Hashable, str] | None = None
try:
if isinstance(path_map, dict):
path_map_ = path_map.copy() # ① 你直接给了字典 → 拷一份
elif isinstance(path_map, list):
path_map_ = {name: name for name in path_map} # ② 你给了列表 → 名字既当 key 又当值
else:
# ③ 啥都没给:尝试从函数返回类型标注里"猜"
func = None
if isinstance(path, (RunnableCallable, RunnableLambda)):
func = path.func or path.afunc
if func is not None:
if (cal := getattr(path, "__call__", None)) and ismethod(cal):
func = cal
if rtn_type := get_type_hints(func).get("return"):
if get_origin(rtn_type) is Literal: # ← 关键
path_map_ = {name: name for name in get_args(rtn_type)}
except Exception:
pass
input_schema = _get_branch_path_input_schema(path) if infer_schema else None
return cls(path=path, ends=path_map_, input_schema=input_schema)
path_map 是 dict最明确:你写 {"yes":"a","no":"b"},直接 .copy() 一份存起来(拷贝是防止外部之后改动这个字典影响到图)。path_map 是 list你写 ["a","b"],含义是"函数会直接返回节点名",于是构造 {"a":"a","b":"b"}——键值相同的恒等映射。都没给 → 猜返回类型最聪明的一档:如果函数标了 -> Literal["a","b"],用 get_origin(...) is Literal 认出来,把 Literal 里的值当成"可能的目标集合"。这就是为什么官方强烈建议给路由函数标 Literal 返回类型——不为运行,为画图和校验。except Exception: pass边界:整段"猜"包在大 try 里,猜失败也不报错,ends 就留 None。哲学是"推断是锦上添花,失败不能拖垮建图"。📝 三种写法产出的 ends
①
②
③
④ 啥标注都没有、也不传 path_map →
①
add_conditional_edges("a", route, {"go":"b","stop":END}) → ends={"go":"b","stop":"__end__"}
②
add_conditional_edges("a", route, ["b","c"]) → ends={"b":"b","c":"c"}
③
def route(s) -> Literal["b","c"]: ... 且不传 path_map → ends={"b":"b","c":"c"}
④ 啥标注都没有、也不传 path_map →
ends=None(合法,但画图时只能画"可能去任意节点")💐 设计取舍①:为什么要费劲从
Literal 里推断,而不强制传 path_map?
这是"开发体验 vs 严格性"的平衡。强制传 path_map 最安全,但每写一条分支都要重复一遍目标名,啰嗦。允许"不传+靠返回值直达节点名"最省事,但图工具就画不出确定的箭头。折中方案:你标个 Literal 返回类型,框架自动帮你补 path_map——你既省了重复,又拿回了可视化和校验。三档从松到严,让不同需求的人各取所需。L03
run:把 spec 包成"会写通道"的节点
编译时调 run,把 BranchSpec 变成一个真正能塞进图去执行的 RunnableCallable。_branch.py:122:
# graph/_branch.py:122
def run(self, writer, reader=None) -> RunnableCallable:
return ChannelWrite.register_writer(
RunnableCallable(
func=self._route, # 同步路由
afunc=self._aroute, # 异步路由
writer=writer, # ← 注入"怎么把目标变成通道写入"
reader=reader, # ← 注入"怎么读当前状态"
name=None, trace=False,
),
list(zip_longest(
writer([e for e in self.ends.values()], True),
[str(la) for la, e in self.ends.items()],
)) if self.ends else None, # ← 预先登记"我可能写哪些通道"(给画图/校验用)
)
func / afunc同一个 Branch 同时准备了同步 _route 和异步 _aroute。引擎按图是同步跑还是异步跑自动选。双份实现是 LangGraph 全库的一贯风格。writer注入进来的"写入器":给它一串目标(节点名/Send),它返回"该往哪些通道写什么"。Branch 自己不关心通道细节,把这活儿委托出去——解耦。reader注入进来的"读取器":从 config 里读出当前节点看到的状态。同样是委托,Branch 不自己碰状态存储。register_writer(..., 可能写的通道)第二个参数预先声明"这条分支可能写入哪些通道"。这不影响运行,是给静态分析和画图用的——它靠 self.ends 反推出所有可能的目标通道。ends=None 时这里传 None,画图就只能保守画"可能去任何地方"。💡 本质:依赖注入让 Branch 只管"决策",不管"存储"
writer 和 reader 是运行时才注入的。Branch 的职责被压缩到极致——只负责"看状态、调函数、得目标";至于"状态从哪读、目标怎么落成通道写入",全由外部注入的两个回调决定。这让同一套 Branch 逻辑既能用在主图、也能用在子图、还能用在函数式 API 里,因为读写方式都是外面给的。L04
_route:读状态 → 调你的路由函数
运行时,源节点一跑完,这个包好的节点被触发,执行 _route。_branch.py:146:
# graph/_branch.py:146
def _route(self, input, config, *, reader, writer) -> Runnable:
if reader:
value = reader(config) # ① 用 reader 读出当前状态
# 把节点输出的额外字段"透传"给路由函数(仅 dict 状态、且没指定 input_schema 时)
if (isinstance(value, dict) and isinstance(input, dict)
and self.input_schema is None):
value = {**input, **value} # ② 合并:节点刚写的 + 通道里的
else:
value = input
result = self.path.invoke(value, config) # ③ 真正调用你的路由函数!
return self._finish(writer, input, result, config)
reader(config)把"当前这一步的状态"读出来。有 reader 用 reader(正常图都有),没有就退化用直接传入的 input。value = {**input, **value}透传技巧:input 是源节点刚刚产出还没落进通道的输出,value 是通道里已有的状态。合并后路由函数既能看到旧状态,也能看到源节点最新写的字段。注意后写的 value 覆盖前面的 input——通道里的权威值优先。input_schema is None 才透传边界:如果你给路由函数标了明确的输入 schema,就不透传——因为你已表明"我只要 schema 声明的字段",尊重你的意图不乱塞。self.path.invoke(value, config)这一行就是调用你写的那个 route(state) 函数,返回值 result 交给 _finish 翻译。🚫 坑:路由函数里读不到源节点刚写的字段?如果你发现路由函数拿到的状态"少了源节点这轮写的东西",多半是你给路由函数标了
input_schema(比如用了带类型的第一个参数),触发了上面"不透传"分支。去掉限定、或把需要的字段纳入 schema 即可。这个"合并/不合并"的分歧点,是条件边最隐蔽的一个行为差异。异步版
_aroute 在 _branch.py:169,逻辑与 _route 逐行一致,只是把 self.path.invoke 换成 await self.path.ainvoke。同步/异步双实现、主体逻辑镜像——记住这个模式,后面阶段 4 到处都是。L05
_finish:把返回值翻译成真实目标并写出去
整条 Branch 的高潮在 _finish——它把路由函数的返回值变成"往哪些通道写"。_branch.py:192:
# graph/_branch.py:192
def _finish(self, writer, input, result, config):
if not isinstance(result, (list, tuple)):
result = [result] # ① 单值也统一成列表 → 支持"扇出多个"
if self.ends:
destinations = [ # ② 有翻译表:查表
r if isinstance(r, Send) else self.ends[r] for r in result
]
else:
destinations = result # ② 无表:返回值本身就是节点名
if any(dest is None or dest == START for dest in destinations):
raise ValueError("Branch did not return a valid destination") # ③ 边界
if any(p.node == END for p in destinations if isinstance(p, Send)):
raise InvalidUpdateError("Cannot send a packet to the END node") # ④ 边界
entries = writer(destinations, False) # ⑤ 目标 → 通道写入项
if not entries:
return input
# ...(PASSTHROUGH 处理,见下)
ChannelWrite.do_write(config, entries) # ⑥ 真正落笔写通道
return input
result = [result]归一化:不管你返回单个字符串还是列表,统一当列表处理。这正是"条件边能一次扇出多个下游"的实现根基——返回列表就产生多个目标。r if isinstance(r,Send) else self.ends[r]翻译规则:如果返回项是 Send 对象(Day 16)就原样保留;否则拿它去 ends 表里查真实节点名。这一行同时兼容"返回节点名"和"返回 Send"两种路由。dest is None or dest == START边界③:目标是 None(查表没查到 / 函数返回了没登记的值)或 START(不能倒流回起点)→ 立刻报错。这拦住了"路由函数返回了 path_map 里不存在的键"这种典型 bug。p.node == END边界④:不能用 Send 往 END 发包——END 是终点,给它"投递带状态的任务"没有意义,抛 InvalidUpdateError。writer(destinations, False)把目标列表交给注入的 writer,得到一串"通道写入项"(往 branch:to:tools 之类的通道写信号)。ChannelWrite.do_write最后一步:真正把这些写入项落到通道里。写完,订阅这些通道的下游节点就会在下一超步被触发——道岔拨动完成。图注:路由函数返回值经
_finish 翻译成目标、再落成通道写入。返回列表 = 一次扇出多个下游。L06
输入 schema 推断:路由函数也能声明"我只看这几个字段"
Day 13 我们看到 from_path 第三参 infer_schema=True 时会推断输入 schema。它靠 _get_branch_path_input_schema(_branch.py:41)读函数第一个参数的类型标注:
# graph/_branch.py:41(裁剪核心)
def _get_branch_path_input_schema(path):
input = None
try:
callable_ = None
if isinstance(path, (RunnableCallable, RunnableLambda)):
if isfunction(path.func) or ismethod(path.func):
callable_ = path.func # 找到真正的底层函数
# ...(还会尝试 __call__、afunc 等多种情况)
elif callable(path):
callable_ = path
if callable_ is not None and (hints := get_type_hints(callable_)):
first_parameter_name = next(
iter(signature(callable_).parameters.keys()) # 第一个参数名
)
if input_hint := hints.get(first_parameter_name):
if isinstance(input_hint, type) and get_type_hints(input_hint):
input = input_hint # 它自己也是个 TypedDict/类 → 采纳
except (TypeError, StopIteration):
pass
return input
找 callable_路由函数可能被 Runnable 包了好几层,这里层层剥壳找到最底层那个真正的 Python 函数,才能读它的类型标注。第一个参数名 + get_type_hints取函数签名第一个参数(就是 state),看它标了什么类型。isinstance(input_hint,type) and get_type_hints(input_hint)双重确认:这个标注本身得是个类、且它自己也有字段标注(像 TypedDict),才认作 schema。防止把 dict / Any 这种没信息量的标注误当 schema。except (TypeError,StopIteration): pass边界:函数没参数(StopIteration)或类型解析失败(TypeError)都安静返回 None。同样是"推断失败不致命"。💡 本质:这段推断让路由函数成为图的"schema 公民"推断出的 schema 会在
add_conditional_edges 末尾被 self._add_schema(schema) 登记进图(Day 13 L04 见过)。意义是:如果你的路由函数想读一个只有它关心的中间字段,只要在它的参数类型里声明,图就会保证那个通道存在、路由函数读得到。路由函数因此不是"图外的旁观者",而是能参与 schema 契约的一等公民。L07
今日小结 + 动手 + 明日预告
🧠 今天你应该能回答
- 返回字符串的路由函数,凭什么能驱动"通道 + 超步"的图?(Branch 把它翻译成通道写入)
from_path三种方式得到ends?(dict 直取 / list 恒等 /Literal返回类型推断)run里注入的 writer、reader 各干嘛?(写通道 / 读状态,依赖注入解耦存储)_route的"透传合并"什么时候发生、什么时候不发生?(dict 状态且无 input_schema 才合并)_finish为什么先把 result 变成列表?(统一支持"返回列表 = 扇出多个下游")_finish有哪两处边界拦截?(目标是 None/START 报错;Send 到 END 报错)
✋ 10 分钟动手
# 1. 通读 _branch.py(才 226 行,值得整读一遍)
sed -n '83,226p' libs/langgraph/langgraph/graph/_branch.py
# 2. 观察"返回列表 = 扇出":让路由函数返回两个目标
python - <<'PY'
from langgraph.graph import StateGraph, START, END
from typing import TypedDict, Annotated
import operator
class S(TypedDict): out: Annotated[list, operator.add]
g = StateGraph(S)
g.add_node("a", lambda s: {"out": ["A"]})
g.add_node("b", lambda s: {"out": ["B"]})
g.add_conditional_edges(START, lambda s: ["a","b"], ["a","b"]) # 一次去两个!
g.add_edge("a", END); g.add_edge("b", END)
print(g.compile().invoke({"out": []})) # {'out': ['A','B']}
PY
# 3. 故意返回没登记的目标,看边界报错
# add_conditional_edges(START, lambda s:"zzz", {"a":"a"}) → 运行时 ValueError
明天预告 · Day 15:条件边是"从节点外面挂一个路由器"。但有时你想让节点自己在返回值里直接说"我更新这些状态,并且下一步去 X"——这就是
Command 对象。Day 15 拆 types.py 的 Command(goto/update/graph),看它如何做到"更新+跳转"合一,甚至跨父子图跳转。