Bedrock Fine-tuningを本番導入して気づいた、データ準備の沼とコスト地獄の現実
Claude 3のファインチューニングを本番で6ヶ月運用。データセット準備で2ヶ月、コストが3倍に…。実装で踏んだ地雷と実践的な解決策をシェアします。
Bedrock Fine-tuning実践|本番6ヶ月で直面した、データ準備・コスト・精度の現実
うちのチームがBedrockのファインチューニングを本番環境に投入して6ヶ月が経った。正直、最初の3ヶ月は地獄だった。データセット準備でドツボ、推論コストが予想の3倍、精度評価の落とし穴…。でも試行錯誤を重ねた今、ようやく見えてきたものがある。実装で痛い目を見たからこそ気づいた、本当に必要なことを共有したい。
なぜうちはファインチューニングに踏み出したのか
きっかけは単純だった。Claude 3のデフォルトモデルでは、うちのドメイン特有の用語や返答スタイルに対応しきれない。社内ドキュメントの品質基準を維持できないのが課題だったんだ。
当初、プロンプトの工夫でなんとかしようとした。RAGを導入したり、Few-shot promptingで頑張ってみたり。でも結果は微妙。チームのデータサイエンティストが「これ、ファインチューニング考えても良くない?」と提案してくれて、検証を始めたわけだ。
Bedrockのファインチューニング機能は、Anthropic社とAWSが連携して提供している。Claude 3 Haiku・Sonnetのカスタムモデルを作成できるんだけど、一見簡単そうに見えるが、実装してみると…。
データセット準備が地獄だった
最大の失敗がこれ。ファインチューニングに必要なデータセットの準備で2ヶ月費やした。
Bedrockのファインチューニングには、JSONL形式のデータセットが必要だ。各行がone-turn conversationのサンプルで、{"messages": [{"role": "user", "content": "...", {"role": "assistant", "content": "..."}}]}という構造になる。実際のデータはこんな感じ。
{"messages": [{"role": "user", "content": "社内システム障害時の報告フォーマットを説明してください"}, {"role": "assistant", "content": "【システム障害報告】\n発生時刻: YYYY-MM-DD HH:MM:SS\n影響範囲: サービス名\n推定原因: \n対応状況: \n復旧予定: "}]}
{"messages": [{"role": "user", "content": "クラウドコスト削減施策を3つ列挙"}, {"role": "assistant", "content": "1. リザーブドインスタンス(RI)の活用\n2. スポットインスタンスの導入\n3. ストレージティアリング"}]}
最初の失敗は、単にデータを集めるだけだと思ってたこと。実際には、データの品質が学習精度を左右するんだ。我々が用意した最初の2000サンプルは、データソース(社内ドキュメント、チャット履歴、過去のQ&A)がバラバラで、返答スタイルも統一されてなかった。
3週間ファインチューニングを走らせた結果、出力したモデルはむしろベースモデルより悪くなってた。いわゆる「garbage in, garbage out」を身をもって体験した。
そこで戦略を変更した。データキュレーションに注力することにしたんだ。
ドメイン特化データの厳選
社内のテクニカルドキュメント(AWS構成、設計パターン、トラブルシューティング)から、高品質な例のみを抽出。むやみに量を増やすんじゃなく、「これは本当に社内標準に合ってるか」を丁寧にチェックした。
返答スタイルの統一
チームで「理想的な返答フォーマット」を決めて、すべてのサンプルを人手で調整。用語の統一、句読点の使い方、技術的表現の精度…細かいところまで揃える。最初は地味だと思ったけど、これが一番効果あった。
バージョン管理
データセットをバージョン分けして、どのバージョンから学習したかトレーサビリティを確保。後から「なんかこのバージョンの精度落ちてるな」って気づいたときに、原因が追跡できる。
こうして「高品質 500サンプル」で学習させたら、ベースモデルとの差が明確に出るようになった。多くのSaaS企業の情報だと「1000-10000サンプルが最適」と書かれてるけど、正直ドメインによる。我々の場合、500でも十分な精度改善が見られたんだ。
推論コストが予想の3倍という地雷
これは実装してから気づいた罠だ。
ファインチューニングしたモデルは、ベースモデル(Claude 3 Haiku)とは別料金が発生する。Bedrockの料金表には記載されてるんだけど、実装前に見落としてた。
| モデル | 入力トークン(100kあたり) | 出力トークン(100kあたり) |
|---|---|---|
| 標準 Haiku | $0.80 | $4.00 |
| ファインチューニング Haiku | $2.40 | $12.00 |
つまり、推論コストが3倍。月間の推論量が100万トークンだった場合、ベースモデルなら約$8.40なのに、ファインチューニングモデルは$25.20になってしまう。
うちはこれに気づかず、本番でファインチューニングモデルを使い続けた。1ヶ月後に請求を見て、「え、これいくら?」という地獄絵図。
現在は戦略を調整してる。
精度が重要なタスクの選別
ドキュメント生成、システム設計提案、トラブルシューティング…こういった「間違うと大変」なタスクにはファインチューニングモデルを使う。一方、簡単なQ&A、リンク集の提示といった汎用的なタスクはベースモデルで対応。
Prompt Caching の活用
Claude 3のPrompt Caching機能を活用して、頻繁に参照するドキュメント前缀をキャッシュ化。これで約20%のトークン削減を実現できた。社内ガイドラインとか、何度も繰り返し入力するシステムプロンプトはここで効果的。
バッチ処理の導入
リアルタイム推論が必須でないタスク(レポート生成など)はBedrockの非同期バッチAPIを活用。ここは割引料金が適用されるので、コスト面でかなり有効。
コストとパフォーマンスのバランスは、プロダクト段階では本当に重要だ。
ファインチューニングの精度評価が思ったより複雑
「モデルの精度を測る」って、実装するまで想像以上に難しいんだ。
LLMの場合、単純な正解/不正解では評価できない。生成タスクだから。うちが最初にやったのは「人手評価」。チーム内のQAエンジニアに、ベースモデルとファインチューニングモデルの出力を比較させた。
でも200サンプルの比較に1週間かかった。スケールしないんですよね。
そこで複数の評価手法を組み合わせることにした。
実装した評価フレームワーク
import anthropic
from typing import TypedDict
class EvaluationResult(TypedDict):
input_text: str
base_output: str
finetuned_output: str
relevance_score: float # 0-1, 関連性
style_match_score: float # 0-1, スタイル統一性
technical_accuracy: float # 0-1, 技術的正確性
preferred: str # "base" / "finetuned" / "equal"
def evaluate_model_output(
input_text: str,
base_output: str,
finetuned_output: str
) -> EvaluationResult:
"""複数の観点でモデル出力を評価"""
# 1. 関連性スコア(BERTScore風)
# 入力質問に対する返答の適切性
relevance_base = calculate_relevance(input_text, base_output)
relevance_finetuned = calculate_relevance(input_text, finetuned_output)
# 2. スタイル統一性(LLM評価)
# ドメイン特有の用語・フォーマットに合致しているか
evaluator = anthropic.Anthropic()
style_eval = evaluator.messages.create(
model="claude-3-5-sonnet-20241022",
max_tokens=100,
messages=[
{
"role": "user",
"content": f"""以下は社内ドキュメントの標準スタイルです:
{COMPANY_STYLE_GUIDE}
質問:{input_text}
回答A(ベースモデル):
{base_output}
回答B(ファインチューニング):
{finetuned_output}
回答AとBのそれぞれについて、スタイルガイドへの適合度を0-1で評価してください。
JSON形式で {{"answer_a": 0.X, "answer_b": 0.X}} を返してください。"""
}
]
)
# パースして返す
return EvaluationResult(
input_text=input_text,
base_output=base_output,
finetuned_output=finetuned_output,
relevance_score=relevance_finetuned - relevance_base,
style_match_score=float(json.loads(style_eval.content[0].text)["answer_b"]),
technical_accuracy=evaluate_technical_accuracy(finetuned_output),
preferred="finetuned" if relevance_finetuned > relevance_base else "base"
)
実装した評価項目を詳しく説明すると:
関連性スコア 入力に対する出力の関連度。簡易的にはBERT embeddings でcosine similarityを計算して、「この質問にちゃんと答えてるか」を数値化する。
スタイル統一性 LLM自身に「社内スタイルガイドに合致しているか」を評価させる。メタ評価だけど、人間が1サンプルずつ見るより圧倒的に速い。
技術的正確性 ドメインエキスパート(このケースではAWSなら認定エンジニア)による抽出チェック。間違った情報が出てないかは、自動評価では検知しづらいから。
ユーザー満足度 実際に社内で使ってもらって、フィードバック収集。結局、社員がどう感じるかが最終指標なんだ。
これらを組み合わせると、より客観的に「ファインチューニングの効果」が可視化される。現在、月1回のペースでテストセット100サンプルに対して評価を回しており、スタイル統一性で平均+0.35点の改善、ユーザー満足度は当初の「気になる」から「十分」へ移行した。地味な改善だけど、積み重ねが大事。
AWS構成図と実装パターン
実際にうちが運用してる構成を図示した。
graph TB
subgraph "Data Preparation"
A["社内ドキュメント<br/>AWS構成/設計<br/>トラブルシューティング"]
B["チャット履歴<br/>承認済みQ&A"]
C["Data Curation Pipeline<br/>Python + DVC"]
A --> C
B --> C
end
subgraph "Fine-tuning"
D["Training Dataset<br/>JSONL Format<br/>500-1000 samples"]
E["Bedrock Fine-tuning Job<br/>Claude 3 Haiku"]
F["Custom Model<br/>v1.0, v1.1, v1.2..."]
C --> D
D --> E
E --> F
end
subgraph "Evaluation"
G["Test Dataset<br/>100 samples/month"]
H["Multi-metric Evaluation<br/>Relevance / Style / Accuracy"]
I["Evaluation Dashboard<br/>CloudWatch + Custom metrics"]
D -.-> G
G --> H
H --> I
end
subgraph "Production"
J["Hybrid Routing<br/>Task Classification"]
K["Bedrock Invoke<br/>base or finetuned"]
L["Prompt Caching<br/>System Prompt"]
M["Application<br/>Internal Chat / Doc Gen"]
F --> K
J --> K
K --> L
L --> M
end
subgraph "Cost Optimization"
N["Batch API<br/>Async Processing"]
O["Token Monitoring<br/>CloudWatch"]
P["Cost Alert<br/>Budget threshold"]
K -.-> N
K --> O
O --> P
end
style C fill:#fff4e6
style E fill:#e3f2fd
style H fill:#f3e5f5
style L fill:#e8f5e9
style O fill:#ffe0b2
複数のパイプラインが連携する構成だ。最初は気に入ったのが、データの準備段階から評価・本番までの流れが一貫してるところ。
ベストプラクティスをまとめると
データセット準備
品質を最優先にすること。1000個の雑なサンプルより、500個の精選されたサンプルのほうが効果的だった。返答フォーマット、トーン、用語を事前に決めて、すべてのサンプルを統一する。これが一番地味だけど、効果あるんですよね。
Git(またはDVC)でデータセットのバージョンを管理して、どのバージョンから学習したか追跡可能にしておく。月1回のペースで新しいドメイン知識を追加し、モデルを再学習する。「作ったら終わり」ではなく、継続的なメンテナンスが必須。
コスト管理
推論コストが3倍であることを前提に、ハイブリッドルーティングを実装する。タスク分類して、精度重視の部分と汎用部分を使い分ける。
Prompt Caching でシステムプロンプト(長い社内ガイドライン)をキャッシュ化すれば、初回以降は約20%トークン削減。バッチ処理でリアルタイム性が不要なタスクを処理すれば、割引率が適用される。
CloudWatch Metricsでトークン使用量を常に監視し、月初に予算警告を設定しておくと、予期しない請求を避けられる。
評価と改善
単一指標では不十分。関連性×スタイル×技術正確性×ユーザー満足度を組み合わせた評価フレームワークが必須だ。人手評価の工数削減のため、LLM自身に評価させる(メタ評価)ことで、スケーラビリティを確保する。
月1回のテストセット評価で、モデルのドリフトを検知する。精度が落ちてきたら、新しいデータを追加して再学習する。
ファインチューニング実行時の注意点
import boto3
import json
from datetime import datetime
bedrock = boto3.client('bedrock', region_name='us-east-1')
def create_finetuning_job(
training_data_uri: str, # s3://bucket/training.jsonl
validation_data_uri: str = None, # s3://bucket/validation.jsonl(オプション)
model_id: str = "anthropic.claude-3-5-haiku-20241022-v1:0",
output_model_name: str = "company-domain-model-v1.1"
) -> dict:
"""Bedrockファインチューニングジョブを実行"""
# 重要: S3にはアップロード完了が必須
# jsonl形式を確認
assert training_data_uri.endswith('.jsonl')
# ファインチューニングジョブ作成
response = bedrock.create_model_customization_job(
customizationJobName=f"{output_model_name}-{datetime.now().strftime('%Y%m%d-%H%M%S')}",
roleArn="arn:aws:iam::ACCOUNT:role/BedrockFineTuningRole",
baseModelIdentifier=model_id,
trainingDataConfig={
"s3Uri": training_data_uri,
"contentType": "application/jsonl"
},
outputDataConfig={
"s3Uri": f"s3://your-output-bucket/finetuned-models/{output_model_name}/"
},
hyperParameters={
"batchSize": "1", # デフォルト(変更不可)
"learningRate": "0.0002", # 小さめ推奨(過学習防止)
"numberOfEpochs": "3" # 3-5回で十分なケースが多い
},
validationDataConfig={
"s3Uri": validation_data_uri
} if validation_data_uri else None,
# 重要: 学習後、自動的に推論対応モデルになる
enableSensitivityWordFiltering=False # 社内用なので無効化
)
print(f"Job created: {response['jobArn']}")
# ジョブステータス確認用のループ
job_arn = response['jobArn']
while True:
status = bedrock.get_model_customization_job(jobIdentifier=job_arn)
print(f"Status: {status['status']} - {status.get('statusMessage', '')}")
if status['status'] in ['Completed', 'Failed', 'Stopped']:
if status['status'] == 'Completed':
print(f"Custom model ARN: {status['outputModelArn']}")
# outputModelArnをメモして、推論時に使用
break
import time
time.sleep(60) # 1分ごとにチェック
return status
実行するうえで気を付ける点を説明する。
S3アップロード前チェック jsonl形式が正しいか、各行が有効なJSONか確認する。変な形式だとジョブが立ち上がってから失敗して、時間の無駄になる。
Learning Rate 0.0002推奨。大きすぎると過学習、小さすぎると学習不足になりやすい。うちも何回か試して、この値に落ち着いた。
Epochs 3-5回で十分。多すぎると過学習リスクが上がる。500サンプルなら3回で十分。
Validation Data あれば用意すると、ジョブ内で自動的に評価される。最終的なモデルの精度推定に役立つ。
正直なところ、ファインチューニングは万能じゃない
6ヶ月運用して気づいたのは、ファインチューニングが本当に活躍するのは、限定的なドメインに限られるってこと。
うちの場合、成功したのは「AWS設計提案」「トラブルシューティング提案」といった、社内に大量の高品質な事例がある領域。失敗したのは「創造的なアイデア出し」や「戦略立案」といった、教科書的なパターンが少ない領域だった。
後者は、むしろRAG(Retrieval Augmented Generation)+ プロンプト工夫のほうが効果的だったりする。重要なのは、タスクの性質に応じて手法を使い分けること。個人的には、最初からファインチューニングを使うんじゃなく、まずRAGとプロンプト工夫で頑張って、それでダメだったときに初めてファインチューニングを検討する、くらいの段階的なアプローチがいいと思う。
また、データセット準備の手間も軽視できない。月1回の更新メンテナンスには、チーム内で専任者が1日/週程度必要。それを見込んだうえで、ROI計算する必要がある。「投資額に見合う精度向上があるか」を冷静に見極める。
まとめ
Bedrockのファインチューニングを本番で6ヶ月運用して学んだことをざっくりまとめた。
データ品質が9割 — 1000個の雑なサンプルより、500個の精選されたサンプルのほうが効果的。スタイル統一が最重要だ。
推論コストが3倍 — 事前計画が不可欠。ハイブリッドルーティング+Prompt Caching で対策しないと、予想外の請求が来る。
評価は複数指標で — 関連性×スタイル×技術正確性×ユーザー満足度を組み合わせた評価フレームワークが必須。単一指標だけでは信用できない。
月次メンテナンス前提 — 「作ったら終わり」では精度が落ちる。定期的なデータ更新とモデル再学習を計画しておく。
タスク選定が重要 — 万能ではない。パターン化できる領域(設計、トラブルシューティング)に限定し、創造的タスクはRAG+プロンプト工夫で対応する方が良いかもしれない。
ファインチューニングは強力だが、導入前に「本当にこれが必要か」「ROIはあるか」を冷静に問い直すことが大事。気に入ってるのは、Anthropic + AWSの連携で、ファインチューニング後のモデルがそのままBedrockの推論APIで使える点。難しい統合は不要で、すぐに本番で回せる。
次のステップは、マルチタスク対応に進むつもり。現在は単一ドメインだけど、複数ドメイン(AWS・GCP・Kubernetes)に対応したファインチューニングモデルの運用を検討中。そのときはまた別の課題が出てくるんだろうけど、今のノウハウがあれば対応できるはず。