数据截至 (上游 commit 7a975c596eca)
组件模型 — 一个 Python 类如何长成画布上的节点
30 秒导读: Langflow 画布上的每个节点,背后都是一个继承
Component的 Python 类。类里只做三件事:用inputs列表声明"我要什么"、用outputs列表声明"我给什么"、写几个普通方法真正干活。节点的表单长什么样、有几个连接桩、桩上允许接什么类型——全部由这个类单向生成,人不用碰 JSON。
本章只讲单个组件:它在代码里是什么、输入输出怎么声明、值什么时候算、以及代码怎么变成 UI。 多个组件怎么连成图、谁先跑、环怎么处理,见 02 / 03 / 04。
1. 一个组件长什么样
一句话定义: 组件 = 一个声明式的 Python 类——类属性描述它在 UI 上的样子,实例方法计算它的输出。
下面是仓库里一个真实的、完整的组件,只有 40 行(src/lfx/src/lfx/components/processing/combine_text.py:6-40,CombineTextComponent):
class CombineTextComponent(Component):
display_name = "Combine Text"
description = "Concatenate two text sources into a single text chunk using a specified delimiter."
icon = "merge"
name = "CombineText"
inputs = [
MessageTextInput(name="text1", display_name="First Text", info="..."),
MessageTextInput(name="text2", display_name="Second Text", info="..."),
MessageTextInput(name="delimiter", display_name="Delimiter", value=" "),
]
outputs = [
Output(display_name="Combined Text", name="combined_text", method="combine_texts"),
]
def combine_texts(self) -> Message:
combined = self.delimiter.join([self.text1, self.text2])
self.status = combined
return Message(text=combined)
代码里的每样东西,在画布上都有一个对应物:
| 代码里写的 | 画布上变成 | 谁负责生成 |
|---|---|---|
display_name / icon / description | 节点标题、图标、悬浮说明 | get_template_config(base_component.py:88) |
inputs = [...] | 节点里的表单字段 + 左侧 输入桩 | ComponentFrontendNode.from_inputs |
outputs = [...] | 右侧输出桩(每个 Output 一个) | to_frontend_node(component.py:1132) |
Output(method="combine_texts") | 这个桩被拉线时要跑哪个函数 | 运行时反射 getattr(self, output.method) |
def combine_texts(self) -> Message | 桩上允许连什么类型(Message) | 从返回类型注解反推 |
self.text1 | 上游节点送来的值 / 表单里填的值 | __getattr__ 魔法(§3.2) |
一句话直觉: 把组件类当成一张报名表——它先填好"我叫什么、需要哪些材料、能产出哪些东西",框架照着这张表画出 UI;等真要出结果时,框架再回头按表上写的方法名去叫对应的函数。类本身从不主动执行。
2. 顶层全景:一个类的三段人生
怎么读这张图:从左到右是时间顺序。同一个类,先被声明,再被实例化成"活的"组件,然后分叉成两条互不相干的用途——生成 UI,或者算值。
① 声明期(import 时) ② 实例化(__init__) ③ 两条出路
┌────────────────────┐ ┌──────────────────────┐
│ class XxxComponent │ │ 类属性 → 实例副本 │ ┌──► (A) 画 UI
│ inputs = [...] │ ──────►│ map_inputs() │──────┤ to_frontend_node()
│ outputs = [...] │ │ → _inputs{name:obj} │ │ → 一坨节点 JSON
│ def foo(self) │ │ map_outputs() │ │
└────────────────────┘ │ → _outputs_map{} │ └──► (B) 算值
仅仅是模板,人人共享 └──────────────────────┘ build_results()
每个实例一份深拷贝 → 反射调 foo()
三段各自的关键符号:
| 阶段 | 干了什么 | 入口符号(src/lfx/src/lfx/custom/custom_component/component.py) |
|---|---|---|
| 声明期 | 什么都不做,inputs/outputs 只是类属性列表 | Component.inputs / Component.outputs:151-152 |
| 实例化 | 深拷贝模板 → 建 _inputs / _outputs_map 两张字典 | __init__:156、map_inputs:644、map_outputs:611 |
| 出路 A(生成 UI) | 把实例翻译成前端节点 JSON | to_frontend_node:1132 |
| 出路 B(算值) | 挑要算的 output,反射调方法 | _build_results:1294 |
为什么第一步要深拷贝? inputs = [...] 写在类体里,是所有实例共享的同一批对象。若不拷贝,A 流程里给某个输入赋的值会漏进 B 流程的同名组件。__init__ 一上来就做了(component.py:157-158 调 _copy_component_template,component.py:107),map_inputs / map_outputs 再各自 deepcopy 一次(component.py:661、component.py:642)——两层拷贝,一层防类污染,一层防实例间串味。
3. 输入:声明就是一个 pydantic 模型
3.1 输入类家族:字段决定 UI 控件
每种输入都是一个 pydantic 模型,继承 BaseInputMixin(src/lfx/src/lfx/inputs/input_mixin.py:60)。所有输入的公共字段就那么十几个:name、display_name、value、required、show、advanced、info、input_types、real_time_refresh。
前端画什么控件,由 field_type 决定;桩上能接什么,由 input_types 决定。 这是两件事:
field_type(如FieldTypes.PASSWORD)→ 渲染成密码框还是滑块还是下拉。input_types(如["Message"])→ 上游哪种输出允许连到这个桩上。input_types为空列表就意味着没有连接桩,只能手填。
常用的几种(src/lfx/src/lfx/inputs/inputs.py):
| 输入类 | 前端长什么样 | 特点 | 行 |
|---|---|---|---|
StrInput | 单行文本 | 最朴素,支持 load_from_db 取全局变量 | 285 |
MessageTextInput | 单行文本 + Message 输入桩 | 收到 Message/Data 会自动拆成 str | 367 |
MultilineInput | 多行文本框 | 继承 MessageTextInput | 450 |
SecretStrInput | 密码框 | password=True、load_from_db=True、不上报遥测 | 488 |
DropdownInput | 下拉/可搜索下拉 | options、combobox | 792 |
BoolInput / IntInput / FloatInput / SliderInput | 开关 / 数字 / 滑块 | 数字类带 range_spec | 664 / 552 / 611 / 976 |
HandleInput | 只有桩,没有表单 | field_type=OTHER,给 LLM、Retriever 这类对象用 | 97 |
JSONInput(别名 DataInput) / DataFrameInput | 只有桩 | input_types=["Data","JSON"] / ["DataFrame","Table"] | 126 / 143 |
TableInput | 可编辑表格 | is_list=True | 57 |
FileInput | 上传框 | file_types 限定后缀 | 943 |
这些类不是各写各的,而是拼 mixin 拼出来的(input_mixin.py):
| Mixin | 加了什么能力 | 行 |
|---|---|---|
ListableInputMixin | is_list → 前端出现 "Add More" | 224 |
DatabaseLoadMixin | load_from_db → 值从全局变量表取,而不是字面量 | 230 |
RangeMixin / DropDownMixin | range_spec / options | 284 / 300 |
ToolModeMixin | tool_mode → 该字段允许由 agent 在运行时填 | 211 |
InputTraceMixin / MetadataTraceMixin | 追踪时这个字段算"输入"还是"元数据" | 215 / 219 |
FileMixin | file_path / file_types | 244 |
所有输入类被并成一个 InputTypes 联合类型(inputs.py:995),并由它自动生成 InputTypesMap 名字→类的反查表(inputs.py:1032)——反序列化保存的流时靠它把 "MessageTextInput" 这个字符串还原成类。
3.2 值一进来就被规整
pydantic 的 validate_assignment=True(input_mixin.py:60)意味着每次给 input.value 赋值都会跑校验器。校验器不只是拦错,它还做类型归一。
最典型的是 MessageTextInput._validate_value(inputs.py:381-410):你连过来一个 Message,它取 v.text;连过来一个 Data,它按 text_key 取那一项、取不到就报一句"请把 text_key 改成以下之一"的人话错误。所以组件方法里 self.text1 拿到的永远是 str,不用自己判断类型。
is_list 的分发也在这一层:StrInput.validate_value(inputs.py:326-342)先看 info.data["is_list"],是列表就逐项校验。
安全上有一条硬线:Credential 类型的全局变量到达时是 SecretStr,_reject_secret_in_non_password_field(inputs.py:38)会拒绝它落进非密码字段——否则密钥会顺着 Message.text 流到日志和追踪里。
3.3 self.some_input 的魔法
组件方法里写 self.delimiter,类里根本没有这个属性。它靠 Component.__getattr__(component.py:1033-1061)兜底——Python 只在常规属性查找失败后才调 __getattr__,所以这段是纯兜底逻辑,不影响正常属性。
查找顺序是固定的四级,命中即返回:
self.delimiter
│
▼
① _attributes 里有吗? ──是──► 返回它(运行期的"已定稿值")
│否
▼
② _inputs 里有吗? ──是──► 返回 _inputs["delimiter"].value ← 输入的魔法在这
│否
▼
③ _outputs_map 里有吗?──是──► 返回 Output 对象本身(注意:不是它的值)
│否
▼
④ 名字是 user_id/vertex/tracing_service/graph? ──是──► 返回内部字段 / 占位 Graph
│否
▼
AttributeError("Attribute X not found in ...")
第 ③ 级容易踩:self.some_output 拿到的是 Output 模型对象,不是计算结果。想要结果得走 await self.resolve_output("some_output")(§5.4)。
第 ④ 级里 graph 这条比较妙:组件在没有图的场合(单测、纯代码调用)访问 self.graph 不会崩,而是拿到一个 PlaceholderGraph(component.py:111-147)——一个假的具名元组,get_vertex_neighbors 永远返回空字典。同一份组件代码,既能在图里跑,也能脱离图跑。
那么值是什么时候写进去的?运行时由 set_attributes(params)(component.py:1202-1228)统一灌:
_validate_inputs把params里的值写进_inputs[key].value(过一遍 pydantic 校验)。- 再把每个输入的最终值抄一份进
_attributes,密码字段顺手unwrap_secret_value解包(_wrap_if_secret,component.py:81-91)、把明文登记进_secret_values供事后脱敏。
因为 _attributes 排在查找的第 ①,set_attributes 跑过之后,self.x 读的是 _attributes 的那份快照。
4. 输出:名字 + 方法名 + 类型 + 缓存
Output 是个比输入简单得多的 pydantic 模型(src/lfx/src/lfx/template/field/base.py:179)。核心就四个字段:
| 字段 | 作用 | 行 |
|---|---|---|
name | 这个桩的标识,边靠它寻址 | 186 |
method | 方法名字符串,运行时反射调用 | 195 |
types | 桩上标的输出类型(如 ["Message"]),前端据此判断能不能连 | 180 |
value | 算出来的结果,默认是哨兵 UNDEFINED | 198 |
cache | 默认 True,同一次运行内复用 value | 201 |
allows_loop | 允许被回边指向(留给 04) | 206 |
为什么 value 的默认值是 UNDEFINED 而不是 None? 因为 None 是合法的计算结果。用一个 Enum 哨兵(UndefinedType.undefined,field/base.py:25-29)才能区分"还没算"和"算出来是空"。序列化时它变成字符串 "__UNDEFINED__",反序列化时再还原成哨兵(Output.serialize_model:239、validate_model:246)。
types 不用手写。 组件 __init__ 的最后一步 _set_output_types(component.py:747)对每个 output 调 _set_output_return_type(:751),后者走 _get_method_return_type(component.py:1118-1127)——用 get_type_hints(method) 读方法的返回类型注解,拆开 Union/list[...] 后格式化成字符串塞进 output.types。
也就是说,def combine_texts(self) -> Message: 里那个 -> Message,同时是 Python 类型注解和 UI 上的连线契约。漏写注解,桩就没类型,前端不知道该不该允许连线。
5. 值什么时候算
5.1 主循环
运行时的入口不是组件自己,而是外面的 vertex:build_component(src/lfx/src/lfx/interface/initialize/loading.py:350-358)先 set_attributes(params) 灌值,再 await build_results()。
_build_results(component.py:1294-1308)本身短得出奇:
_build_results()
│
├─ _pre_run_setup_if_needed() 钩子:组件可自定义跑前准备
├─ _handle_tool_mode() 若有字段开了 tool_mode,追加一个 Toolset 输出
│
└─ for output in _get_outputs_to_process(): ← 只挑"需要算的"
result = await _get_output_result(output) ← 缓存 + 反射 + 线程池
results[output.name] = result
artifacts[output.name] = _build_artifact(result) ← 给前端看的展示形态
_log_output(output) ← 该 output 期间的日志归档
注意最后两行:每个 output 除了原始结果,还产出一份 artifact 和一份日志。artifact 是"给人看的样子"(repr 字符串 + raw 数据 + 类型标签,_build_artifact:1413),日志则按 output 名分桶(_log_output:1486),所以画布上能分别展开每个桩的输出预览。