跳到主要内容

数据截至 (上游 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
namesource源组件的输出名(对应 Output.name_validate_edgesource.outputs 里查
output_typessource这个输出产出什么类型类型握手
fieldNametarget目标组件的入参名(对应 template 里的字段名)直接变成 Edge.target_param
inputTypestarget这个入参接受哪些类型类型握手
typetarget这个入参的原始字段类型(如 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_payloadsrc/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.pyvertex_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_edgesbase.py:267)里:

  1. 先把原始 node id 记进 top_level_verticesbase.py:272-274)——注意是展开分组节点之前,这一步的时机很关键,第 3 节讲。
  2. process_flow 展开分组节点,得到扁平的 nodes/edges(base.py:278)。
  3. 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_flowsrc/lfx/src/lfx/graph/graph/utils.py:88)先 copy.deepcopy 整个 flow(:89)——不改调用方的数据,然后用一个队列反复扫:发现某个 node 带 data.node.flow,就递归展开它、再把展开出来的新节点也塞回队列(utils.py:99-103),所以嵌套分组也能层层拆开

ungroup_nodeutils.py:56)是单个组的展开逻辑,四件事:

动作干什么行号
add_parent_node_id给每个子节点打上 parent_node_id = 组节点 idutils.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_edgesutils.py:290)遍历外层所有边,只处理两端沾到组 id 的(utils.py:314-321):

  • 组是 target(外面的东西流进组里)→ update_target_handleutils.py:145)。它从 target handle 的 proxy 字段拿到真实节点 id,然后直接改 new_edge["target"],并重建一个指向真实字段名的新 handle(set_new_target_handleutils.py:166,改 target 在 :175)。
  • 组是 source(组里的东西流出去)→ update_source_handleutils.py:264)。它优先按组的 outputs[].proxy 元数据定位真实输出节点(_update_source_handle_from_group_outpututils.py:236);找不到 proxy 时退化成"找组内最后一个节点"find_last_nodeutils.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_verticesbase.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):

顺序判断依据命中结果
1id 前缀或 typeInterfaceComponentTypesInterfaceVertex
2id 前缀是 SharedState / Notify / ListenStateVertex
3template["_type"] 在类型表里(现代组件是 "Component"ComponentVertex
4id 前缀 或 type 在类型表里表里对应的类
5都不中基类 Vertex

注意规则 1 里的 node_name = node_id.split("-")[0]base.py:2244)——顶点 id 的横杠前缀就是组件类型名ChatInput-pNKvOChatInput),Langflow 到处依赖这个约定。

类型表本身是懒加载的(src/lfx/src/lfx/graph/graph/constants.py:51-57 get_type_dict),只有三个条目:CustomComponentCustomComponentVertexComponentComponentVertex,以及 CHAT_COMPONENTS 里的两个聊天组件 → InterfaceVertex

4.3 四种 Vertex 的职责差异

四个子类都在 src/lfx/src/lfx/graph/vertex/vertex_types.py

行号相比基类多了什么为什么需要
CustomComponentVertex:31只覆写 built_object_reprbase_type="custom_components"老式自定义组件的展示
ComponentVertex:41get_input/get_output 转发到组件实例;_update_built_object_and_artifacts 会拆 2 元/3 元返回值并把每个输出登记进 results现代组件的默认实现,多输出的基础
InterfaceVertex:213steps = [self._build, self._run](多一步 _run)、stream()build_stream_url()聊天/文本/数据 I/O 节点要把结果转成前端能显示的 artifact,还要支持流式
StateVertex:470is_state = Truesteps = [self._build]Notify/Listen 这类状态组件,会被 activate_state_vertices 特殊唤醒

记一条就够:ComponentVertex 管"算结果",InterfaceVertex 在它基础上多管"把结果讲给人听"。

InterfaceVertex_runvertex_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 里每个字段的 typeinput_types 全部摊平进两个字符串列表(vertex/base.py:271-279)。

最后一条是理解第 5 节校验的关键:target_reqs 不是"某个入参接受什么",而是"这个组件所有入参加起来接受什么"的大杂烩。


5. 第 ④ 步:造边 —— 两道握手校验

5.1 造边本身很短

_build_edgesbase.py:2211)遍历原始边、逐条调 build_edge,结果塞进一个 set 去重(base.py:2216-2219)。Edge.__hash__ 基于 repr,而 repr 含两端 id + 输出名 + 入参名(edge/base.py:263-271),所以完全重复的连线会被自动合并,不同 handle 的多条平行边会保留。

build_edgebase.py:2224)唯一的分支是:只要两端有任何一端在 cycle_vertices 里,就造 CycleEdge 而不是 Edgebase.py:2234-2237)。CycleEdge 多了 is_fulfilled / resulthonor()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 fastedge/base.py:102)。类型错的流程根本建不出 Graph 对象,不会等到运行时才炸。

5.3 第一道:validate_handles —— 比 handle 上写的类型

validate_handlesedge/base.py:108)先分流:source handle 是字符串、或者带 base_classes(老格式)→ 走 _legacy_validate_handles:146);否则走 _validate_handles:114)。

_validate_handles 有三个分支:

分支条件比什么行号
Atarget_handle.input_types is Nonesource.output_types vs [target.type]:115-119
Btarget_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_handleedge/schema.py:83-96)从一个 Output 构造 handle,不传 type,所以 typeNone——靠这个"缺字段"来识别循环边。代码里自己吐槽 # ! This is not a good solutionedge/base.py:121)。

分支 A 在实践中基本走不到(inferred):TargetHandle.input_typesdefault_factory=listedge/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_edgeedge/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 的 namesource.outputs 里定位那一个输出)

然后拿 target.required_inputs + target.optional_inputs(就是 4.4 里那个大杂烩)当作可接受类型集合(edge/base.py:216),算出 validmatched_type:221-231)。matched_type 是"这条边实际传的是什么类型",CycleEdge.honor 会用它决定传 built_result 还是 built_objectedge/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 handleshas no matched type
副产物valid_handlesmatched_type(运行时要用)

为什么要查两遍: handle 是前端写进 JSON 的快照,可能过时(组件升级了、输出改名了);顶点的 outputs这次加载时从组件代码重新算出来的。第二道校验实际上是在拿"当下的真相"复核"存盘时的快照"。

5.6 老流兼容:TYPE_MIGRATIONS

Langflow 改过两个核心类型的名字。老流程存的还是旧名,新组件声明的是新名,直接字符串比对就全断了。解法是一张迁移表(src/lfx/src/lfx/graph/edge/base.py:14-17):

旧类型名新类型名
DataJSON
DataFrameTable

上面所有类型比对都不用 in,而是走 types_compatibleedge/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_dictvertex/base.py:554):扫一遍 raw_params,凡是值是 Vertex 的就 await vertex.get_result(...) 换成真结果(vertex/base.py:662-668)。这一步属于执行阶段,细节在 03 章

本章只需记住:装配的产物是一个"半成品字典"——字面值已经就位,连线位置放的是上游顶点的引用。

6.3 入口:build_params

Vertex.build_paramssrc/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: returnvertex/base.py:343-346)——运行期通过 API 注入过的参数不许被重新装配覆盖。

6.4 连线侧:process_edge_parameters

ParameterHandler 构造时先把 template 里所有 dict 类型的条目挑出来当 template_dictparam_handler.py:47-49)——这就是"这个组件有哪些字段"的字典。

process_edge_parametersparam_handler.py:65)遍历顶点的全部边(包括出边),逐条交给 _set_params_from_normal_edge:79-82)。

_set_params_from_normal_edgeparam_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_paramparam_handler.py:103)处理一个特例:字段当前值是只有一个 key 的 dict时,保持 dict 形状、把那个 key 的值换成上游顶点(:106-107);否则直接放顶点。

6.5 面板侧:process_field_parameters

process_field_parametersparam_handler.py:110)遍历 template_dict 的每个字段,路由三选一:

字段 type处理行号
"file"逻辑路径 flow_id/filename 解析成组件能读的真实路径:130-131process_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_fieldparam_handler.py:144):type == "other"、已被处理过、名字是 _type、或者字段 show=False 且不叫 code——都跳过。

三个值得记的细节:

  • 可选字段的清理:非必填且值为 None 时,有 default 就用 default,没有就把 key 整个从 params 里删掉handle_optional_fieldparam_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_paramsvertex/base.py:362)给 API 注入输入用(比如 /build 时塞一句用户消息)。它有两道保护:

  1. 要更新的 key 在 raw_params 里对应的是 Vertex(即那个入参已经被连线占了)→ 整个更新直接放弃vertex/base.py:376-377)。连线的优先级高于外部注入。
  2. 非 overwrite 模式下,raw_params 里不存在的 key 会被静默丢弃(vertex/base.py:378-381)。

成功后打上 updated_raw_params = True,从而让后续的 build_params 短路(对应 6.3 那个短路)。


7. 第 ⑥ 步:索引与实例化

7.1 三张索引表

build_graph_mapsbase.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 多做一步:给没有入边的顶点显式补 0base.py:2510-2512),这样调度器可以放心地直接 in_degree_map[vid] 而不用处理 KeyError。

一个要注意的地方:build_in_degree 的注释说"同一个组件多次连到同一顶点不需要重复计数",但实现是逐边 += 1base.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_verticesbase.py:1525)遍历所有顶点调 instantiate_component,后者在组件实例还不存在时调 initialize.loading.instantiate_classvertex/base.py:386-391)把 template 里的 code 字符串变成活的 Python 对象。这一步的机制属于 01 章 的地盘。

注意 _build_graph 里的顺序(base.py:1477-1488):_build_vertex_params(),后 _instantiate_components_in_vertices()。params 是先算好、再交给实例的。


8. 巧妙之处(可以带走的)

  1. "先占位、后替换"把装配和执行彻底解耦。 装配期不需要任何异步、任何上游结果,只需要图结构;params[key] = <Vertex> 这一个约定就把"数据依赖"编码进了普通字典里(param_handler.py:93vertex/base.py:557)。

  2. 构造函数里 fail fast。 类型校验不放在单独的 validate() 里,而是塞进 Edge.__init__edge/base.py:102-103)——不可能存在一个"没校验过的 Edge 对象"

  3. 一张两行的迁移表挡住了全部历史包袱。 types_compatible 对称地比对原名和迁移名(edge/base.py:20-41),使 Data/JSONDataFrame/Table 四种搭配全兼容,老流程零改动。

  4. 同一份数据两套视图。 执行看展开后的扁平图,前端进度看折叠回组节点的图,靠 parent_node_id + top_level_vertices 一个布尔量对齐(utils.py:44-48vertex/base.py:245-246)。

  5. 校验查两遍,一遍信 JSON、一遍信代码。 第二道 _validate_edge 回源码重算 outputs(edge/base.py:192),相当于每次加载都在复核存盘快照有没有过时。

  6. 用"字段缺失"当循环边的标记。 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_edgevertex/base.py:308)是死代码。 全仓库无调用方(build_params 走的是 ParameterHandler 那份)。读源码时容易改错地方。

  • 无 proxy 元数据的分组输出靠猜。 update_source_handle 退化到 find_last_nodeutils.py:279-283),组内有多个终点时结果不保证正确。

  • target_reqs 是全组件级的大杂烩。 _validate_edge 比的是"目标组件所有入参加起来接受的类型"(vertex/base.py:271-279edge/base.py:216),不是"这个具体入参接受的类型"。所以第二道校验比第一道松:A 字段能收 Message,就够让一条接到 B 字段的 Message 边通过第二道。真正的精确把关在第一道 _validate_handles

  • _validate_handles 的分支 A 实际走不到(inferred,依据:edge/schema.py:78-80default_factory=listedge/base.py:65-79 的 except 分支)。

  • 顶点 id 的横杠前缀是隐式协议。 _get_vertex_classbase.py:2244)、Vertex.base_namevertex/base.py:65)都靠 id.split("-")[0]。组件类名一改,老流程的顶点分类就会退化——这也是仓库文档反复强调"绝不要改组件类名"的原因之一。


10. 代码地图

主题文件路径符号名
装配总入口src/lfx/src/lfx/graph/graph/base.pyGraph.from_payload
装载 nodes/edges + 记录顶层 idsrc/lfx/src/lfx/graph/graph/base.pyGraph.add_nodes_and_edges
装配骨架三步src/lfx/src/lfx/graph/graph/base.pyGraph.initializeGraph._build_graph
造顶点src/lfx/src/lfx/graph/graph/base.pyGraph._build_verticesGraph._create_vertex
顶点选类src/lfx/src/lfx/graph/graph/base.pyGraph._get_vertex_class
顶点类型表(懒加载)src/lfx/src/lfx/graph/graph/constants.pyVertexTypesDict.get_type_dict
造边 / 环边分流src/lfx/src/lfx/graph/graph/base.pyGraph._build_edgesGraph.build_edge
邻接表 / 入度 / 出口src/lfx/src/lfx/graph/graph/base.pybuild_graph_mapsbuild_adjacency_mapsbuild_in_degreeget_terminal_nodes
组件实例化src/lfx/src/lfx/graph/graph/base.pyGraph._instantiate_components_in_vertices
四种顶点的职责差异src/lfx/src/lfx/graph/vertex/vertex_types.pyComponentVertexInterfaceVertexStateVertexCustomComponentVertex
node data → 运行期属性src/lfx/src/lfx/graph/vertex/base.pyVertex.parse_data
参数装配入口src/lfx/src/lfx/graph/vertex/base.pyVertex.build_paramsVertex.update_raw_params
边 → 入参src/lfx/src/lfx/graph/vertex/param_handler.pyParameterHandler.process_edge_parameters_set_params_from_normal_edgeprocess_non_list_edge_param
面板字段 → 入参src/lfx/src/lfx/graph/vertex/param_handler.pyParameterHandler.process_field_parametersshould_skip_fieldhandle_optional_field
边的两道校验src/lfx/src/lfx/graph/edge/base.pyEdge.validate_handles_validate_handles_legacy_validate_handlesEdge.validate_edge_validate_edge
老流类型兼容src/lfx/src/lfx/graph/edge/base.pytypes_compatibleTYPE_MIGRATIONS
handle 数据模型src/lfx/src/lfx/graph/edge/schema.pySourceHandleTargetHandleTargetHandle.from_loop_target_handle
分组节点展开src/lfx/src/lfx/graph/graph/utils.pyprocess_flowungroup_nodeget_updated_edgesupdate_target_handleupdate_source_handleupdate_template

接着读: 图装配好了,下一个问题是"谁先跑"——见 调度引擎。想知道 CycleEdge 和条件路由怎么打破 DAG 假设,见 环、循环与条件路由。想知道这张图是怎么被 HTTP 请求触发、事件怎么流回前端,见 运行时与事件流