[Amazon SageMaker AI] 教師 gpt-oss-20b → 生徒 Llama 3.2 1B の応答蒸留(レスポンス蒸留)を試してみました
1 はじめに
製造ビジネステクノロジー部の平内(SIN)です。
大きなモデル(教師)の能力の一部を、手元で動かせる小さなモデル(生徒)に移す手法として「蒸留」があります。蒸留には大きく 2 つの方式があります。
- ロジット蒸留(Logit Distillation): 教師の出力分布(logits / soft target)そのものを生徒に学ばせる、本来の知識蒸留。教師と生徒が同じ語彙(トークナイザ)を持っている必要があります
- 応答蒸留(レスポンス蒸留 / sequence-level KD): 教師の出力テキストを正解ラベルとして生徒を SFT する方式。教師と生徒の語彙やチャットテンプレートが違っても成立するため、プロバイダーの異なるモデル間でも使えます。実務で「蒸留」と呼ばれるものの多くはこちらの方式です(例えば DeepSeek-R1 の出力で Qwen や Llama を SFT した Distill モデル群もこの方式です)
今回はこの応答蒸留で、教師 gpt-oss-20b(OpenAI)→ 生徒 Llama 3.2 1B(Meta)という、ファミリーをまたぐ組み合わせ(以降、クロスファミリーと呼びます)の蒸留を試してみました。
なお、AWS には Amazon Bedrock Model Distillation というマネージドな蒸留機能があります。公式ドキュメントでは、教師モデルから応答(合成データ)を生成し、それを使って生徒モデルを fine-tuning する仕組みと説明されており、方式としては応答ベースです。便利な機能ですが、教師と生徒の組み合わせが同一プロバイダー内に限られる等の制約があるため、今回は使用せず、Amazon SageMaker AI の Training Job で自前実装しています。
先に結論を3点述べます。
- 教師と生徒のファミリーが異なっても、応答蒸留であれば知識を移せました。生徒の全フィールド一致は 2% から 63% へ、サービス名の正規化は 34% から 96% まで向上しました
- サービス名の正規化では、生徒(96%)が教師(90%)を上回りました。教師の個々の誤りが、学習を通じて均されたためと思われます
- 一方 few-shot は形式だけ整って内容が悪化し、プロンプトの工夫では対応できませんでした
2 構成
全体の構成は次のとおりです。

(1) 教師はローカル、学習は SageMaker
教師はローカルの ollama で動かし、学習だけ SageMaker Training Job を使う構成にしました。
| 使用するモデル | 実行場所 | |
|---|---|---|
| 教師データ生成 | gpt-oss-20b(OpenAI / Apache 2.0) | ローカル(ollama) |
| LoRA 学習 | Llama 3.2 1B Instruct(Meta) | SageMaker Training Job(ml.g5.xlarge) |
| 評価(4者比較) | 上記の教師と生徒 | ローカル |
教師が OpenAI、生徒が Meta ですので、プロバイダーをまたぐ構成になっています。
なお、教師データの生成も SageMaker Training Job 上で行うことは可能です(ジョブ内で ollama を起動すれば、13GB の量子化済みモデルは ml.g5.xlarge の A10G(24GB)に収まる計算です)。ただ、後述するように生成プロンプトは検品を挟んで作り直すことになり、試行錯誤のたびにジョブの起動時間と課金が発生するため、今回は反復しやすいローカルを選びました。
(2) gpt-oss-20b を選んだ理由
教師の候補はいくつかありましたが、以下の理由で gpt-oss-20b にしました。
- Apache License 2.0 であり、出力の利用に制限がないこと
- OpenAI から Meta へという組み合わせが、クロスファミリーであることを分かりやすく示せること
- 20.9B パラメータのうち 1 トークンあたり約 3.6B しか使わない MoE 構成のため 20B クラスでも実用的な速度で動き、MXFP4 量子化により 13GB 程度に収まって、手元の MacBook Pro(M2 / 24GB)で動作すること
なお、ここで使用しているのは、ダウンロードした重みをローカルで動かすオープンウェイト版です。同じ OpenAI のモデルでも、API の利用規約には出力の利用に関する制限(競合するモデルの開発への利用禁止など)があります。API の出力で同じことを行うと規約に抵触するおそれがあるため、オープンウェイト版と API では適用される規約が異なる点にご注意ください。
日本語での AWS の知識が十分かどうかは未知数でしたので、実際に生成させて確認しました。結果は「3 教師データの生成」で述べます。
(3) 評価はローカルで実施
評価(4 者比較)は、教師も生徒もローカルの同じマシンで実施します。教師と生徒を同じマシンで測ることで、レイテンシの比較条件が揃うためです。
この構成で作成する AWS リソースは S3 バケットと IAM ロールのみで、常時課金されるリソースはありません。学習に使う SageMaker Training Job は、ジョブの完了とともに自動的に終了します。
(4) タスクの設計
日本語の問い合わせ文から、次の JSON を抽出するタスクにしました。
{
"service": "Amazon Aurora",
"category": "network",
"severity": "high",
"summary": "30字以内の日本語要約"
}
service は 20 種類、category は 6 種類、severity は 3 種類に固定しています。
severity は問い合わせの深刻度で、次の基準で判定させています。
high= 本番停止・データ損失・セキュリティ侵害の可能性medium= 一部機能の不具合、性能劣化low= 質問、仕様確認、軽微な相談
service が「知識」(俗称から正式名称を導く)、category が「分類」を試すのに対し、severity は文脈の重みを読む「判断」を試すフィールドという位置づけです。ただし、後述するように severity は狙いどおりには機能しませんでした(「5 4 者比較の結果」で述べます)。
このタスクを選んだ理由は次のとおりです。
serviceの正規化(「エススリー」からAmazon S3を導く)には知識が必要で、書式の模倣ではなく知識の転移になっていること- 値が固定されているため、評価を完全に自動化でき、完全一致で数値が出せること
- Llama 3.2 1B の素の状態では値の一覧を無視したり JSON が壊れたりするため、学習前後の差が確認しやすいこと
(5) ライセンス上の遵守事項
モデルにはそれぞれライセンスがあります。
- 教師 gpt-oss-20b: Apache License 2.0。出力の利用・再配布に制限はありません
- 生徒 Llama 3.2 1B Instruct: Llama 3.2 Community License
Llama 3.2 Community License では、Llama を使って作成したモデルを配布する場合、モデル名を Llama で始める必要があり、あわせて "Built with Llama" の表示が求められます。本稿で作成したモデルを配布される場合はご注意ください。
3 教師データの生成
(1) シード grid で 1,076 件を生成する
20 サービス × 6 カテゴリ = 120 セルのシード grid を作り、1 セルあたり 9 件の問い合わせ文を生成しました。
Github scripts/generate_distill_dataset.py
$ python scripts/generate_distill_dataset.py --step 1 # 問い合わせ文の生成
$ python scripts/inspect_raw.py --fix # 検品
$ python scripts/generate_distill_dataset.py --step 2 # 教師によるラベル付け
$ python scripts/generate_distill_dataset.py --step 3 # train / test / holdout に分割
なお、1 件ずつ API を呼ぶと 1,080 回の呼び出しになります。Step 1 は 1 セル(9 件)をまとめて生成させることで 120 回に抑えています。
(2) 正式名称で書かせないための工夫
このタスクの肝は、問い合わせ文に正式名称を書かせないことです。「Amazon S3 のバケットが〜」と書かれていたら、正規化は単なる文字列コピーになってしまい、知識の転移になりません。
そこで、サービスごとの略称・俗称の辞書を用意し、生成プロンプトに渡しています。
ALIASES = {
"Amazon EC2": ["EC2", "イーシーツー", "インスタンス", "仮想サーバ"],
"Amazon S3": ["S3", "エススリー", "バケット", "オブジェクトストレージ"],
"Amazon Route 53": ["Route53", "ルート53", "53", "ホストゾーン"],
# ... 20 サービス分
}
最初は辞書を使わず、「略称・俗称・カタカナ表記を必ず使ってください。例: 「S3」「エススリー」「オーロラ」「ラムダ」「ECSのタスク」「CFn」」という汎用の指示にしていました。しかしこれには問題がありました。例に出した他サービスの俗称が、そのまま生成文に混入します。
Amazon S3 のセルで生成された文です。
こんにちは。オーロラ的なデータをアップロードしようとしたら認証エラーが出ます。
オーロラにあるデータをダウンロードしたいのですが、認証が通らない。
オーロラのデータを別アカウントに転送しようとしたら、認証トークンが無効というエラーが出ます。
「オーロラ」は Amazon Aurora の俗称です。この文をそのまま教師に渡すと Aurora とラベル付けされ、シードの意図(S3)と食い違ったデータになります。
加えて「S3っぽいサービス」「S3風のオブジェクト」「エススリーのようなストレージ」といった、実際の問い合わせには出てこない言い回しも多発しました。「曖昧に書け」と指示すると、曖昧さの方向をモデルが勝手に決めてしまうということかと思います。
対象サービスの俗称だけを渡す方式に変えたところ、他サービスの俗称の混入はなくなりました。
(3) 生成結果
私の環境(MacBook Pro / Apple M2 / 24GB / ollama 0.14.1)での実測は次のとおりです。
| 項目 | 実測値 |
|---|---|
| 生成件数 | 1,076 件 / 120 セル |
| 所要時間 | 40.1 分(34,403 トークン / 15.7 tok/s) |
| 完全重複 | 0 件 |
| 正式名称がそのまま書かれた文 | 0 件(0.0%) |
| 文字数 | 最小 19 / p50 55 / 最大 179 |
俗称は次のように散りました。指示どおり表記がゆれています。
Amazon Route 53 Route53:14 / ルート53:12 / 53:43 / ホストゾーン:16
Amazon DynamoDB DynamoDB:9 / ダイナモ:23 / DDB:16 / ダイナモDB:10
AWS Lambda Lambda:15 / ラムダ:16 / 関数:34
(4) 生成データの検品
1,076 件を人が読むのは現実的ではないので、機械で拾える異常を先に洗い出す inspect_raw.py を用意しました。
Github scripts/inspect_raw.py
検出しているのは、セルの欠損・件数不足、完全重複、他サービス語の混入、対象を指す語が入っていない文、文字数の外れ値、正式名称がそのまま書かれた文、俗称の使用分布です。
ここで想定していなかったものが見つかりました。プロンプトで明示的に禁止したはずの「文体の名前」が 191 件(約 15%)混入していました。
ビジネスメール調で:ダイナモDBを利用したテーブルの読み取りレートが…
チャットの口語調で聞きたいんですが、ラムダ関数からインターネットへ…
チャット口語調:やっほー、クラウドウォッチのロググループにログが…
【ビジネスメール調】お疲れ様です。ブイピーシーで作成した…
メモ・箇条書き調: - Bedrockのエンドポイントを呼び出し
生成プロンプトには「文体の名前そのものを本文に書かないこと」と書いていましたが、それでも残ります。禁止すれば消えるものとして扱わず、後処理で削る前提で設計した方が確実だと思いました。
後処理では、正当な文(「【要確認】」で始まる文や「チャットでの問い合わせ履歴を〜」のようにチャットが本題の文)を壊さないよう、先頭のラベル的な表現だけを対象にしています。
(5) 教師によるラベル付け
生成した 1,076 件に、教師 gpt-oss-20b でラベルを付けます。
| 項目 | 実測値 |
|---|---|
| ラベル付け成功 | 1,069 / 1,076 件 |
| 所要時間 | 47.2 分(48,940 トークン / 19.0 tok/s) |
| シード service との一致率 | 93.5% |
| 値域外れ等での失敗 | 9 件 |
失敗した 9 件の内訳が興味深いものでした。
7 件 bad service: Amazon Glue ← 正しくは AWS Glue
2 件 no JSON found
7 件すべてが Amazon Glue でした。 プロンプトで 20 個の正式名称を列挙し「表記は完全一致させること」と指示していますが、20 個のうち AWS で始まるのは 4 つ(Lambda / IAM / CloudFormation / Glue)だけで、残り 16 個は Amazon です。多数派に引きずられているようです。
なお、この誤りは値域チェックで弾いているので学習データには入っていません。教師も間違えるという前提で、enum の値域チェックは必ず入れておいた方が良いと思います。
(6) 評価用データは目視で確認する
教師の出力をそのまま正解として使うと、教師の精度がそのまま評価の上限になってしまいます。「生徒が教師を超える」ことが原理的に起こり得なくなるためです。
そこで、評価に使う test の 100 件だけは人が確認しました。とはいえ 100 件を漫然と眺めるのは効率が悪いので、怪しい順に並べる review_test.py を用意しています。
Github scripts/review_test.py
if label["service"] != r["seed_service"]: score += 3 # 教師とシードが食い違う
if not any(a in r["text"] for a in ALIASES[...]): score += 2 # 本文に service を指す語が無い
if others: score += 1 # 別サービスの語も含む
if len(str(label.get("summary", ""))) > 30: score += 1 # 仕様外れ
確認の結果、9 件を修正しました。教師の誤りには次のような傾向がありました(全 1,069 件での集計)。
| 表記 | 該当件数 | 教師の誤答 | 誤答率 |
|---|---|---|---|
| 「イーシーツー」を Amazon ECS と解釈 | 16 | 11 | 69% |
| 「CF」を AWS CloudFormation と解釈 | 24 | 13 | 54% |
| 「エスエヌエス」を Amazon SQS と解釈 | 11 | 4 | 36% |
| 「エスキューエス」を Amazon SNS と解釈 | 13 | 0 | 0% |
「CF」は CloudFront と CloudFormation のどちらとも読めるので、間違えるのは理解できます。一方「イーシーツー」は EC2 の読みそのものですが、7 割近くを ECS と判定していました。「エスキューエス」の誤答が 0% なのに「エスエヌエス」が 36% という非対称も、事前には予想していませんでした。
4 LoRA 学習
(1) 流用元との差分
学習パイプラインは、以前書いた記事のものを流用しています。
ただし、そのままではなく次の点を変更しました。
| 項目 | 流用元 | 本稿 |
|---|---|---|
| LoRA target_modules | 4 個(q, k, v, o) | 7 個(+ gate, up, down) |
| max_seq_length | 1024 | 512 |
| プロンプト部分の loss マスク | 未実装 | 実装 |
| インスタンス | ml.g5.xlarge | ml.g5.xlarge(後述) |
(2) プロンプト部分の loss をマスクする
流用元のコードでは、SFTTrainer に messages を渡すだけでした。この場合、system と user のトークンにも loss がかかります。生徒に覚えさせたいのは assistant の出力だけなので、プロンプト部分は損失計算から外します。
Github src/train.py
# Llama 3 系のチャットテンプレートで assistant 応答が始まる位置
RESPONSE_TEMPLATE = "<|start_header_id|>assistant<|end_header_id|>"
trainer = SFTTrainer(
...
data_collator=DataCollatorForCompletionOnlyLM(
response_template=RESPONSE_TEMPLATE, tokenizer=tokenizer
),
)
実際にマスクされているかは、学習前に手元で確認しておくと安心です。1 件を collator に通すと、次のようになりました。
全トークン 133 / マスク 92 / 学習対象 41
loss がかかる部分:
'\n\n{"service": "Amazon SNS", "category": "cost", "severity": "low", "summary": "SNS使用量が予算超過で警告未受信"}'
マスクされた部分(末尾):
'<|start_header_id|>user<|end_header_id|>\n\n問い合わせ: 今日のSNS使用量が…\n出力:<|eot_id|><|start_header_id|>assistant<|end_header_id|>'
system と user が -100 に置き換わり、assistant の JSON だけが学習対象になっています。
(3) 学習の実行
$ export HF_TOKEN=$(cat ~/.cache/huggingface/token)
$ python scripts/run_training.py --instance-type ml.g5.xlarge
実測値です。
| 項目 | 実測値 |
|---|---|
| インスタンス | ml.g5.xlarge |
| 学習データ | 869 件 × 3 epoch(651 ステップ) |
| Billable seconds | 869 秒(14 分 29 秒) |
| 料金 | 約 35 円 |
| loss | 0.75 → 0.05 |

loss の推移です。

学習そのものは 8 分 42 秒で、残りはコンテナイメージの取得と pip install の時間でした。
なお、当初は ml.g5.2xlarge を指定していましたが、Training job waiting for capacity のまま 20 分以上進みませんでした。1B モデルの LoRA に g5.2xlarge は過剰なので、流用元と同じ ml.g5.xlarge に落としたところ、15 秒でインスタンスを確保できました。
停止後のステータス履歴です。Pending のまま約 26 分経過していたことが分かります。

5 4 者比較の結果
(1) 比較の設計
同じ test 100 問を次の 4 者に解かせて、成績を比較します。試行回数ではなく、「学習せず素のまま」「学習せず見本だけ」「学習した」の 3 条件 + 教師という対照実験です。
- 教師 gpt-oss-20b: 教師そのもの。生徒がどこまで迫れれば十分かの基準になります
- 生徒 zero-shot: 素の Llama 3.2 1B に、例を一切見せず指示だけで解かせます(素の実力)
- 生徒 few-shot: 同じ素の Llama 3.2 1B に、プロンプトへ「問→答」の見本を 3 件入れてから解かせます。重みは変えず、その場で真似させるだけです
- 生徒 蒸留後: 教師データで重みを学習させた後の Llama 3.2 1B
3 者(教師 / 素の生徒 / 蒸留後)で比べると、「プロンプトを工夫すれば 1B でもできたのではないか」という疑問に答えられません。そこで few-shot を加えた 4 者で比較しました。
誰にどのプロンプトを与えるかは固定しています。
| 対象 | system | few-shot |
|---|---|---|
| 教師 gpt-oss-20b | enum を列挙した長い指示 | 3 件 |
| 生徒 zero-shot | enum を列挙した長い指示 | なし |
| 生徒 few-shot | enum を列挙した長い指示 | 3 件(教師と完全に同じ) |
| 生徒 蒸留後 | enum を書かない短い指示 | なし |
蒸留後だけ短いプロンプトにしているのは意図的です。enum の中身はプロンプトではなく重みに入っているはずで、それを確かめるのが蒸留の目的だからです。学習時も同じ system を使っています。
推論はすべて手元の MacBook Pro で実行しました。教師と生徒を同じマシンで測るので、レイテンシの比較として条件が揃います。
(2) 結果
test 100 件(目視で確認・修正済み)に対する結果です。
| JSON パース | service | category | severity | 全フィールド一致 | summary 30字 | JSON を複数出力 | p50 レイテンシ | |
|---|---|---|---|---|---|---|---|---|
| 教師 gpt-oss-20b(13GB) | 100% | 90% | 96% | 94% | 81% | 97% | 0% | 8.38s |
| 生徒 zero-shot(2.5GB) | 72% | 34% | 29% | 18% | 2% | 55% | 0% | 3.43s |
| 生徒 few-shot(2.5GB) | 100% | 7% | 22% | 28% | 1% | 97% | 100% | 15.71s |
| 生徒 蒸留後(2.5GB) | 100% | 96% | 83% | 80% | 63% | 96% | 0% | 2.09s |
全フィールド一致は 2% から 63% になりました。service に限れば 34% から 96% です。レイテンシも教師の 8.38 秒に対して 2.09 秒と 4 倍速く、モデルサイズは 1/5 です。
一方で、category(83%)と severity(80%)は教師(96% / 94%)に届いていません。全フィールド一致で 63% 対 81% の差があるのは、主にこの 2 つによるものです。
主要な 2 指標をグラフにすると次のとおりです。

(3) few-shot は形式だけ学んで内容を捨てていた
この検証で一番意外だったのが few-shot の結果です。
JSON パース成功率は 72% から 100% に、summary の 30 字遵守も 55% から 97% に改善しています。形式だけ見れば大きく改善しています。 ところが service の一致は 34% から 7% に悪化しました。20 クラスなので、ランダムに答えた場合の 5% とほとんど変わりません。
理由は予測の分布に表れていました。
zero-shot : 予測した service の種類 19 / 20 (S3:14, EC2:13, RDS:8, CloudFront:5, ...)
few-shot : 予測した service の種類 2 / 20 (Amazon S3:73, Amazon EC2:27)
few-shot では 20 種類中 2 種類しか出力していません。 そして Amazon S3 は few-shot 例の 1 件目そのものです。
出力を見ると、何が起きているか分かります。
以下のJSONを出力します。
{"service": "Amazon S3", ..., "summary": "バケットのオブジェクトが403で読めない"} ← few-shot 例の 1 件目
{"service": "Amazon Aurora", ..., "summary": "ライターインスタンス停止で本番影響"} ← 2 件目
{"service": "AWS Lambda", ...} ← 3 件目
与えた例の続きを書き続けています。 100 件すべてで JSON を複数出力していました。分類をしているのではなく、例のパターンを再生しているだけです。
few-shot を入れた 3 者比較なら「1B では無理」で終わっていたところですが、実際はプロンプトの工夫では代替できないどころか、形式が整うぶん質の低下に気づきにくくなるという結果でした。形式の遵守率だけを見ていると改善したように錯覚してしまう点は、注意が要ると思いました。
なお、レイテンシも 3.43 秒から 15.71 秒へと 4.6 倍に増えています。プロンプトが長くなるうえに、余計な JSON を出力し続けるためです。
(4) 生徒が教師を超えた
service において、蒸留後の生徒(96%)が教師(90%)を上回りました。当初は「教師の精度が生徒の上限になる」と考えていたので、これは想定と逆の結果です。
内訳を見ると次のようになっていました。
教師✗ → 生徒○ : 7 件
教師○ → 生徒✗ : 1 件
両方✗ : 3 件
差し引き 6 件ぶん、生徒が上回っています。
特に分かりやすいのが「イーシーツー」の例です。学習データでは誤ったラベルの方が多数派でした。
学習データ内の「イーシーツー」を含む文のラベル:
6 件 Amazon ECS ← 誤り(多数派)
3 件 Amazon EC2 ← 正しい
1 件 Amazon SQS ← 誤り
それにもかかわらず、test では 3 件中 2 件で生徒が正解していました。
正解=Amazon EC2 教師=Amazon ECS 生徒=Amazon ECS ← 教師の誤りを引き継いだ
正解=Amazon EC2 教師=Amazon ECS 生徒=Amazon EC2 ← 生徒が正解
正解=Amazon EC2 教師=Amazon ECS 生徒=Amazon EC2 ← 生徒が正解
教師は 1 件ずつ独立に判断するので、たまたま間違えた誤りがそのまま出ます。一方の生徒は 869 件全体から学習するため、「インスタンス」「仮想サーバ」といった EC2 特有の語彙と Amazon EC2 の対応を、個々の誤ラベルを超えて獲得したものと考えられます。教師のランダムな誤りが学習によって均された、という理解をしています。
ただし、これは誤りが一定方向に偏っていない場合に限る話かと思います。仮に教師が「イーシーツー」を 10 件中 10 件 ECS と判定していれば、生徒もそう学習したはずです。
(5) 3 者に共通して現れた誤り
Amazon と AWS の取り違えは、教師・生徒の双方に現れました。
| 対象 | 誤った出力 | 正しい表記 | 件数 |
|---|---|---|---|
| 教師 gpt-oss-20b | Amazon Glue |
AWS Glue | 7 件(値域チェックで除外) |
| 生徒 zero-shot | Amazon IAM |
AWS IAM | 出力に含まれる |
| 生徒 蒸留後 | Amazon Lambda |
AWS Lambda | 1 件 |
20 個の候補のうち 16 個が Amazon で始まり、AWS で始まるのは 4 個だけという偏りが影響していると思われます。蒸留後も 1 件残っているので、学習で完全には矯正されていません。値域チェックを入れておく理由がここにもあります。
(6) severity は評価指標として弱かった
正直に書いておくと、severity は狙いどおりには機能しませんでした。生成されたデータの分布が大きく偏ったためです。
ラベル付けされた 1,069 件の内訳は、low が 699 件(65%)、medium が 354 件(33%)、high は 16 件(1.5%) でした。test 100 件に至っては low 70 / medium 30 で、high は 1 件も含まれていません。シード grid で制御していたのは service × category だけで、severity は成り行きに任せた結果、生成された問い合わせの大半が「質問・軽微な相談」に落ちたということです。
この分布では、常に low と答えるだけで 70% になります。蒸留後の 80% はそれを上回ってはいるものの、service の 96% のような明確な差にはなりませんでした。合成データでは grid で担保していない軸の分布は偏る、という前提に立ち、評価指標として使うのであれば生成段階で severity の分布も制御しておくべきだったと思います。
6 つまずいた点
(1) JSON の抽出は「最初の { から最後の }」ではうまくいかない
評価スクリプトを最初に動かしたとき、few-shot だけ全項目が 0% になりました。原因は JSON の取り出し方でした。
# 修正前: 複数の JSON があると全体を 1 つとして拾ってしまう
start = text.find("{")
end = text.rfind("}")
json.loads(text[start:end + 1]) # → 必ず失敗する
前述のとおり few-shot の生徒は JSON を 3 個も 4 個も並べて出力します。括弧の対応を数えて最初の 1 個だけを取り出すよう修正しました。あわせて「JSON を複数出力した割合」を評価指標に加えています。この指標自体が、出力が崩れていることを可視化してくれます。
(2) think: false が効かず、かえって遅くなる
gpt-oss は reasoning model です。ollama で "think": false を指定しても reasoning の出力は止まらず、指定しない場合より遅くなりました。
| reasoning effort | service 正規化 | p50 レイテンシ | thinking の長さ |
|---|---|---|---|
| 指定なし(既定) | 10/10 | 15.9s | 495〜1,644 字 |
"low"(採用) |
10/10 | 7.8s | 288〜416 字 |
false |
10/10 | 21.3s | 711〜1,354 字 |
精度はいずれも変わらなかったので、"low" を採用しました。バッチ処理で reasoning model を使う場合は、effort を明示的に下げる価値があります。今回は所要時間が半分以下になりました。
(3) ローカル GPU では並列化がほとんど効かない
クラウドの推論 API を使う前提で書いていたコードでは MAX_WORKERS = 8 としていましたが、ローカル推論に移したあとも同じ設定のままにしていました。実測すると、ほとんど効果がありません。
| worker 数 | 1,076 件の推定所要 |
|---|---|
| 1 | 148 分 |
| 2 | 139 分 |
| 4 | 138 分 |
GPU が 1 台なので当然ではありますが、クラウド API 向けのコードをそのままローカルに持ってくると「並列にしたのに速くならない」と悩むことになります。API の待ち時間を埋めるための並列と、GPU の演算そのものがボトルネックになる場合とでは前提が違う、ということかと思います。
(4) 20B のモデルは 24GB のマシンではかなり重い
教師データの生成中、マシン全体が非常に重くなりました。
| 項目 | 生成中 | モデルのアンロード後 |
|---|---|---|
| ollama のメモリ使用 | 13.1GB | 1.1GB |
| 空きメモリ | 0.0GB | 解放 |
| スワップ使用 | 32.4GB | 26.8GB → 漸減 |
24GB のマシンで 13GB のモデルを常駐させると、残りは 11GB です。物理メモリが枯渇し、スワップが 32GB まで膨らんでいました。
ollama show で確認すると context length が 131072 だったため、KV キャッシュを疑って num_ctx を 2048 に絞ってみましたが、11.96GB が 11.87GB になっただけでした。12GB はモデルの重み本体なので削れません。
作業マシンで長時間のバッチを回すなら、モデルサイズ + 6〜8GB 程度の余裕を見ておくか、作業しない時間帯に流すのが良さそうです。24GB のマシンであれば、教師は 8〜9GB クラス(14B の 4bit 量子化相当)までが現実的かと思います。
(5) 長時間のバッチは中断・再開できるようにしておく
Step 1 は 40 分、Step 2 は 47 分かかります。最初はすべての処理が終わってから一括で書き出す実装にしていたため、途中で中断した際に 17 分ぶんを失いました。
その後、1 セル(1 件)ごとに追記して flush() し、再実行時は処理済みを読み飛ばす形に変更しています。
done = {(r["seed_service"], r["seed_category"]) for r in load_jsonl(RAW_PATH)}
cells = [(s, c) for s in SERVICES for c in CATEGORIES if (s, c) not in done]
実際、生成中にマシンが重くなって中断した際も、736 件を保持したまま続きから再開できました。あわせて python -u でバッファリングを無効にしておかないと、中断時にログすら残らない点にも注意が要ります。
(6) ローカルに HF のキャッシュがあると、トークンの失効に気づけない
学習ジョブが 4 分で失敗しました。原因は Hugging Face の認証です。
OSError: You are trying to access a gated repo.
401 Client Error.
Cannot access gated repo for url .../meta-llama/Llama-3.2-1B-Instruct/resolve/main/config.json
~/.cache/huggingface/token に保存されていたトークンが失効していました。ここで分かりにくいのは、手元では評価が問題なく動いていたことです。Llama 3.2 1B が既にローカルにキャッシュされていたため、ダウンロードが発生せず認証も使われていませんでした。学習コンテナは何もキャッシュを持たないので、そこで初めて表面化します。
この失敗で 221 秒ぶん課金されました。同じことを繰り返さないよう、ジョブを投げる前にトークンを検証するようにしています。
Github scripts/run_training.py
def check_hf_token(token):
who = HfApi(token=token).whoami()["name"]
HfApi(token=token).model_info(STUDENT_MODEL) # gated repo へのアクセス権も確認
print(f"HF token OK: {who} / {STUDENT_MODEL} にアクセスできます")
あわせて、DLC 内の huggingface_hub のバージョンによっては旧名の HUGGING_FACE_HUB_TOKEN を参照するため、両方の環境変数を渡すようにしました。
(7) temperature=0 でも答えは完全には固定されない
教師データを作ったときと評価したときで、同じ問い合わせ文に対する教師の答えが 1 件だけ変わっていました。
1 回目(教師データ作成時): AWS CloudFormation
2 回目(評価時) : Amazon VPC
temperature=0 で実行しているので同じ結果になるものと考えていましたが、100 件中 1 件で異なりました。再現性が必要な場合は、教師の出力をキャッシュして使い回す方が確実かと思います。
7 最後に
応答蒸留による、教師 gpt-oss-20b から生徒 Llama 3.2 1B へのクロスファミリー蒸留を試してみました。分かったことを 3 点にまとめます。
1. 教師の選定は、性能の選定であると同時にライセンスの選定でもあります。 教師の出力を学習に使う以上、出力の利用に制限がないモデルを選ぶ必要があります。今回は Apache 2.0 の gpt-oss-20b を教師にすることで、この点を明確にしました。生徒側の Llama 3.2 にも配布時の条件があるため、組み合わせごとの確認が必要かと思います。
2. 1B のモデルでも、蒸留すれば今回のタスクでは実用的な精度に届きました。 service の正規化は 34% から 96% になり、教師(90%)を上回りました。モデルサイズは 1/5、レイテンシは 4 倍速です。この規模であればエッジデバイスでも動かせそうです。
3. プロンプトの工夫では代替できませんでした。 few-shot は JSON の形式こそ整った一方で、service の一致は 7% まで落ちました。20 種類中 2 種類しか出力せず、与えた例を再生しているだけの状態です。形式の遵守率だけを見ていると改善したように見えてしまうので、内容の指標を併せて見る必要があると思いました。
あわせて、想定と違った点も残しておきます。当初は「教師の精度が生徒の上限になる」と考えていましたが、実測では生徒が教師を上回りました。教師のランダムな誤りが、学習を通じて均されたためと理解しています。ただしこれは誤りが一定方向に偏っていない場合の話で、教師が体系的に間違えるパターンは生徒にも引き継がれていました。
この記事で使用したコードは、以下に置きました。




