Langflow 扩展 Bundle 移植实战:把组件从 `lfx.components` 提取到 `src/bundles` 的完整流程
本文讲解 Langflow/LFX 仓库中把某个 provider 组件从树内目录 src/lfx/src/lfx/components/<provider>/ 提取为独立分发的 Extension Bundle(src/bundles/<provider>/)的完整流程:目录骨架、pyproject.toml 与 extension.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里声明清楚,不形成对lfx或langflow-base的循环依赖。
确定两个标识符,它们是整个移植过程中唯一会反复出现的字符串:
- bundle 名:snake_case 小写,与目录名一致,如
duckduckgo、arxiv; - 分发名(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.json 中 bundles[].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线,上限卡在下一个lfxmajor 之前——例如"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 中的三处引用:
- import 块里的
<bundle>,行(约第 10 行); - 类型映射字典里的
"<bundle>": "__module__",条目; __all__风格列表里的"<bundle>",字符串。
自检:改完后
grep -n "<bundle>" src/lfx/src/lfx/components/__init__.py应无任何输出。
3. 接线工作区
3a. 根 pyproject.toml
三处机械性修改(当前仓库已用 # langflow-extensions:bundle-deps-start/end、bundle-sources-end、bundle-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 为范本。四个关键用例:
- 裸类名 → 规范 ID(
migrate_flow_payload重写后rewritten_count == 1,且legacy_form_kind == "bare_class_name"); - 完整 import 路径 → 规范 ID;
- 包级 import 路径 → 规范 ID;
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.Dockerfile 把 src/bundles 整体拷进构建上下文(COPY ./src/bundles /app/src/bundles);被加入根依赖的精选 bundle 由 full target 的 workspace sync 自动拾取。base target 有意不装任何 provider 扩展。
- docker/build_and_push_backend.Dockerfile:把
./src/bundles/<bundle>加进显式的uv pip install行。
不要把 provider 扩展加进 base target。验证 full 镜像能发现该 bundle,且 base 镜像的分发清单保持不变。
常见坑位
- 组件 import 了
from langflow...:bundle 是装在lfx上,不是langflow上。要么把 import 改写成公共BUNDLE_API面,要么让组件留在树内。 extension.json没进 wheel:dist.files看不到它,非 editable 安装会静默跳过 bundle。确认[tool.hatch.build.targets.wheel]的 include glob 覆盖到了。- bundle 名里带连字符:只有分发名用连字符(
lfx-duckduckgo),bundle 名是 snake_case(duckduckgo);schema 会拒绝bundles[].name里的连字符。 - 忘了
langflow.extensionsentry 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 X → from lfx_<bundle> import X、lfx.components.<bundle>. → lfx_<bundle>.components.<bundle>.、lfx.base.<bundle> → lfx_<bundle>.base),并刻意排除迁移表 JSON、组件索引、pyproject 与保存 Flow 的 JSON——那些字符串是迁移表要在 Flow 加载时改写的数据,机械替换会破坏"冻结的历史快照仍能加载"这一测试目标。
跑完脚本后,照本文第 7 节的验证块逐项执行;任何一步失败,脚本产生的 diff 就是唯一需要评审的工件。
atomcodeClaude Code 的开源替代方案。连接任意大模型,编辑代码,运行命令,自动验证 — 全自动执行。用 Rust 构建,极致性能。 | An open-source alternative to Claude Code. Connect any LLM, edit code, run commands, and verify changes — autonomously. Built in Rust for speed. Get StartedRust0627
Hy4-previewHy4 preview 是由腾讯混元团队研发的新一代混合专家(MoE)旗舰模型。模型总参数量 770B,每个 token 激活 49B,主干共包含78层,第一层采用标准 FFN,其余 77 层均为 MoE 结构,每层包含 256 个路由专家与 1 个共享专家,每个 token 激活 top-8 路由专家及共享专家。主干之外原生内置 1 层 MTP(总参数量 10B,激活 0.7B)以支持投机解码。Python00
GLM-5.3GLM-5.3 与 GLM-5.2 使用相同的基座模型——所有提升均来自后训练。与 GLM-5.2 相比,它在复杂编程和长程任务上的表现显著提升。Jinja00
GLM-5.3-FlashGLM-5.3-Flash (320B-A18B),是GLM-5系列的首个原生多模态模型。320B总参数,能力超过GLM-5.2Jinja00
Spark-X2.5-4BSpark-X2.5-4B 旨在让强大的 AI 更实用、更高效、更易获得。在广泛日常任务中表现强劲,涵盖对话、写作、翻译、推理、编码、工具调用以及智能体工作流,并在同等规模的开源模型中取得领先成绩。Spark-X2.5 将面向效率的架构与最高 1M tokens 的原生上下文窗口相结合,并支持 200 多种语言。Python00
Spark-X2.5-1.7BSpark-X2.5-1.7B 旨在让强大的 AI 更加实用、高效且易于获取。这些模型在广泛的日常任务中表现出色,涵盖对话、写作、翻译、推理、编程、工具调用和智能体工作流,并在同等规模的开源模型中取得领先结果。Spark-X2.5 将面向效率的架构与最高 1M tokens 的原生上下文窗口相结合,并支持 200 多种语言。Python00