首页
/ Langflow 扩展 Bundle 移植实战:把组件从 `lfx.components` 提取到 `src/bundles` 的完整流程

Langflow 扩展 Bundle 移植实战:把组件从 `lfx.components` 提取到 `src/bundles` 的完整流程

2026-09-06 13:11:28作者:卓炯娓

本文讲解 Langflow/LFX 仓库中把某个 provider 组件从树内目录 src/lfx/src/lfx/components/<provider>/ 提取为独立分发的 Extension Bundle(src/bundles/<provider>/)的完整流程:目录骨架、pyproject.tomlextension.json 的写法、工作区接线、迁移表条目、组件索引重建、集成测试、六步验证命令与 Docker 镜像适配,并给出 port_bundle.py 自动化工具与常见坑位。读完本文,你可以独立完成一次不破坏已保存 Flow 的 bundle 移植,并用仓库内置脚本完成版本发布计划。参考实现是 DuckDuckGo bundle:src/bundles/duckduckgo,每一步改动都有对应的验证命令。

0. 移植前的候选检查

动手前先确认组件适合被提取,四个检查项:

  • provider 目录 src/lfx/src/lfx/components/<provider>/ 存在,且包含至少一个 Component 子类;
  • 组件只从 lfx.* 导入,出现 from langflow...——bundle 是对着公共 BUNDLE_API 面(lfx)安装的,不是对着 Langflow 内部实现。可用 grep -r "from langflow" src/lfx/src/lfx/components/<provider>/ 自查;
  • src/lfx/src/lfx/components/deactivated/<provider>/ 下没有已停用/遗留的重复副本;
  • 组件引入的运行时依赖(如 langchain-community、厂商 SDK)能在 bundle 自己的 pyproject.toml 里声明清楚,不形成对 lfxlangflow-base 的循环依赖。

确定两个标识符,它们是整个移植过程中唯一会反复出现的字符串:

  • bundle 名:snake_case 小写,与目录名一致,如 duckduckgoarxiv
  • 分发名(distribution name)lfx-<bundle>,如 lfx-duckduckgo

两者只差一个字符(-_),不要混用。

1. 搭建 bundle 目录

创建 src/bundles/<bundle>/,目录树完全对齐参考实现 src/bundles/duckduckgo

src/bundles/<bundle>/
├── README.md
├── pyproject.toml
└── src/
    └── lfx_<bundle>/
        ├── __init__.py
        ├── extension.json
        └── components/
            └── <bundle>/
                ├── __init__.py
                └── <source>.py        # 一个组件类一个文件

为什么是嵌套的 src/lfx_<bundle>/components/<bundle>/?外层 lfx_<bundle> 是可导入的 Python 包(与 wheel 布局一致,是 importlib.metadata.files() 遍历的对象);内层 components/<bundle>/extension.jsonbundles[].path 声明的路径——保持 components/<bundle> 这一形状,意味着历史上引用过 lfx.components.<bundle>.<file>.<Class> 的已保存 Flow 只需迁移表里一条 import-path 条目就能干净地完成重定向。

1a. pyproject.toml

src/bundles/duckduckgo/pyproject.toml 为模板替换名称和运行时依赖块。几个不直观的要点:

  • dependencies:列出组件 import 的全部运行时依赖。lfx 的下限锚定在当前 Langflow/LFX 的 major.minor 线,上限卡在下一个 lfx major 之前——例如 "lfx>=1.10.0,<2.0.0"。这条通常不需要手写:port_bundle.py 在移植时从 src/lfx/pyproject.toml 读取当前版本填入(当前仓库的 duckduckgo 是 lfx>=1.12.0.dev0,<2.0.0);后续每次发版由 make patch 通过 sync_bundle_lfx_pin.py 重新同步所有既有 bundle。细粒度的 BUNDLE_API 兼容性则由 extension.json"lfx": {"compat": [...]} 契约在加载期对运行中 lfx 的 BUNDLE_API_VERSION 强制,而不是靠版本上限。.dev0 下限是有意的:release 分支的 nightly 是规范化的 X.Y.0.devN 预发布版本,PEP 440 下它排序在 X.Y.0 之前,只有 X.Y.0.dev0 这类下限才能同时接纳分支自己的 nightly。
  • 平台受限依赖:若某运行时依赖在部分平台没有 wheel(如 ibm-db 不提供 linux/aarch64),用 PEP 508 marker 门控,保证 pip install langflow 在那些平台上依然成功,例如 "ibm-db>=3.2.9,<4.0.0; sys_platform != 'linux' or platform_machine != 'aarch64'"。同时该依赖必须懒加载(写在使用它的方法内部,而不是模块顶部),让 bundle 在被排除的平台上仍能加载,组件优雅降级而不是拖垮整个组件发现流程。跨平台安装测试通过 langflow 主安装把关硬依赖 bundle;如果 bundle 本体(而非仅传递依赖)在某平台装不上,还要在根 pyproject.toml 里该依赖行加同样的 marker,避免 langflow 主包在那儿强制要求它。
  • [project.entry-points."langflow.extensions"]:写 <dist-name> = "lfx_<bundle>"。加载器 src/lfx/src/lfx/extension/loader/_plugins.py 中的 _manifest_via_entry_point 靠它找到 manifest;当 editable 安装的 dist.files 只暴露 dist-info 条目时,就走这个 entry point 兜底(它用 importlib.util.find_spec 定位包目录,不会触发 bundle __init__ 的副作用)。
  • [tool.hatch.build.targets.wheel] 必须包含 src/lfx_<bundle>/extension.json 和 components 的 glob——wheel 安装通过 dist.files 读 manifest,文件没打进 wheel,bundle 就会被静默跳过。duckduckgo 的写法是:
[tool.hatch.build.targets.wheel]
packages = ["src/lfx_duckduckgo"]
include = ["src/lfx_duckduckgo/extension.json", "src/lfx_duckduckgo/components/**/*.py"]

1b. src/lfx_<bundle>/extension.json

{
  "$schema": "https://schemas.langflow.org/extension/v1.json",
  "id": "lfx-<bundle>",
  "version": "0.1.0",
  "name": "<Human-readable bundle name>",
  "description": "<One-line description>.",
  "lfx": { "compat": ["1"] },
  "bundles": [
    { "name": "<bundle>", "path": "components/<bundle>" }
  ]
}

对照仓库中的真实文件 src/bundles/duckduckgo/src/lfx_duckduckgo/extension.json"id": "lfx-duckduckgo""bundles": [{"name": "duckduckgo", "path": "components/duckduckgo"}]"lfx": {"compat": ["1"]}id 是带连字符的分发名;bundles[0].name 是用于已保存 Flow ID 的 snake_case bundle 名(ext:<bundle>:<Class>@official)。

1c–1d. 两层 __init__.py

包根 init.py 负责从包根重新导出组件类,使 lfx_<bundle>.<Class> 可解析——迁移表的 bare_class_name 条目依赖这个 import 成立:

"""lfx-<bundle>: <description>."""

from lfx_<bundle>.components.<bundle>.<source> import <Class>

__all__ = ["<Class>"]

components/<bundle>/__init__.py 则:

from .<source> import <Class>

__all__ = ["<Class>"]

1e. 移动组件源码

src/lfx_<bundle>/components/<bundle>/<source>.py 就是被移动的文件:从 src/lfx/src/lfx/components/<bundle>/<source>.py 逐字节复制,不要改写 import。组件里的 from lfx.* import 原样可用,因为 lfx 是 bundle 的运行时依赖。

1f. README.md

简短说明 bundle 提供什么、如何安装、如何开发,模板用 duckduckgo/README.md

2. 删除树内组件

整个遗留目录删掉:

git rm -r src/lfx/src/lfx/components/<bundle>/

然后外科手术式地删除 src/lfx/src/lfx/components/init.py 中的三处引用:

  1. import 块里的 <bundle>, 行(约第 10 行);
  2. 类型映射字典里的 "<bundle>": "__module__", 条目;
  3. __all__ 风格列表里的 "<bundle>", 字符串。

自检:改完后 grep -n "<bundle>" src/lfx/src/lfx/components/__init__.py 应无任何输出。

3. 接线工作区

3a. 根 pyproject.toml

三处机械性修改(当前仓库已用 # langflow-extensions:bundle-deps-start/endbundle-sources-endbundle-members-end 标记对圈出锚点,port_bundle.py 就是按这些标记插入的,可抗依赖重排/版本升级):

# 1. 加入 [project] dependencies —— 普通依赖,保证 `pip install langflow`
#    仍然拉到该组件,用户侧安装体验零变化。
dependencies = [
    "langflow-base~=1.12.0",
    "lfx-duckduckgo>=0.1.0,<1.0.0",
    "lfx-<bundle>>=0.1.0",                 # <-- 新增
]

# 2. 加入 [tool.uv.sources]
lfx-<bundle> = { workspace = true }

# 3. 加入 [tool.uv.workspace] members
members = [
    "src/backend/base",
    ".",
    "src/lfx",
    "src/sdk",
    "src/bundles/duckduckgo",
    "src/bundles/<bundle>",                # <-- 新增
]

3b. src/backend/base/pyproject.toml(可选)

仅当组件原有 langflow-base[<bundle>] extra 时才动它:删掉该 extra,并把 complete 里的 langflow-base[<bundle>] 引用一并移除。duckduckgo 移植时做了这一步;若组件没有 extra(如 arxiv),整节跳过。

3c. 锁文件

uv lock
git add uv.lock

4. 追加迁移表条目

src/lfx/src/lfx/extension/migration/migration_table.json 追加条目。schema 要求四类遗留形态,覆盖已保存 Flow 可能出现过的所有写法:

{
  "bare_class_name": "<Class>",
  "target": "ext:<bundle>:<Class>@official",
  "added_in": "<release>"
},
{
  "import_path": "lfx.components.<bundle>.<source>.<Class>",
  "target": "ext:<bundle>:<Class>@official",
  "added_in": "<release>"
},
{
  "import_path": "lfx.components.<bundle>.<Class>",
  "target": "ext:<bundle>:<Class>@official",
  "added_in": "<release>"
},
{
  "legacy_slot": "ext:<bundle>:<Class>@official-pre-a",
  "target": "ext:<bundle>:<Class>@official",
  "added_in": "<release>"
}

对照仓库中 duckduckgo 的真实条目("added_in": "1.10.0"),可以看到四种形态一一对应:裸类名 DuckDuckGoSearchComponent、文件级完整路径 lfx.components.duckduckgo.duck_duck_go_search_run.DuckDuckGoSearchComponent、包级 re-export 路径 lfx.components.duckduckgo.DuckDuckGoSearchComponent、以及 Phase-A 前的 slot ID。组件若声明了多个类,每个类重复这四条目块;其中 bare_class_name 条目仅当类名在当次发布的所有 Bundle 中全局唯一时才加入——这一点由 scripts/migrate/check_bare_names.py 在 CI 中强制(它用标准库 AST 遍历树内组件目录与已提取 bundle,断言每个裸名条目只映射到一个文件夹里的一个类)。

迁移表是只追加的:永远不要删除或改写既有条目——CI 会拒绝删除操作,否则多年前针对早已提取 bundle 保存的 Flow 将无法加载。

5. 重建组件索引

预构建的组件索引驱动懒加载,被移动组件的旧条目必须移除:

LFX_DEV=1 uv run python scripts/build_component_index.py

LFX_DEV=1 强制走 pkgutil.walk_packages 的动态发现;不带它,脚本会读现有索引,即使源模块已经删掉也照样把过期条目再生产一遍。diff 应当只删除 <bundle> 块;若还动了别的,说明本地 checkout 有无关漂移。

6. 添加集成测试

创建 src/lfx/tests/integration/extension/test_pilot_<bundle>_upgrade.py,以 test_pilot_duckduckgo_upgrade.py 为范本。四个关键用例:

  1. 裸类名 → 规范 ID(migrate_flow_payload 重写后 rewritten_count == 1,且 legacy_form_kind == "bare_class_name");
  2. 完整 import 路径 → 规范 ID;
  3. 包级 import 路径 → 规范 ID;
  4. lfx-<bundle> 分发包可导入, extension.json 位于 importlib.metadata.files 能发现的位置(editable 安装则验证 direct_url.json 可解析到源码树中的 manifest)。

duckduckgo 的 pilot 测试还多做了一层:用 load_extension 把迁移目标解析成运行时类,断言加载类与 bundle 导出类同源(loader 把 bundle 模块挂在 _lfx_ext.<slot>.<bundle> 命名空间下,所以对象恒等断言不成立,仓库锁定的是"同一源文件、同一限定名"这一已保存 Flow 真正依赖的不变量),再用一个 stub 包装器跑 fetch_content_dataframe 验证 content/snippet 列、max_results 切片与 max_snippet_length 截断等运行时契约。集成测试是已保存 Flow 契约端到端被演练的唯一位置,不要跳过。

随移植一起迁移的测试覆盖

树内 src/backend/tests/unit/components/<bundle>/test_<bundle>_component.py 通常含 test_component_versions 用例,遍历 file_names_mapping 夹具验证旧 schema 版本的保存夹具仍能实例化。该夹具 import 自 tests.base,在 bundle 内部不可导入。新的 bundle 本地测试(src/bundles/<bundle>/tests/)会丢掉它,而 test_pilot_<bundle>_upgrade.py 只覆盖命名空间迁移、不覆盖类内 schema 演化。若遗留夹具里有非空条目,用 bundle 友好的形式(对同一 mapping 参数化 bundle 测试,不 import tests.base)复刻该版本检查;若夹具是空的,就在 PR 描述里说明该回归,让评审人决定是否在合并前复刻。

7. 验证

按顺序运行"一旦某步出错就会大声失败"的最小命令集:

# 1. manifest 结构合法。validate 指向包目录(extension.json 所在处)
#    而非 bundle 根 —— manifest 嵌在 src/lfx_<bundle>/ 里,wheel 才装得上。
#    验证器同时接受 ``def build(self): ...`` 与 ``outputs = [Output(method="...")]``
#    两种形态;两种都没有的组件会以 ``build-method-missing`` 失败,
#    届时补一个 ``outputs`` 声明。
uv run lfx extension validate src/bundles/<bundle>/src/lfx_<bundle>

# 2. 工作区可解析、bundle 可导入。
uv sync
uv run python -c "from lfx_<bundle> import <Class>; print(<Class>.__name__)"

# 3. 迁移表可解析、新条目可见。
uv run pytest src/lfx/tests/unit/extension/migration -q

# 4. 加载器经 direct_url.json 发现 editable 安装。
uv run python -c "
from lfx.extension.loader._plugins import installed_extension_roots
roots = installed_extension_roots()
assert 'lfx-<bundle>' in roots, roots
print('discovered:', roots['lfx-<bundle'])
"

(上面第 4 条引号按原意应为 roots['lfx-<bundle>']。)

# 5. 集成测试通过。
uv run pytest src/lfx/tests/integration/extension/test_pilot_<bundle>_upgrade.py -q

# 6. Ruff 对触碰到的 Python 文件干净。不要把迁移表 JSON 传给 ruff
#    —— 它会当 Python lint,抱怨顶层表达式。
uv run ruff check src/bundles/<bundle> src/lfx/src/lfx/components/__init__.py src/lfx/tests/integration/extension/test_pilot_<bundle>_upgrade.py

端到端冒烟测试(可选但廉价):带 bundle 起 dev server,在画布上点 Reload:

uv run lfx extension dev src/bundles/<bundle>
# 在浏览器打开 http://localhost:7860:
#   - 确认 <Class> 出现在 <bundle> bundle 分组下;
#   - 右键 <bundle> 标题 -> Reload,无报错。

发布计划与版本变更

src/bundles/<bundle>/src/ 下任何可发布的变更,或对 bundle pyproject.toml 的修改,都要求分发版本号递增。先用 plan 命令生成与 CI 审查相同的计划(不改文件):

python scripts/ci/bundle_release_plan.py plan \
  --base-ref origin/release-1.11.0 \
  --check \
  --output bundle-release-plan.json

应用计划时改用 update 命令,不要手改版本字段。它会默认把受影响的 bundle 各升一个 patch,同步其 extension.json 版本与 LFX 依赖区间,抬升所有匹配的 Langflow 依赖下限,并作为一次可整体回滚的操作重新生成 uv.lock

python scripts/ci/bundle_release_plan.py update \
  --base-ref origin/release-1.11.0 \
  --output bundle-release-plan.json

patch 不够时可用 --bump minor 或显式 --version lfx-<bundle>=X.Y.Z。发布工作流会上传版本/构件计划供审查,并拒绝复用已存在的 PyPI 版本号——除非其规范化 wheel 内容与本次构建的 wheel 完全一致。所有 bundle 固定同一精确版本的 Hatchling 构建后端,保证源不变时能复现不可变的已发布 wheel 元数据;该 pin 只应在一次明确的、全仓范围的发布迁移中更新。

8. Docker 镜像(仅当新 bundle 要进运行时镜像时)

共享的 docker/build_and_push.Dockerfilesrc/bundles 整体拷进构建上下文(COPY ./src/bundles /app/src/bundles);被加入根依赖的精选 bundle 由 full target 的 workspace sync 自动拾取。base target 有意不装任何 provider 扩展。

不要把 provider 扩展加进 base target。验证 full 镜像能发现该 bundle,且 base 镜像的分发清单保持不变。

常见坑位

  • 组件 import 了 from langflow...:bundle 是装在 lfx 上,不是 langflow 上。要么把 import 改写成公共 BUNDLE_API 面,要么让组件留在树内。
  • extension.json 没进 wheeldist.files 看不到它,非 editable 安装会静默跳过 bundle。确认 [tool.hatch.build.targets.wheel] 的 include glob 覆盖到了。
  • bundle 名里带连字符:只有分发名用连字符(lfx-duckduckgo),bundle 名是 snake_case(duckduckgo);schema 会拒绝 bundles[].name 里的连字符。
  • 忘了 langflow.extensions entry point:editable 安装会静默发现失败——installed_extension_roots() 返回空字典,bundle 永远进不了注册表。
  • 迁移条目缺失:已保存 Flow 仍能通过校验,但画布渲染不出遗留节点,用户看到的是 "component not found" 提示。第 4 步的四条目块覆盖了 Langflow 历史上序列化过的所有形态。

自动化:port_bundle.py

机械步骤可以交给 scripts/migrate/port_bundle.py:它生成 bundle 骨架、移动树内目录(含 lfx.base.<bundle> 共享基座与嵌套子包)、剥离 components/__init__.py 三处引用、按 langflow-extensions:bundle-* 标记修补根 pyproject.toml、迁移 ruff per-file-ignores、移动后端测试目录。带 --migration-release 时它还会直接追加迁移表四条目块、生成 pilot 集成测试骨架、删除组件索引里的该 bundle 分类并重算内嵌 SHA256,必要时修补 backend Dockerfile。它不会替你编辑需要人工判断的部分——发布版本号、类名全局唯一性检查(那是 check_bare_names.py 的职责)。脚本默认 dry-run,先打印计划再 --apply 落盘:

# Dry run 打印计划,评审通过后 --apply 再落盘;
# 完整形态可加 --rewrite-consumers --update-index --update-dockerfiles
# --remove-base-extra 等开关,详见脚本 docstring。
uv run python scripts/migrate/port_bundle.py --bundle arxiv --apply

--rewrite-consumers 会 grep 全仓外部消费者,按"先具体后兜底"的顺序做规范化替换(from lfx.components.<bundle> import Xfrom lfx_<bundle> import Xlfx.components.<bundle>.lfx_<bundle>.components.<bundle>.lfx.base.<bundle>lfx_<bundle>.base),并刻意排除迁移表 JSON、组件索引、pyproject 与保存 Flow 的 JSON——那些字符串是迁移表要在 Flow 加载时改写的数据,机械替换会破坏"冻结的历史快照仍能加载"这一测试目标。

跑完脚本后,照本文第 7 节的验证块逐项执行;任何一步失败,脚本产生的 diff 就是唯一需要评审的工件。

登录后查看全文
热门项目推荐
相关项目推荐