数据截至 (上游 commit 7a975c596eca)
从 JSON 到图 — 画布存的那坨数据怎么变成可执行对象
30 秒导读: 你在 Langflow 画布上拖出来的流程,存盘时只是一坨
{"nodes": [...], "edges": [...]}的 JSON。 本章讲的就是后端拿到这坨 JSON 之后,怎么把它还原成一个可以真跑的Graph对象: 每个 node 变成一个Vertex,每条 edge 变成一个Edge,而每条边最终会变成目标组件某个方法的一个入参。
上一章 组件模型 讲的是"一个 Python 类怎么长成画布上的节点"。 本章是它的反向:画布上的节点和连线,怎么变回可执行的 Python 对象。
本章不讲谁先跑、谁能并行(见 调度引擎),也不讲环和条件分支怎么处理(见 环、循环与条件路由)。这里只管"装配",不管"运行"。
1. 先看清楚:前端存下来的到底是什么
1.1 一条真边长这样
下面是仓库自带的 starter project 里的一条真边(src/backend/base/langflow/initial_setup/starter_projects/Basic Prompt Chaining.json),删掉了纯前端字段:
{
"source": "ChatInput-pNKvO",
"target": "LanguageModelComponent-Q5bRY",
"data": {
"sourceHandle": {
"dataType": "ChatInput",
"id": "ChatInput-pNKvO",
"name": "message",
"output_types": ["Message"]
},
"targetHandle": {
"fieldName": "input_value",
"id": "LanguageModelComponent-Q5bRY",
"inputTypes": ["Message"],
"type": "str"
}
}
}
一句话读懂:这条边说的是"把 ChatInput-pNKvO 组件名叫 message 的那个输出,接到 LanguageModelComponent-Q5bRY 组件名叫 input_value 的那个入参上"。
1.2 两端的 handle 各自带什么信息
handle(画布上连线两端那个小圆点)是整章的核心数据结构,它带的字段决定了后面所有的校验和绑定:
| 字段 | 在哪一端 | 含义 | 谁会用它 |
|---|---|---|---|
id | 两端 | 顶点 id(不是边 id) | 环检测 _get_edges_as_list_of_tuples |
name | source | 源组件的输出名(对应 Output.name) | _validate_edge 去 source.outputs 里查 |
output_types | source | 这个输出产出什么类型 | 类型握手 |
fieldName | target | 目标组件的入参名(对应 template 里的字段名) | 直接变成 Edge.target_param |
inputTypes | target | 这个入参接受哪些类型 | 类型握手 |
type | target | 这个入参的原始字段类型(如 str) | 类型握手的备选比对项 |
两端 handle 被解析成两个 pydantic 模型,靠 alias 把前端的驼峰名映射到 Python 蛇形名(src/lfx/src/lfx/graph/edge/schema.py:74 TargetHandle、:99 SourceHandle)。
SourceHandle 还藏了一个专门给分组节点用的清洗器:当 dataType == "GroupNode" 时,name 形如 OpenAIModel-u4iGV_text_output,validator 会把 _ 前的那段前缀切掉,只留真实输出名(src/lfx/src/lfx/graph/edge/schema.py:109-119 validate_name)。
2. 顶层全景:六步流水线
入口只有一 个:Graph.from_payload(src/lfx/src/lfx/graph/graph/base.py:1468)。
怎么读这张图: 从上往下是严格顺序,任何一步抛错整张流程就加载失败。
payload {nodes, edges}
│
▼
① 迁移 + 安全校验 旧组件引用改写、拦截被禁用/自定义组件
│ from_payload base.py:1267 / :1329
▼
② 展开分组节点 组节点 → 一堆真节点 + 改写过的边
│ process_flow utils.py:88
▼
③ 造顶点 每个 node → 一个 Vertex 子类实例
│ _build_vertices base.py:2258
▼
④ 造边 + 握手校验 每条 edge → 一个 Edge,类型不匹配当场抛错
│ _build_edges base.py:2211
▼
⑤ 边 → 参数 params[入参名] = 上游 Vertex(占位)
│ build_params vertex/base.py:333
▼
⑥ 建索引 + 实例化 前驱/后继表、入度表;真正 new 出组件对象
build_graph_maps base.py:957
2.1 部件一句话职责
| 部件 | 干什么 | 文件 |
|---|---|---|
Graph | 装配总控 + 顶点/边的容器 + 邻接索引 | src/lfx/src/lfx/graph/graph/base.py |
Vertex 及其子类 | 一个节点的运行期壳子:解析 data、算 params、持有组件实例 | src/lfx/src/lfx/graph/vertex/base.py、vertex_types.py |
Edge / CycleEdge | 一条连线 + 两道类型校验 | src/lfx/src/lfx/graph/edge/base.py |
ParameterHandler | 把「边」和「template 字段」一起翻译成 params 字典 | src/lfx/src/lfx/graph/vertex/param_handler.py |
process_flow 等工具 | 分组节点展开、handle 改写 | src/lfx/src/lfx/graph/graph/utils.py |
2.2 主线走一遍(不进代码)
from_payload 先跑迁移和安全校验,然后只做两件事:cls(...) 建空 Graph、graph.add_nodes_and_edges(vertices, edges)(base.py:1331-1334)。
真正的活全在 add_nodes_and_edges(base.py:267)里:
- 先把原始 node id 记进
top_level_vertices(base.py:272-274)——注意是展开分组节点之前,这一步的时机很关键,第 3 节讲。 process_flow展开分组节点,得到扁平的 nodes/edges(base.py:278)。initialize()(base.py:285)。
initialize 只有三行,是整章的骨架(base.py:547-550):
def initialize(self) -> None:
self._build_graph()
self.build_graph_maps(self.edges)
self.define_vertices_lists()
结论先行:装配是"急切"的,不是懒的。 from_payload 返回时,顶点造好了、边校验过了、params 算好了、组件实例也 new 出来了。只有"跑"这件事没做。
3. 第 ② 步:展开分组节点(Group Node)
3.1 它要解决的小问题
用户可以把一坨节点框成一个"分组节点",画布上只显示一个框。但执行引擎不认识分组——它只认识扁平的顶点和边。所以装配前必须把组拆平。
3.2 思路
一个分组节点的 data.node.flow 字段里,原封不动存着子流程的 nodes / edges。展开就是:把子流程的节点搬到外层,然后把原本连到组框上的那些外部边,改接到组内真正的那个节点上。
展开前 展开后
┌──────────────┐
A ──→ │ Group │ ──→ C A ──→ B1 ──→ B2 ──→ C
│ B1 → B2 │ (A→Group 改成 A→B1
└──────────────┘ Group→C 改成 B2→C)
3.3 真实实现
process_flow(src/lfx/src/lfx/graph/graph/utils.py:88)先 copy.deepcopy 整个 flow(:89)——不改调用方的数据,然后用一个队列反复扫:发现某个 node 带 data.node.flow,就递归展开它、再把展开出来的新节点也塞回队列(utils.py:99-103),所以嵌套分组也能层层拆开。
ungroup_node(utils.py:56)是单个组的展开逻辑,四件事:
| 动作 | 干什么 | 行号 |
|---|---|---|
add_parent_node_id | 给每个子节点打上 parent_node_id = 组节点 id | utils.py:65 |
add_frozen | 把组的 frozen 状态刷给所有子节点 | utils.py:66 |
get_updated_edges | 改写所有连到组框的外部边 | utils.py:70 |
update_template | 把组暴露出来的 proxy 字段值写回真正的子节点 | utils.py:73 |
最后把组节点从 nodes 里删掉、把跟组相关的旧边删掉,换成子节点 + 子边 + 改写过的边(utils.py:75-83)。
3.4 边是怎么被改写的
get_updated_edges(utils.py:290)遍历外层所有边,只处理两端沾到组 id 的(utils.py:314-321):
- 组是 target(外面的东西流进组里)→
update_target_handle(utils.py:145)。它从 target handle 的proxy字段拿到真实节点 id,然后直接改new_edge["target"],并重建一个指向真实字段名的新 handle(set_new_target_handle,utils.py:166,改 target 在:175)。 - 组是 source(组里的东西流出去)→
update_source_handle(utils.py:264)。它优先按组的outputs[].proxy元数据定位真实输出节点(_update_source_handle_from_group_output,utils.py:236);找不到 proxy 时退化成"找组内最后一个节点"(find_last_node,utils.py:279-283)。
后一条退化路径是本节最值得记的一个坑:没有 proxy 元数据时,组的输出被当成"组内唯一的终点节点"——组内如果有多个终点,这个猜测就不一定是用户想要的(utils.py:35 find_last_node 的 定义是"不作为任何边 source 的第一个节点")。
3.5 展开之后,谁还记得原来的组
回到 3 中卖的关子:top_level_vertices 是在展开之前从原始 nodes 收集的(base.py:272-274),所以里面装的是组节点的 id。
展开后每个子顶点的 parent_node_id 正好就是组 id,于是 Vertex.set_top_level 一比对就知道"我属于某个顶层组"(src/lfx/src/lfx/graph/vertex/base.py:245-246):
def set_top_level(self, top_level_vertices: list[str]) -> None:
self.parent_is_top_level = self.parent_node_id in top_level_vertices
这个标记后来被 get_top_level_vertices(base.py:2495-2501)用来把子顶点的进度折叠回组节点上报给前端。执行看扁平图,展示看折叠图,两套视图靠这一个布尔量对齐。
4. 第 ③ 步:造顶点
4.1 三个函数,一条直线
_build_vertices 遍历所有 node,跳过 NoteNode(便签,不是组件)
│ base.py:2258 / 跳过在 :2262
▼
_create_vertex 取出 type 和 template["_type"],选类,new 出来
│ base.py:2272
▼
_get_vertex_class 按四条规则决定用哪个 Vertex 子类
base.py:2241
_build_vertices 还有个小细节:它先试着从已有的 vertex_map 里取,取不到才新建(base.py:2264-2267)。这让 Graph.update(增量更新画布)能复用旧顶点对象。
4.2 选类规则(顺序敏感,命中即停)
_get_vertex_class(node_type, node_base_type, node_id) 的判断顺序(base.py:2241-2256):
| 顺序 | 判断依据 | 命中结果 |
|---|---|---|
| 1 | id 前缀或 type 在 InterfaceComponentTypes 里 | InterfaceVertex |
| 2 | id 前缀是 SharedState / Notify / Listen | StateVertex |
| 3 | template["_type"] 在类型表里(现代组件是 "Component") | ComponentVertex |
| 4 | id 前缀 或 type 在类型表里 | 表里对应的类 |
| 5 | 都不中 | 基类 Vertex |
注意规则 1 里的 node_name = node_id.split("-")[0](base.py:2244)——顶点 id 的横杠前缀就是组件类型名(ChatInput-pNKvO → ChatInput),Langflow 到处依赖这个约定。
类型表本身是懒加载的(src/lfx/src/lfx/graph/graph/constants.py:51-57 get_type_dict),只有三个条目:CustomComponent → CustomComponentVertex、Component → ComponentVertex,以及 CHAT_COMPONENTS 里的两个聊天组件 → InterfaceVertex。
4.3 四种 Vertex 的职责差异
四个子类都在 src/lfx/src/lfx/graph/vertex/vertex_types.py:
| 类 | 行号 | 相比基类多了什么 | 为什么需要 |
|---|---|---|---|
CustomComponentVertex | :31 | 只覆写 built_object_repr,base_type="custom_components" | 老式自定义组件的展示 |
ComponentVertex | :41 | get_input/get_output 转发到组件实例;_update_built_object_and_artifacts 会拆 2 元/3 元返回值并把每个输出登记进 results | 现代组件的默认实现,多输出的基础 |
InterfaceVertex | :213 | steps = [self._build, self._run](多一步 _run)、stream()、build_stream_url() | 聊天/文本/数据 I/O 节点要把结果转成前端能显示的 artifact,还要支持流式 |
StateVertex | :470 | is_state = True,steps = [self._build] | Notify/Listen 这类状态组件,会被 activate_state_vertices 特殊唤醒 |
记一条就够:ComponentVertex 管"算结果",InterfaceVertex 在它基础上多管"把结果讲给人听"。
InterfaceVertex 的 _run(vertex_types.py:366-376)就是这个"多一步":按 vertex_type 分流到 _process_chat_component(:244)或 _process_data_component(:331),产出 artifacts。
4.4 顶点自解析:parse_data
Vertex.__init__ 里第一件实质的事就是 self.parse_data()(src/lfx/src/lfx/graph/vertex/base.py:83)。 它把 node 的 data 拆成一堆运行期属性(vertex/base.py:248):
outputs:现代组件(_type == "Component")必须有outputs,没有直接抛错(vertex/base.py:250-254);老组件退化到base_classes(:252-253)。display_name/icon/description/frozen(:255-259)。required_inputs/optional_inputs:把 template 里每个字段的type和input_types全部摊平进两个字符串列表(vertex/base.py:271-279)。
最后一条是理解第 5 节校验的关键:target_reqs 不是"某个入参接受什么",而是"这个组件所有入参加起来接受什么"的大杂烩。
5. 第 ④ 步:造边 —— 两道握手校验
5.1 造边本身很短
_build_edges(base.py:2211)遍历原始边、逐条调 build_edge,结果塞进一个 set 去重(base.py:2216-2219)。Edge.__hash__ 基于 repr,而 repr 含两端 id + 输出名 + 入参名(edge/base.py:263-271),所以完全重复的连线会被自动合并,不同 handle 的多条平行边会保留。
build_edge(base.py:2224)唯一的分支是:只要两端有任何一端在 cycle_vertices 里,就造 CycleEdge 而不是 Edge(base.py:2234-2237)。CycleEdge 多了 is_fulfilled / result 和 honor()(edge/base.py:286-318),细节留给 04 章。
5.2 校验发生在构造函数里
Edge.__init__ 解析完 handle、算出 target_param 之后,当场跑两道校验(edge/base.py:85-103):
Edge.__init__
│
├─→ ① validate_handles(source, target) "两个圆点的类型对得上吗"
│ edge/base.py:108
│
└─→ ② validate_edge(source, target) "源的这个输出,目标真的收得下吗"
edge/base.py:179
注释写得很直白:# Validate in __init__ to fail fast(edge/base.py:102)。类型错的流程根本建不出 Graph 对象,不会等到运行时才炸。
5.3 第一道:validate_handles —— 比 handle 上写的类型
validate_handles(edge/base.py:108)先分流:source handle 是字符串、或者带 base_classes(老格式)→ 走 _legacy_validate_handles(:146);否则走 _validate_handles(:114)。
_validate_handles 有三个分支:
| 分支 | 条件 | 比什么 | 行号 |
|---|---|---|---|
| A | target_handle.input_types is None | source.output_types vs [target.type] | :115-119 |
| B | target_handle.type is None | 源有输出类型,且目标 input_types 为空或有交集 | :120-130 |
| C | 其余 | output_types vs input_types,或 output_types vs [type] | :132-138 |
分支 B 的注释直说了这是循环边:TargetHandle.from_loop_target_handle(edge/schema.py:83-96)从一个 Output 构造 handle,不传 type,所以 type 是 None——靠这个"缺字段"来识别循环边。代码里自己吐槽 # ! This is not a good solution(edge/base.py:121)。
分支 A 在实践中基本走不到(inferred):TargetHandle.input_types 用 default_factory=list(edge/schema.py:78-80),字段缺失时是 [] 而不是 None;而显式传 null 会让 pydantic 校验失败,被 __init__ 里的 except 接住、翻译成"字段可能不是合法输入"的友好报错(edge/base.py:65-79)。
第 1.1 节那条真边走的是分支 C:源 output_types=["Message"],目标 inputTypes=["Message"],第一个 types_compatible 就返回 True。
5.4 第二道:validate_edge —— 查源组件真实的输出定义
_validate_edge(edge/base.py:187)不信 handle 上写的,它回头去查源顶点真正声明的 outputs:
self.source_types = [output for output in source.outputs if output["name"] == self.source_handle.name]
(edge/base.py:192,用 source handle 的 name 去 source.outputs 里定位那一个输出)
然后拿 target.required_inputs + target.optional_inputs(就是 4.4 里那个大杂烩)当作可接受类型集合(edge/base.py:216),算出 valid 和 matched_type(:221-231)。matched_type 是"这条边实际传的是什么类型",CycleEdge.honor 会用它决定 传 built_result 还是 built_object(edge/base.py:312-315)。
匹配不上就抛 has no matched type.(edge/base.py:233-238)。
5.5 两道校验的分工
第一道 validate_handles | 第二道 validate_edge | |
|---|---|---|
| 数据来源 | 边 JSON 里的 handle 字段 | 顶点的 outputs / required_inputs / optional_inputs |
| 相当于问 | "画布上这两个圆点该不该连" | "源组件真的有这个输出、目标真的收得下吗" |
| 失败信息 | has invalid handles | has no matched type |
| 副产物 | valid_handles | matched_type(运行时要用) |
为什么要查两遍: handle 是前端写进 JSON 的快照,可能过时(组件升级了、输出改名了);顶点的 outputs 是这次加载时从组件代码重新算出来的。第二道校验实际上是在拿"当下的真相"复核"存盘时的快照"。
5.6 老流兼容:TYPE_MIGRATIONS
Langflow 改过两个核心类型的名字。老流程存的还是旧名,新组件声明的是新名,直接字符串比对就全断了。解法是一张迁移表(src/lfx/src/lfx/graph/edge/base.py:14-17):
| 旧类型名 | 新类型名 |
|---|---|
Data | JSON |
DataFrame | Table |
上面所有类型比对都不用 in,而是走 types_compatible(edge/base.py:20)。它的做法是:把两边的类型名各自过一遍迁移表,然后做一次覆盖四种组合的比对(edge/base.py:39):
if input_type in {output_type, migrated_output} or migrated_input in {output_type, migrated_output}:
return True
妙在哪: 迁移表只有两行,但因为比对同时看"原名"和"迁移后的名",所以新→新、旧→旧、旧→新、新→旧四种搭配全部放行。老流程不用改一个字就能连上新组件。
6. 第 ⑤ 步:边 → 参数(本章的重头戏)
6.1 它要解决的小问题
组件的方法签名长这样(示意):
class LanguageModelComponent(Component):
inputs = [MessageTextInput(name="input_value", ...)]
def build_output(self) -> Message:
return self.some_llm.invoke(self.input_value) # input_value 从哪来?
input_value 可能来自两个地方:用户在面板里手填的值,或者上游组件的输出。装配阶段要把这两个来源合成同一个 params 字典。
6.2 思路:先占位,跑的时候再换成真值
装配时上游还没跑,拿不到真值。所以 Langflow 的做法是:
装配期 运行期
params["input_value"] = <Vertex 对象> ──→ params["input_value"] = "你好"
(占位:上游顶点本身) (替换:上游顶点的结果)
替换发生在 _build_each_vertex_in_params_dict(vertex/base.py:554):扫一遍 raw_params,凡是值是 Vertex 的就 await vertex.get_result(...) 换成真结果(vertex/base.py:662-668)。这一步属于执行阶段,细节在 03 章。
本章只需记住:装配的产物是一个"半成品字典"——字面值已经就位,连线位置放的是上游顶点的引用。
6.3 入口:build_params
Vertex.build_params(src/lfx/src/lfx/graph/vertex/base.py:337)短到可以整段看懂逻辑:
param_handler = ParameterHandler(self, storage_service=None) # :345
edge_params = param_handler.process_edge_parameters(self.edges) # :348 来自连线
field_params, load_from_db_fields = param_handler.process_field_parameters() # :351 来自面板
self.params = {**field_params, **edge_params} # :354 连线覆盖面板
优先级一目了然:edge_params 写在后面,所以连线值覆盖面板填的值。 用户在面板里填了字符串、又从上游连了一条线过来,以连线为准。
开头还有个短路:if self.updated_raw_params: return(vertex/base.py:343-346)——运行期通过 API 注入过的参数不许被重新装配覆盖。
6.4 连线侧:process_edge_parameters
ParameterHandler 构造时先把 template 里所有 dict 类型的条目挑出来当 template_dict(param_handler.py:47-49)——这就是"这个组件有哪些字段"的字典。
process_edge_parameters(param_handler.py:65)遍历顶点的全部边(包括出边),逐条交给 _set_params_from_normal_edge(:79-82)。
_set_params_from_normal_edge(param_handler.py:85)是"边→参数"这一步真正干活的地方,三条分支:
param_key = edge.target_param (就是 targetHandle.fieldName)
│
├─ 在 template 里 && 这条边确实指向我 ──┬─ 字段是 list → params[key].append(上游 Vertex)
│ param_handler.py:88 │ :90-93 (多个上游可以喂同一个入参)
│ │
│ └─ 否则 → process_non_list_edge_param
│ :95 :103
│
└─ param_key 在 self.output_names 里 → params[key] = 上游 Vertex
:96-100 (循环边:目标是我的"输出"名,不是入参名)
第一条分支里 edge.target_id == self.vertex.id 这个判断是必要的——顶点的 edges 属性同时含进边和出边(vertex/base.py:194-195),不加这个判断,出边的 target_param 会污染自己的 params。
process_non_list_edge_param(param_handler.py:103)处理一个特例:字段当前值是只有一个 key 的 dict时,保持 dict 形状、把那个 key 的值换成上游顶点(:106-107);否则直接放顶点。
6.5 面板侧:process_field_parameters
process_field_parameters(param_handler.py:110)遍历 template_dict 的每个字段,路由三选一:
| 字段 type | 处理 | 行号 |
|---|---|---|
"file" | 逻辑路径 flow_id/filename 解析成组件能读的真实路径 | :130-131 → process_file_field :155 |
在 DIRECT_TYPES 里(str/int/bool/dict/code/table/slider…) | 按类型转换 | :132-135 → _process_direct_type_field :193 |
| 其余 | 抛错:is not a valid field type | :136-138 |
跳过规则在 should_skip_field(param_handler.py:144):type == "other"、已被处理过、名字是 _type、或者字段 show=False 且不叫 code——都跳过。
三个值得记的细节:
- 可选字段的清理:非必填且值为 None 时,有 default 就用 default,没有就把 key 整个从 params 里删掉(
handle_optional_field,param_handler.py:275-281)。让组件用自己的 Python 默认值,而不是收到一个None。 - 密钥字段的三个免拉取条件:标了
load_from_db的字段本该去数据库拉全局变量,但如果①字段自己有进边、②这是密钥字段且model字段有进边、③处于"连接其它模型"模式——就跳过(param_handler.py:208-224)。连了外部模型组件的时候,凭据由那个组件自己提供,不该再去拉本地密钥。 - str 字段的宽容转换:值可能是字符串、
Data、甚至序列化过的 Message dict,统一由_coerce_str_value抽出文本(param_handler.py:21-33)。
6.6 运行期回写:update_raw_params
update_raw_params(vertex/base.py:362)给 API 注入输入用(比如 /build 时塞一句用户消息)。它有两道保护:
- 要更新的 key 在
raw_params里对应的是Vertex(即那个入参已经被连线占了)→ 整个更新直接放弃(vertex/base.py:376-377)。连线的优先级高于外部注入。 - 非 overwrite 模式下,
raw_params里不存在的 key 会被静默丢弃(vertex/base.py:378-381)。
成功后打上 updated_raw_params = True,从而让后续的 build_params 短路(对应 6.3 那个短路)。
7. 第 ⑥ 步:索引与实例化
7.1 三张索引表
build_graph_maps(base.py:957)一次性算出四张表:
| 表 | 内容 | 由谁算 |
|---|---|---|
predecessor_map | 顶点 id → 前驱 id 列表 | build_adjacency_maps base.py:2565 |
successor_map | 顶点 id → 后继 id 列表 | 同上 |
in_degree_map | 顶点 id → 入边条数 | build_in_degree base.py:2503 |
parent_child_map | 组节点 id → 子顶点 id 列表 | build_parent_child_map base.py:1147 |
build_adjacency_maps 是个静态方法,八行遍历一遍边就完事(base.py:2565-2572)。build_in_degree 多做一步:给没有入边的顶点显式补 0(base.py:2510-2512),这样调度器可以放心地直接 in_degree_map[vid] 而不用处理 KeyError。
一个要注意的地方:build_in_degree 的注释说"同一个组件多次连到同一顶点不需要重复计数",但实现是逐边 += 1(base.py:2506-2509)。真正的去重发生在更早的 _build_edges(那个 set)——完全相同的边会被合并,但同一对顶点之间走不同 handle 的多条边仍会各计一次。
7.2 找出口:get_terminal_nodes
def get_terminal_nodes(self) -> list[str]:
return [vertex.id for vertex in self.vertices if not self.successor_map.get(vertex.id, [])]
(base.py:2359-2368)
没有后继的顶点就是终点。 一行定义,用来确定这张流程图最终要把结果交给谁。
7.3 真正 new 出组件
_instantiate_components_in_vertices(base.py:1525)遍历所有顶点调 instantiate_component,后者在组件实例还不存在时调 initialize.loading.instantiate_class(vertex/base.py:386-391)把 template 里的 code 字符串变成活的 Python 对象。这一步的机制属于 01 章 的地盘。
注意 _build_graph 里的顺序(base.py:1477-1488):先 _build_vertex_params(),后 _instantiate_components_in_vertices()。params 是先算好、再交给实例的。
8. 巧妙之处(可以带走的)
-
"先占位、后替换"把装配和执行彻底解耦。 装配期不需要任何异步、任何上游结果,只需要图结构;
params[key] = <Vertex>这一个约定就把"数据依赖"编码进了普通字典里(param_handler.py:93、vertex/base.py:557)。 -
构造函数里 fail fast。 类型校验不放在单独的
validate()里,而是塞进Edge.__init__(edge/base.py:102-103)——不可能存在一个"没校验过的 Edge 对象"。 -
一张两行的迁移表挡住了全部历史包袱。
types_compatible对称地比对原名和迁移名(edge/base.py:20-41),使Data/JSON、DataFrame/Table四种搭配全兼容,老流程零 改动。 -
同一份数据两套视图。 执行看展开后的扁平图,前端进度看折叠回组节点的图,靠
parent_node_id+top_level_vertices一个布尔量对齐(utils.py:44-48、vertex/base.py:245-246)。 -
校验查两遍,一遍信 JSON、一遍信代码。 第二道
_validate_edge回源码重算 outputs(edge/base.py:192),相当于每次加载都在复核存盘快照有没有过时。 -
用"字段缺失"当循环边的标记。
TargetHandle.type is None就意味着这个 handle 是从Output构造的循环边(edge/base.py:120-130)。省事但脆——源码里自己标了# ! This is not a good solution。
9. 边界与坑
-
装配是全量急切的。 一百个节点的流程,
from_payload就会 new 出一百个组件实例。没有懒装配,加载成本随节点数线性增长。 -
Vertex._set_params_from_normal_edge(vertex/base.py:308)是死代码。 全仓库无调用方(build_params走的是ParameterHandler那份)。读源码时容易改错地方。 -
无 proxy 元数据的分组输出靠猜。
update_source_handle退化到find_last_node(utils.py:279-283),组内有多个终点时结果不保证正确。 -
target_reqs是全组件级的大杂烩。_validate_edge比的是"目标组件所有入参加起来接受的类型"(vertex/base.py:271-279、edge/base.py:216),不是"这个具体入参接受的类型"。所以第二道校验比第一道松:A 字段能收Message,就够让一条接到 B 字段的Message边通过第二道。真正的精确把关在第一道_validate_handles。 -
_validate_handles的分支 A 实际走不到(inferred,依据:edge/schema.py:78-80的default_factory=list与edge/base.py:65-79的 except 分支)。 -
顶点 id 的横杠前缀是隐式协议。
_get_vertex_class(base.py:2244)、Vertex.base_name(vertex/base.py:65)都靠id.split("-")[0]。组件类名一改,老流程的顶点分类就会退化——这也是仓库文档反复强调"绝不要改组件类名"的原因之一。
10. 代码地图
| 主题 | 文件路径 | 符号名 |
|---|---|---|
| 装配总入口 | src/lfx/src/lfx/graph/graph/base.py | Graph.from_payload |
| 装载 nodes/edges + 记录顶层 id | src/lfx/src/lfx/graph/graph/base.py | Graph.add_nodes_and_edges |
| 装配骨架三步 | src/lfx/src/lfx/graph/graph/base.py | Graph.initialize、Graph._build_graph |
| 造顶点 | src/lfx/src/lfx/graph/graph/base.py | Graph._build_vertices、Graph._create_vertex |
| 顶点选类 | src/lfx/src/lfx/graph/graph/base.py | Graph._get_vertex_class |
| 顶点类型表(懒加载) | src/lfx/src/lfx/graph/graph/constants.py | VertexTypesDict.get_type_dict |
| 造边 / 环边分流 | src/lfx/src/lfx/graph/graph/base.py | Graph._build_edges、Graph.build_edge |
| 邻接表 / 入度 / 出口 | src/lfx/src/lfx/graph/graph/base.py | build_graph_maps、build_adjacency_maps、build_in_degree、get_terminal_nodes |
| 组件实例化 | src/lfx/src/lfx/graph/graph/base.py | Graph._instantiate_components_in_vertices |
| 四种顶点的职责差异 | src/lfx/src/lfx/graph/vertex/vertex_types.py | ComponentVertex、InterfaceVertex、StateVertex、CustomComponentVertex |
| node data → 运行期属性 | src/lfx/src/lfx/graph/vertex/base.py | Vertex.parse_data |
| 参数装配入口 | src/lfx/src/lfx/graph/vertex/base.py | Vertex.build_params、Vertex.update_raw_params |
| 边 → 入参 | src/lfx/src/lfx/graph/vertex/param_handler.py | ParameterHandler.process_edge_parameters、_set_params_from_normal_edge、process_non_list_edge_param |
| 面板字段 → 入参 | src/lfx/src/lfx/graph/vertex/param_handler.py | ParameterHandler.process_field_parameters、should_skip_field、handle_optional_field |
| 边的两道校验 | src/lfx/src/lfx/graph/edge/base.py | Edge.validate_handles、_validate_handles、_legacy_validate_handles、Edge.validate_edge、_validate_edge |
| 老流类型兼容 | src/lfx/src/lfx/graph/edge/base.py | types_compatible、TYPE_MIGRATIONS |
| handle 数据模型 | src/lfx/src/lfx/graph/edge/schema.py | SourceHandle、TargetHandle、TargetHandle.from_loop_target_handle |
| 分组节点展开 | src/lfx/src/lfx/graph/graph/utils.py | process_flow、ungroup_node、get_updated_edges、update_target_handle、update_source_handle、update_template |
接着读: 图装配好了,下一个问题是"谁先跑"——见 调度引擎。想知道 CycleEdge 和条件路由怎么打破 DAG 假设,见 环、循环与条件路由。想知道这张图是怎么被 HTTP 请求触发、事件怎么流回前端,见 运行时与事件流。