LoRAファインチューニングを本番投入して6ヶ月——失敗だらけで学んだQLoRA・DoRA・学習率の現実
「サクッとドメイン特化LLMが作れる」と思ったら泥沼に。rank設定ミス・損失発散・VRAM溢れを全部踏んだ6ヶ月の実録。同じ失敗を繰り返さないための知見をまとめました。
半年前、「LoRAでサクッと自社ドメインに特化したLLM作れるじゃん」と思って軽い気持ちで始めたプロジェクトが、気づいたら泥沼になっていた。rank設定を間違えて精度が出ず、学習率スケジューラの選択を誤って損失が発散し、量子化の設定ミスでVRAMが溢れ——という失敗を全部踏んできた。
この記事はその6ヶ月で得た「事前に知っておけばよかった」知見をまとめたもの。ローカルLLM構築の基盤についてはローカルLLM構築完全ガイド2026|Ollama・llama.cpp本番運用の実践戦略に詳しく書いたので、今回はファインチューニング特化の話に絞る。
LoRA・QLoRA・DoRA、2026年時点での選び分けの正解
最初に整理しておくと、2026年現在、LoRAファミリーはかなり選択肢が増えている。最初のプロジェクトでは「とりあえずQLoRA」と決め打ちしたせいで、後から「あそこはDoRAにすべきだった」と後悔することが何度もあった。
| 手法 | VRAMコスト | 精度 | 学習速度 | 適用場面 |
|---|---|---|---|---|
| LoRA | 中 | 良 | 速 | VRAM 16GB以上、精度優先 |
| QLoRA | 低 | 良〜中 | やや遅 | VRAM 8〜12GB、コスト制約あり |
| DoRA | 中高 | 最良 | 遅 | タスク特化・精度最優先 |
| LoftQ | 低 | 良 | 中 | QLoRA+初期化改善が必要な場合 |
| LoRA+ | 中 | 良〜最良 | 速 | A/Bパラメータ分離学習率 |
うちのチームが最終的に落ち着いたのは、コスト制約がある開発フェーズはQLoRA、精度要件が厳しい本番モデルはDoRAという使い分け。DoRAは2024年に登場した手法で、重みを大きさ(magnitude)と方向(direction)に分解して学習するアーキテクチャなんだけど、実際にQLoRAと比較したとき、うちのドメイン特化タスクで評価スコアが3〜8%改善した。学習時間が1.4倍になるのは正直しんどいが、精度が出るなら受け入れられる——そのトレードオフはまだチームで議論中ではある。
# DoRA + QLoRA の設定例(transformers + peft 2026年版)
from peft import LoraConfig, get_peft_model, TaskType
from transformers import BitsAndBytesConfig
import torch
# 4bit量子化(QLoRA ベース)
bnb_config = BitsAndBytesConfig(
load_in_4bit=True,
bnb_4bit_use_double_quant=True, # Double quantization で更にVRAM節約
bnb_4bit_quant_type="nf4", # NF4がほぼデフォルト解
bnb_4bit_compute_dtype=torch.bfloat16,
)
# DoRA を有効化した LoraConfig
lora_config = LoraConfig(
task_type=TaskType.CAUSAL_LM,
r=64, # rank:後述するが最初16にして後悔した
lora_alpha=128, # alpha = r * 2 がだいたい安定
target_modules=[
"q_proj", "k_proj", "v_proj", "o_proj",
"gate_proj", "up_proj", "down_proj" # MLP層も含めるのがポイント
],
lora_dropout=0.05,
bias="none",
use_dora=True, # DoRA有効化:これだけで切り替わる
)
use_dora=True の一行だけでDoRAに切り替わるのは地味に便利。最初、設定方法が分からず実装を読んでいたけど、peftライブラリ側でかなり整備されていた。
rankと学習率で半月溶かした話
正直、ここが一番ハマった。「rankは小さいほどパラメータが少なくて良い」と思って r=16 で始めたら、複雑な推論タスクで精度が全然出なかった。その後 r=64 に上げたら改善したが、今度は学習が不安定になった。半月、ほぼこれだけに使った。
rank選択の経験則として、2026年現在は以下を使っている:
xychart-beta
title "rankと評価スコアの関係(うちのドメインタスク)"
x-axis ["r=8", "r=16", "r=32", "r=64", "r=128", "r=256"]
y-axis "評価スコア(F1)" 0.60 --> 0.90
bar [0.63, 0.71, 0.78, 0.84, 0.85, 0.84]
r=64 と r=128 でほぼ変わらないのが見て取れる。大抵のタスクで r=32〜64 がスイートスポットになることが多い。ただしコード生成系のタスクは r=128 まで効果があったので、タスク特性による。個人的には「とりあえず r=32 で試して、精度が足りなければ r=64 に上げる」くらいの温度感でいい気がしている。
学習率スケジューラは最初 cosine を使っていたが、損失が後半で不安定になる現象が出て、cosine_with_restarts に変えた。最終的には warmup_ratio=0.05 + cosine_with_restarts の組み合わせが今のところ一番安定している。
from transformers import TrainingArguments
training_args = TrainingArguments(
output_dir="./lora-output",
num_train_epochs=3,
per_device_train_batch_size=4,
gradient_accumulation_steps=8, # 実効バッチサイズ32
learning_rate=2e-4,
lr_scheduler_type="cosine_with_restarts", # cosine単体より安定
warmup_ratio=0.05,
fp16=False,
bf16=True, # A100/H100はbf16一択
gradient_checkpointing=True, # VRAMが足りないときの救世主
optim="paged_adamw_8bit", # QLoRA時はこれ必須
logging_steps=10,
eval_strategy="steps",
eval_steps=100,
save_steps=200,
load_best_model_at_end=True,
metric_for_best_model="eval_loss",
report_to="wandb",
)
gradient_checkpointing=True は最初入れていなかったら、Llama-3-70Bのファインチューニングで普通にOOMした。VRAMを時間でトレードするオプションなので学習速度は落ちるけど、そもそも動かなければ意味がない。これは最初から入れておくべきオプションだと思う。
学習パイプライン全体の設計と監視体制
本番でLoRAファインチューニングを安定して回すには、ワンショットのスクリプトじゃなくてちゃんとしたパイプラインが必要だと気づいたのは3ヶ月目だった。それまで何をしていたかというと、Jupyterノートブックで都度手動実行していた。今思うと恐ろしい。
flowchart TB
subgraph DataPrep["データ準備"]
A[生データ収集] --> B[品質フィルタリング]
B --> C[フォーマット変換]
C --> D[train/val/test分割]
end
subgraph Training["学習フェーズ"]
E[ベースモデルロード] --> F[QLoRA/DoRA設定]
F --> G[SFTTrainer実行]
G --> H[チェックポイント保存]
H --> I{eval_loss改善?}
I -->|Yes| G
I -->|No: Early Stop| J[Best Checkpointロード]
end
subgraph Eval["評価・マージ"]
J --> K[タスク別評価スコア計算]
K --> L{スコア閾値クリア?}
L -->|Yes| M[LoRAマージ]
L -->|No| N[ハイパーパラメータ再検討]
M --> O[量子化 GGUF/AWQ]
O --> P[本番デプロイ]
end
DataPrep --> Training
Training --> Eval
特に「評価スコアが閾値を下回ったらデプロイしない」ゲートを設けたのは正解だった。それ以前は手動でチェックしていたけど、忙しいとき見落とすことがあって、精度の低いモデルが本番に出てしまった経緯がある。あれは普通に事故だった。
監視については、WandBとの連携は必須レベル。損失曲線だけじゃなく、gradient normも監視するようにしたら、学習が不安定になる予兆を早期に検知できるようになった。
# SFTTrainer(trl 0.9.x+)での実装例
from trl import SFTTrainer, SFTConfig
from datasets import load_dataset
dataset = load_dataset("json", data_files={"train": "train.jsonl", "validation": "val.jsonl"})
trainer = SFTTrainer(
model=model,
args=SFTConfig(
**training_args.to_dict(),
max_seq_length=2048,
packing=True, # 短いサンプルを詰め込んで効率化
dataset_text_field="text",
),
train_dataset=dataset["train"],
eval_dataset=dataset["validation"],
peft_config=lora_config,
tokenizer=tokenizer,
)
trainer.train()
# LoRAアダプタを保存(ベースモデルとは別に保存する)
trainer.model.save_pretrained("./lora-adapter")
trainer.tokenizer.save_pretrained("./lora-adapter")
packing=True はトークン使用効率を30〜40%改善してくれるけど、サンプルの境界でコンテキストが混ざる問題がある。うちではEOS/BOSトークンの設定をしっかりやることで対処している。これを忘れると微妙な品質劣化が起きるので、地味に見落とせないポイントだ。
LoRAマージと量子化、本番で踏んだ落とし穴
学習が終わった後のLoRAマージと量子化で、また詰まった。ここは事前に知っておきたかった点が多い。
# LoRAマージの実装
from peft import PeftModel
from transformers import AutoModelForCausalLM, AutoTokenizer
base_model = AutoModelForCausalLM.from_pretrained(
"meta-llama/Llama-3.1-8B",
torch_dtype=torch.bfloat16,
device_map="auto",
)
# アダプタをロードしてマージ
model = PeftModel.from_pretrained(base_model, "./lora-adapter")
merged_model = model.merge_and_unload() # これでLoRAを統合
# 保存
merged_model.save_pretrained("./merged-model", safe_serialization=True)
tokenizer.save_pretrained("./merged-model")
最初にやった失敗は、merge_and_unload() 後のモデルをfp16で保存していたこと。学習がbf16だったので精度劣化が起きていた。当時は原因がわからず1週間無駄にしたので、同じ轍を踏まないでほしい。safe_serialization=True + bf16で保存するようにしてから安定した。
その後の量子化については、推論速度とのトレードオフを実測した結果がこちら:
xychart-beta
title "量子化方式別の推論スループット(tokens/sec, Llama-3.1-8B)"
x-axis ["FP16", "AWQ-4bit", "GPTQ-4bit", "GGUF-Q4_K_M", "GGUF-Q5_K_M"]
y-axis "スループット (tokens/sec)" 0 --> 120
bar [45, 98, 92, 87, 72]
AWQ-4bitが一番速かったのは意外だった。GPTQ-4bitとの差は誤差レベルだけど、AWQのほうがキャリブレーションデータなしで使いやすいという利点もある。GGUFはllama.cppでそのまま使えるのでローカル推論向け。デプロイ先が決まっていれば選択肢はほぼ絞られるので、迷ったらそこから考えると楽だと思う。
RAG構成との組み合わせについてはRAG本番運用1年で痛感したこと|チャンキングで半分決まるという現実も参考になる。ファインチューニングしたモデルをRAGと組み合わせるケースも増えてきているので、合わせて読んでもらえると。
もう一つ地味に困ったのが、LoRAマージ後にシステムプロンプトへの追従性が変わる問題。ドメイン特化学習をすると、ベースモデルが持っていたインストラクションフォローの能力が一部失われることがある。これを防ぐには、SFTデータに汎用的なインストラクションチューニングデータを一定割合(うちは10〜20%)混ぜるのが現状の対策。ただしタスク特性によるので、そのまま真似するより割合は自分のデータで調整したほうがいい。
AIエージェントとの連携実装で詰まったことについてはAIエージェント開発で痛い目を見た話|2026年の実装課題と解決策も読んでみてほしい。ファインチューニングしたモデルをエージェントのベースにするパターンで共通する課題がある。
まとめ
6ヶ月やってみて、LoRAファインチューニングは「サクッとできる」という触れ込みは半分正解・半分誇張だと思っている。アダプタの学習自体はシンプルになってきているけど、データ品質・ハイパーパラメータチューニング・マージ後の品質検証・量子化と推論最適化まで含めたら、しっかりした工数がかかる。それを最初に知っていたかったというのが正直なところ。
要点をまとめるとこうなる:
- rankは32〜64がスイートスポット。最初から小さいrankで妥協しないこと。タスク複雑度が高いなら128まで試す価値がある。
- QLoRAとDoRAの使い分けはVRAM制約と精度要件で決める。DoRAは精度で3〜8%改善できるが学習時間が増えるトレードオフ。
- 学習率スケジューラは
cosine_with_restarts+warmup_ratio=0.05が今のところ安定。cosineだけだと後半に損失が暴れることがある。 - マージ後の品質劣化に注意。特にインストラクションフォローの能力は、汎用データを混ぜることである程度保持できる。
- 量子化はAWQ-4bitが推論速度・品質バランスで優秀。ただしGGUFはローカル推論の柔軟性が高いので、デプロイ環境に合わせて選ぶ。
次のステップとしては、まず小さいデータセット(1000〜5000サンプル)でrank・alpha・学習率の組み合わせを素早く試すグリッドサーチから始めるのがおすすめ。OptunaをWandBと組み合わせると自動化もできるので、次の記事ではそのあたりも書くつもり。
皆さんのチームではLoRAの学習をどう管理してますか?特にデータパイプラインの品質管理周りは色々やり方があると思うので、ぜひ聞いてみたい。