Hugging Face Transformers 模块化模型开发指南:用 modular 文件与自动转换器从继承中生成完整模型
本篇技术文章基于 Transformers 仓库文档 modular_transformers.md,系统讲解 Modular Transformers(模块化模型)的工作机制:如何在一个 modular_<name>.py 文件中通过 Python 继承复用已有模型组件,再借助仓库内置的 linter/转换器自动生成符合"单模型、单文件"哲学的 modeling_*.py、configuration_*.py 等独立文件。读完本文,你将能够独立编写 modular 文件、掌握属性删除/方法覆盖/依赖推断等进阶模式,并理解底层转换器 modular_model_converter.py 的工作方式。
背景:为什么要 Modular Transformers
Transformers 的库设计遵循"一个模型、一个文件"(single model, single file)哲学。这一哲学的副作用是限制了跨文件的继承与导入,导致大量模型组件在不同模型目录中重复出现——src/transformers/models/ 下有成百上千个模型,注意力层、归一化层等实现高度雷同,却又各自独立维护,随着修复与功能变更逐渐分化。
早期仓库用 # Copied from 注释标记"这段代码是从别处复制来的",由 CI 和本地命令保证副本不漂移。但这种方式简单却繁琐,给贡献者带来额外负担。更现实的问题是:一个新模型的贡献往往需要约 1000 行建模代码、约 500 行处理代码,加上测试与文档,常常达到 3000–5000 行,且大多是样板代码,抬高了贡献门槛。
Modular Transformers 的目标就是把这个门槛降到可接受的水平:用"模态文件"(modular file)+ 自动 linter 替代 # Copied from,让贡献者只写"与父模型之间的差异"。
Modular 文件是什么
在模型目录中添加一个 modular_<name>.py 文件(<name> 与 snake_case 的模型目录名一致,如 src/transformers/models/olmo2/modular_olmo2.py),该文件接受 modeling/processing 文件中通常不被允许的两类写法:
- 从相邻模型导入:例如
from ..bert.modeling_bert import BertModel; - 类对类的继承:例如
class RobertaModel(BertModel)。
modular 文件中定义的模型、处理器和配置类,会被 linter 拆解到各自对应的 modeling_*.py、processing_*.py、configuration_*.py 模块中。每次运行生成脚本都会自动重写这些文件,贡献者只需维护 modular 文件本身。最终用户仍然导入和使用熟悉的单文件接口(如 from transformers import RobertaModel),对外接口不发生任何变化——即"简单贡献"与"设计哲学"兼得。
从 modular_model_converter.py 的实现可以看到,生成文件顶部会插入一段强制警示(AUTO_GENERATED_MESSAGE,见 modular_model_converter.py#L44-L50):
This file was automatically generated from {relative_path}. Do NOT edit this file manually...
也就是说生成文件绝不能手改,CI 会强制校验 modular 文件与生成文件的同步性,相关校验逻辑位于 check_modular_conversion.py。
依赖自动推断的细节
linter 会"展开"(unpack)继承关系,为 Python 用户保持生成结果的透明。核心规则(当前实现只展开一层继承):
- 配置类增删参数:
RobertaConfig从BertConfig继承并新增/删除了参数时,生成的配置类会直接指向父文件(新增情况)或完全移除该参数(删除情况); - 模型子模块自动推断:如果
class GemmaModel(LlamaModel),linter 会推断所有子模块依赖并自动从父类所在模型生成对应类; - 新函数自动搬运:在 modular 文件中定义新函数并在类体内使用时,linter 会自动将其复制进被使用的生成文件。
理论上你完全可以把分词器、图像处理器、模型、配置都写在一个 modular 文件里,对应文件全部自动生成。
完整示例:从 BERT 生成 RoBERTa
文档给出的经典示例是 BERT 与 RoBERTa:两者紧密相关,官方实现几乎只差一个 embedding 层(填充位置可学习 vs 位置嵌入可学习)。仓库中对应示例见 modular_roberta.py。文档中的示例(此处保留原文档代码,注意 tokenizer 因差异过大暂被忽略):
from torch import nn
from ..bert.configuration_bert import BertConfig
from ..bert.modeling_bert import (
BertModel,
BertEmbeddings,
BertForMaskedLM
)
# RoBERTa 配置与 BERT 相同
class RobertaConfig(BertConfig):
model_type = 'roberta'
# 重新定义嵌入层以突出填充位置可学习的差异,并重新定义位置嵌入
class RobertaEmbeddings(BertEmbeddings):
def __init__(self, config):
super().__init__(config)
self.padding_idx = config.pad_token_id
self.position_embeddings = nn.Embedding(
config.max_position_embeddings, config.hidden_size, padding_idx=self.padding_idx
)
# RoBERTa 模型与 BERT 相同,除了嵌入层;上面已重定义嵌入层,这里无需额外工作
class RobertaModel(BertModel):
def __init__(self, config):
super().__init__(config)
self.embeddings = RobertaEmbeddings(config)
# 任务头只需要把内部模型重新指向正确的 RobertaModel
class RobertaForMaskedLM(BertForMaskedLM):
def __init__(self, config):
super().__init__(config)
self.model = RobertaModel(config)
一个重要的错误提示机制:如果你在 modular 文件中定义了某个依赖类却没有在父类 __init__ 里实际使用它,转换器会报错:
ValueError: You defined `RobertaEmbeddings` in the modular_roberta.py, it should be used
when you define `BertModel`, as it is one of it's direct dependencies. Make sure
you use it in the `__init__` function.
更多示例可在 examples/modular-transformers 目录中找到,其中同时存放了各 modular 文件与转换后的生成文件(如 modular_my_new_model.py 与 modeling_my_new_model2.py),便于对照输入/输出。批量运行转换的脚本为 convert_examples.sh:
# 对 examples/modular-transformers 下所有 modular_*.py 执行转换器
sh examples/modular-transformers/convert_examples.sh
真实案例剖析:Olmo2 从 Olmo 继承
仓库中 src/transformers/models/olmo2/modular_olmo2.py 是文档所述流程的完整落地,可逐行印证上述规则。
配置类(modular_olmo2.py#L48-L85):Olmo2Config 继承 OlmoConfig,新增 rms_norm_eps: float = 1e-5,而不再使用的 clip_qkv 则被赋值为 AttributeError(),linter 会在生成文件中彻底移除该属性:
@auto_docstring(checkpoint="allenai/OLMo-2-1124-7B")
@strict
class Olmo2Config(OlmoConfig):
model_type = "olmo2"
base_model_tp_plan = { ... } # 张量并行切分策略
base_model_pp_plan = { ... } # 流水线并行切分策略
rms_norm_eps: float = 1e-5
clip_qkv = AttributeError()
无改动复用:Olmo2RotaryEmbedding(OlmoRotaryEmbedding): pass(第 99-100 行)——仅继承 pass,linter 就会把父类内容完整复制并重命名引用。
行为差异:Olmo2RMSNorm 相对 LlamaRMSNorm 只差一行——乘法发生在转回输入 dtype 之前而非之后(第 90-96 行);Olmo2Attention 的 __init__ 中 super().__init__(...) 之后追加了 q_norm/k_norm 两行(第 114-117 行)。
属性删除:Olmo2DecoderLayer 在 super().__init__ 后执行 del self.input_layernorm(第 173 行),因为 Olmo2 把归一化移到了注意力之后——这正是文档"高级用法"一节所讲的 del 语义的实例。
任务头零改动:Olmo2ForCausalLM(OlmoForCausalLM): pass(第 222 行)。
文件末尾声明 __all__ 列表(第 230-236 行),转换器与下游导入都依赖它正确导出公开类。最终生成的独立文件见 modeling_olmo2.py,其中还包含 modular 文件从未显式定义的 Olmo2MLP、Olmo2PreTrainedModel 等类——它们是 linter 根据 OlmoDecoderLayer 体内 self.mlp = OlmoMLP(config) 这类依赖自动推断生成的,等价于对父类写了 pass 继承。
进阶用法
1. 删除属性和方法
删除 nn.Module 属性:在 super().__init__(...) 之后写 del self.attribute。linter 只会移除父类复制体中 self.attribute = ... 这一赋值行;若父类的 forward 等其他方法还引用该属性,需要同时重写这些方法。文档示例:
class GemmaModel(LlamaModel):
def __init__(self, config):
super().__init__(self, eos_token)
del self.embed_tokens # 生成文件中仅删除该赋值行
对照 modular_olmo2.py#L167-L173 可以看到:del self.input_layernorm 只删除了赋值行,所以 Olmo2DecoderLayer.forward 必须完整重写(第 175-203 行),把归一化挪到注意力/FFN 之后。
删除方法:把该方法重写成 raise AttributeError("")(或自定义信息),linter 会直接从生成文件中移除该方法:
class GemmaTokenizer(LlamaTokenizer):
...
def get_spm_processor(self):
raise AttributeError("Not needed for Gemma")
def unk_token_length(self):
raise AttributeError("Not needed for Gemma")
2. 定义并使用新函数
在 modular 文件中定义新函数并在类体内调用它,转换器会自动把该函数(以及它递归调用的其他新函数)复制到使用它的生成文件中:
def my_new_function(*args, **kwargs):
# Do something here
pass
class GemmaModel(LlamaModel):
def forward(*args, **kwargs):
# Call the function
example = my_new_function(*args, **kwargs)
# continue here
仓库中的实例:modular_olmo2.py#L103-L107 的 rotate_half 函数被 Olmo2Attention.forward 使用,因此会被自动搬运进生成的 modeling_olmo2.py。
3. 显式调用 super() 的两种模式
super().__init__(config) 告诉转换器"把父类函数体复制进子类"。当你不希望展开 super() 调用、需要精确控制生成结果时,有两种模式:
模式一:直接调用祖父类。当生成的类应该直接继承 nn.Module 而非 modular 中的父类时,显式写出目标类:
# 左侧 modular 写法 | 右侧生成结果
class GemmaTokenizer(LlamaTokenizer, PretrainedTokenizerFast): | class GemmaModel(nn.Module):
def __init__(self, eos_token="</s>"): | def __init__(self):
eos_token = AddedToken(eos_token) | eos_token = AddedToken(eos_token)
PretrainedTokenizerFast.__init__(self, eos_token) | super().__init__(eos_token)
模式二:**super_kwargs。继承父方法的完整签名,同时可以附加自定义 docstring 或更换装饰器;在重写签名中写 **super_kwargs,linter 会在生成输出中展开父类全部参数。最典型用法是不重写签名就为 forward 补一段模型专属的 docstring:
# modular_gemma.py
class GemmaForCausalLM(LlamaForCausalLM):
def forward(**super_kwargs):
r"""
Example:
>>> from transformers import AutoTokenizer, GemmaForCausalLM
...
"""
return super().forward(**super_kwargs)
4. 特殊命名与多模态前缀
linter 继承类时会自动重命名一切引用,因此同一文件内所有类必须使用统一前缀。对于 class GemmaVisionModel(CLIPModel) 这种"类名前缀(GemmaVision)≠ 模型前缀(Gemma)"的复合模型场景,转换器支持以 pass 显式重命名某个依赖来消除歧义:
class Emu3TextMLP(LlamaMLP):
pass
当 linter 检测到多个候选前缀时会发出警告,提示只用最高频前缀推断依赖,并建议使用单一前缀(最佳实践)或显式子类化中间类。仓库的转换器中有对应的名字保护表 NAMES_TO_NEVER_REPLACE(modular_model_converter.py#L80-L91),避免误替换 mamba 等与模型名冲突的第三方标识符。
5. 装饰器继承
重写带装饰器的父方法时,父类装饰器会自动保留;若你加上自己的装饰器,则替换父类的。库中最常见的是中间输出捕获与 docstring 自动生成(@auto_docstring)两类装饰器,Olmo2Config 上的 @auto_docstring 与 @strict(见上文案例)即为实例——注意这两个装饰器不会从父类继承,必须在 modular 配置类上显式声明。
运行转换器与生成文件
确认 modular 文件写好后,执行:
# 按模型目录名生成
python utils/modular_model_converter.py your_model
# 或直接指定文件
python utils/modular_model_converter.py --files_to_parse /path/to/modular_my_new_model2.py
转换器内部基于 libcst 构建 AST(而非原生 ast,因为原生 AST 不保留 docstring、注释和代码格式,参见 examples/modular-transformers/README.md),流程为:解析 modular 文件 → 对被导入的 transformers.models.modeling_xxxx 模块解析源码并构建类依赖映射 → 合并代码与依赖映射 → 用 ruff 清理重复导入。注意它只展开一层继承:若 Olmo2Attention 继承 OlmoAttention,生成的 Olmo2Attention 完全自包含(无跨模型导入、无继承);但若 OlmoAttention 本身还继承了别的类,祖父类不会被继续内联。
两条硬性纪律:
- 绝不手改生成文件——下次运行转换器时所有修改都会被覆盖,CI 也会拦截;
- 生成后验证真实权重可正确加载(写
convert_<model>_to_hf.py转换上游 checkpoint,再from_pretrained核对 shape 与 key 匹配),仓库中每个模型的convert_*_to_hf.py(如 convert_olmo2_weights_to_hf.py)都可作为模板。
它不是什么
最后必须明确边界:Modular Transformers 不是手写建模代码的替代品。如果你的模型不基于任何已有模型,或者配置/分词/处理文件无法从相近文件干净地继承,仍然应该按常规方式手写 modeling_*.py 等文件。此外,写 modular 文件还是先手写再重构,两种方式都可行,没有唯一正确的顺序。
小结与延伸阅读
| 主题 | 关键文件/命令 |
|---|---|
| modular 示例集合(含生成结果对照) | examples/modular-transformers |
| 转换器实现(libcst AST + 依赖推断) | utils/modular_model_converter.py |
| modular 与生成文件一致性校验 | utils/check_modular_conversion.py |
| 完整真实案例(Olmo2 ← Olmo ← Llama) | src/transformers/models/olmo2/modular_olmo2.py |
| 批量转换示例 | sh examples/modular-transformers/convert_examples.sh |
| 单模型转换 | python utils/modular_model_converter.py <model_name> |
Modular Transformers 本质上把"模型贡献"从"复制 + 改"变成了"继承 + 声明差异",同时用 linter 守护了 Transformers 的核心设计哲学。掌握 super().__init__ 展开、del/AttributeError() 删除、pass 继承、**super_kwargs 展开这四种语义,就能覆盖绝大多数新模型接入场景。
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 StartedRust0622
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