[Amazon SageMaker] Llama 3.2 1B を LoRA でファインチューニングして、質問に Markdown 表で答えさせてみました
1 はじめに
製造ビジネステクノロジー部の平内(SIN)です。
LLM をアプリケーションに組み込むとき、「回答を必ずこのフォーマットで返してほしい」という要件はよくあります。プロンプトで「Markdown の表で答えてください」と指示する方法が手軽ですが、小型モデルでは指示が無視されたり、会話が続くうちにフォーマットが崩れたりすることがあります。
そこで、今回は、プロンプトに頼らず、モデル自体に出力フォーマットを覚えさせるため、Meta の軽量モデル Llama 3.2 1B Instruct を、Amazon SageMaker Training Job 上で LoRA(Low-Rank Adaptation)によりファインチューニングし、「比較・列挙系の質問には必ず Markdown形式の表で回答する」よう学習させてみました。
結果は、以下のとおりです。
学習前後で同じ質問(学習データには含まれない 30 問)を投げ、回答に Markdown形式の表が含まれるかを判定したところ、以下のようになりました。
| 表フォーマット遵守率(held-out 30 問) | |
|---|---|
| Before(素の Llama 3.2 1B) | 0/30(0.0%) |
| After(LoRA 適用後) | 30/30(100.0%) |
推論時のプロンプトには「表で答えて」というような指示は、一切入れていません。ファインチューニングにより、この差が出ていることになると思います。
たとえば「お茶とコーヒー、カフェイン量はどちらが多い?」と尋ねると、After のモデルは次のように回答します(回答は Markdown 形式の表を含むテキストです)。
質問: お茶とコーヒー、カフェイン量はどちらが多い?
回答:
お茶とコーヒー、カフェイン量はどちらが多いかを比較します。
| 項目 | 内容 |
|---------|----------------------------------------------------------------------|
| お茶 | 約20-40mg(1杯約250ml) |
| コーヒー | 約95-200mg(1杯約250ml) |
| 比較 | コーヒーはお茶よりカフェイン量が多い。ただし、カフェイン量は種類によって異なります。|
(見やすさのため表のスペースのみ調整しています)
学習データは Amazon Bedrock(Amazon Nova)で作成し、データ合成から学習・評価まで一通りの流れを AWS 中心で構成しています。学習ジョブは全体で約 7 分(学習本体は約 2 分)、学習にかかった費用は失敗したジョブを含めても 1 ドル未満程度と、気軽に試せる規模だと思います。
2 全体構成と費用
全体の流れとコスト発生ポイントは以下のとおりです。
| 手順 | 使用サービス・環境 | コスト | |
|---|---|---|---|
| 1 | インフラ作成(S3・IAM ロール) | AWS CDK | S3 数円未満/月 |
| 2 | 学習データ合成(質問 330 件 + 模範回答) | Amazon Bedrock(Nova Lite / Nova Pro) | 1 ドル未満程度 |
| 3 | LoRA 学習 | SageMaker Training Job(ml.g5.2xlarge) | 課金 442 秒(0.2〜0.3 ドル程度) |
| 4 | Before/After 比較・遵守率算出 | ローカル Mac(MPS) | - |
| 5 | クリーンアップ | cdk destroy | - |
学習は、SageMaker Training Job で必要時のみの起動とし、推論は、ローカルで実施しています。1B モデル + LoRA アダプタであれば、Apple Silicon の Mac で十分推論できていました。
サンプルコードは GitHub で公開しています。
GitHub: sagemaker-lora-markdown-table
3 事前準備
(1) Llama のライセンス同意と HF_TOKEN
Llama 3.2 は gated model のため、モデルページで Meta のライセンス(LLAMA 3.2 COMMUNITY LICENSE AGREEMENT)に同意し、アクセス承認を受ける必要があります。ページ上部のフォームに氏名・所属等を入力して送信すると、ステータスが PENDING になり、承認されると ACCEPTED に変わります(今回は 10 分程度で承認されました)。


あわせて、Settings > Access Tokens で Read 権限のアクセストークンを作成しておきます。このトークンは学習ジョブとローカル推論の両方で使用します。

(2) SageMaker のクォータ確認
GPU インスタンスの Training Job 用クォータは、デフォルトで 0 のアカウントがあります。事前に確認しておきましょう。
aws service-quotas list-service-quotas --service-code sagemaker --region ap-northeast-1 \
--query "Quotas[?contains(QuotaName, 'ml.g5.2xlarge for training job usage')].{Name:QuotaName,Value:Value}" \
--output table
値が 0 の場合は Service Quotas から引き上げ申請が必要です。
(3) インフラの作成
S3 バケットと SageMaker 実行ロールを CDK で作成します。
git clone https://github.com/furuya02/sagemaker-lora-markdown-table.git
cd sagemaker-lora-markdown-table/cdk
pnpm install
pnpm cdk deploy
4 学習データの設計と合成
学習データは、Amazon Bedrock で合成しました。
(1) データ設計の方針
学習させたいのは「比較・列挙が自然な質問に対して、短い導入文 + Markdown 表で回答する」という振る舞いです。そこで、以下の形式の対話データを数百件用意します。
{"messages": [
{"role": "user", "content": "EC2とLambdaの違いを教えて"},
{"role": "assistant", "content": "(1〜2文の導入)\n\n| 項目 | EC2 | Lambda |\n|---|---|---|\n| ... | ... | ... |"}
]}
ポイントは、質問のジャンルを技術に限定せず、料理・旅行・歴史・スポーツなど幅広く散らしたことです。「特定ドメインの知識」ではなく「表で答えるという形式」を学習させたいためです。
(2) 質問の生成(Nova Lite)
Github scripts/generate_questions.py
1 段階目として、比較・列挙が自然な質問を Nova Lite で 330 件生成します。プロンプトの要点は以下です。
PROMPT = """あなたは学習データ作成のアシスタントです。
「比較」や「列挙」で答えるのが自然な日本語の質問を{n}個生成してください。
- 技術に限らず、料理・旅行・歴史など幅広いジャンルにする
- 質問のみをJSON配列で出力(["質問1", "質問2", ...])"""
30 件ずつバッチで生成し、重複を除去しながら目標件数まで繰り返します。

(3) 模範回答の生成(Nova Pro)
Github scripts/generate_answers.py
2 段階目として、各質問への模範回答を Nova Pro で生成します。ここで出力フォーマットを厳密に固定するのがポイントです。
PROMPT = """次の質問に対する模範回答を作成してください。
質問: {question}
条件:
- 1〜2文の短い導入文の後、必ず Markdown 表でまとめる
- 表は `| 項目 | 内容 |` のようにヘッダ行と区切り行を持つ正しい Markdown 形式にする
- 表の後には文章を続けず、表で回答を終える"""

(4) 検証と分割
Github scripts/validate_dataset.py
合成データが本当に表を含んでいるかを、正規表現(ヘッダ行 + 区切り行)で機械検証します。
total: 330, valid: 330, invalid(表なし): 0
train: 300 -> data/train.jsonl
heldout: 30 -> data/heldout.jsonl
今回は 330 件すべてが有効でした。このうち 30 件は学習に使わず、評価用(held-out)として取り分けています。この正規表現は後述の遵守率判定でも同じものを使い、データ品質と評価の基準を揃えています。
5 LoRA 学習
(1) Estimator の定義
Github scripts/run_training.py
学習は SageMaker の Hugging Face Deep Learning Container(DLC)を使った Script Mode で実行します。
estimator = HuggingFace(
entry_point="train.py",
source_dir="src", # src/requirements.txt が起動時に pip install される
role=role,
instance_type="ml.g5.2xlarge",
transformers_version="4.36.0",
pytorch_version="2.1.0",
py_version="py310",
environment={"HF_TOKEN": os.environ["HF_TOKEN"]}, # gated model の認証
hyperparameters={"epochs": 3, "learning-rate": 2e-4, "lora-r": 16, "lora-alpha": 32},
max_run=3600, # 暴走時も1時間で強制終了
)
DLC 自体の transformers は 4.36.0 ですが、source_dir に置いた requirements.txt がコンテナ起動時にインストールされ、ライブラリを上書きできます。再現性のためバージョンは固定されています。
transformers==4.46.3
trl==0.12.1
peft==0.13.2
datasets==3.1.0
accelerate==1.1.1
gated model をコンテナ内でダウンロードするため、HF_TOKEN を環境変数としてジョブに渡しています。
(2) 学習スクリプト
Github src/train.py
学習本体は TRL の SFTTrainer と PEFT を組み合わせた、ごく標準的な LoRA 学習です。
# train.py(要点抜粋)
use_bf16 = torch.cuda.is_bf16_supported() # T4等の非対応GPUではfp16に切替
dtype = torch.bfloat16 if use_bf16 else torch.float16
model = AutoModelForCausalLM.from_pretrained(args.model_id, torch_dtype=dtype)
tokenizer = AutoTokenizer.from_pretrained(args.model_id)
if tokenizer.pad_token is None:
tokenizer.pad_token = tokenizer.eos_token # Llama系はpad_tokenを持たない
peft_config = LoraConfig(
r=16, lora_alpha=32, lora_dropout=0.05,
target_modules=["q_proj", "k_proj", "v_proj", "o_proj"],
task_type="CAUSAL_LM",
)
training_args = SFTConfig(
output_dir="/opt/ml/model",
num_train_epochs=3,
per_device_train_batch_size=1,
gradient_accumulation_steps=4, # 実効バッチ4
gradient_checkpointing=True, # 省メモリ化
learning_rate=2e-4,
max_seq_length=1024,
bf16=use_bf16, fp16=not use_bf16,
)
trainer = SFTTrainer(model=model, args=training_args,
train_dataset=dataset, processing_class=tokenizer,
peft_config=peft_config)
trainer.train()
データセットが messages 形式であれば、SFTTrainer がチャットテンプレートを自動適用してくれるため、前処理コードはほとんど不要です。/opt/ml/model に保存したアダプタは、ジョブ完了時に model.tar.gz として S3 へ自動アップロードされます。
(3) ハイパーパラメータ
| 項目 | 値 | 補足 |
|---|---|---|
| LoRA rank (r) | 16 | フォーマット学習が目的のため控えめの値 |
| lora_alpha | 32 | r の 2 倍という一般的な設定 |
| 対象モジュール | q/k/v/o_proj | Attention 層のみ |
| learning_rate | 2e-4 | LoRA でよく使われる値 |
| エポック | 3 | 300 件 × 3 エポック ÷ 実効バッチ 4 = 225 ステップ |
| 実効バッチ | 4(1 × 勾配累積 4) | OOM 対策。詳細は 7 章 |
(4) Loss 推移と実測値
学習は 225 ステップ、実行時間 131 秒で完了しました。Loss は 2.08 から 1.20 まで素直に収束しています。

{'train_runtime': 130.9612, 'train_samples_per_second': 6.872,
'train_loss': 1.4082220183478462, 'epoch': 3.0}
Training seconds: 442
Billable seconds: 442
課金対象は 442 秒(イメージ取得や pip install を含む)でした。SageMaker の料金に照らすと、成功したこのジョブ単体で 0.2〜0.3 ドル程度です。

6 Before/After 比較
(1) 定量評価: 表フォーマット遵守率
Github scripts/compare_before_after.py
評価は held-out の 30 問を学習前後のモデルに投げ、回答が正しい Markdown 表(ヘッダ行 + 区切り行)を含むかを正規表現で判定しました。推論はローカルの Mac(MPS)で行い、プロンプトにフォーマット指示は入れていません。
# Before(素の Llama 3.2 1B)
python scripts/compare_before_after.py --output results/before.jsonl
# After(LoRA アダプタ適用)
python scripts/compare_before_after.py --adapter model/ --output results/after.jsonl
| 遵守率 | |
|---|---|
| Before | 0/30(0.0%) |
| After | 30/30(100.0%) |
Before は 30 問すべてで表を出さず、After は 30 問すべてで表を出しました。
(2) 定性比較: 同じ質問への出力
冒頭で示した「お茶とコーヒー、カフェイン量はどちらが多い?」を、学習前のモデルにも同じように投げてみます。Before はプレーンな文章で、しかも結論を誤っています。
お茶とコーヒーは両方ともカフェインを含むものですが、量は異なります。
お茶は、主に茶碗から取り出された茶の液体にカフェインが含まれるため、カフェイン量が
高いです。一般的なお茶のカフェイン量は、1杯お茶が約20-40mgのカフェインを含むことが
あります。
コーヒーは、コーヒーの種類やカフェインの量によってカフェイン量が異なりますが、
一般的にコーヒーはカフェイン量が約60-120mgのものです。
したがって、お茶はカフェイン量が高いです。
Before は「お茶のほうがカフェインが多い」と誤って結論づけています。一方、After は冒頭で示したとおり導入文 + Markdown 表で回答し、この例では内容も概ね妥当でした。同じモデル・同じ質問でも、出力の形式(と、この例では正確さ)が変わっています。
なお、モデルが出力しているのは、あくまで Markdown 記法で書かれたテキストです。チャット UI やブログなど Markdown をレンダリングする環境に載せれば、そのまま表として表示されます。
(3) 注意: 回答の「中身」の正確さは別問題
今回学習したのはあくまで「表というフォーマット」であり、回答内容の正確さは改善していません。1B という小型モデルのため、After でも事実誤りは発生します。たとえば「マクドナルドとケンタッキーを比較して」と尋ねると、表の体裁は整っていましたが「食材: 鍋で調理されたメニュー」といった、事実と異なる内容が出力されていました。
フォーマット制御と知識・正確性は別の問題です。実務で使う場合は、正確性が必要な内容は RAG などで供給し、LoRA は出力形式の安定化に使う、という役割分担が現実的だと思います。
(4) 副作用の簡易チェック
表を強制学習した副作用として「どんな質問にも表で答えてしまう」ようになっていないかを、表が不適切な 5 問で確認しました。
| 質問 | 表を出したか |
|---|---|
| こんにちは | 出さない |
| 1たす1は? | 出さない |
| ありがとう | 出さない |
| 日本の首都はどこ? | 出さない(「東京です」と正答) |
| 俳句を一つ詠んでください | 出した |
挨拶や単純な質問には表を出さず自然に応答しており、極端な過学習は避けられています。一方で「俳句を詠んで」という創作依頼には表を当てはめてしまいました。学習データの質問ジャンルは、工夫が必要だと思いました。
7 ハマったこと
今回の検証で実際に遭遇したエラーを、発生順に共有します。いずれも 2026 年 8 月時点の情報です。
(1) SageMaker Python SDK v3 で HuggingFace Estimator が消えた
ローカルの venv に pip install sagemaker すると v3 系(3.18.0)が入り、以下のエラーになりました。
ModuleNotFoundError: No module named 'sagemaker.huggingface'
SageMaker Python SDK は v3 で API が刷新され、従来のフレームワーク Estimator は ModelTrainer に置き換えられています(移行ガイド)。従来の書き方を使う場合は v2 系の固定が必要です。
sagemaker>=2.230.0,<3
(2) g4dn(T4)は bf16 非対応
より安価な g4dn.xlarge を試したところ、学習開始直後に失敗しました。
ValueError: Your setup doesn't support bf16/gpu. You need torch>=1.10,
using Ampere GPU with cuda>=11.0
bf16 が使えるのは Ampere 世代以降(G5 の A10G など)で、G4dn の T4(Turing 世代)は非対応です。torch.cuda.is_bf16_supported() で判定して bf16/fp16 を切り替える必要がありました。
(3) Llama のトークナイザに pad_token がない
ValueError: Asking to pad but the tokenizer does not have a padding token.
Llama 系トークナイザは pad_token を持たないため、バッチ学習のパディングで失敗します。対処として tokenizer.pad_token = tokenizer.eos_token を設定しました。
(4) A10G 24GB でも CUDA OOM
バッチサイズ 4 で学習を始めたところ、36 ステップ目で OOM になりました。
torch.cuda.OutOfMemoryError: CUDA out of memory. Tried to allocate 1.77 GiB.
GPU 0 has a total capacty of 22.30 GiB of which 1006.69 MiB is free.
スタックトレースを見ると、loss 計算時に logits を fp32 へ変換する箇所(logits.float())で発生していました。Llama 3.2 は語彙数が約 12.8 万と大きく、「バッチ 4 × 系列長 1024 × 語彙 12.8 万」の logits の fp32 化は 1B モデルでも無視できないサイズになります。バッチサイズを 1 に下げ、勾配累積 4 で実効バッチを維持し、gradient checkpointing を有効化することで解決しました。
8 まとめ
Llama 3.2 1B を SageMaker 上で LoRA ファインチューニングし、Markdown形式の表という出力フォーマットをモデルに焼き込んでみました。
- 表フォーマット遵守率は 0% → 100% になり、プロンプトなしでフォーマットが安定した
- 学習データ 300 件・ジョブ約 7 分・課金 442 秒(0.2〜0.3 ドル程度)と、手軽な規模で効果が出た
- Bedrock でのデータ合成(質問生成 → 模範回答生成の 2 段階)により、学習データを人手なしで用意できた
- 一方で、回答内容の正確さはフォーマットとは別問題であり、副作用(創作依頼まで表化)もゼロではない
プロンプトだけでは崩れやすい出力フォーマットの固定は、LoRA の得意分野だと感じました。このモデルであればローカル推論でも利用可能です。フォーマット部分を LoRA で安定させ、知識は RAG で供給する、といった組み合わせにも応用できそうです。
サンプルコードはこちらに置きました。
GitHub: sagemaker-lora-markdown-table






