【vLLM】LoRAアダプターを動的ロードする方法と「No module named ‘peft’」エラーの解決策

問題の概要:vLLMでLoRAアダプターを動的に切り替えられない

vLLMは、大規模言語モデル(LLM)を高速に推論するためのライブラリです。Fine-tuningされたモデルを効率的にサービス化する際、LoRA(Low-Rank Adaptation)アダプターを動的にロード・切り替えたいという要求は多くあります。しかし、vLLMの標準的な使用方法では、サーバー起動時に特定のアダプターパスを指定する必要があり、実行中のサービスに対して新しいLoRAアダプターを追加したり切り替えたりするのが困難です。

開発者が直面する典型的なエラーと課題は以下の通りです:

  • サーバー起動後、新しいアダプターパスを指定して推論リクエストを送ると、アダプターが認識されない
  • 複数のLoRAアダプターを同時に管理・切り替える方法が公式ドキュメントで明確でない
  • 動的ロードを試みた際に発生するRuntimeError: The adapter ... is not found.
  • 環境構築時に発生するModuleNotFoundError: No module named 'peft'

本記事では、vLLMのLoRAModelAsyncLLMEngineを活用し、複数のLoRAアダプターを動的にロード・管理する実践的な方法を解説します。

原因の解説:なぜ動的ロードが難しいのか

vLLMのデフォルトの動作は、推論エンジン(LLMEngine)の初期化段階でモデルとアダプターのパスを固定して読み込むように設計されています。これはパフォーマンス最適化の観点から理にかなっていますが、以下のユースケースでは制限となります:

  1. マルチテナントサービス: 複数の顧客(テナント)ごとに個別にFine-tuningされたアダプターを使用したい場合
  2. A/Bテスト: 異なるアダプターバージョンをリアルタイムで切り替えて評価したい場合
  3. 継続的学習: 新しいデータで学習したアダプターを、サービスを再起動せずにデプロイしたい場合

技術的な根本原因は、vLLMのエンジンが内部で保持するモデルの重みとキャッシュが、初期ロード時のアダプター情報に強く紐づいている点にあります。動的にアダプターを変更するには、このアーキテクチャを理解した上で、適切なAPIとワークフローを使用する必要があります。

解決方法:AsyncLLMEngineとLoRAModelを使用した動的ロード

vLLMの比較的新しい機能であるAsyncLLMEngineLoRAModelクラスを組み合わせることで、動的なアダプター管理が可能になります。以下のステップで実装します。

ステップ1: 環境構築と依存関係のインストール

まず、必要なパッケージをインストールします。peft関連のエラーが発生する場合は、vLLMが内部でPeftをバンドルしているため、通常は別途インストール不要ですが、環境によっては明示的なインストールが必要です。

# 基本的なインストール
pip install vllm

# 'No module named 'peft'' エラーが発生する場合
pip install peft transformers

# オプション: 最新機能を使用するためにソースからインストール
# pip install git+https://github.com/vllm-project/vllm.git

ステップ2: ベースモデルとAsyncLLMEngineの初期化

動的ロードの核心は、AsyncLLMEngineを直接使用することです。これにより、推論リクエストごとに細かい制御が可能になります。

from vllm.engine.async_llm_engine import AsyncLLMEngine
from vllm.engine.arg_utils import AsyncEngineArgs
from vllm import SamplingParams
import asyncio

# 1. エンジン引数の設定
engine_args = AsyncEngineArgs(
    model="meta-llama/Llama-2-7b-hf",  # ベースモデル
    tensor_parallel_size=1,            # GPU数
    gpu_memory_utilization=0.9,
    max_num_seqs=256,
    enable_lora=True,                  # LoRAサポートを有効化(重要!)
    max_loras=4,                       # 同時に保持するLoRAの最大数
    max_lora_rank=16,                  # LoRAランクの最大値
)

# 2. 非同期エンジンの作成
engine = AsyncLLMEngine.from_engine_args(engine_args)

ステップ3: LoRAアダプターの動的登録と推論実行

エンジン初期化後、add_loraメソッドを使用してアダプターを動的に登録し、リクエスト時に指定して推論を行います。

async def generate_with_lora(prompt, lora_path, lora_name="my_lora"):
    """
    動的にLoRAアダプターをロードして推論を実行する
    """
    # 1. LoRAアダプターをエンジンに登録(既に登録済みの場合はスキップ)
    try:
        # アダプターを追加。lora_nameはリクエスト時に指定する識別子
        engine.add_lora(lora_name, lora_path)
        print(f"LoRAアダプター '{lora_name}' を登録しました: {lora_path}")
    except Exception as e:
        # 既に登録されている場合などはエラーになることがある
        print(f"アダプター登録注意: {e}")

    # 2. サンプリングパラメータの設定
    sampling_params = SamplingParams(
        temperature=0.7,
        top_p=0.9,
        max_tokens=100
    )

    # 3. 推論リクエストの作成(lora_nameを指定)
    request_id = f"req_{lora_name}_{hash(prompt) % 10000}"
    results_generator = engine.generate(
        prompt=prompt,
        sampling_params=sampling_params,
        request_id=request_id,
        lora_request={"name": lora_name}  # ここで使用するLoRAを指定
    )

    # 4. 結果の取得
    final_output = None
    async for request_output in results_generator:
        final_output = request_output

    if final_output and len(final_output.outputs) > 0:
        return final_output.outputs[0].text
    else:
        return ""

# 使用例
async def main():
    prompt = "日本の首都は"
    
    # アダプターAで推論
    lora_path_a = "/path/to/your/lora/adapter_a"
    result_a = await generate_with_lora(prompt, lora_path_a, "lora_a")
    print(f"LoRA A 結果: {result_a}")
    
    # アダプターBで推論(動的に切り替え)
    lora_path_b = "/path/to/your/lora/adapter_b"
    result_b = await generate_with_lora(prompt, lora_path_b, "lora_b")
    print(f"LoRA B 結果: {result_b}")

# 実行
if __name__ == "__main__":
    asyncio.run(main())

ステップ4: 複数アダプターの管理とメモリ最適化

多くのアダプターを扱う場合、メモリ使用量に注意が必要です。max_lorasパラメータで制限を設け、不要なアダプターは明示的に削除します。

class DynamicLoraManager:
    def __init__(self, engine, max_adapters=10):
        self.engine = engine
        self.max_adapters = max_adapters
        self.loaded_adapters = {}  # {adapter_name: lora_path}
        
    async def get_or_load_adapter(self, adapter_name, lora_path):
        """アダプターがロードされていなければロードする"""
        if adapter_name not in self.loaded_adapters:
            # 最大数に達している場合は古いものを削除(簡易LRU)
            if len(self.loaded_adapters) >= self.max_adapters:
                oldest_adapter = next(iter(self.loaded_adapters))
                self.remove_adapter(oldest_adapter)
            
            # 新しいアダプターをロード
            try:
                self.engine.add_lora(adapter_name, lora_path)
                self.loaded_adapters[adapter_name] = {
                    'path': lora_path,
                    'usage_count': 0
                }
                print(f"アダプター '{adapter_name}' をロードしました")
            except RuntimeError as e:
                print(f"アダプターロードエラー: {e}")
                raise
        
        self.loaded_adapters[adapter_name]['usage_count'] += 1
        return adapter_name
    
    def remove_adapter(self, adapter_name):
        """アダプターを削除する(vLLMの現在のバージョンでは明示的削除APIは限定的)"""
        # 注意: vLLMの現在の実装では、エンジン再起動までメモリ完全解放は難しい場合がある
        if adapter_name in self.loaded_adapters:
            # 実際のプロダクションでは、エンジンの再初期化を検討する
            del self.loaded_adapters[adapter_name]
            print(f"アダプター '{adapter_name}' を管理リストから削除しました")
            
    async def generate_with_manager(self, prompt, adapter_name, lora_path):
        """マネージャー経由で推論を実行"""
        effective_name = await self.get_or_load_adapter(adapter_name, lora_path)
        
        sampling_params = SamplingParams(max_tokens=50)
        request_id = f"req_{effective_name}_{id(prompt)}"
        
        results_generator = self.engine.generate(
            prompt=prompt,
            sampling_params=sampling_params,
            request_id=request_id,
            lora_request={"name": effective_name}
        )
        
        async for request_output in results_generator:
            return request_output.outputs[0].text if request_output.outputs else ""

コード例・コマンド例:よくあるエラーとその解決策

エラー1: 「No module named ‘peft’」

vLLMのインストールやインポート時にこのエラーが発生することがあります。

# エラーメッセージ例:
# ModuleNotFoundError: No module named 'peft'
# または
# ImportError: cannot import name 'PeftModel' from 'peft'

# 解決策1: peftを明示的にインストール
pip install peft

# 解決策2: vLLMを再インストール(依存関係を含めて)
pip uninstall vllm -y
pip install vllm

# 解決策3: 環境の競合を避ける(conda環境を使用している場合)
conda create -n vllm_env python=3.9
conda activate vllm_env
pip install vllm

エラー2: 「RuntimeError: The adapter … is not found.」

アダプターを登録する前に、そのアダプターを使用して推論しようとした場合に発生します。

# エラーメッセージ例:
# RuntimeError: The adapter 'my_lora' is not found.

# 解決策: 必ずadd_loraで登録してから使用する
# 間違い:
# results = engine.generate(prompt, lora_request={"name": "unregistered_lora"})

# 正しい順序:
engine.add_lora("my_lora", "/path/to/lora")
results = engine.generate(prompt, lora_request={"name": "my_lora"})

エラー3: 「ValueError: Too many loras. …」

max_lorasパラメータで設定した数を超えるアダプターを登録しようとした場合です。

# エラーメッセージ例:
# ValueError: Too many loras. Maximum number of loras: 4

# 解決策1: エンジン初期化時にmax_lorasを増やす
engine_args = AsyncEngineArgs(
    model="your/model",
    enable_lora=True,
    max_loras=10,  # 必要数に増やす
    # ...
)

# 解決策2: 使用していないアダプターを管理して削除する
# 上記のDynamicLoraManagerクラスのような管理システムを実装

まとめ・補足情報

vLLMでLoRAアダプターを動的にロード・切り替えるには、AsyncLLMEngineを直接使用し、add_loraメソッドとリクエスト時のlora_requestパラメータを組み合わせる方法が効果的です。このアプローチにより、サービス再起動なしでのアダプターデプロイ、マルチテナント対応、A/Bテストなど、実プロダクションでの柔軟な運用が可能になります。

重要なポイント:

  1. AsyncEngineArgsenable_lora=Trueを必ず設定する
  2. 動的ロードにはAsyncLLMEngineが推奨される(LLMクラスより低レベルだが柔軟性が高い)
  3. max_lorasパラメータはメモリ使用量とトレードオフの関係にある
  4. アダプターパスはvLLMがアクセス可能なストレージ(ローカルFSやネットワークマウント)にある必要がある

パフォーマンスに関する注意点:

  • 動的ロードは便利ですが、アダプター切り替え時にわずかなレイテンシ増加が発生する可能性があります
  • 非常に多くのアダプター(数十以上)を扱う場合は、エンジンインスタンスを複数立ち上げるアーキテクチャも検討してください
  • プロダクション環境では、アダプターのバージョン管理とロールバック戦略を事前に計画しておくことが重要です

vLLMは活発に開発が進んでいるプロジェクトです。本記事の手法はvLLMバージョン0.3.0以降を対象としています。最新の機能や変更については、公式GitHubリポジトリのドキュメントを常に参照することをお勧めします。

この記事は役に立ちましたか?