NeMo Switchyard の Classifier に DeepSeek V4 Flash の判定を蒸留した 2B のモデルを使ってみる

NeMo Switchyard の Classifier に DeepSeek V4 Flash の判定を蒸留した 2B のモデルを使ってみる

大きなモデルの判定を小さなモデルに学習させた Classifier を DGX Spark 上に置き、Slack エージェントの判定時間を短縮しました。検証データでの一致率は教師モデルを上回り、入力も外に出ません。
2026.09.24

はじめに

こんにちは、クラスメソッド製造ビジネステクノロジー部の嶋田です。

Slack 常駐エージェント(NemoHermes)の前段に NeMo Switchyard を置き、リクエストを weak と strong の 2 つのモデルに振り分けています。
判定役は、最初は事後学習した judge モデル(生成モデル)で、前回の記事では TypeSafe の Jev(生成をしない判定専用の API)に置き換えました。

https://dev.classmethod.jp/articles/reona-02-dgx-spark-switchyard-routing/

https://dev.classmethod.jp/articles/reona-dgx-spark-switchyard-jev-classifier/

Jev は速くて正確でしたが、判定の入力(依頼文)が TypeSafe に送られます。
judge をローカルに置いていた理由はこの 1 点だったので、速さと「入力を DGX の外に出さない」ことが両立しませんでした。

今回は判定役そのものを別の小さいモデル(2B と 4B)に替え、大きなモデルの判定を学習させた(蒸留した)分類器を DGX Spark 上に置きます。
生成をせず、選択肢と確信度だけを返す点は Jev と同じで、入力は外に出ません。
結果として、判定 1 件の時間は judge の約 1.5 秒から 40 ミリ秒前後になり、検証データでの判定の一致率は教師モデルの即答モードを上回りました。

きっかけと、この記事で比べるもの

きっかけは X の投稿です。
Jev が合成データと大規模モデルのラベルで学習しているらしいことを受けて、DeepSeek V4 Flash の判断を DGX Spark 上で蒸留し、4B のモデルに移したところ、教師の即答モードを超える精度で 1 判断あたり約 22 ミリ秒になった、という内容でした。

https://x.com/taroleo/status/2101106887840370919

Switchyard の判定「この依頼は weak で足りるか」は選択肢が 2 つの分類なので、同じことがそのまま当てはまります。
私たちの環境には判定の問いと選択肢の文章、以前の検証時に使用した 13 個の評価シナリオ、judge と Jev の計測値が既にあるので、投稿と同じ手順を進めるのに足りない部分は学習データと学習コードだけでした。

この記事では、次の 5 つの判定役を並べます。

  1. judge(生成)。現行の出力形式で、判定の根拠(crux)を書く
  2. judge(生成)。出力形式から crux を外す。学習はしない
  3. judge を 1 トークン読み出しに学習し直したもの
  4. 蒸留した分類器(今回)。選択肢と確信度だけを返す
  5. Jev。選択肢と確信度だけを返す

1 から 2 への差は「根拠を書くのをやめると何秒縮むか」、2 から 3 への差は「生成そのものをやめると何秒縮むか」、3 から 4 への差は「判定役を 30B から 2B にすると何秒縮むか」です。
どれが一番速いかではなく、この 3 つの差を見るのが、この記事の主旨です。

教師に使えるモデルと、使えないモデル

蒸留は、教師モデルの出力を学習データにします。
モデルや API によっては、出力を他のモデルの学習に使うことが規約で禁止されているので、先に確認しました。

役割 対象 出力を学習に使えるか 根拠
教師 DeepSeek V4 Flash 使える MIT
教師(裁定) Kimi K3 使える 改変 MIT。出力の利用制限なし
教師を通す基盤 Fireworks 蒸留を禁じる条項は見当たらない 禁止は「Fireworks と競合する製品の開発」と「Fireworks 自体のベンチマーク」
比較相手 Jev(TypeSafe) 使えない Master Customer Agreement が、Services や Output を使った model distillation、出力を模倣するモデルの学習、類似・競合製品の開発(の助長)を禁止
生徒 Qwen3.5-2B、Qwen3.5-4B 派生モデルを作れる Apache 2.0

Fireworks に依頼文を送る点は、strong の実行役として既に使っている経路と同じです。
今回学習に使うのは合成した依頼文だけなので、社内の情報が新しく外に出ることもありません。

構成

Switchyard への載せ方は、judge を呼んでいる llm_classifier ルートをそのまま使います。
分類器の前に、judge と同じ形式の応答を返す小さなサーバーを置くので、Switchyard 側の改修はありません。

依頼文を合成する

学習データは合成だけで作りました。
運用中の judge のログには判定の結果(境界、確率、crux)しか残しておらず、依頼文そのものが無いためです。

Slack に来る依頼の種類を 30 個(挨拶、用語の確認、翻訳、要約、Backlog の状況確認、社内 wiki の検索、予定の確認、技術の壁打ち、コードレビュー、エラーの切り分け、複数資料の突き合わせ、構成の比較、見積もり、リスクの洗い出し、意図が曖昧な短文、スレッドの続き、添付だけの依頼、英語混じり、など)、文体を 8 個(一言で短く、丁寧なビジネス文、くだけた口調、誤字や省略がある、期限や優先度を書き添える、など)決め、その組み合わせごとに DeepSeek V4 Flash に日本語の依頼文を書かせました。
実在の人名や顧客名は使わせず、架空の名前にしています。

依頼文の形は、Switchyard が判定役に送る state(発言者と本文を改行で連結した平文)に揃えました。
学習時と判定時で入力の形が違うと、そこで精度が落ちるためです。

書かせてみて直したことが 2 つあります。
1 つは、temperature を 1.0 にすると応答が中国語になることがあったので、日本語で書く指示を足し、仮名を含まない依頼文を除くようにしました。
もう 1 つは、10 件をまとめて JSON のオブジェクトの配列で返させると、長い本文を含む種類で配列の閉じ括弧が落ちるなど JSON が崩れることがあり、依頼文の文字列だけを並べた配列に変えました。
最終的に 2,731 件になりました。

教師でラベルを付ける

判定の問いと選択肢の説明文は、Jev 用の設定に書いたものをそのまま使いました。
基準を揃えておかないと、あとで Jev と比べたときに何が違うのか分からなくなるためです。

問い: Slack の社内アシスタントがこの依頼に答えるとき、どのモデル階層が必要かを選んでください。
weak: 短い質問、用語の確認、翻訳・要約、Backlog や社内 wiki の検索や引用のような、手順が明確で完了条件がはっきりした依頼。
strong: 設計判断や方針の相談、壁打ち、複数の資料を突き合わせる調査、コードの設計やレビュー、正確さの誤りが高くつく依頼など、深い推論や多段の作業が必要な依頼。

1 件につき DeepSeek V4 Flash(thinking あり)に 3 回判定させ、weak か strong のラベルと weak で足りる確率を JSON で返させました。
3 回とも同じならそのラベルです。
3 回が割れた件と、確率の平均が 0.4 から 0.6 の件は、Kimi K3 に 1 回だけ判定させて、その結果を採りました。
K3 の裁定に回ったのは 186 件です。

記事の比較のために、同じ依頼文で DeepSeek V4 Flash の thinking なし(即答)の判定も 1 回ずつ取っています。
Fireworks の API では {"thinking": {"type": "disabled"}} を渡すと reasoning のトークンが 0 になりました。

費用は、合成が約 0.6 ドル、ラベル付けが約 2.4 ドル(DeepSeek 約 1.7 ドル、K3 約 0.7 ドル)でした。
thinking ありでも判定の推論は 1 件あたり 75 トークン前後と短く、費用は事前の見積もり(約 10 ドル)を大きく下回りました。

ラベルの内訳は、weak が 1,663 件、strong が 1,068 件です。
種類別に見ると、挨拶や用語の確認はほぼ全件が weak、壁打ちや構成の比較はほぼ全件が strong で、意図が曖昧な短文、添付だけの依頼、スレッドの続き、英語混じりが weak と strong に分かれています。

分類器を学習する

生徒は Qwen3.5-2B と Qwen3.5-4B の 2 つを用意しました。
どちらも本体の最後のトークンの状態から 2 クラスを出す分類ヘッドを足し、LoRA(r=16)で 3 epoch 学習しています。
ラベルは 3 票の割合をそのまま使い(ソフトラベル)、K3 が裁定した件だけは硬いラベルにしました。
学習後は LoRA をマージして 1 つのモデルとして保存し、検証データで温度スケーリングして確信度を較正しています。

学習データは 2,275 件、検証データは 456 件(種類ごとに 6 分の 1 を抜いたもの)です。
学習時間は DGX Spark 1 台で 2B が 17 分、4B が 28 分でした。
他のサービスと同居した状態なので、専有すればもう少し短いはずです。

学習環境は、DGX Spark で judge を動かしている vLLM のコンテナイメージ(torch と transformers 入り)に、peft と flash-linear-attention を足したものを使いました。
flash-linear-attention は次の節で出てくる推論速度に関わります。

結果

判定の一致

検証データ 456 件で、教師(thinking あり 3 票と K3 の裁定)のラベルとの一致を見ました。
比較として、教師自身の即答モード(thinking なし)の一致も並べます。

判定役 一致 strong を weak に振った weak を strong に振った
教師の即答モード(DeepSeek V4 Flash、thinking なし) 428 / 456(93.9%) - -
分類器 2B 430 / 456(94.3%) 7 / 177 19 / 279
分類器 4B 434 / 456(95.2%) 5 / 177 17 / 279

生徒はどちらも、教師の即答モードを上回りました。
X の投稿と同じ構図です。
thinking ありの判定を 3 回ぶん学習しているので、即答 1 回の教師より一貫した判定になっている、と見ています。

外れた件を見ると、2B と 4B の両方が外した 18 件のうち 16 件は weak の依頼を strong に振ったものです。
外れは費用が増える側に寄っていて、品質が落ちる側(strong の依頼を weak に振る)は少ないです。

13 シナリオを 3 回ずつ判定させた結果は、2B も 4B も 39 件すべて期待どおりでした。
judge が回によって揺れた「会議 URL の確認」も、分類器は 3 回とも weak に置いています。
生成をしないので、同じ入力には同じ確率が返り、temperature による揺れがありません。

判定の時間

判定 1 件の時間は、判定役のサーバーに直接投げて測りました。

判定役 検証データ 456 件(中央値 / p95) 13 シナリオ(中央値 / p95)
分類器 2B 41 / 50 ミリ秒 27 / 47 ミリ秒
分類器 4B 64 / 80 ミリ秒 64 / 69 ミリ秒

4B は X の投稿の 22 ミリ秒に届きませんでした。
理由を切り分けるために、入力の長さごとに forward 1 回の時間を測りました。

モデル 64 トークン 256 トークン 512 トークン 1,024 トークン
Qwen3.5-4B(flash-linear-attention なし) 82 134 - 388
Qwen3.5-4B(あり) 45 66 98 205
Qwen3-4B(標準の注意機構のみ) 43 60 89 187
Qwen3.5-2B(あり) 21 29 44 84

単位はミリ秒、bf16、バッチ 1 です。

分かったことが 2 つあります。
Qwen3.5 は線形注意の層を持っていて、専用カーネル(flash-linear-attention)が無いと torch の実装に落ち、時間が倍になります。
カーネルを入れても、標準の注意機構だけの Qwen3-4B とほぼ同じ時間なので、残りは GB10 の prefill の計算量です。
時間が入力の長さにほぼ比例していることからも、重みの読み出しではなく計算が上限になっていると読めます。

Jev に送っていた入力は平均 549 トークンだったので、4B では 100 ミリ秒前後かかります。
50 ミリ秒を切るなら 2B です。
一致率の差は 456 件で 4 件なので、判定役には 2B を使うことにしました。
以下の比較も 2B の数字です。

5 つの判定役を並べる

5 つの判定役の計測値です。
judge の 1 行目は前回の記事と同じ 13 シナリオの計測、2 行目は 3 つの依頼を 2 回ずつ judge に直接投げた計測(同じ Capability Card、temperature 0、thinking なし)です。
3 行目は同僚の記事の値で、評価データも計測条件も他の行とは別です。

判定役 生成 根拠(crux) 判定 1 件(中央値) 13 シナリオ × 3 回 入力の行き先
judge、現行の出力形式 する 書く 1.49 秒(運用中のログでは 1.83 秒) 36 / 39 DGX 内
judge、出力形式から crux を外す する 書かない 0.88 秒 - DGX 内
judge を 1 トークン読み出しに学習(同僚の記事) しない 無い 0.25 秒 -(別の評価データ) DGX 内
分類器 2B(今回) しない 無い 0.027 秒(直接)、Switchyard から見て 0.043 秒 39 / 39 DGX 内
Jev しない 無い 0.514 秒(API 直叩き)、Switchyard から見て 0.27 から 0.42 秒 39 / 39 TypeSafe

judge から crux を外すと、70 から 90 トークンあった出力が 31 トークンに減り、判定は 1.49 秒から 0.88 秒になります。
学習なしで 0.6 秒短くなりますが、それでも生成の時間が残ります。
生成をやめると、同じ 30B の judge でも 0.25 秒、Jev で 0.3 秒前後です。
判定役を 2B にすると 0.03 秒で、残りの差は prefill の計算量です。

速さだけを見れば 2B の分類器ですが、判定の質は同じ土俵で比べていません。
1 トークン版の judge は Capability Card の規則を学習していて、「このエージェントにその手段があるか」を判定に含みます。
今回の分類器は「weak で足りる依頼か」を 2 つの説明文で学習しているだけで、エージェントの能力は見ていませんし、評価も合成データと 13 シナリオです。

判定の入力を外に出さずにこの速さを得るには、分類器を自分で学習させる必要がありました。
そのぶん、次の節で書く引き換えがあります。

Switchyard に載せる

分類器の出力を judge の verdict(4 フィールドの JSON)に写像して返す口を足し、llm_classifier ルートから judge の代わりに呼びます。
Switchyard 本体は無改変です。

{"crux": "classifier route-clf: p_weak=0.9812", "primary_rule": "SUP-1", "capability_boundary": "supported", "p_solve": 0.9812}

p_solve には weak で足りる確率(較正済み)を入れ、capability_boundary は常に supported にして、閾値は Switchyard 側の base_threshold に任せます。
crux は空にできない(スキーマで 1 文字以上)ので、確率を書いた固定の文にしました。

routes.toml
[llm_clients.clf]
format = "openai_chat"
base_url = "http://127.0.0.1:8016/v1"   # DGX 上の分類器。認証なし

[targets.clf]
id = "route-clf"
llm_client = "clf"

[routes.switchyard]
type = "llm_classifier"
mode = "capability"
classifier_target = "clf"
strong_target = "strong"
weak_target = "weak"
base_threshold = 0.5      # p_solve は較正済みの確率なので argmax と同じ 0.5 で切る
threshold_step = 0.10
# recent_turn_window は設定しない。冒頭の依頼と最新の user 発言だけが渡り、学習時の入力と同じ形になる

1 つ引っかかった点があります。
Switchyard は verdict の primary_rulecapability_boundary の組み合わせを検査していて、noneunmatched としか組めません。
最初に nonesupported で返したところ、verdict が無効と扱われて全件が既定の strong に振られました。
SUP-1supported の組に直すと通ります。

この構成で 13 シナリオを 3 回ずつ投げた結果は 39 件すべて期待どおりでした。
Switchyard の統計では、分類器の呼び出しは中央値 43 ミリ秒、判定から経路の決定までのオーバーヘッド全体で中央値 44 ミリ秒(最大 69 ミリ秒)です。
前回の Jev は同じ見方で 0.27 秒から 0.42 秒、judge は 1.55 秒でした。

使ってみて分かった引き換え

判定の基準が重みに入ります。
Jev では選択肢の説明文(criteria)を書き換えれば基準が変わりましたが、分類器では基準を変えるたびに教師でラベルを付け直して再学習になります。
今回の規模なら、ラベル付けは数ドルと 1 時間、学習は 20 分なので、運用が止まるほどではありません。
ただ、Capability Card を頻繁に直している時期には向きません。

判定の根拠は読めません。
judge の crux のように「この依頼で一番難しい要件」を書き出す手段が無いので、なぜ strong に振ったかは確信度と入力から推測することになります。
これは Jev と同じ引き換えです。

学習データも評価データも合成です。
実際に Slack に来た依頼で学習も評価もしていないので、合成に含めていない種類の依頼では精度が落ちる可能性があります。
検証データ 456 件は学習データと同じ手順で作った依頼文で、13 シナリオも 1 往復の短い依頼です。
実際の依頼や難しい依頼を集めた評価データでの質の検証は、この記事ではしていません。
手順が明確に見えて実際にはエージェントに無い手段が要る依頼は、分類器が能力を見ていないぶん、weak に振ってしまう可能性が高いと見ています。
運用中の依頼文をログに残す改修を入れれば、実データで評価し、混ぜて学習し直せます。

入力は 512 トークンで切っています。
配信では 50 ミリ秒を優先して入力を 512 トークンで切りました。
依頼の冒頭と最新の発言が入ればほぼ足りますが、長い本文を貼った依頼では本文の後半を見ていません。

おわりに

大きいモデルの判定を 2B の分類器に学習させることで、Switchyard の判定を judge の約 1.5 秒から 40 ミリ秒前後にし、13 シナリオ × 3 回の振り分けは 39 件すべて期待どおりでした。
検証データでの一致率は教師の即答モードを上回り、学習データの作成にかかった費用は約 3 ドルです。

判定を速くする手段は 4 つありました。
judge の出力から根拠を外して 0.88 秒、judge を 1 トークン読み出しに学習し直して 0.25 秒、Jev に出して 0.3 秒、小さいモデルに学習させて 0.03 秒です。
判定の入力を外に出さないという前提を置くと、judge を学習し直すか、小さいモデルに学習させるかの 2 つが残ります。
どちらも基準の変更は文章の編集ではなく再学習になり、判定の根拠は読めなくなります。
両者の差は、速さ(0.25 秒と 0.03 秒)と、Capability Card の規則を判定に含むかどうかです。
実運用の依頼で質を比べるのは、これからの課題です。

X の投稿にあった「Local LLM だけでここまでできる」は、教師のラベル付けまでローカルでやった話です。
私たちは教師を Fireworks に出したので、そこは同じではありません。
教師の役目は合成した依頼文にラベルを付けることだけで、生徒はそのラベルしか見ないので、同じモデルなら教師がどこで動いていても学習の結果は変わらない見込みです。

参考資料

この記事をシェアする

関連記事