3つの LoRA を学習し終えた翌朝、本当の課題が見えてきました
アプリのレビューを要約する LoRA、ストアの説明文を日英で訳す LoRA、そしてコードレビュー用の LoRA。個人開発の運用で毎日のように発生する3つの作業に合わせて、Gemma 4 へ別々に学習させてみました。それぞれの評価は満足のいく出来で、週末の達成感をひとしきり噛みしめておりました。
手が止まったのは翌朝です。3つを別々のサーバーで常駐させる見積もりを出したところで、月々の請求額が頭に浮かび、胃のあたりが重くなりました。レビュー要約は朝に集中し、翻訳はストア更新の前だけ、コードレビューは夜。使う時間帯がばらばらの3タスクに、GPU を3枚分払い続けるのは個人の財布には合いません。
この記事は、そのとき私が辿った試行錯誤を残した実装ノートです。複数 LoRA の「マージ」と「動的スイッチング」という2つのアプローチを軸に、Antigravity のエージェントを伴走させながら、実運用できるかたちに落としていきます。Weighted と TIES の違い、マージ前に必ず確かめるべき設定、set_adapter() の罠まで、つまずいた場所を順に書き残します。
対象は、LoRA / QLoRA での学習をすでに一度は経験し、次に「本番でどう組み合わせるか」を考えている方です。ファインチューニング自体の基礎は、Gemma 4 の LoRA / QLoRA ファインチューニング実務ノート に譲ります。
複数タスクに LoRA を使うときの選択肢は3つです
整理すると、選べる戦略はおおむね次の3つに集約されます。
戦略A: マージして1つの単一モデルにする — 学習後の LoRA 重みを統合し、通常の Gemma 4 と同じように動かす方法です。推論経路が単純で、既存の vLLM などの環境にそのまま載せられます
戦略B: ベースモデル1つにアダプタを動的に差し替える — Gemma 4 本体は1つだけロードし、リクエストの性質に応じて LoRA アダプタだけを切り替える方法です。メモリは節約できますが、切り替えのレイテンシと同時実行の難しさを引き受けることになります
戦略C: アダプタを並列で有効化し、ルーターで重みを制御する — S-LoRA や Punica のような専用サーバーで複数 LoRA を同時に載せ、入力ごとに使い分ける方法です。もっとも柔軟ですが、実装と運用のコストは一段高くなります
私はいつも、次の目安で選んでいます。
タスクが2〜3個で、性格が似ている場合(英日翻訳と日英翻訳、要約と抽出など)→ 戦略A のマージ
タスクが4個以上あり、互いに独立している場合(要約、コードレビュー、SQL 生成など)→ 戦略B の動的スイッチ
1つの応答の中でタスクが混ざる場合(技術文書の要約をそのまま英訳する、など)→ 戦略C のマルチアダプタ
マージ手法は4つ、選ぶ基準は単純です
戦略A を取る場合、複数の LoRA をどう統合するかで結果は大きく変わります。
Weighted(Linear) : 重みを線形結合する最古参の手法です。new_W = α * W_A + β * W_B という単純な形で、タスク同士の干渉が小さいときに向いています
TIES(Trim, Elect Sign, Merge) : 小さな変化量を切り捨て、符号の衝突を多数決で解消してから統合する手法です。3つ以上を混ぜるとき、私はこれを最初に試します
DARE(Drop And REscale) : ランダムに変化量を落としてから再スケールする手法です。TIES と組み合わせた DARE-TIES は、多数の LoRA を混ぜる場面で候補になります
SLERP(球面線形補間) : 2つのモデルを球面上で補間する手法です。口調の混合などには向きますが、3つ以上の統合には使えません
2つなら Weighted か SLERP、3つ以上なら TIES、さらに多ければ DARE-TIES。この順に試し、自分の評価セットでいちばん良いものを採用します。目隠しでひとつに賭けるより、数パターンを並べて比べるほうが結局は早いのです。
Antigravity を伴走役にして環境を用意し、マージ前の点検から始めます
ここからは手を動かします。私が試したのは CUDA の GPU 1 枚の Linux 環境と、Apple Silicon の Mac の2つで、どちらでも読み替えられる書き方にしています。
Antigravity のエージェントに「Gemma 4 で複数 LoRA をマージして動的に切り替えたい。peft を入れて、最小の動作確認コードまで作って」と依頼すると、下のような構成まで一気に提案してくれます。ただし、ここで一つ失敗をしました。最初に出てきた案はモデル ID が gemma-4-2b-it という存在しない名前で、ロードの段階で 404 になったのです。Gemma 4 の小型モデルは google/gemma-4-E2B-it のように「E」付きで公開されており、ローダーも画像や音声を扱える AutoModelForMultimodalLM が公式の書き方です。
# requirements.txt の中身(バージョンは自分の GPU とドライバで必ず確かめてください)
# torch
# transformers # Gemma 4 に対応した最新版
# peft
# accelerate
# safetensors
# httpx fastapi uvicorn # 後半のサーバーとベンチマーク用
python -m venv .venv && source .venv/bin/activate
pip install -U -r requirements.txt
# Hugging Face にログイン(Gemma 4 の重みを取得するため)
hf auth login
# 動作確認: Gemma 4 E2B の最小ロードと1回の生成
python - << 'PY'
from transformers import AutoProcessor, AutoModelForMultimodalLM
MODEL_ID = "google/gemma-4-E2B-it"
processor = AutoProcessor.from_pretrained(MODEL_ID)
model = AutoModelForMultimodalLM.from_pretrained(MODEL_ID, dtype="auto", device_map="auto")
messages = [{"role": "user", "content": "次の文を一行で要約してください: 壁紙アプリの更新後、読み込みが遅いというレビューが三件続きました。"}]
inputs = processor.apply_chat_template(
messages, tokenize=True, return_dict=True, return_tensors="pt",
add_generation_prompt=True, enable_thinking=False,
).to(model.device)
n = inputs["input_ids"].shape[-1]
out = model.generate(**inputs, max_new_tokens=64, do_sample=False)
print(processor.decode(out[0][n:], skip_special_tokens=True))
PY
エージェントの提案は叩き台として優秀ですが、モデル名とライブラリのバージョンだけは、公式のモデルカードで自分の目で確かめます。この順番を守るようになってから、同じ種類のつまずきはなくなりました。
マージ前に adapter_config.json を突き合わせる
マージの失敗の多くは、実行する前に分かります。LoRA のフォルダには必ず adapter_config.json があり、そこに「どの土台モデルで」「どのランクで」「どの層に」学習したかが書かれているからです。私は一度、翻訳用だけ別の土台で学習したことを忘れたままマージを試み、数十分待ったあとにエラーで止まりました。
それ以来、マージの前には次の点検スクリプトを必ず通しています。
# lora_preflight.py
# 役割: 複数 LoRA の adapter_config.json を読み、マージ可能かを事前に判定する。
# 使い方: python lora_preflight.py ./artifacts/lora_summary ./artifacts/lora_translate ...
# 終了コード: 0=そのままマージ可 / 1=手法の選び直しが必要 / 2=マージ不可(土台違い)
import json
import sys
from pathlib import Path
def load (path: str ) -> dict :
cfg = json.loads((Path(path) / "adapter_config.json" ).read_text())
return {
"path" : path,
"base" : cfg.get( "base_model_name_or_path" ),
"r" : cfg.get( "r" ),
"alpha" : cfg.get( "lora_alpha" ),
"targets" : sorted (cfg.get( "target_modules" ) or []),
}
def main (paths: list[ str ]) -> int :
cfgs = [load(p) for p in paths]
for c in cfgs:
print ( f " { c[ 'path' ] } : base= { c[ 'base' ] } r= { c[ 'r' ] } alpha= { c[ 'alpha' ] } targets= { len (c[ 'targets' ]) } " )
if len ({c[ "base" ] for c in cfgs}) > 1 :
print ( "❌ 土台モデルが一致しません。同じ土台で学習し直すか、マージ対象から外してください" )
return 2
code = 0
if len ({c[ "r" ] for c in cfgs}) > 1 :
print ( "⚠️ ランクが揃っていません。linear / ties は使えないため、cat か *_svd 系を選んでください" )
code = 1
if len ({ tuple (c[ "targets" ]) for c in cfgs}) > 1 :
print ( "⚠️ 対象層が異なります。片方にしか無い層は、その LoRA の効果だけが残ります" )
code = max (code, 1 )
if len ({c[ "alpha" ] / c[ "r" ] for c in cfgs if c[ "r" ]}) > 1 :
print ( "⚠️ alpha/r の比が異なります。同じ重みでも効き方が変わるため、weight 側で補正してください" )
code = max (code, 1 )
if code == 0 :
print ( "✅ 土台・ランク・対象層が一致しています。linear / ties をそのまま試せます" )
return code
if __name__ == "__main__" :
sys.exit(main(sys.argv[ 1 :]))
peft の add_weighted_adapter() は、linear や ties のようにアダプタの A 行列と B 行列をそのまま足し合わせる手法では、ランクが揃っていることを前提にしています。揃っていないときの選択肢は、行列を連結する cat か、差分を SVD で近似し直す svd 系です。点検で「1」が返ったら、次の実装でどの combination_type を使うかをここで決めておきます。
実装1: Weighted マージで「要約 × 翻訳」のデュアルタスクモデルを作ります
最小の成功例を1つ作ると、以降のマージで迷わなくなります。ここでは学習済みの2つの LoRA を、6:4 の比率で線形結合します。
# merge_weighted.py
# 役割: 要約用 LoRA と翻訳用 LoRA を重み付きでマージし、単一モデルとして保存する。
# 前提: lora_preflight.py が 0 を返していること(ランクと土台が一致)。
from pathlib import Path
from peft import PeftModel
from transformers import AutoModelForMultimodalLM, AutoProcessor
BASE = "google/gemma-4-E2B-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 ():
processor = AutoProcessor.from_pretrained( BASE )
base = AutoModelForMultimodalLM.from_pretrained( BASE , dtype = "auto" , device_map = "auto" )
# 複数アダプタを名前付きでロード
model = PeftModel.from_pretrained(base, LORA_A , adapter_name = "summary" )
model.load_adapter( LORA_B , adapter_name = "translate" )
# 重み付き線形結合。ランクが違う場合は combination_type="cat" に変える
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 )
processor.save_pretrained( OUT_DIR )
print ( f "✅ saved to { OUT_DIR } " )
if __name__ == "__main__" :
merge_weighted()
merge_and_unload() のあとの merged は、アダプタを持たない通常のモデルとして保存されます。推論側はアダプタの存在を意識しなくてよい——これが戦略A のいちばんの利点です。
なぜ Weighted を選んだのか、と問われれば、「2つだけだから」というのが答えです。性格の近い2つの LoRA なら、線形結合でも実務に耐える品質に届くことが多いのです。3つ目を足す段階で、次の TIES に切り替えます。
重みの値は、タスクの重要度ではなく、学習時の alpha/r と応答の癖 に合わせて決めます。点検スクリプトで alpha/r の比が違うと出たなら、比の大きい側が強く出がちなので、その LoRA の重みを少し下げます。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 の芯だけを取り出し、向きの揃ったものだけを足す」という考え方です。
いちばん手軽なのは、peft のまま TIES を指定する方法です。実装1から変わるのは combination_type と density の2か所だけです。
# merge_ties_peft.py(merge_weighted.py からの差分部分)
ADAPTERS = {
"summary" : ( "./artifacts/lora_summary" , 0.3 ),
"translate" : ( "./artifacts/lora_translate" , 0.3 ),
"code_review" : ( "./artifacts/lora_code_review" , 0.2 ),
"sql" : ( "./artifacts/lora_sql" , 0.2 ),
}
first, (path0, _) = next ( iter ( ADAPTERS .items()))
model = PeftModel.from_pretrained(base, path0, adapter_name = first)
for name, (path, _) in list ( ADAPTERS .items())[ 1 :]:
model.load_adapter(path, adapter_name = name)
model.add_weighted_adapter(
adapters = list ( ADAPTERS .keys()),
weights = [w for _, w in ADAPTERS .values()],
adapter_name = "merged_ties" ,
combination_type = "ties" , # ランク違いなら "ties_svd"
density = 0.7 , # 上位 70% の変化量だけを残す
majority_sign_method = "total" ,
)
model.set_adapter( "merged_ties" )
merged = model.merge_and_unload()
一方で、peft の TIES は A 行列と B 行列それぞれに処理をかけるため、「LoRA が土台に与える差分そのもの」に TIES をかける本来の手順とは少し異なります。より忠実に試したいときは mergekit を使います。mergekit では「土台モデル+LoRA のパス」と書くと、LoRA を焼き込んだ状態のモデルとして扱ってくれます。
# merge_ties.yaml
# 実行: mergekit-yaml merge_ties.yaml ./artifacts/merged_ties_4 --cuda
# 注意: お使いの mergekit が Gemma 4 のアーキテクチャに対応しているかを先に確かめてください
models :
- model : google/gemma-4-E2B-it+./artifacts/lora_summary
parameters : { density : 0.7 , weight : 0.3 }
- model : google/gemma-4-E2B-it+./artifacts/lora_translate
parameters : { density : 0.7 , weight : 0.3 }
- model : google/gemma-4-E2B-it+./artifacts/lora_code_review
parameters : { density : 0.6 , weight : 0.2 }
- model : google/gemma-4-E2B-it+./artifacts/lora_sql
parameters : { density : 0.6 , weight : 0.2 }
merge_method : ties
base_model : google/gemma-4-E2B-it
parameters :
normalize : true
int8_mask : true
dtype : bfloat16
density は Trim の閾値です。1.0 にすると切り捨てが起きず、下げるほど芯だけを残すモードに近づきます。タスク同士が遠いほど下げ、近いほど上げる、という調整がよく効きます。
TIES の結果を評価するときは、個別 LoRA の点数を上回らなくてよい と最初から割り切っておきます。単一タスクの性能を少し手放してでも、複数タスクを1つのモデルでこなせる価値を取りに行くトレードオフだからです。ここを見失うと、設定を延々といじり続けることになります。
実装3: 動的 LoRA スイッチングで1つのベースモデルを使い回します
戦略B は実装が重めですが、タスクの独立性が高いときには品質がいちばん安定します。ここでは FastAPI で「タスク名を受け取ったら LoRA を切り替えて推論する」サーバーを書きます。後半のベンチマークで使うため、切り替え回数を数える /stats も用意しました。
# serve_dynamic.py
# 役割: Gemma 4 本体は1つだけロードし、リクエストの task に応じて LoRA を切り替えて推論する。
# 注意: 切り替えは排他的。並列リクエストは asyncio.Lock で直列化する。
import asyncio
from contextlib import asynccontextmanager
import torch
from fastapi import FastAPI, HTTPException
from pydantic import BaseModel
from peft import PeftModel
from transformers import AutoModelForMultimodalLM, AutoProcessor
BASE = "google/gemma-4-E2B-it"
ADAPTERS = {
"summary" : "./artifacts/lora_summary" ,
"translate" : "./artifacts/lora_translate" ,
"code_review" : "./artifacts/lora_code_review" ,
"sql" : "./artifacts/lora_sql" ,
}
state = { "model" : None , "proc" : None , "current" : None ,
"lock" : asyncio.Lock(), "switches" : 0 , "requests" : 0 }
@asynccontextmanager
async def lifespan (app: FastAPI):
proc = AutoProcessor.from_pretrained( BASE )
base = AutoModelForMultimodalLM.from_pretrained( BASE , dtype = "auto" , device_map = "auto" )
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)
model.eval()
state.update( model = model, proc = proc, 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" ]:
state[ "requests" ] += 1
if state[ "current" ] != req.task:
state[ "model" ].set_adapter(req.task)
state[ "current" ] = req.task
state[ "switches" ] += 1
proc = state[ "proc" ]
inputs = proc.apply_chat_template(
[{ "role" : "user" , "content" : req.prompt}],
tokenize = True , return_dict = True , return_tensors = "pt" ,
add_generation_prompt = True , enable_thinking = False ,
).to(state[ "model" ].device)
n = inputs[ "input_ids" ].shape[ - 1 ]
try :
with torch.inference_mode():
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
return { "task" : req.task, "text" : proc.decode(out[ 0 ][n:], skip_special_tokens = True )}
@app.get ( "/stats" )
async def stats ():
r = state[ "requests" ] or 1
return { "requests" : state[ "requests" ], "switches" : state[ "switches" ],
"switch_ratio" : round (state[ "switches" ] / r, 3 )}
# 起動: uvicorn serve_dynamic:app --port 8000
generate はイベントループを塞ぐため、本番ではスレッドプールへ逃がすか、タスクごとにキューを分けてまとめて処理します。いずれにしても、切り替えの回数そのものを減らす ことが、この戦略の性能を決めます。
Antigravity のエージェントに「このサーバーにタスク別キューを足したい」と伝えると、asyncio.Queue と時間窓でまとめる雛形まで書いてくれます。ただ、雛形は雛形です。負荷をかけて測るところだけは、自分で手を動かす必要があります。
ベンチマーク — 切り替えの頻度を変えて、自分の負荷で測ります
戦略を決めるときに私がいちばん知りたかったのは、「タスクが入り混じったとき、どれだけ遅くなるのか」でした。平均の数字は、ほとんど参考になりません。差が出るのは、切り替えが連続したときの P99 です。
そこで、同じ本数のリクエストを「タスクごとにまとめて送る」場合と「毎回タスクを変えて送る」場合の2通りで流し、レイテンシの分布と /stats の切り替え率を並べるスクリプトを用意しました。
# bench_switch_churn.py
# 役割: serve_dynamic.py に対し、タスクの並び順だけを変えて負荷をかけ、P50 / P99 / 切り替え率を比べる。
# 前提: golden_prompts.json に {"summary": [...], "translate": [...], ...} の形で評価用プロンプトを置く。
import asyncio
import json
import random
import time
import httpx
URL = "http://localhost:8000"
TASKS = [ "summary" , "translate" , "code_review" , "sql" ]
PROMPTS = json.load( open ( "golden_prompts.json" , encoding = "utf-8" ))
def make_order (n: int , mode: str ) -> list[ str ]:
if mode == "interleaved" : # 毎回タスクが変わる最悪ケース
return [ TASKS [i % len ( TASKS )] for i in range (n)]
block = n // len ( TASKS ) # タスクごとにまとめて届く最良ケース
return [t for t in TASKS for _ in range (block)]
def pct (values: list[ float ], q: float ) -> float :
s = sorted (values)
return s[ min ( len (s) - 1 , int (q * len (s)))]
async def run (mode: str , n: int = 200 , concurrency: int = 8 ) -> None :
order = make_order(n, mode)
sem = asyncio.Semaphore(concurrency)
lat: list[ float ] = []
async with httpx.AsyncClient( timeout = 180 ) as c:
before = ( await c.get( f " { URL } /stats" )).json()
async def one (task: str ) -> None :
async with sem:
t0 = time.perf_counter()
r = await c.post( f " { URL } /generate" , json = {
"task" : task, "prompt" : random.choice( PROMPTS [task]), "max_new_tokens" : 128 })
r.raise_for_status()
lat.append((time.perf_counter() - t0) * 1000 )
t0 = time.perf_counter()
await asyncio.gather( * (one(t) for t in order))
wall = time.perf_counter() - t0
after = ( await c.get( f " { URL } /stats" )).json()
switches = after[ "switches" ] - before[ "switches" ]
print ( f " { mode :12s } n= { n } rps= { n / wall :5.2f } "
f "p50= { pct(lat, .50 ) :6.0f } ms p99= { pct(lat, .99 ) :6.0f } ms switches= { switches } " )
async def main () -> None :
random.seed( 7 )
for mode in ( "grouped" , "interleaved" ):
await run(mode)
if __name__ == "__main__" :
asyncio.run(main())
読み方は単純です。grouped と interleaved で P99 の差が小さければ、戦略B をそのまま採用して構いません。差が大きく、しかも本番のアクセスが interleaved に近い——たとえば1つの画面から複数のタスクが交互に呼ばれる——なら、タスク別キューを入れるか、戦略A のマージへ寄せます。
戦略A と比べるときは、同じ golden_prompts.json をマージ済みモデルにも流し、品質の差を ROUGE-L や chrF++ で並べます。速度は戦略A が有利になりやすく、品質は戦略B が有利になりやすいのです。その差が自分のプロダクトで許容できるかどうかが、判断のすべてです。
ここで得た原則は一つです。戦略は、ベンチマークの平均値ではなく、自分の本番のリクエストの並び方で決めます。 同じ4つの LoRA でも、アクセスの並びが違えば答えは変わります。
戦略C を選ぶのは、3つの条件が揃ったときだけです
戦略C は深入りしませんが、検討する価値があるのは次の3つが揃ったときだけだと考えています。
1つの応答の中で、タスクの境界が文単位で変わること
単一タスクの精度低下が、そのまま利用者の不利益につながる領域であること
専用の推論サーバーを自分で運用し続ける時間と体力があること
S-LoRA や Punica は、複数の LoRA を1つのバッチの中で同時に適用できるため、タスク分布の変化には強い設計です。その代わり、GPU メモリと運用の手間は確実に増えます。
個人開発にとって、この道は重すぎることが多いのです。私は戦略C には踏み込まず、戦略A を土台に、TIES の設定を月に一度見直す運用を選びました。最先端を追わない判断も、運用を続けるという意味では立派な選択だと感じています。
よくある落とし穴 — 私が実際に踏んだもの
LoRA の扱いは細部に罠があります。次の5つは、原因の特定までに時間を溶かしたものです。
merge_and_unload() の前の set_adapter() 忘れ — add_weighted_adapter で作ったアダプタを有効にしないまま実行すると、直前に有効だった別のアダプタが焼き込まれて保存されます。評価の数字が妙に一方のタスクに偏っていたら、まずここを疑います
ランク違いのまま linear や ties を指定すること — peft はランクが揃っていない組み合わせを受け付けません。点検スクリプトで先に分かるので、cat か *_svd 系に切り替えます
mergekit の density の指定漏れ — TIES と書いたつもりでも、設定次第では切り捨てがほとんど起きず、実質的に線形結合と変わらない結果になります。density は毎回明示します
保存時の dtype の取り違え — Gemma 系は bfloat16 での学習と推論が前提の場面が多く、float16 に落として保存すると出力が崩れることがあります。dtype="auto" でロードし、保存も元の型のまま行います
動的スイッチングのサーバーで merge_and_unload() を呼ぶこと — 呼んだ瞬間にアダプタが本体へ焼き込まれ、以降は切り替えられなくなります。サーバー側のコードでは決して呼ばない、とコメントに書き残しておきます
本番運用のための観測とロールバック
マージや切り替えで「ある日から出力の質が落ちる」事故は珍しくありません。モデルは変えていないのに入力の分布だけが変わり、LoRA の得意な範囲から外れ始める、というかたちです。
最低限、次の3点を見るようにしています。
タスク別の応答長と応答時間の中央値 — 応答長の急変(急に短くなる、同じ句を繰り返し始める)は、LoRA がうまく効いていないサインです
Golden Prompt 群での差分回帰 — タスクごとに 30〜50 件の評価用プロンプトを用意し、更新のたびに前の版と ROUGE-L や chrF++ で比べます。ベンチマークで使った golden_prompts.json をそのまま流用できます
利用者側の反応 — 再生成ボタンの押下率や、翻訳結果を手で直した回数は、定量評価より先に動くことが多い指標です
ロールバックは、マージモデルならタグ付きの safetensors を差し替えるだけ、動的スイッチングならアダプタのディレクトリを指すシンボリックリンクを付け替えるだけ、というかたちで、すぐに戻せるようにしておきます。戻す手段が一瞬で使えると分かっているだけで、更新の夜の眠りがずいぶん深くなりました。
今日やってほしい最初の一歩
まずは手元の LoRA を lora_preflight.py に通してください。そのうえで同じ土台・同じランクの2つを、Weighted マージで 0.5:0.5 と 0.7:0.3 の2パターンだけ作って比べてみてください 。それだけで、マージの手触りはほとんど掴めます。
TIES や動的スイッチングは、最初の一歩を踏んでから積むほうが、腑に落ちるのが早いと感じています。週末に最初の比較だけを終わらせ、翌週からベンチマークを回し始める——私にはこの順番がちょうどよい歩幅でした。
Gemma 4 をローカルで動かす土台そのものについては、Gemma 4 本番運用ガイド もあわせてご覧ください。最後まで読んでくださり、ありがとうございました。