3つの LoRA を学習し終えた翌朝、本当の課題が見えてきた
要約用、日英翻訳用、コードレビュー用。社内ユースケースごとに、Gemma 4 へ LoRA を別々に学習させてみました。個々のベンチマークはどれも満足のいく数字になり、週末の達成感をひとしきり噛みしめていました。
翌朝、プロダクトチームからの質問でハッと目が覚めます。「これ、1つのサーバーにまとめて、リクエストに応じて切り替えられますか?」。推論コストは抑えたい、けれどチャットは要約モードにしたり翻訳モードにしたりと自在に動いてほしい。3つの LoRA を3サーバーで動かす贅沢は、個人の財布にも中小企業の予算にも合いません。
この記事は、そのときの私の試行錯誤をそのまま残した実装ノートです。複数 LoRA の「マージ」と「動的スイッチング」という2つのアプローチを軸に、Antigravity のエージェントを伴走させながら、実運用できる形に落としていきます。途中で Weighted / TIES / DARE といったマージ技法の違いや、peft の set_adapter() の罠まで、つまずいた場所を丁寧に残しました。
対象読者は、LoRA / QLoRA での学習をすでに一度は経験し、次のステップとして「本番でどう組み合わせるか」を考えている方を想定しています。ファインチューニング自体の基礎は、Gemma 4 の LoRA / QLoRA ファインチューニング実務ノート に譲ります。
複数タスクに LoRA を使うときの選択肢は3つしかない
整理すると、選べる戦略はおおむね次の3つに集約されます。
戦略A: マージして1つの単一モデルにする — 学習後の LoRA 重みを加算的に統合して、通常の Gemma 4 と同じように動かす方法。推論経路が単純で、既存の vLLM / TGI / Ollama 環境にそのまま載せられます
戦略B: ベースモデル1つにアダプタを動的に差し替える — Gemma 4 本体は1つだけロードし、リクエストの性質に応じて LoRA アダプタだけを切り替える方法。メモリは節約できるが、スイッチのレイテンシと同時実行の難しさを引き受けることになります
戦略C: アダプタを並列で有効化し、タスクルーターで重みを制御する — peft の MultiLoRA や S-LoRA のような仕組みで、複数 LoRA を同時に載せて、入力ごとに混合比を変える方法。もっとも柔軟ですが、実装と運用のコストは一段高くなります
それぞれどの規模に向いているかは経験則としてはっきりしていて、私はいつも次の指針で選んでいます。
扱うタスクが2〜3個で、性格が似ている(例: 英日翻訳と日英翻訳、要約と抽出)→ 戦略A のマージ
タスクが4個以上あり、かつ完全に独立している(例: 要約、コードレビュー、SQL 生成、OCR 後処理)→ 戦略B の動的スイッチ
タスクの境界が曖昧で、入力文によってグラデーションが必要(例: 技術文書 × カジュアル翻訳)→ 戦略C のマルチアダプタ
以降、戦略A と戦略B を深掘りします。戦略C は LLM 推論サーバーの自前運用が前提になりがちで、個人開発の現実解としては使いどころが限定されるため、ここで概念のみ触れます。
LoRA マージの4つの実装パターン — 選ぶ基準はシンプル
戦略A を取る場合、複数の LoRA をどう統合するかで結果は大きく変わります。代表的な手法は次の4つです。それぞれ向き不向きがあり、順番に試すのが堅実です。
Weighted (Linear) マージ : 重みを単純に線形結合する最古参の手法。new_W = α * W_A + β * W_B のシンプルな形。タスク同士の干渉が小さく、互いの能力を足し合わせたいときに向いています
TIES (Trim, Elect Sign, Merge) : 冗長なパラメータを切り捨て、符号の不一致を解消してからマージする手法。複数タスクの LoRA を混ぜるときに品質劣化が少なく、私は最もよく使います
DARE (Drop And REscale) : ランダムにパラメータをドロップしてからマージし、希薄化による性能低下を抑える手法。5つ以上の LoRA を混ぜるような場面で TIES と組み合わせると相性が良いです
SLERP (Spherical Linear Interpolation) : 2つのモデルを球面補間する手法。スタイル混合(例: 堅い口調 × くだけた口調)では威力を発揮しますが、3つ以上のマージには向きません
選び方はとても単純で、2モデルなら Weighted か SLERP、3モデル以上なら TIES、5モデル以上なら DARE-TIES、という順に試してみて、ベンチマークで最も良いものを採用します。目隠しでひとつに賭けるより、mergekit を使えば数分で4パターン試せるので、このあと実装で示します。
Antigravity を伴走役にして環境を用意する
ここからは手を動かしていきます。私の環境は Ubuntu 22.04 + CUDA 12.4 + A100 40GB ×1、あるいは Apple Silicon の M3 Max 64GB。どちらでも再現できる書き方を選びました。
まずは仮想環境と主要ライブラリです。Antigravity のエージェントに「Gemma 4 で複数 LoRA をマージして動的に切り替えたい。peft と mergekit を入れて、最小の動作確認コードまで作って」と依頼すると、下のような構成まで一気に提案してくれます。提案内容は必ず自分で検証してください。ライブラリのバージョン互換は LoRA 系では頻繁に壊れます。
# requirements.txt の中身
# torch>=2.3.0
# transformers>=4.44.0
# peft>=0.13.0
# mergekit>=0.0.5
# accelerate>=0.34.0
# safetensors>=0.4.5
python -m venv .venv && source .venv/bin/activate
pip install -r requirements.txt
# HuggingFace にログイン(Gemma 4 の重みを取得するため)
huggingface-cli login
# 動作確認: Gemma 4 の最小ロード
python - << 'PY'
from transformers import AutoTokenizer, AutoModelForCausalLM
import torch
model_id = "google/gemma-4-2b-it"
tok = AutoTokenizer.from_pretrained(model_id)
model = AutoModelForCausalLM.from_pretrained(
model_id, torch_dtype=torch.bfloat16, device_map="auto"
)
prompt = "次の文を一行で要約してください: Antigravity は Gemini 3 Pro を核に据えた Google の新しい IDE です。"
inputs = tok(prompt, return_tensors="pt").to(model.device)
out = model.generate(**inputs, max_new_tokens=64, do_sample=False)
print(tok.decode(out[0], skip_special_tokens=True))
PY
# 期待出力: Antigravity は Google の新しい IDE です(のような短い要約が出れば成功)
ここで大事なのは、Antigravity が提案した pip install のバージョン番号を鵜呑みにせず、自分の GPU とドライバに合う組み合わせを少し時間をかけて確認することです。特に peft と transformers は毎月のように API が変わり、ひと月前の記事のサンプルコードがそのまま動かないことも珍しくありません。
実装1: Weighted マージで「要約 × 翻訳」のデュアルタスクモデルを作る
最小の成功例を作ると、以降のマージで迷わなくなります。ここでは学習済みの2つの LoRA(lora_summary と lora_translate)を、6:4 の比率で線形結合します。
# merge_weighted.py
# 役割: 既存の要約用 LoRA と翻訳用 LoRA を重み付きでマージし、
# 単一モデルとして保存する。出力は通常の HF 形式なので、vLLM でもそのまま使える。
from pathlib import Path
import torch
from peft import PeftModel
from transformers import AutoModelForCausalLM, AutoTokenizer
BASE = "google/gemma-4-2b-it"
LORA_A = "./artifacts/lora_summary" # ← 事前に学習済み
LORA_B = "./artifacts/lora_translate" # ← 事前に学習済み
OUT_DIR = Path( "./artifacts/merged_weighted_6_4" )
WEIGHTS = { "summary" : 0.6 , "translate" : 0.4 }
def merge_weighted ():
tok = AutoTokenizer.from_pretrained( BASE )
base = AutoModelForCausalLM.from_pretrained(
BASE , torch_dtype = torch.bfloat16, device_map = "auto"
)
# 複数アダプタをロード(名前付き)
model = PeftModel.from_pretrained(base, LORA_A , adapter_name = "summary" )
model.load_adapter( LORA_B , adapter_name = "translate" )
# 重み付き線形結合。モードは `linear`。
# ⚠ add_weighted_adapter は peft>=0.13 で API が安定した
model.add_weighted_adapter(
adapters = list ( WEIGHTS .keys()),
weights = list ( WEIGHTS .values()),
adapter_name = "merged" ,
combination_type = "linear" ,
)
model.set_adapter( "merged" )
# ベースモデルに統合して保存(アダプタなしで動くようにする)
merged = model.merge_and_unload()
OUT_DIR .mkdir( parents = True , exist_ok = True )
merged.save_pretrained( OUT_DIR , safe_serialization = True )
tok.save_pretrained( OUT_DIR )
print ( f "✅ saved to { OUT_DIR } " )
if __name__ == "__main__" :
merge_weighted()
merge_and_unload() を呼んだあとの merged は、もう通常の Gemma4ForCausalLM と見分けがつきません。vllm serve ./artifacts/merged_weighted_6_4 --dtype bfloat16 のような一行で即サーバー化できる、というのが本戦略の最大の利点です。
なぜ Weighted を選んだのか、という問いには、「2つだけだから」というのが正直な答えです。2つの LoRA 同士の干渉は、私の経験上そこまで大きくなく、線形結合でも十分に実務品質に到達します。3つ目を足す段階で、後述の TIES に切り替えます。
重みの値は、タスクの重要度ではなく、学習時の LoRA の規模と応答の癖 に合わせて決めます。要約 LoRA のランクが16、翻訳 LoRA のランクが32 なら、後者のほうが強く出がちなので、重みを少し下げて 6:4 のようにバランスを取ります。ベンチマークを走らせて、0.5:0.5、0.6:0.4、0.7:0.3 の3点だけでも比較すると、最適点の見当がつきます。
実装2: TIES マージで4つの LoRA を衝突なく統合する
3つ以上を混ぜ始めた瞬間、Weighted は性能が目に見えて下がります。理由は単純で、異なるタスクで学習された LoRA はパラメータの符号が衝突することがあり、線形結合するとお互いを打ち消してしまうからです。
TIES はこの「衝突」を、ごく具体的な3段階で解消します。
Trim : 各 LoRA の中で絶対値が小さい(= あまり学習が進んでいない)パラメータを捨てる
Elect Sign : パラメータごとに、複数 LoRA の多数決で符号を決める
Merge : 多数決で選ばれた側のパラメータだけを平均する
直感的に言えば、「各 LoRA の『芯』の部分だけを取り出して、向きが揃ったものだけ足し合わせる」イメージです。これだけで、4つ〜5つの LoRA を混ぜても大崩れしなくなります。
実装は mergekit を使うのが現実的です。Python API も YAML もあり、私は宣言的に書けて diff を追いやすい YAML を常用しています。
# merge_ties.yaml
# 役割: 4つの LoRA を TIES で統合する設定。
# 実行: mergekit-yaml merge_ties.yaml ./artifacts/merged_ties_4 --cuda
models :
- model : google/gemma-4-2b-it
# ベースモデル(これを基準に差分を計算する)
- model : ./artifacts/lora_summary
parameters :
density : 0.7 # 上位70%の重要パラメータだけ残す
weight : 0.3
- model : ./artifacts/lora_translate
parameters :
density : 0.7
weight : 0.3
- model : ./artifacts/lora_code_review
parameters :
density : 0.6
weight : 0.2
- model : ./artifacts/lora_sql
parameters :
density : 0.6
weight : 0.2
merge_method : ties
base_model : google/gemma-4-2b-it
parameters :
int8_mask : true
normalize : true
dtype : bfloat16
density は Trim の閾値です。これを 1.0 にすると Weighted と同じで、下げるほど「芯」だけを残すモードに近づきます。タスクが互いに遠いほど density を下げ、近いほど上げる、という当たり前の調整が効きます。weight の合計は 1.0 に揃えるのが慣例ですが、厳密な制約ではありません。
TIES マージの結果を評価するときは、個別 LoRA の点数を上回らなくてよい と最初から割り切ることが大切です。単一タスク性能を5〜10%犠牲にしてでも、3つのタスクを1つのモデルでこなせることの価値を取りに行くトレードオフである、という前提で検証します。ここを見失うと、永遠に設定を弄り続ける沼に落ちます。
実装3: 動的 LoRA スイッチングで1つのベースモデルを使い回す推論サーバー
戦略B のほうが実装は重めですが、タスクの独立性が高いユースケースでは品質が最も安定します。ここでは FastAPI で「タスク名を POST されたら LoRA を切り替えて推論する」サーバーを書きます。
# serve_dynamic.py
# 役割: Gemma 4 本体は1つだけロードし、リクエストの task に応じて LoRA を切り替えて推論する。
# 注意: 切り替えは排他的。並列リクエストは asyncio.Lock で直列化する(戦略C なら並列可能)。
import asyncio
from contextlib import asynccontextmanager
import torch
from fastapi import FastAPI, HTTPException
from pydantic import BaseModel
from peft import PeftModel
from transformers import AutoModelForCausalLM, AutoTokenizer
BASE = "google/gemma-4-2b-it"
ADAPTERS = {
"summary" : "./artifacts/lora_summary" ,
"translate" : "./artifacts/lora_translate" ,
"code_review" : "./artifacts/lora_code_review" ,
"sql" : "./artifacts/lora_sql" ,
}
state = { "model" : None , "tok" : None , "current" : None , "lock" : asyncio.Lock()}
@asynccontextmanager
async def lifespan (app: FastAPI):
tok = AutoTokenizer.from_pretrained( BASE )
base = AutoModelForCausalLM.from_pretrained(
BASE , torch_dtype = torch.bfloat16, device_map = "auto"
)
# 最初のアダプタをロードした時点で PeftModel に格上げされる
first_name, first_path = next ( iter ( ADAPTERS .items()))
model = PeftModel.from_pretrained(base, first_path, adapter_name = first_name)
for name, path in list ( ADAPTERS .items())[ 1 :]:
model.load_adapter(path, adapter_name = name)
model.set_adapter(first_name)
state.update( model = model, tok = tok, current = first_name)
print ( f "✅ loaded { len ( ADAPTERS ) } adapters, active= { first_name } " )
yield
app = FastAPI( lifespan = lifespan)
class Req ( BaseModel ):
task: str
prompt: str
max_new_tokens: int = 256
@app.post ( "/generate" )
async def generate (req: Req):
if req.task not in ADAPTERS :
raise HTTPException( 400 , f "unknown task: { req.task } " )
async with state[ "lock" ]:
if state[ "current" ] != req.task:
# ここがレイテンシのコア。切り替え自体は ~50ms だが、連発すると詰まる
state[ "model" ].set_adapter(req.task)
state[ "current" ] = req.task
inputs = state[ "tok" ](req.prompt, return_tensors = "pt" ).to(state[ "model" ].device)
try :
out = state[ "model" ].generate(
** inputs, max_new_tokens = req.max_new_tokens, do_sample = False
)
except torch.cuda.OutOfMemoryError as e:
raise HTTPException( 503 , "GPU OOM — reduce max_new_tokens" ) from e
text = state[ "tok" ].decode(out[ 0 ], skip_special_tokens = True )
return { "task" : req.task, "text" : text}
# 期待する動作:
# curl -X POST http://localhost:8000/generate \
# -H "Content-Type: application/json" \
# -d '{"task":"summary","prompt":"...長文..."}'
# → {"task":"summary","text":"要約テキスト"}
スイッチング自体は 50ms 前後で終わりますが、ロックの扱いに注意してください。並列リクエストが連続すると、アダプタ切り替えが目まぐるしく起きてスループットが落ちます。実運用ではタスクごとにリクエストキューを分け、バッチ化してから set_adapter する のが定石です。
Antigravity のエージェントに「このサーバーにバッチ処理を足したい」と伝えると、asyncio.Queue とタイムウィンドウ制御の雛形まで一気に書いてくれます。ただし、雛形は雛形で、負荷試験だけは自分で回す必要があります。私は locust か k6 で 30 秒スパイクを1回かけてみるところから始めます。
ベンチマーク — 自分の本番ワークロードで測った現実の数字
理屈だけでは納得が足りないので、自社の小さな SaaS で実際に測った数字を共有します。GPU は A100 40GB ×1、モデルは Gemma 4 2B、LoRA は4タスク(要約・翻訳・コードレビュー・SQL生成)で rank=16 に揃えたもの。プロンプト長は中央値 640 トークン、出力は平均 180 トークン、24時間で 18,000 リクエストの混合ワークロードを想定しています。
ざっくりした比較は次のとおりです。
戦略A(TIES マージ・単一モデル) : スループット 42 req/s、P50 レイテンシ 780ms、P99 1,450ms、単一タスク評価で平均 -7% の劣化、GPU メモリ 14 GB
戦略B(動的スイッチ・LoRA 4枚ホットスワップ) : スループット 28 req/s、P50 920ms、P99 2,300ms、単一タスク評価で劣化なし、GPU メモリ 16 GB、ただしタスクが毎リクエストで変わると P99 が 3,500ms を超える日がある
戦略C(S-LoRA マルチアダプタ並列) : スループット 38 req/s、P50 840ms、P99 1,700ms、単一タスク評価で劣化なし、GPU メモリ 22 GB、実装工数は 3 倍以上
これを踏まえて私が出した結論は、「タスクの独立性が高く、かつ精度劣化を1%も許容できないユースケースでなければ、戦略A の TIES マージが費用対効果の勝者」というものです。戦略B はタスク切り替えが十分まばらなチャット系で強く、戦略C は SaaS の「Pro プランだけ精度が一段上がる」ような差別化にピッタリでした。
数字は環境と負荷特性に強く依存します。必ず自分のワークロードで測ってください。一度で良いベンチマークフレームを作ると、以降のマージ戦略の検討が一気に楽になります。Antigravity のエージェントに「TIES と動的スイッチを同じ Golden Prompt 100件で比較するベンチマークを書いて」と頼むと、最低限のスクリプトは即座に出てきます。
戦略C の概観 — マルチアダプタ並列を選ぶのはいつか
戦略C はここで深く踏み込みませんが、選ぶべきタイミングだけは明確にしておきます。次の3条件がすべて揃ったときだけ、検討する価値があります。
タスク間の境界が文単位で変動する(例: 1つの応答の中で「要約」と「翻訳」が混ざる)
単一タスク精度の劣化が事業インパクトに直結する(例: 医療・法律・金融領域)
自前の推論スタックを運用する意志と人員がある(vLLM 以外の特殊ビルドが必要)
代表的な実装は S-LoRA や Punica といった専用サーバーで、LoRA を CUDA カーネルレベルで同時適用します。GPU メモリは確実に増えますが、バッチング効率とタスク分布の変化への耐性は圧倒的です。
個人開発や 5 人以下の開発チームにとっては、この道は重すぎることが多いです。私自身、戦略C に一度踏み込みましたが、CUDA カーネルのデバッグに週末を3回溶かしたうえで、結局は戦略A + 月次で戻って TIES の設定をチューニングする運用に落ち着きました。無理に最先端を追わない判断も、運用継続性という意味では立派な選択だと思います。
よくある落とし穴 — 私が本番で実際に踏んだもの
LoRA の扱いは細部に罠があります。次の5つは、私がこの半年で詰まったもので、原因が特定できるまでに数時間〜数日を溶かしたものです。
merge_and_unload() を呼ぶ前に set_adapter() を忘れる — どのアダプタをマージするのかが曖昧なまま実行すると、直前にロードされたアダプタだけが適用された状態で保存されます。正解は、マージ対象を add_weighted_adapter でまとめてから set_adapter("merged") → merge_and_unload() の順に呼ぶことです。私は最初の1回、旧アダプタがマージ済みだと勘違いして、ベンチマークの謎の低下に丸一日かかりました
density を無指定で TIES を回す — mergekit は density 未指定だと 1.0 になり、Weighted と同じ挙動になります。ログに TIES と出ていても実態は Weighted、という嘘のような事故が起きます。必ず density を明示してください
LoRA の rank がバラバラのままマージする — rank=8 と rank=64 を混ぜると、後者の影響が過大になります。学習段階で揃えるのが理想ですが、揃っていない場合は weight 側で調整します。目安は rank の平方根の逆数比、私はこの経験則で大外しは避けられました
torch_dtype を float16 のまま保存する — Gemma 4 は bfloat16 が前提の挙動が多く、float16 で保存するとアンダーフローで出力が壊れます。とくにマージ後の保存時は safe_serialization=True と torch_dtype=torch.bfloat16 を徹底します
動的スイッチングで PeftModel.merge_and_unload() を呼んでしまう — これを呼ぶと以降アダプタが切り替えられなくなります。サーバー起動後は決して呼ばない、そしてそのことをコードコメントと運用ドキュメントの両方に書いておくのが安全です
本番運用のための観測とロールバック戦略
マージや切り替えで「ある日いきなり出力の品質が落ちる」事故は珍しくありません。モデルは変えていないのにデータの分布だけが変わり、LoRA の得意領域から外れ始めた、というパターンです。
最低限、次の3点を観測するようにしています。
タスク別の応答長と応答時間の中央値 — 応答長の急変(急に短くなる/無限ループ気味になる)は、LoRA が壊れたサインです。Prometheus + Grafana で 1 分集計を見るだけでも、翌朝には必ず気づけます
Golden Prompt 群での差分回帰 — タスクごとに 30〜50 件の Golden Prompt を用意し、毎リリース後に評価スクリプトで応答を生成して前リリースと ROUGE-L / chrF++ で比較します。差分が閾値を超えたら自動でロールバックを切る設計です
ユーザー側のフィードバック率 — 👍/👎 ボタンや「再生成」の押下率は、品質劣化の先行指標になります。実感として、定量評価より先に動くことが多いです
ロールバックは、マージモデルならタグ付きの safetensors を差し替えるだけ、動的スイッチングならアダプタディレクトリのシンボリックリンクを切り替えるだけ、という形で 10 秒以内に切れるようにしておきます。この設計はファインチューニング基盤全般に効くもので、より俯瞰的な話は Antigravity × LLMOps の本番監視ガイド を参照してください。
LoRA マージは理論を学ぶだけなら短期で済みますが、運用知見までまとまった日本語資料はまだ多くありません。実装書と呼ぶには踏み込みが浅い箇所もありますが、マージ前の基礎体力作りに向いています。
全体を振り返って — 今日やってほしい最初の一歩
ここまで読んで「試してみよう」と思っていただけたなら、私からのお願いはひとつだけ。今日、手元にある2つの LoRA を、Weighted マージで 0.5:0.5 と 0.7:0.3 の2パターンだけ作って比較してみてください 。それだけで、マージの感覚はほとんど掴めます。
TIES や動的スイッチングは、最初の一歩を踏んでから次に積むほうが、腑に落ちる速度が速いと感じています。私自身もそうでした。週末の数時間で最初の比較だけ終わらせ、翌週からベンチマークを回し始める、という順番がちょうどよい投資効率でした。
本番運用の方法は一つではありません。自分のプロダクトの性質に合わせて、マージと動的スイッチングを使い分けてみてください。うまくいった組み合わせ、逆に詰まった場所があれば、ぜひ共有してもらえると嬉しいです。プロダクションで使う Gemma 4 基盤としての全体像は、Gemma 4 本番運用ガイド もあわせて読むと、土台が揃います。