はじめに
先に白状します。この記事は失敗談だけです。
Amazon Bedrock の Model Distillation に3回、Reinforcement Fine-tuning(以下 RFT)に2回、合計5回ジョブを投入して、1回も Completed に到達できませんでした。
「Nova Pro の知識を Nova Lite に移して、精度とコストを実測比較する」というつもりで始めた検証は、精度データを1件も取れずに終わっています。
ただ、5回落ちる過程で「公式ドキュメントに書かれていないバリデーション仕様」が次々に出てきました。
最低データ数の扱い、未文書化の禁止タグ、RFT の Grader Lambda が要求する JSON 形式、IAM 権限の二重設定。どれも投入してエラーを食らって初めてわかる類のもので、しかもジョブ系サービスは1回の試行が数十分〜数時間待ちです。
同じ待ち時間を他の人が払う必要はないと思ったので、失敗の中身をそのまま置いておきます。
どこで何に詰まったかを1枚にすると、こうなります。
前提とスコープ
- 検証時期は 2026年8月。対応モデル・バリデーション仕様は今後変わる可能性があります
- セットアップ手順そのものは公式ドキュメントに譲り、この記事はハマりどころに絞ります
- 精度比較の実データは、ジョブが完走しなかったためありません(「取れなかった」という事実だけ書きます)
- リージョンは us-east-1、AWS CLI v2 を使用
Distillation / SFT / RFT の違いと制約(早見表)
Bedrock のモデルカスタマイズには3つの系統があります。名前が似ているうえに、必要なデータも対応モデルも別物なので、まず整理しておきます。
| 観点 | Model Distillation | Supervised Fine-tuning (SFT) | Reinforcement Fine-tuning (RFT) |
|---|---|---|---|
| 一言で | Teacher の知識を Student に移植 | 正解データで模倣学習 | 報酬信号で行動改善 |
| 必要なデータ | プロンプトのみ(最低100件) | 入出力ペア(1K件〜のオーダー) | プロンプト + 評価関数 |
| 正解ラベル | 不要(Teacher が生成) | 必須(人手で作成) | 不要(評価関数が報酬を返す) |
| コスト構造 | Teacher 推論費 + Student の学習費 | 学習費のみ | 推論ループ × epoch × サンプル数 |
| 推論コスト | Student 料金(安い) | Base 料金 | Base 料金 |
| 得意なこと | コスト削減(大→小に知識移植) | ドメイン特化(医療・法務など) | ハルシネーション抑制・出力形式統一 |
| 苦手なこと | Student の能力限界を超える知識 | 正解データの用意 | 報酬設計が難しいタスク |
ざっくり言うと、Distillation は「賢いモデルの答えをカンニングさせて安いモデルを育てる」、 RFT は「採点関数を渡して点数が上がる方向に振る舞いを寄せる」 です。SFT だけが大量の正解データを要求するので、「正解データがないから Distillation か RFT」という入り方になります。
そして、対応モデルとリージョンの制約がなかなか厳しいです(いずれも 2026年8月時点で確認した内容)。
| 手法 | Teacher / Base | Student | リージョン | Provisioned Throughput |
|---|---|---|---|---|
| Distillation | Nova Pro | Nova Lite / Micro | us-east-1 | 不要(On-Demand 可) |
| Distillation | Nova Premier | Nova Lite / Micro / Pro | us-east-1 | 不要 |
| Distillation | Llama 3.1 405B / 70B | Llama 8B / 70B / 3.2 1B ほか | us-west-2 | 必要 |
| RFT | Nova 2 Lite(amazon.nova-2-lite-v1:0:256k) |
— | us-east-1 | 不要 |
押さえておきたい点が3つあります。
- Anthropic モデルは Distillation 非対応でした。Claude を Teacher にする構想は最初から成立しません
- Llama 系は Provisioned Throughput が必須です。カスタムモデルを呼ぶだけで時間課金が発生するので、検証用途で軽い気持ちで選ぶと予算を焼きます。Nova を選んだ最大の理由が「On-Demand で推論できる」ことでした
- Distillation は Nova 1 系、RFT は Nova 2 Lite のみ。つまり同じベースモデルで「Distillation 版」と「RFT 版」を作って比べる、という apple-to-apple 比較はそもそも組めません
3つ目は検証設計の段階で気づくべきでした。
当初は「Base / Distilled / RFT の3者比較」を計画していたのですが、Distilled は Nova 1 Lite ベース、RFT は Nova 2 Lite ベースなので、差分が手法によるものかモデル世代によるものかを分離できません。ここは投入前に比較のフレームごと組み直す必要がありました。
Distillation で踏んだ落とし穴
まず結果です。3回投入して3回とも Completed に届いていません。
| 試行 | 結果 | 原因 |
|---|---|---|
| v1 | Failed | 50問 → 最低100問要件を満たさず |
| v2 | Failed |
Bot: の禁止タグで1問 reject → 99/100 で要件未達 |
| v3 | Stopped | 24時間超 InProgress のまま。手動停止 |
「100問推奨」は実は必須だった
公式ドキュメントには「100 prompt-response pairs を推奨」と書かれています。素直に「推奨値」と読んで、まず50問で投入しました。
返ってきたのは Failed !!
推奨ではなく必須でした。
Input data must have at minimum 100 valid prompts
紛らわしいのは、出力先 S3 に吐かれるレポート CSV を見ると 50/50 accepted になっていた点です。「形式は全部通っているのに、なぜ落ちる」と一瞬混乱しましたが、単に通ったプロンプトの件数が閾値に足りていないという話でした。
レポート CSV は「形式が正しいか」しか教えてくれません。accepted 数が 100 に届いているかは自分で数える必要があります。ここが次の落とし穴に直結します。
ドキュメントに載ってない禁止タグ(99/100で全滅する)
この記事で一番共有したいのがこれです。
100問に増やして再投入したところ、また Failed。レポート CSV は 99/100 accepted。1問だけ reject されていて、その1問のせいで最低100問要件に届かず、ジョブ全体が落ちます。
reject された1問の中身を見に行くと、原因はプロンプト本文に含まれていた次の一文でした。
ITヘルプデスクBot: レベル2判定
Bot: です。会話ロールの制御トークンとして予約されている文字列が、自然文の中に出てくるだけで reject されます。この挙動はドキュメントで見つけられませんでした。
実際に確認できた禁止文字列の一覧はこちらです。
| 種別 | 文字列 |
|---|---|
| ロールタグ |
System: / SYSTEM:
|
| ロールタグ |
User: / USER:
|
| ロールタグ |
Bot: / BOT:
|
| ロールタグ |
Assistant: / ASSISTANT:
|
| 思考タグ | Thought: |
| 特殊トークン | [EOS] |
| メディアタグ |
<image> / <video>
|
| 未知語トークン | <unk> |
ポイントは3つです。
-
大文字・小文字のバリエーションが両方チェックされる(
Bot:もBOT:も NG) - 文脈は見てくれない。製品名や UI 表記として自然に出てくる文書でも容赦なく落ちます
- 対策はコロンを消すだけで済みます。
Bot:→Bot(のように置換すれば通りました
社内文書・チャットログ・ヘルプデスクの応対記録を訓練データにする場合、User: や Bot: は普通に混入します。投入前に必ず grep してください。1問 reject されるだけで、その回のジョブと待ち時間が丸ごと無駄になります。
データ形式: content は配列、system は外
Distillation のスキーマは bedrock-conversation-2024 です。ここで最初に間違えたのが content の型でした。実際に投入した1行(JSONL の1レコード)はこの形です。
{
"schemaVersion": "bedrock-conversation-2024",
"system": [{"text": "あなたは社内AIアシスタントです。提供されたコンテキスト情報のみに基づいて回答してください。"}],
"messages": [
{"role": "user", "content": [{"text": "<context>\n【申請方法】取得希望日の3営業日前までに上長へ申請する。\n</context>\n\n<question>有給休暇の申請方法を教えて</question>"}]}
]
}
引っかかりやすいのは次の3点です。
-
contentは文字列ではなく配列です。[{"text": "..."}]の形にします。Converse API のメッセージ構造と同じだと思えば納得できますが、OpenAI 形式に慣れているとまず間違えます -
systemはmessagesの外側、しかも配列です - マルチターンは Nova のみ対応でした。Anthropic / Meta は single-turn のみ
CLI パラメータ: --distillation-config は存在しない
地味に時間を溶かしたのがこれです。create-model-customization-job に --distillation-config というパラメータはありません。正解は --customization-config の中に distillationConfig を入れる形です。
aws bedrock create-model-customization-job \
--job-name "$JOB_NAME" \
--custom-model-name "$CUSTOM_MODEL_NAME" \
--role-arn "$ROLE_ARN" \
--base-model-identifier "amazon.nova-lite-v1:0:300k" \
--training-data-config '{"s3Uri": "s3://<bucket>/distillation/input/training.jsonl"}' \
--output-data-config '{"s3Uri": "s3://<bucket>/distillation/output/"}' \
--customization-type "DISTILLATION" \
--customization-config '{"distillationConfig": {"teacherModelConfig": {"teacherModelIdentifier": "amazon.nova-pro-v1:0", "maxResponseLengthForInference": 2048}}}' \
--region us-east-1
--base-model-identifier に指定するのは Student(Nova Lite)で、Teacher は customization-config の中、という対応関係も直感に反しました。「ベースモデル=元にするモデル=Teacher」と読んでしまうと逆になります。
迷ったら次のコマンドで正しい構造が出ます。ドキュメントを探すより速いです。
aws bedrock create-model-customization-job --generate-cli-skeleton input
3回目の断念: 24時間超 InProgress
禁止タグを潰して100問すべてが通る状態にし、3回目を投入しました。ここは抜けるはずでした。
そして 24時間以上 InProgress のまま動かなくなりました。
Distillation は内部に「Teacher にプロンプトを流して応答を合成する」フェーズがあり、ここでプロンプト数が最大 15K 程度まで増えることがあるとされています。ただ今回の入力は100問なので、そのボリュームで24時間かかる説明はつきません。データ合成フェーズで止まっているのだろう、という推定までで、それ以上の切り分け材料は手元にありませんでした。
get-model-customization-job が返すのは status と failureMessage くらいで、フェーズごとの進捗は見えません。「止まっている」のか「時間はかかるが進んでいる」のかを利用者側から判別する手段が見つかりませんでした。
クレジットの期限が迫っていたこともあり、ここは手動でジョブを停止しました。AWS 側の原因を断定できる材料はないので、「こういう挙動だったので停止を判断した」という事実として書いておきます。
RFT で踏んだ落とし穴
RFT は2回投入して2回とも Failed です。
| 試行 | 結果 | 原因 |
|---|---|---|
| v1 | Failed | Grader Lambda のレスポンスが LIST ではなく OBJECT だった |
| v2 | Failed |
Encountered an internal error(Lambda 未到達) |
対応モデルは Nova 2 Lite のみ
Distillation と揃えるつもりで Nova 1 Lite(amazon.nova-lite-v1:0:300k)を指定したら、即座に弾かれました。
The provided Customization Type is invalid or not supported for this model
RFT が使えるのは Nova 2 Lite(amazon.nova-2-lite-v1:0:256k)のみでした。エラーメッセージが「Customization Type が invalid」と言ってくるので、最初はモデルではなく customizationType の指定を疑って時間を使いました。
そしてその customizationType も間違えます。ファインチューニングだから FINE_TUNING だろうと指定すると、こうなります。
Invalid input: hyperParameters is a required parameter for given customizationType
一見「ハイパーパラメータが足りない」というエラーですが、実際はカスタマイズ種別そのものが違います。正解は REINFORCEMENT_FINE_TUNING です。エラーメッセージが原因を指していないので、ここは素直にハマりました。
Lambda Grader は入力も出力もリスト形式
RFT の肝は「モデルの回答を採点する Lambda(Grader)」です。ここの入出力 JSON は公式の例が薄く、最初は素朴に {"score": 0.8} を返す実装にしていました。結果はこれです。
Lambda function returned invalid format: expected LIST, got OBJECT
入力も出力も、両方リスト形式でした。実際の形はこうです。
入力(Bedrock → Lambda):
[
{
"id": "Q01-with-context",
"messages": [
{"role": "user", "content": "..."},
{"role": "assistant", "content": "モデルの生成回答"}
],
"metadata": {
"reference_answer": "期待する回答",
"その他カスタムフィールド": "..."
}
}
]
出力(Lambda → Bedrock):
[
{
"id": "Q01-with-context",
"aggregate_reward_score": 0.85,
"metrics_list": [
{"name": "keyword_match", "value": 0.9, "type": "Reward"},
{"name": "conciseness", "value": 0.8, "type": "Metric"}
]
}
]
実装上の注意点をまとめます。
- 報酬本体は
aggregate_reward_score(0.0〜1.0)。ここが学習を駆動する値です -
metrics_listのtypeは"Reward"か"Metric"の2択。Metricは観測用の指標です -
idを必ず返すこと。入力のidと突き合わせられます - 訓練データで
messages以外にトップレベルへ置いたフィールドは、metadataの下に入って渡ってきます。JSONL 側でトップレベルに書いたreference_answerを、Lambda ではmetadata["reference_answer"]で取りに行く形になります
handlerの骨格はこれくらいシンプルです。
def lambda_handler(event, context):
results = []
items = event if isinstance(event, list) else [event]
for item in items:
messages = item.get("messages", [])
metadata = item.get("metadata", {})
response_text = ""
for msg in messages:
if msg.get("role") == "assistant":
response_text = msg.get("content", "")
reference = metadata.get("reference_answer", "")
score = compute_score(response_text, reference) # 0.0〜1.0 を返す自作関数
results.append({
"id": item.get("id", "unknown"),
"aggregate_reward_score": score,
"metrics_list": [
{"name": "keyword_match", "value": score, "type": "Metric"},
],
})
return results
今回の compute_score は「参照回答のキーワード一致率 0.6 + 簡潔性 0.2 + ハルシネーション抑制 0.2」の重み付けにしました。
参照回答が「この情報は提供されたドキュメントに含まれていません」系の場合は、モデルも同じく「わかりません」と言えたかどうかだけで採点する分岐を入れています。報酬設計は完全に自由に書けるので、ここが RFT の面白さであり難しさです。
IAM 権限は Lambda 側と IAM ロール側の二重設定
Lambda の形式を直して再投入したら、今度は Lambda が呼ばれませんでした。原因は権限です。
aws lambda add-permission によるリソースベースポリシーだけでは足りません。 Bedrock のカスタマイズジョブ用 IAM ロール側にも lambda:InvokeFunction が必要でした。両方を設定します。
# 1. Lambda 側: リソースベースポリシー
aws lambda add-permission \
--function-name rft-grader \
--statement-id bedrock-invoke \
--action lambda:InvokeFunction \
--principal bedrock.amazonaws.com \
--region us-east-1
# 2. IAM ロール側: インラインポリシー
aws iam put-role-policy \
--role-name <customization-job-role> \
--policy-name RftGraderInvoke \
--policy-document '{
"Version": "2012-10-17",
"Statement": [{
"Effect": "Allow",
"Action": "lambda:InvokeFunction",
"Resource": "arn:aws:lambda:us-east-1:<account-id>:function:rft-grader"
}]
}'
Bedrock がジョブロールを assume した上で Lambda を呼びに来るので、呼ぶ側(ロール)と呼ばれる側(Lambda)の両方で許可が要る、という理屈です。言われれば当たり前ですが、片方だけ設定して「なぜ呼ばれない」と悩みました。
batchSize の最小値は 16
検証なのでとにかく安く速く回したくて batchSize: 4 を指定したら、バリデーションで落ちました。
valid min value: 16
RFT のハイパーパラメータについて、把握できた範囲をまとめます。
| パラメータ | 最小値 | ドキュメント例の値 |
|---|---|---|
batchSize |
16 | 64 |
epochCount |
1 | 2 |
learningRate |
— | 0.00001 |
maxPromptLength |
— | 4096 |
trainingSamplePerPrompt |
— | 4 |
inferenceMaxTokens |
— | 8192 |
reasoningEffort |
— | "high" |
evalInterval |
— | 10 |
RFT のコストは「推論ループ × epoch × サンプル数」で効いてきます。batchSize の下限が 16 に固定されている以上、「極小構成でお試し」ができないのは投入前に知っておいたほうがいいです。今回は最小に近い構成として、以下で投入しました。
aws bedrock create-model-customization-job \
--job-name "$JOB_NAME" \
--custom-model-name "$CUSTOM_MODEL_NAME" \
--role-arn "$ROLE_ARN" \
--base-model-identifier "amazon.nova-2-lite-v1:0:256k" \
--training-data-config '{"s3Uri": "s3://<bucket>/rft/input/rft-training.jsonl"}' \
--output-data-config '{"s3Uri": "s3://<bucket>/rft/output/"}' \
--customization-type "REINFORCEMENT_FINE_TUNING" \
--customization-config '{"rftConfig": {"graderConfig": {"lambdaGrader": {"lambdaArn": "<lambda-arn>"}}, "hyperParameters": {"epochCount": 2, "batchSize": 16, "learningRate": 0.00001, "maxPromptLength": 2048, "trainingSamplePerPrompt": 4, "inferenceMaxTokens": 1024, "evalInterval": 10}}}' \
--region us-east-1
2回目の断念: internal error(Lambda 未到達)
ここまで全部潰して投入した2回目。返ってきたのはこれでした。
Encountered an internal error when processing the request.
情報がありません。そこで切り分けをしました。
- Grader Lambda の CloudWatch Logs にログが1件もない → Lambda はそもそも呼ばれていない
- ベースモデル(Nova 2 Lite)のアクセス状態は
ACTIVE - 訓練データは形式チェック済み(
messages+reference_answerの JSONL) - IAM は Lambda リソースポリシー + ジョブロールのインラインポリシーの両方を設定済み
- ハイパーパラメータはバリデーションを通過している(通らなければ
ValidationExceptionが返る)
バリデーションは通っているのに、Grader に到達する前に落ちている。この形が2回とも再現しました。設定側で触れる範囲は潰したので、少なくとも「こちらの設定ミス」とは考えにくい状態です。
とはいえ Bedrock 内部で何が起きたかは利用者から見えないので、原因は断定しません。事実として書けるのは「バリデーション通過後・Lambda 呼び出し前の段階で internal error が2回再現した」ということだけです。
クレジット期限が迫っており、これ以上リトライに時間を使うのは非合理と判断して、ここで検証を打ち切りました。
共通: S3 + IAM セットアップ
Distillation / RFT で共通のインフラ側の注意点です。ここは素直に動いた部分ですが、間違えやすいところを残しておきます。
バケットはジョブと同じリージョンに作る。 Nova の Distillation / RFT は us-east-1 なので、バケットも us-east-1 です。クロスリージョンに置くと余計な問題を呼び込みます。
信頼ポリシーは Condition で絞る。 bedrock.amazonaws.com を無条件で信頼するのではなく、aws:SourceAccount と aws:SourceArn で絞ります(いわゆる confused deputy 対策)。
{
"Version": "2012-10-17",
"Statement": [
{
"Effect": "Allow",
"Principal": {"Service": "bedrock.amazonaws.com"},
"Action": "sts:AssumeRole",
"Condition": {
"StringEquals": {"aws:SourceAccount": "<account-id>"},
"ArnLike": {
"aws:SourceArn": "arn:aws:bedrock:us-east-1:<account-id>:model-customization-job/*"
}
}
}
]
}
権限側は、入力パスに s3:GetObject / s3:ListBucket、出力パスに s3:PutObject を付けます。RFT の場合はこれに加えて前述の lambda:InvokeFunction が必要です。
出力先の S3 パスは末尾スラッシュを付ける。 s3://<bucket>/output/ のようにしておくと、ジョブごとにサブディレクトリが自動生成されます。ここにレポート CSV が出るので、accepted 数の確認に使います。
モニタリングは status と failureMessage を取るだけ。
aws bedrock get-model-customization-job \
--job-identifier "<job-arn>" \
--region us-east-1 \
--query '{Status: status, FailureMessage: failureMessage}'
InProgress → Completed / Failed の遷移しか見えません。前述のとおり内部フェーズの進捗は取れないので、「長時間 InProgress のとき、待つべきか止めるべきか」の判断材料が実質ありません。投入前に「何時間待ったら諦めるか」を自分で決めておくのが現実的だと思います。
まとめ: 投入前チェックリストと使い分け
5回失敗して得た「投入前に潰しておくべきこと」をチェックリストにします。
Distillation
- valid な prompt が 100件以上あるか(推奨ではなく必須。1件足りないだけで全体 Failed)
-
訓練データに禁止タグ(
System:User:Bot:Assistant:Thought:[EOS]<image><video><unk>、大文字版含む)が混入していないか grep したか -
contentが配列[{"text": "..."}]になっているか。systemはmessagesの外側の配列か -
CLI は
--customization-configの中にdistillationConfigを入れているか(--distillation-configは存在しない) -
--base-model-identifierに Student を指定しているか
RFT
-
ベースモデルは Nova 2 Lite(
amazon.nova-2-lite-v1:0:256k)か -
customizationTypeはREINFORCEMENT_FINE_TUNINGか(FINE_TUNINGではない) -
Grader Lambda の入力・出力が両方リスト形式か。
id/aggregate_reward_score/metrics_listを返しているか -
IAM は Lambda リソースポリシー + ジョブロールの
lambda:InvokeFunctionの二重設定か -
batchSizeは 16以上か
共通
- S3 バケットはジョブと同じリージョン(us-east-1)か
-
信頼ポリシーに
SourceAccount/SourceArnの Condition を入れたか - 出力先 S3 パスは末尾スラッシュか
- 「何時間 InProgress なら停止するか」を決めてあるか
そして手法の使い分けです。ジョブは完走しませんでしたが、この判断軸は提案の場でそのまま使えます。
| 状況 | 選ぶべき手法 |
|---|---|
| 大量の正解データ(1K件〜)がある | Supervised Fine-tuning |
| 高精度なモデルの出力を教師にできる / 推論コストを下げたい | Model Distillation |
| 「良い回答」の判定基準をコードで書ける | Reinforcement Fine-tuning |
| いずれも当てはまらない | プロンプト改善・RAG のチューニングに戻る |
最後に、今回いちばん実感した学びです。
GA 前後の新機能、とくにカスタマイズ系は、成功例より先にバリデーション仕様を掴みにいくべきです。 今回の5回の失敗のうち3回は「投入前に知っていれば回避できた」ものでした。しかもそのほとんどは公式ドキュメントに書かれておらず、エラーを1回食らって初めてわかるものでした。ジョブ系サービスは1回の試行コストが「数十分〜数時間の待ち時間」なので、試行回数を減らす情報の価値がとにかく高いです。
そして、5回投入して精度データはゼロでしたが、データ形式・禁止タグ・API 仕様・IAM 設計は手元に残りました。カスタマイズ系を検証するときは、**「ジョブが通らなくても仕様の理解は資産として残る」**という前提で予算と時間を切っておくと、精神衛生上もいいと思います。
同じところで詰まった方の待ち時間が少しでも減れば幸いです。