「LoRAファインチューニングはチュートリアル通りにできた。でも精度が上がらありません。本番で使える品質にならありません。」
この壁に当たった経験はありませんか?私自身、Gemma 4のカスタムモデルを作ろうとしたとき、まったく同じ問題にぶつかりました。入門記事のコードはそのまま動くのに、自分のユースケースに適用すると結果がぶれる。デプロイしてみたら本番の速度要件を満たせありません。
「動くファインチューニング」から「本番で価値を生むモデル」 への橋渡しを、Antigravityを開発環境として使いながら実装する方法を順を追って整理していきます。データセット設計の考え方、Gemma 4に最適化されたQLoRAの設定値、評価パイプラインの構築、そしてCloudflare Workers AIへのデプロイまで、一本のラインとして繋げていきます。
本番ファインチューニングを阻む「3つの壁」
ファインチューニングを本番投入しようとすると、大きく3つの壁が立ちはだかります。それぞれの根本原因を理解しておくことが、この記事全体の前提になります。
第一の壁:データ品質。公式チュートリアルではtoy datasetを使います。自分のドメインデータに切り替えたとき、形式は合っているのに精度が出ないケースの大半は、データの「量」ではなく「質」の問題です。ノイズの多いサンプル、ターゲットの揺れ、ドメイン分布の偏り——これらをどう検出・除去するかが本番品質の分水嶺になります。
第二の壁:ハイパーパラメータの組み合わせ爆発。learning_rate、lora_rank、lora_alpha、batch_size——調整可能なパラメータは無数にあります。ここで「とりあえず動いた設定をそのまま使う」のが最大の罠です。Gemma 4には、他のモデルとは異なる最適レンジがあります。
第三の壁:評価の難しさ。perplexityは下がっているのに実際の出力が良くならない、あるいはベンチマークスコアは改善しているのにユーザーからの評判が変わらない——評価指標とビジネス価値の乖離は、本番投入後に初めて気づく問題です。
環境構築とAntigravityでのワークフロー設計
Antigravityを使う最大のメリットは、トレーニングスクリプトの設計→実行→評価→デプロイのサイクルをAIエージェントに補助させながら回せる点です。
まず必要なライブラリを確認します。
# 推奨GPU環境: A100 80GB または H100(Google Colab Pro+ / Vertex AI Workbench)
pip install transformers==4.47.0 \
peft==0.14.0 \
trl==0.13.0 \
bitsandbytes==0.45.0 \
datasets==3.2.0 \
wandb
# バージョン固定が重要 — バージョン不整合はサイレントに精度を悪化させます
python -c "import transformers; print(transformers.__version__)"Antigravityのターミナルを開き、プロジェクトルートに以下の構成でファイルを配置します。
gemma4-finetune/
├── data/
│ ├── raw/ ← 生データ
│ ├── processed/ ← 品質チェック後
│ └── final/ ← トレーニング用
├── scripts/
│ ├── prepare_data.py
│ ├── train.py
│ └── evaluate.py
├── configs/
│ └── qlora_config.yaml
└── AGENTS.md ← Antigravityエージェント向け指示
AGENTS.mdにはエージェントの役割を記述します。
# Gemma 4 Fine-tuning Agent Instructions
## Role
データ前処理、トレーニング設定の最適化、評価結果の分析を支援する。
## Key Constraints
- 量子化設定は `configs/qlora_config.yaml` を必ず参照する
- データ件数が1,000件未満のときはエージェントから警告を出す
- 評価スクリプト実行後は必ず結果をsummary.mdに追記する
## Common Tasks
- データ品質チェック: `python scripts/prepare_data.py --validate`
- トレーニング開始: `python scripts/train.py --config configs/qlora_config.yaml`
- 評価実行: `python scripts/evaluate.py --model-dir output/`本番品質データセットの設計
ここが記事のコアです。良いデータセットはモデルアーキテクチャの選択よりも重要です。
Gemma 4の学習形式
Gemma 4はinstruction tuningに<start_of_turn> / <end_of_turn>トークンを使います。形式を間違えるとファインチューニング自体は完走しても、推論時に正しく動作しません。
# prepare_data.py — Gemma 4 instruction format
def format_for_gemma4(sample: dict) -> str:
"""
Gemma 4のinstruction tuning形式に変換する。
input: {"instruction": "...", "output": "..."}
output: "<start_of_turn>user\n{instruction}<end_of_turn>\n<start_of_turn>model\n{output}<end_of_turn>"
"""
instruction = sample.get("instruction", "").strip()
output = sample.get("output", "").strip()
if not instruction or not output:
return None # 空サンプルは除外
formatted = (
f"<start_of_turn>user\n{instruction}<end_of_turn>\n"
f"<start_of_turn>model\n{output}<end_of_turn>"
)
return formatted
def validate_dataset(samples: list) -> dict:
"""
データセットの品質を定量評価する。
本番投入前に必ず実行すること。
"""
stats = {
"total": len(samples),
"empty_removed": 0,
"too_short": 0, # output < 20文字
"too_long": 0, # トークン数 > 1024
"duplicate": 0,
"valid": 0,
}
seen_instructions = set()
valid_samples = []
for s in samples:
formatted = format_for_gemma4(s)
if formatted is None:
stats["empty_removed"] += 1
continue
output_len = len(s.get("output", ""))
if output_len < 20:
stats["too_short"] += 1
continue
# 簡易トークン数推定(実際はtokenizerを使うこと)
approx_tokens = len(formatted) // 3
if approx_tokens > 1024:
stats["too_long"] += 1
continue
# 重複チェック(instructionのfuzzy matchは省略、exactのみ)
instruction_key = s["instruction"].strip().lower()[:100]
if instruction_key in seen_instructions:
stats["duplicate"] += 1
continue
seen_instructions.add(instruction_key)
valid_samples.append({"text": formatted})
stats["valid"] += 1
stats["valid_ratio"] = stats["valid"] / stats["total"] if stats["total"] > 0 else 0
return stats, valid_samples
if __name__ == "__main__":
import json, sys
with open("data/raw/samples.jsonl") as f:
raw = [json.loads(line) for line in f]
stats, valid = validate_dataset(raw)
print(f"データセット検証結果:")
print(f" 総サンプル: {stats['total']}")
print(f" 空サンプル除外: {stats['empty_removed']}")
print(f" 短すぎる出力除外: {stats['too_short']}")
print(f" 長すぎる出力除外: {stats['too_long']}")
print(f" 重複除外: {stats['duplicate']}")
print(f" 有効サンプル: {stats['valid']} ({stats['valid_ratio']:.1%})")
if stats["valid"] < 500:
print("⚠️ WARNING: 有効サンプルが500件未満です。汎化性能が低下する可能性があります。")
with open("data/processed/train.json", "w") as f:
json.dump(valid, f, ensure_ascii=False, indent=2)
print(f"\n✅ data/processed/train.json に書き出しました ({stats['valid']} 件)")Antigravityエージェントによるデータ自動生成
実際のユースケースでは、手作業でサンプルを作るよりもAntigravityエージェントに補助させる方が効率的です。以下はGemma 4向けのカスタマーサポートQAデータを自動生成する例です。
# Antigravity エージェントに渡すプロンプトパターン(AGENTS.md経由で呼び出す)
GENERATION_PROMPT = """
以下の製品ドキュメントをもとに、カスタマーサポートQAペアを20件生成してください。
【制約】
- questionは実際のユーザーが使う自然な言葉で書く(技術用語を避ける)
- answerは200〜400文字で完結させる
- 同じ質問パターンを繰り返さない
- JSON形式で出力: {"instruction": "...", "output": "..."}
【参照ドキュメント】
{document_content}
"""このアプローチで注意すべきは、生成されたデータはそのまま使わず必ず人間がレビューすることです。LLMが生成したデータでLLMをトレーニングすると、誤りが増幅されるリスクがあります。最低でも10%のサンプルは人間の目で確認してください。
QLoRA設定の最適化 — Gemma 4固有の設定値
Gemma 4でのファインチューニングに最適なQLoRA設定を、実験から得た値として共有します。
# configs/qlora_config.yaml
model:
name: "google/gemma-4-4b-it" # または 12b-it / 27b-it
torch_dtype: "bfloat16" # float16ではなくbfloat16を推奨
quantization:
load_in_4bit: true
bnb_4bit_compute_dtype: "bfloat16"
bnb_4bit_use_double_quant: true
bnb_4bit_quant_type: "nf4" # fp4よりnf4が精度良好
lora:
r: 16 # Gemma 4は8〜32の範囲が良好。大きくするとメモリ増加
lora_alpha: 32 # alpha = 2 * r が経験的な出発点
lora_dropout: 0.05
bias: "none"
# Gemma 4のattention layerに適用
target_modules:
- "q_proj"
- "k_proj"
- "v_proj"
- "o_proj"
- "gate_proj"
- "up_proj"
- "down_proj"
training:
output_dir: "./output"
num_train_epochs: 3
per_device_train_batch_size: 2 # A100 40GBの場合
gradient_accumulation_steps: 8 # 実効バッチサイズ = 16
learning_rate: 2.0e-4 # Gemma 4の推奨値。1e-4〜5e-4の範囲で探索
warmup_ratio: 0.03
lr_scheduler_type: "cosine"
max_seq_length: 512 # 本番要件に合わせて調整
logging_steps: 10
save_strategy: "steps"
save_steps: 100
evaluation_strategy: "steps"
eval_steps: 100
load_best_model_at_end: true
report_to: "wandb"# train.py — 本番品質のトレーニングスクリプト
from transformers import (
AutoTokenizer,
AutoModelForCausalLM,
BitsAndBytesConfig,
TrainingArguments,
)
from peft import LoraConfig, get_peft_model, prepare_model_for_kbit_training
from trl import SFTTrainer
from datasets import load_dataset
import torch
import yaml
import sys
def load_config(config_path: str) -> dict:
with open(config_path) as f:
return yaml.safe_load(f)
def setup_model_and_tokenizer(config: dict):
bnb_config = BitsAndBytesConfig(
load_in_4bit=config["quantization"]["load_in_4bit"],
bnb_4bit_compute_dtype=torch.bfloat16,
bnb_4bit_use_double_quant=config["quantization"]["bnb_4bit_use_double_quant"],
bnb_4bit_quant_type=config["quantization"]["bnb_4bit_quant_type"],
)
model = AutoModelForCausalLM.from_pretrained(
config["model"]["name"],
quantization_config=bnb_config,
device_map="auto",
torch_dtype=torch.bfloat16,
trust_remote_code=False, # セキュリティ上の理由からFalseを推奨
)
# 4bit量子化後の勾配チェックポイント設定(重要)
model = prepare_model_for_kbit_training(model)
tokenizer = AutoTokenizer.from_pretrained(config["model"]["name"])
# Gemma 4はpadding_sideをrightにする必要がある
tokenizer.padding_side = "right"
if tokenizer.pad_token is None:
tokenizer.pad_token = tokenizer.eos_token
return model, tokenizer
def main(config_path: str):
config = load_config(config_path)
print(f"設定ファイル読み込み完了: {config_path}")
model, tokenizer = setup_model_and_tokenizer(config)
lora_config = LoraConfig(
r=config["lora"]["r"],
lora_alpha=config["lora"]["lora_alpha"],
lora_dropout=config["lora"]["lora_dropout"],
bias=config["lora"]["bias"],
target_modules=config["lora"]["target_modules"],
task_type="CAUSAL_LM",
)
model = get_peft_model(model, lora_config)
# 学習可能パラメータの確認(デバッグ用)
trainable_params = sum(p.numel() for p in model.parameters() if p.requires_grad)
total_params = sum(p.numel() for p in model.parameters())
print(f"学習可能パラメータ: {trainable_params:,} / {total_params:,} "
f"({100 * trainable_params / total_params:.2f}%)")
# 期待値: Gemma 4-4Bで約0.5〜1%。これを大きく超える場合はtarget_modulesを確認
dataset = load_dataset("json", data_files={
"train": "data/processed/train.json",
"validation": "data/processed/val.json",
})
training_args = TrainingArguments(
output_dir=config["training"]["output_dir"],
num_train_epochs=config["training"]["num_train_epochs"],
per_device_train_batch_size=config["training"]["per_device_train_batch_size"],
gradient_accumulation_steps=config["training"]["gradient_accumulation_steps"],
learning_rate=config["training"]["learning_rate"],
warmup_ratio=config["training"]["warmup_ratio"],
lr_scheduler_type=config["training"]["lr_scheduler_type"],
logging_steps=config["training"]["logging_steps"],
save_strategy=config["training"]["save_strategy"],
save_steps=config["training"]["save_steps"],
evaluation_strategy=config["training"]["evaluation_strategy"],
eval_steps=config["training"]["eval_steps"],
load_best_model_at_end=config["training"]["load_best_model_at_end"],
report_to=config["training"]["report_to"],
bf16=True, # A100/H100推奨。RTX系はfp16に変更
gradient_checkpointing=True, # VRAMを約30%削減
optim="paged_adamw_32bit", # bitsandbytes製。4bit量子化と相性が良い
dataloader_num_workers=2,
)
trainer = SFTTrainer(
model=model,
tokenizer=tokenizer,
train_dataset=dataset["train"],
eval_dataset=dataset["validation"],
args=training_args,
dataset_text_field="text",
max_seq_length=config["training"]["max_seq_length"],
packing=False, # 短いサンプルが多い場合はTrueで高速化可能
)
print("トレーニング開始...")
trainer.train()
# LoRAアダプタのみ保存(ベースモデルは含まない)
model.save_pretrained(f"{config['training']['output_dir']}/lora_adapter")
tokenizer.save_pretrained(f"{config['training']['output_dir']}/lora_adapter")
print(f"✅ アダプタ保存完了: {config['training']['output_dir']}/lora_adapter")
if __name__ == "__main__":
config_path = sys.argv[1] if len(sys.argv) > 1 else "configs/qlora_config.yaml"
main(config_path)評価パイプラインの構築
「loss が下がったから良いモデル」は間違いです。 ファインチューニング後のモデルは必ず多角的に評価してください。
自動評価スクリプト
# evaluate.py — ファインチューニング効果の定量評価
from transformers import AutoTokenizer, AutoModelForCausalLM
from peft import PeftModel
from datasets import load_dataset
import torch
import json
def load_finetuned_model(base_model_name: str, adapter_path: str):
"""LoRAアダプタをマージしたモデルをロードする"""
tokenizer = AutoTokenizer.from_pretrained(adapter_path)
base_model = AutoModelForCausalLM.from_pretrained(
base_model_name,
torch_dtype=torch.bfloat16,
device_map="auto",
)
model = PeftModel.from_pretrained(base_model, adapter_path)
model = model.merge_and_unload() # 推論用にアダプタをマージ
model.eval()
return model, tokenizer
def generate_response(model, tokenizer, instruction: str, max_new_tokens: int = 256) -> str:
"""単一サンプルの推論"""
prompt = (
f"<start_of_turn>user\n{instruction}<end_of_turn>\n"
f"<start_of_turn>model\n"
)
inputs = tokenizer(prompt, return_tensors="pt").to(model.device)
with torch.no_grad():
outputs = model.generate(
**inputs,
max_new_tokens=max_new_tokens,
temperature=0.1, # 評価時は低temperatureで再現性を確保
do_sample=True,
pad_token_id=tokenizer.eos_token_id,
)
generated = tokenizer.decode(
outputs[0][inputs["input_ids"].shape[1]:],
skip_special_tokens=True
)
return generated.strip()
def evaluate_on_testset(model, tokenizer, test_path: str) -> dict:
"""テストセット全体での評価"""
with open(test_path) as f:
test_data = json.load(f)
results = []
for i, sample in enumerate(test_data[:50]): # 最初の50件で評価
instruction = sample.get("instruction", "")
expected = sample.get("output", "")
generated = generate_response(model, tokenizer, instruction)
# 簡易スコア: expected の主要キーワードが含まれているか
keywords = [w for w in expected.split() if len(w) > 3][:10]
keyword_match = sum(1 for k in keywords if k in generated) / len(keywords) if keywords else 0
results.append({
"instruction": instruction[:100],
"expected": expected[:200],
"generated": generated[:200],
"keyword_match": keyword_match,
})
if (i + 1) % 10 == 0:
print(f" 評価進捗: {i+1}/50")
avg_score = sum(r["keyword_match"] for r in results) / len(results)
return {
"total_evaluated": len(results),
"avg_keyword_match": avg_score,
"results": results,
}
if __name__ == "__main__":
import sys
model_dir = sys.argv[1] if len(sys.argv) > 1 else "output/lora_adapter"
base_model = "google/gemma-4-4b-it"
print("モデルロード中...")
model, tokenizer = load_finetuned_model(base_model, model_dir)
print("テストセット評価開始...")
eval_results = evaluate_on_testset(model, tokenizer, "data/processed/test.json")
print(f"\n=== 評価結果 ===")
print(f"評価サンプル数: {eval_results['total_evaluated']}")
print(f"キーワード一致率 (平均): {eval_results['avg_keyword_match']:.2%}")
# 結果をファイルに保存
with open("output/eval_results.json", "w", ensure_ascii=False) as f:
json.dump(eval_results, f, ensure_ascii=False, indent=2)
print("\n✅ 評価結果を output/eval_results.json に保存しました")
# 判定
if eval_results["avg_keyword_match"] >= 0.7:
print("✅ デプロイ可能な品質です")
elif eval_results["avg_keyword_match"] >= 0.5:
print("⚠️ 追加トレーニングを検討してください")
else:
print("❌ データセットの見直しが必要です")よくある落とし穴と対策
ここまでの実装を試して詰まるポイントを3つ、対策とセットで紹介します。
落とし穴1: 学習率が高すぎてcatastrophic forgetting
ファインチューニング後にモデルが「ベースモデルのときにできていたことができなくなった」という問題は、学習率が高すぎることが原因の大半です。2.0e-4が出発点ですが、データが少ない場合(500件以下)は5.0e-5まで下げることを検討してください。また評価セットにはドメイン外のサンプルも含め、汎化性能が落ちていないことを確認するのが鉄則です。
落とし穴2: pad_token = eos_token設定忘れ
Gemma 4にはデフォルトでpad_tokenが設定されていません。前述のコードではtokenizer.pad_token = tokenizer.eos_tokenを設定していますが、これを忘れると学習ステップの初期に謎のエラーが出るか、精度が著しく悪化します。tokenizer.pad_token_idがNoneでないことを必ずログで確認してください。
落とし穴3: マージしていないアダプタを本番デプロイ
model.save_pretrained()でLoRAアダプタを保存したあと、そのままCloudflare Workers AIにアップロードしようとするとエラーになります。本番デプロイ前には必ずmodel.merge_and_unload()でアダプタをベースモデルに統合してから、GGUFまたはONNX形式に変換する必要があります。
Cloudflare Workers AIへのデプロイ
LoRAアダプタをマージしたモデルを、Cloudflare Workers AI Workers AI(@cf/workers-ai)にデプロイします。
# scripts/convert_to_gguf.py — デプロイ前の変換スクリプト
import subprocess
import os
from pathlib import Path
def convert_to_gguf(model_dir: str, output_name: str, quantization: str = "Q4_K_M"):
"""
llama.cppを使ってGGUF形式に変換する。
quantization: Q4_K_M(バランス)/ Q5_K_M(精度重視)/ Q8_0(高精度・大容量)
"""
merged_path = Path(model_dir)
output_path = merged_path.parent / f"{output_name}.gguf"
# llama.cpp の convert スクリプト(事前にインストール必要)
cmd = [
"python", "-m", "llama_cpp.convert",
str(merged_path),
"--outfile", str(output_path),
"--outtype", quantization.lower(),
]
print(f"GGUF変換開始: {quantization} 量子化")
result = subprocess.run(cmd, capture_output=True, text=True)
if result.returncode \!= 0:
print(f"❌ 変換エラー: {result.stderr}")
raise RuntimeError(f"GGUF変換失敗: {result.stderr}")
file_size_mb = output_path.stat().st_size / (1024 * 1024)
print(f"✅ 変換完了: {output_path} ({file_size_mb:.1f} MB)")
# Cloudflare Workers AI のファイルサイズ制限確認
if file_size_mb > 10_000: # 10GB超はアップロード不可
print(f"⚠️ ファイルサイズが大きすぎます。Q4_K_Mへの変更を検討してください")
return str(output_path)
if __name__ == "__main__":
gguf_path = convert_to_gguf(
model_dir="output/merged_model",
output_name="gemma4-custom-q4",
quantization="Q4_K_M"
)変換後のGGUFをCloudflare Workers AIのAPIに登録することで、エッジで低レイテンシな推論が可能になります。Cloudflare Workers AIの料金は2026年4月時点で1,000トークンあたり$0.011(入力)/ $0.044(出力)と比較的リーズナブルで、月間10万リクエスト程度であれば数千円で運用できます。
コスト最適化と次のステップ
本番運用を始めてから気づく最大のコスト要因は、ユーザーリクエストに対して過剰なモデルサイズを使っているケースです。Gemma 4 4B-ITがほとんどのユースケースで十分な精度を出せるなら、12Bや27Bを使う必要はありません。
運用コスト試算の目安:
- Gemma 4 4B-IT (Q4_K_M) × 月10万リクエスト × 平均500トークン出力 ≒ 月$2,200(Cloudflare Workers AI)
- 同等のGPUインスタンス自前運用(A10G × 1台)≒ 月$700 + 管理コスト
リクエスト数が多い場合は自前GPU運用の方がコスト効率は良くなりますが、スケールアウトの柔軟性とSLAの担保を考えるとマネージドサービスの方が個人開発者には扱いやすいと私は感じています。
ファインチューニングの理論から実装まで丁寧にカバーされており、本記事の内容を補完してくれる一冊です。
ファインチューニングの本番投入は「一度やれば終わり」ではありません。ユーザーの利用パターンが変わるにつれてモデルの精度もドリフトしていきます。まず今日できることは、既存のデータセットをこの記事の検証スクリプトにかけて、品質比率がどの程度かを確認することです。データ品質の現状把握から始めると、次の改善アクションが見えてきます。