本番LLMのLoRAアダプタ管理設計ガイド
バージョニング・ホットスワップ・マージ戦略の実務パターン
複数のLoRAアダプタを本番MLパイプラインで管理するための4パターンを実装コード付きで解説する。HuggingFace PEFT+MLflowによるバージョニング、vLLM dynamic LoRA loadingでのホットスワップ、Task Arithmetic・DARE・TIESでのマージ戦略、評価ゲート設計まで体系的に整理する。
複数のLoRAアダプタを本番に乗せると、「どのアダプタがどのベースモデルに対応しているか」「新しいアダプタをデプロイするとき既存リクエストを止めたくない」「タスクが増えるたびにアダプタ同士が干渉する」といった問題が次々と出てくる。
LoRAの基礎やファインチューニング入門は日本語記事が豊富になってきた。LoRA vs QLoRAの概念比較や継続学習の戦略選択(LLM継続学習の戦略選択ガイドを参照)も整理されつつある。しかし「本番MLパイプラインでのアダプタ運用」、つまりバージョン管理・デプロイ戦略・タスク間干渉回避を扱う実装特化の記事は、日本語ではまだ希少な領域だ。
本記事はその空白を埋める。HuggingFace PEFTとMLflow・vLLM・マージアルゴリズムを組み合わせた4パターンを、実際に動くPythonコードと設計判断の根拠とあわせて解説する。
パターン1:HuggingFace PEFT + MLflowでアダプタをバージョン管理する
なぜアダプタのバージョン管理が必要か
LoRAアダプタはベースモデルに対するデルタ(差分)だ。アダプタ単体のファイルサイズは小さくても、「どのベースモデルのどのリビジョンに対して学習したか」という対応関係を記録しておかないと、ベースモデルを更新したときに既存アダプタが正しく機能しなくなる。
また、チームで複数のタスク向けアダプタを開発していると、「先週デプロイしたコードレビュー用アダプタはどこ?」「このアダプタの学習データのバージョンは?」という追跡問題が必ず発生する。
HuggingFace PEFTのsave_pretrainedはアダプタの重みとadapter_config.json(ベースモデル名・LoRAランク・対象モジュールなどの設定)をセットで保存する。これをMLflowのアーティファクトとして記録することで、再現性のある管理が実現できる。
実装パターン:ファインチューニング後のMLflow記録
import mlflow
import mlflow.pyfunc
from peft import PeftModel, LoraConfig, get_peft_model
from transformers import AutoModelForCausalLM, AutoTokenizer
def train_and_log_adapter(
base_model_id: str,
task_name: str,
lora_rank: int = 16,
lora_alpha: int = 32,
):
"""LoRAアダプタを学習してMLflowに記録する"""
with mlflow.start_run(run_name=f"lora_{task_name}"):
# LoRA設定をパラメータとして記録
mlflow.log_params({
"base_model_id": base_model_id,
"task_name": task_name,
"lora_rank": lora_rank,
"lora_alpha": lora_alpha,
"lora_target_modules": "q_proj,v_proj",
})
# ベースモデルとLoRA設定
model = AutoModelForCausalLM.from_pretrained(base_model_id)
lora_config = LoraConfig(
r=lora_rank,
lora_alpha=lora_alpha,
target_modules=["q_proj", "v_proj"],
lora_dropout=0.05,
task_type="CAUSAL_LM",
)
peft_model = get_peft_model(model, lora_config)
# --- 学習処理(省略) ---
# trainer.train()
# アダプタのみを保存してMLflowにアーティファクトとして記録
adapter_save_path = f"/tmp/adapters/{task_name}"
peft_model.save_pretrained(adapter_save_path)
mlflow.log_artifacts(adapter_save_path, artifact_path=f"adapters/{task_name}")
# 評価メトリクスも記録
# mlflow.log_metrics({"eval_loss": eval_loss, "rouge_l": rouge_score})
return mlflow.active_run().info.run_id
アダプタのロードと利用
def load_adapter_from_mlflow(
base_model_id: str,
run_id: str,
task_name: str,
adapter_name: str = "default",
):
"""MLflowからアダプタをダウンロードしてロードする"""
# MLflowからアーティファクトをローカルにダウンロード
local_path = mlflow.artifacts.download_artifacts(
run_id=run_id,
artifact_path=f"adapters/{task_name}",
)
# ベースモデルにアダプタをロード
base_model = AutoModelForCausalLM.from_pretrained(base_model_id)
model = PeftModel.from_pretrained(
base_model,
local_path,
adapter_name=adapter_name,
)
return model
# 複数アダプタを同一モデルインスタンスで切り替える
model = AutoModelForCausalLM.from_pretrained("meta-llama/Llama-3-8B")
model = PeftModel.from_pretrained(model, "/path/to/code-review-adapter", adapter_name="code_review")
model.load_adapter("/path/to/summarize-adapter", adapter_name="summarize")
# タスクに応じて切り替え
model.set_adapter("code_review") # コードレビュータスク
model.set_adapter("summarize") # 要約タスク
パターン2:vLLM dynamic LoRA loading でダウンタイムゼロのホットスワップ
vLLM v0.6以降のdynamic LoRA機能
vLLM v0.6から、サービスを停止せずにリクエスト単位でLoRAアダプタを切り替えられるdynamic LoRA loadingが利用できる(vLLM公式ドキュメント参照)。
これにより、複数タスクのリクエストを同一の推論サーバで捌きながら、それぞれに適切なアダプタを適用できる。「タスクAとタスクBで別々のサーバを立てる」という非効率な構成が不要になる。
vLLMサーバの起動設定
# LoRAを有効にしてvLLMサーバを起動
vllm serve meta-llama/Llama-3-8B \
--enable-lora \
--max-lora-rank 64 \
--max-loras 4 \
--max-cpu-loras 8 \
--lora-extra-vocab-size 256
主なパラメータの意味:
| パラメータ | 説明 | 目安 |
|---|---|---|
--enable-lora | dynamic LoRA loadingを有効化 | 必須 |
--max-lora-rank | 受け入れるLoRAのrankの上限 | 学習したrankに合わせる |
--max-loras | GPU上に同時保持するアダプタ数 | GPUメモリと相談 |
--max-cpu-loras | CPUメモリでキャッシュするアダプタ数 | max-lorasより大きくする |
APIリクエストでアダプタを指定する
import requests
# アダプタを動的にロード(初回のみ)
def load_lora_adapter(server_url: str, lora_name: str, lora_path: str):
response = requests.post(
f"{server_url}/v1/load_lora_adapter",
json={
"lora_name": lora_name,
"lora_path": lora_path,
},
)
response.raise_for_status()
return response.json()
# アダプタを指定してリクエスト
def generate_with_lora(server_url: str, prompt: str, lora_name: str):
response = requests.post(
f"{server_url}/v1/completions",
json={
"model": lora_name, # モデル名にアダプタ名を指定
"prompt": prompt,
"max_tokens": 512,
"temperature": 0.1,
},
)
response.raise_for_status()
return response.json()["choices"][0]["text"]
# 使用例
SERVER = "http://localhost:8000"
# アダプタを事前にロード
load_lora_adapter(SERVER, "code-review-v2", "/adapters/code_review_v2")
load_lora_adapter(SERVER, "summarize-v1", "/adapters/summarize_v1")
# タスクごとに適切なアダプタを使って推論
code_feedback = generate_with_lora(SERVER, code_snippet, "code-review-v2")
summary = generate_with_lora(SERVER, long_document, "summarize-v1")
パターン3:Task Arithmetic・DARE・TIESでアダプタをマージする
なぜアダプタをマージするのか
複数タスク向けのアダプタをそれぞれ独立して運用するのがシンプルだが、タスク数が増えると管理コストが線形に増加する。「コードレビューと日本語翻訳の両方をこなす1つのアダプタにまとめられないか」というニーズが出てくる。
ここで有効なのがモデルマージ(Model Merging)のアプローチだ。Task Arithmetic(arXiv:2212.04089)・DARE/TIES(arXiv:2306.01708)は、異なるタスクで学習したモデルのパラメータを線形演算で合成する手法だ。
Task Arithmetic の仕組みと実装
Task Arithmeticの核心はタスクベクトルだ。ファインチューニング後のパラメータからベースモデルのパラメータを引き算すると、そのタスクに特化した「差分ベクトル」が得られる。複数のタスクベクトルを加算(または減算)することで、複数タスクに対応したモデルを作れる。
import torch
from transformers import AutoModelForCausalLM
from peft import PeftModel
def compute_task_vector(base_model_path: str, finetuned_adapter_path: str):
"""タスクベクトル(LoRAデルタ)を取得する"""
base = AutoModelForCausalLM.from_pretrained(base_model_path)
peft_model = PeftModel.from_pretrained(base, finetuned_adapter_path)
# PEFTのマージ機能でLoRA重みをベースモデルに統合
merged = peft_model.merge_and_unload()
# タスクベクトル = マージ済みモデル - ベースモデル
task_vector = {}
base_params = dict(base.named_parameters())
for name, param in merged.named_parameters():
if name in base_params:
task_vector[name] = param.data - base_params[name].data
return task_vector
def merge_task_vectors(
base_model_path: str,
task_vectors: list[dict],
scaling_factor: float = 0.4,
):
"""複数のタスクベクトルを加算してマージモデルを作成する"""
base = AutoModelForCausalLM.from_pretrained(base_model_path)
merged_params = {name: param.data.clone() for name, param in base.named_parameters()}
for tv in task_vectors:
for name, delta in tv.items():
if name in merged_params:
merged_params[name] += scaling_factor * delta
# マージ済みパラメータをモデルに適用
with torch.no_grad():
for name, param in base.named_parameters():
if name in merged_params:
param.data.copy_(merged_params[name])
return base
DARE/TIESによるコンフリクト解消
Task Arithmeticの弱点は、タスクベクトル同士が干渉(コンフリクト)する場合だ。たとえば「コードレビュー」と「日本語翻訳」のタスクベクトルを足すと、一方のタスク精度が大きく落ちることがある。
DARE(arXiv:2306.01708)はタスクベクトルのうち絶対値の小さいデルタをランダムにドロップ(スパース化)し、残りをスケール補正することでコンフリクトを抑える。TIESは「正負の符号が多数決で一致するパラメータだけを採用する」という選択基準でコンフリクトを解消する。
def apply_dare(task_vector: dict, sparsity: float = 0.9) -> dict:
"""DARESによるタスクベクトルのスパース化"""
dare_vector = {}
for name, delta in task_vector.items():
# sparsity割合のデルタをランダムにゼロにする
mask = torch.rand_like(delta) > sparsity
dare_vector[name] = delta * mask / (1 - sparsity) # スケール補正
return dare_vector
def apply_ties(task_vectors: list[dict], density: float = 0.2) -> dict:
"""TIESによる符号統一マージ"""
ties_vector = {}
# 全タスクベクトルのキーを収集
all_keys = set()
for tv in task_vectors:
all_keys.update(tv.keys())
for name in all_keys:
deltas = [tv[name] for tv in task_vectors if name in tv]
stacked = torch.stack(deltas) # shape: (num_tasks, ...)
# 上位density%の大きな絶対値を持つデルタだけを選択(Trim)
threshold = torch.quantile(stacked.abs(), 1 - density)
trimmed = stacked * (stacked.abs() >= threshold)
# 符号の多数決(Elect)
sign_sum = trimmed.sum(dim=0)
elected_sign = torch.sign(sign_sum)
# 多数決符号と一致するデルタだけをマージ(Merge)
consistent = trimmed * (torch.sign(trimmed) == elected_sign)
ties_vector[name] = consistent.sum(dim=0)
return ties_vector
パターン4:評価ゲートでアダプタの品質を担保する
本番デプロイ前に必ず評価ゲートを通す
新しいLoRAアダプタを本番にデプロイするとき、「学習損失が下がった」だけでは不十分だ。本番で実際に使われるユースケースに対して品質を検証するゲートが必要になる。
評価ゲートは以下の3層で設計するのが実務上の定番だ:
- 自動指標チェック:対象タスクのベンチマークで閾値をクリアするか
- 既存タスクへの後退テスト:他のアダプタのタスク精度が低下していないか(マージ後はとくに重要)
- 判断ゲート:自動チェックで閾値を下回ったら自動ロールバック
実装パターン:評価ゲートの自動化
from dataclasses import dataclass
from typing import Callable
import mlflow
@dataclass
class EvalGateConfig:
task_name: str
primary_metric: str
threshold: float
regression_tasks: list[str] # 後退チェックするタスク名
regression_tolerance: float = 0.02 # 許容する精度低下(2%)
def run_eval_gate(
model,
gate_config: EvalGateConfig,
eval_fn: Callable, # タスク固有の評価関数
baseline_scores: dict, # 既存タスクのベースラインスコア
run_id: str,
) -> bool:
"""評価ゲートを実行してデプロイ可否を返す"""
with mlflow.start_run(run_id=run_id, nested=True):
# 1. 主要指標チェック
primary_score = eval_fn(model, gate_config.task_name)
mlflow.log_metric(gate_config.primary_metric, primary_score)
if primary_score < gate_config.threshold:
mlflow.set_tag("gate_result", "FAIL_PRIMARY")
print(f"FAIL: {gate_config.primary_metric}={primary_score:.4f} < {gate_config.threshold}")
return False
# 2. 後退テスト(既存タスクの精度チェック)
for task in gate_config.regression_tasks:
score = eval_fn(model, task)
baseline = baseline_scores.get(task, 0)
regression = baseline - score
mlflow.log_metric(f"regression_{task}", regression)
if regression > gate_config.regression_tolerance:
mlflow.set_tag("gate_result", f"FAIL_REGRESSION_{task}")
print(f"FAIL: {task}が{regression:.4f}低下(許容: {gate_config.regression_tolerance})")
return False
mlflow.set_tag("gate_result", "PASS")
return True
# 使用例
gate_config = EvalGateConfig(
task_name="code_review",
primary_metric="rouge_l",
threshold=0.72,
regression_tasks=["summarize", "translation"],
regression_tolerance=0.02,
)
baseline_scores = {
"summarize": 0.85,
"translation": 0.91,
}
can_deploy = run_eval_gate(
model=new_adapter_model,
gate_config=gate_config,
eval_fn=your_task_eval_function,
baseline_scores=baseline_scores,
run_id=mlflow_run_id,
)
if can_deploy:
print("評価ゲート通過: 本番デプロイを開始します")
# deploy_to_production(model)
else:
print("評価ゲート失敗: 前バージョンを維持します")
# rollback_to_previous()
4パターンの実務での組み合わせ方
4パターンを個別に使うこともできるが、本番パイプラインでは次のような組み合わせが現実的だ。
小規模チーム(アダプタ数10以下)の構成
- パターン1(PEFT + MLflow)でアダプタをバージョン管理
- パターン2(vLLM dynamic loading)でマルチタスクを単一サーバで対応
- パターン4(評価ゲート)でデプロイ前にROUGE/BERTScoreをチェック
マージは「タスク数が増えてGPUメモリが逼迫してきたとき」に検討する。最初からマージを試みると運用の複雑さが増す。
大規模・多タスク環境(アダプタ数50以上)の構成
- パターン1(PEFT + MLflow)でバージョン管理とリネージ追跡
- パターン3(DARE/TIESマージ)で関連タスクのアダプタを統合し管理数を削減
- パターン4(評価ゲート)でマージ後の後退テストを自動化
- パターン2(vLLM dynamic loading)で残るアダプタのホットスワップ
# パイプライン全体の簡易スケッチ
class LoRAAdapterPipeline:
def __init__(self, mlflow_tracking_uri: str, vllm_server_url: str):
mlflow.set_tracking_uri(mlflow_tracking_uri)
self.vllm_url = vllm_server_url
def register_new_adapter(self, task_name: str, adapter_path: str) -> str:
"""新アダプタをMLflowに登録し、評価ゲートを通してvLLMにロードする"""
run_id = log_adapter_to_mlflow(task_name, adapter_path)
gate_passed = run_eval_gate(
model=load_adapter_for_eval(adapter_path),
gate_config=self.get_gate_config(task_name),
eval_fn=self.evaluate,
baseline_scores=self.get_baselines(),
run_id=run_id,
)
if gate_passed:
load_lora_adapter(self.vllm_url, task_name, adapter_path)
return f"Deployed: {task_name}"
else:
return f"Gate failed: {task_name} not deployed"
注意点:運用設計でよくあるミス
ベースモデルのバージョン固定を忘れる
最も多いミスはベースモデルのバージョンを固定せずにアダプタだけを更新するケースだ。HuggingFaceのモデルIDは同一でも内部リビジョンが変わることがある。adapter_config.jsonに記録されるモデルIDがHuggingFace上のタグを指している場合、特定のコミットSHAで固定しておくことを強く推奨する。
# 悪い例: タグ指定(内部が変わりうる)
base_model = AutoModelForCausalLM.from_pretrained("meta-llama/Llama-3-8B")
# 良い例: コミットSHAで固定
base_model = AutoModelForCausalLM.from_pretrained(
"meta-llama/Llama-3-8B",
revision="abc123def456...", # 特定コミットSHAを指定
)
merge_and_unload後のアダプタ切り替えに注意
PEFTのmerge_and_unload()はLoRA重みをベースモデルに統合してLoRA構造を解体する。この後はset_adapter()が使えなくなる。vLLMでのホットスワップと組み合わせたい場合は、merge_and_unload()を使わずアダプタ重みをそのまま扱うこと。
Task Arithmeticのscaling_factorは必ずバリデーションする
scaling_factorを大きくしすぎると特定タスクが過剰適合し、元の汎用能力が損なわれる。0.1〜0.6の範囲で小刻みに試して評価ゲートで検証するのが実務上の安全策だ。
よくある質問(FAQ)
よくある質問
クリックで展開。
vLLMのdynamic LoRA loadingはどのバージョンから使えますか?
vLLM v0.6.0からdynamic LoRA loadingが安定して使えます。起動時に--enable-loraフラグが必要です。max-lorasやmax-lora-rankの設定はGPUメモリ容量に応じて調整してください。最新の設定オプションはvLLM公式ドキュメント(https://docs.vllm.ai/en/latest/features/lora/)で確認することを推奨します。
Task Arithmeticはベースモデルが同じでないと使えませんか?
はい、Task Arithmeticはタスクベクトルの「加算」が意味を持つために、同一アーキテクチャ・同一ベースモデルで学習したアダプタ間でのみ有効です。異なるベースモデル(例:Llama-3-8BとMistral-7B)のアダプタを足し合わせることはできません。
MLflowなしでも運用できますか?
DVC(Data Version Control)やHuggingFace Hubも同様のアーティファクト管理ができます。重要なのは「ベースモデルのバージョン」「LoRAハイパーパラメータ」「学習データのバージョン」「評価メトリクス」の4点をアダプタごとにセットで記録することです。MLflowはその一つの選択肢に過ぎません。
DARとTIESはどちらを優先すべきですか?
まずTIESを試すのが推奨です。TIESは符号の一致という明確な基準でコンフリクトを解消し、解釈しやすいためです。DARはスパース化の割合(sparsity)の調整が必要で、適切な値を見つけるのにグリッドサーチが必要になります。どちらも評価ゲートで効果を検証してから採用を判断してください。
アダプタのサイズはどのくらいですか?ストレージ計画の参考に教えてください。
アダプタのサイズはLoRAのrankとターゲットモジュール数に依存します。一般的に7Bクラスのモデルでr=16・q_proj/v_projのみを対象にした場合、アダプタサイズは数十MB程度です。ベースモデル全体(数十GB)と比べると非常に小さいため、アダプタだけを管理することでストレージコストを大幅に削減できます。
まとめ
LoRAアダプタの本番運用には「学習して保存する」だけでなく、バージョン追跡・ダウンタイムゼロのデプロイ・マージ時のコンフリクト解消・評価ゲートという4つの設計判断が必要だ。
本記事で紹介した4パターンのうち、まずパターン1(PEFT + MLflow)とパターン4(評価ゲート)から始めて、運用が安定してきたらパターン2(vLLMホットスワップ)やパターン3(マージ戦略)を追加していくのが現実的な順番だ。
継続学習の手法選択(EWC・リプレイ・LoRAアダプタ分離の比較)についてはLLM継続学習の戦略選択ガイドで詳しく解説している。本記事の「運用実装」とあわせて参照することで、設計から実装まで一貫した知識が得られる。
次に読むおすすめ
本記事で扱ったLoRAアダプタの運用設計をさらに深めたい方には、LLMOpsの実践的なパターンをまとめたコンテンツをnoteで公開している。アダプタ管理と並行して考える推論コストの最適化や、マルチタスクLLMのアーキテクチャ設計についても触れている。
noteで続きを読む(たきびAIラボのnote独自コンテンツ)
関連記事
- LLM継続学習の戦略選択ガイド:EWC・リプレイ・LoRAアダプタ分離・ProCLの設計判断 — 手法の理論比較と3パターン別推奨。本記事の実装の前提となる設計判断を整理している
- 推論スキル再利用でトークンを削減する:TRS(Thinking with Reasoning Skills)の仕組みと実務への応用 — 推論コスト削減の別アプローチ。LoRAによるタスク特化と組み合わせて検討できる
- PicoSpec論文解説:エッジクラウド協調推論でネットワーク遅延を隠蔽する非同期Speculative Decoding — vLLMを使った推論最適化の事例。LoRAと推論高速化の両立を検討する際に参照できる
参考リンク
- HuggingFace PEFT LoRA Package Reference —
save_pretrained・load_adapter・merge_and_unloadの公式APIリファレンス - vLLM LoRA Documentation — dynamic LoRA loading の設定オプションと使用例
- MLflow PEFT Fine-Tuning Tutorial — PEFTアダプタをMLflowで管理するチュートリアル
- Task Arithmetic 論文(arXiv:2212.04089) — タスクベクトルによるモデルマージの原論文(Ilharco et al., 2022)
- DARE/TIES 論文(arXiv:2306.01708) — スパース化とコンフリクト解消によるマージ改善の論文(Yu et al., 2023)
- ProCL 論文(arXiv:2605.13162) — プログラムメモリ型継続学習の論文(2026年5月、arXiv査読前プレプリント)