【vLLM】LoRAアダプターを動的にロード・切り替える方法と「No adapter found」エラー解決

問題の概要:vLLMでLoRAアダプターを動的に扱う際の課題

大規模言語モデル(LLM)の推論エンジンであるvLLMは、その高速な推論速度から広く利用されています。ここに、特定タスク用にファインチューニングされたLoRA (Low-Rank Adaptation) アダプターを組み合わせることで、ベースモデルの能力を拡張するユースケースが増えています。

しかし、vLLMで複数のLoRAアダプターを「動的」に、つまりサーバーを再起動せずにロードしたり、リクエストごとに切り替えて推論を行おうとすると、以下のようなエラーや課題に直面することがあります。

# 典型的なエラーメッセージ例
RuntimeError: No adapter found for id: lora_sentiment_analysis
# または
ValueError: The model does not have an adapter named 'my_lora_adapter'.
# アダプターリスト取得時の空の結果
Loaded adapters: []

この問題は、単一のアダプターを静的にロードするチュートリアルは多くあるものの、複数アダプターの動的管理に関する情報が限られているため、開発者を悩ませます。本記事では、この課題の原因と、vLLMのAsyncLLMEngineLoRA機能を用いた実用的な解決方法を詳しく解説します。

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

vLLMの標準的な推論サーバー(vllm.entrypoints.openai.api_server)を起動する際、--enable-loraオプションと--lora-modulesオプションでアダプターを指定する方法が一般的です。しかし、この方法には根本的な制限があります。

主な原因1:起動時限定のロード

--lora-modulesオプションはサーバー起動時にのみ有効です。この方法でロードされたアダプターは使用できますが、サーバー実行中に新しいアダプターを追加したり、不要なアダプターをアンロードしたりすることはできません。アダプターを追加する度にサーバー再起動が必要となり、可用性と運用性が損なわれます。

主な原因2:OpenAI APIサーバーの設計

標準のOpenAI API互換サーバーは、シンプルさと互換性を重視した設計となっており、動的なリソース管理(LoRAアダプターの追加/削除)を行うためのAPIをデフォルトでは公開していません。そのため、より低レベルなAsyncLLMEngineを直接利用して、カスタムのサーバー邏輯を構築する必要があります。

主な原因3:アダプターIDの管理

動的にアダプターをロードする場合、各アダプターを一意に識別するためのID(アダプター名)を正しく管理し、推論リクエスト時にそのIDを指定する必要があります。IDの不一致や、エンジンがアダプターの存在を認識できていない状態でリクエストが送られると、「No adapter found」エラーが発生します。

解決方法:AsyncLLMEngineを用いた動的LoRA管理

vLLMのコアエンジンであるAsyncLLMEngineを直接使用し、カスタムAPIサーバーを構築することで、LoRAアダプターの完全な動的管理を実現できます。以下に、その実装手順をステップバイステップで説明します。

ステップ1:環境構築と必要なインポート

まず、vLLMがLoRAをサポートするバージョンであることを確認します(v0.3.0以降が安定)。必要なクラスと関数をインポートします。

# requirements.txt の例
vllm>=0.3.0
fastapi
uvicorn
pydantic

# メインコードのインポート部
from vllm.engine.arg_utils import AsyncEngineArgs
from vllm.engine.async_llm_engine import AsyncLLMEngine
from vllm import SamplingParams
from fastapi import FastAPI, HTTPException
from pydantic import BaseModel
from typing import List, Optional
import uvicorn
import os

ステップ2:AsyncLLMEngineの初期化

ベースモデルをロードするAsyncLLMEngineを初期化します。ここではLoRA関連の引数をengine_argsで設定します。

# エンジン引数の設定
engine_args = AsyncEngineArgs(
    model="meta-llama/Llama-2-7b-chat-hf", # ベースモデル
    tensor_parallel_size=1, # GPU数に応じて調整
    enable_lora=True, # LoRAサポートを有効化(最重要)
    max_loras=10, # メモリに保持する最大LoRA数
    max_lora_rank=64, # LoRAの最大ランク
    lora_extra_vocab_size=256, # トークナイザー追加語彙数
    dtype="half", # データタイプ
)

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

ステップ3:カスタムAPIサーバーの構築(FastAPI)

FastAPIを用いて、アダプターのロード/アンロード/リスト取得を行うエンドポイントと、推論を行うエンドポイントを作成します。

app = FastAPI(title="vLLM Dynamic LoRA Server")

# リクエスト/レスポンスのデータモデル定義
class LoadLoraRequest(BaseModel):
    lora_id: str # アダプター識別子 (e.g., "lora_sentiment")
    lora_path: str # アダプターファイルへのローカルパス

class GenerateRequest(BaseModel):
    prompt: str
    lora_id: Optional[str] = None # 指定なければベースモデルで推論
    max_tokens: int = 128

class LoraInfoResponse(BaseModel):
    loaded_adapters: List[str]

@app.post("/load_lora")
async def load_lora(request: LoadLoraRequest):
    """新しいLoRAアダプターを動的にロード"""
    try:
        # エンジンにアダプターを追加
        engine.add_lora(
            lora_id=request.lora_id,
            lora_path=request.lora_path
        )
        return {"status": "success", "message": f"LoRA '{request.lora_id}' loaded."}
    except Exception as e:
        raise HTTPException(status_code=500, detail=str(e))

@app.delete("/unload_lora/{lora_id}")
async def unload_lora(lora_id: str):
    """LoRAアダプターをアンロード"""
    try:
        # エンジンからアダプターを削除
        engine.remove_lora(lora_id)
        return {"status": "success", "message": f"LoRA '{lora_id}' unloaded."}
    except ValueError as e: # アダプターが存在しない場合
        raise HTTPException(status_code=404, detail=str(e))
    except Exception as e:
        raise HTTPException(status_code=500, detail=str(e))

@app.get("/list_loras", response_model=LoraInfoResponse)
async def list_loras():
    """現在ロードされている全LoRAアダプターのリストを取得"""
    # エンジンからロード済みアダプターIDのリストを取得
    loaded_adapters = list(engine.engine.lora_manager.list_loras())
    return LoraInfoResponse(loaded_adapters=loaded_adapters)

@app.post("/generate")
async def generate_text(request: GenerateRequest):
    """LoRAアダプターを指定してテキスト生成"""
    sampling_params = SamplingParams(
        temperature=0.8,
        top_p=0.95,
        max_tokens=request.max_tokens
    )

    # リクエストデータの準備。lora_idが指定されていれば適用。
    request_data = {
        "prompt": request.prompt,
        "sampling_params": sampling_params,
        "lora_request": {"lora_id": request.lora_id} if request.lora_id else None
    }

    try:
        # エンジンで非同期推論を実行
        results_generator = engine.generate(**request_data, request_id="dynamic_req")
        final_output = None
        async for request_output in results_generator:
            final_output = request_output

        if final_output and final_output.outputs:
            generated_text = final_output.outputs[0].text
            return {"generated_text": generated_text}
        else:
            raise HTTPException(status_code=500, detail="Generation failed.")
    except RuntimeError as e:
        # "No adapter found" エラーはここで捕捉
        if "No adapter found" in str(e):
            raise HTTPException(
                status_code=400,
                detail=f"指定されたLoRAアダプター '{request.lora_id}' はロードされていません。先に /load_lora エンドポイントでロードしてください。"
            )
        raise HTTPException(status_code=500, detail=str(e))

if __name__ == "__main__":
    uvicorn.run(app, host="0.0.0.0", port=8000)

ステップ4:サーバーの起動とアダプターの動的管理

上記スクリプトをdynamic_lora_server.pyとして保存し、サーバーを起動します。

# サーバー起動
python dynamic_lora_server.py

サーバー起動後、以下のようにcURLやPythonクライアントからAPIを呼び出せます。

# 1. LoRAアダプターをロード
curl -X POST "http://localhost:8000/load_lora" 
  -H "Content-Type: application/json" 
  -d '{"lora_id": "lora_japanese", "lora_path": "./adapters/japanese_finetune"}'

# 2. ロード済みアダプターを確認
curl -X GET "http://localhost:8000/list_loras"

# 3. 特定のLoRAアダプターを適用して生成
curl -X POST "http://localhost:8000/generate" 
  -H "Content-Type: application/json" 
  -d '{"prompt": "以下の文章の感情を分析してください: この製品は素晴らしい!", "lora_id": "lora_japanese", "max_tokens": 50}'

# 4. アダプターが不要になったらアンロード
curl -X DELETE "http://localhost:8000/unload_lora/lora_japanese"

コード例・コマンド例:よくあるユースケース

複数アダプターのロードと使い分け

異なるタスク用のアダプターをロードし、1つのベースモデルで切り替えて使います。

import aiohttp
import asyncio

async def multi_adapter_demo():
    base_url = "http://localhost:8000"
    async with aiohttp.ClientSession() as session:
        # 複数アダプターをロード
        adapters = [
            ("sentiment", "./loras/sentiment_model"),
            ("code_gen", "./loras/code_llama"),
            ("summarize", "./loras/summarization")
        ]
        for lora_id, path in adapters:
            async with session.post(f"{base_url}/load_lora",
                                     json={"lora_id": lora_id, "lora_path": path}) as resp:
                print(await resp.json())

        # タスクに応じてアダプターを選択して推論
        tasks = [
            ("感情分析して", "sentiment"),
            ("Pythonでクイックソートを書いて", "code_gen"),
            ("要約して", "summarize")
        ]
        for prompt, adapter in tasks:
            async with session.post(f"{base_url}/generate",
                                     json={"prompt": prompt, "lora_id": adapter}) as resp:
                result = await resp.json()
                print(f"Adapter: {adapter}, Output: {result['generated_text'][:100]}...")

asyncio.run(multi_adapter_demo())

まとめ・補足情報

vLLMでLoRAアダプターを動的に管理するには、標準のOpenAI APIサーバーではなく、AsyncLLMEngineを中核としたカスタムサーバーを構築する方法が最も柔軟で強力です。これにより、サーバーのダウンタイムなしにアダプターのホットスワップが可能になり、マルチテナント環境や、多数のカスタマイズされたモデルを1つのベースで提供するサービスに最適です。

重要な注意点

1. メモリ管理: max_lorasパラメータは、GPUメモリにキャッシュされるアダプターの最大数です。これを超えると、古いアダプターが自動的にアンロード(エビクト)される場合があります。使用パターンに応じて適切に設定してください。
2. パフォーマンス: 動的ロード自体は軽量ですが、初回ロード時やアダプター切り替え時の最初のリクエストでは、若干のレイテンシが発生する可能性があります。
3. アダプター形式: vLLMは、PEFT (Parameter-Efficient Fine-Tuning) ライブラリで保存されたLoRAアダプター(adapter_model.binadapter_config.json)の読み込みをサポートしています。ファインチューニング時にはこの形式で保存するようにしてください。
4. エラー処理: 本番環境では、アダプターパスの検証、ロード失敗時のリトライ機制、リクエスト頻度に基づくアダプターのキャッシュ戦略など、より堅牢なエラー処理とリソース管理を実装することをお勧めします。

このアーキテクチャを応用すれば、ユーザーごと、プロジェクトごと、タスクごとに最適化されたモデルを、単一の高性能推論エンジンから提供するシステムの構築が可能になります。

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