NeMo Switchyard と事後学習済みの judge モデルで Slack エージェントのリクエストを自動ルーティングする

NeMo Switchyard と事後学習済みの judge モデルで Slack エージェントのリクエストを自動ルーティングする

NeMo Switchyardでリクエストを振り分けるエージェントを構築する中で、判定役のモデルを事後学習版に替えたことで、weak/strong の配分が1:9から6:4に劇的に改善した経験をまとめました。
2026.08.28

はじめに

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

前回は NemoClaw/NemoHermes で Slack 常駐エージェントを立てました。

https://dev.classmethod.jp/articles/reona-01-dgx-spark-nemohermes-slack-agent/

今回はその前段に NeMo Switchyard を挟んで、リクエストごとにモデルを振り分けます。

チームの Slack から飛んでくる依頼は幅が広いです。
「処理中の課題を担当者別に一覧して」のような、必要な情報を取ってきて整えれば終わるものと、「来期のリソース配分をトレードオフ付きで提案して」のような、考えることが本体のものが、同じ入口に来ます。
前者に高価なモデルを使うのは無駄で、後者を安いモデルに投げると結果を使えません。

そこで、リクエストを最初に読んで振り分け先を決める役(判定役)を置きます。
今回の構成では、この判定役だけが DGX Spark のローカルで動きます。

判定役をローカルに置くのは、すべてのリクエストの中身を最初に読むのがこの役だからです。
実行役はタスクに応じて外に出しますが、振り分けの判断そのものは手元で完結します。

なお、本記事の執筆時点で Switchyard は 0.2.0、vLLM は v0.27.1 です。
Switchyard は活発に開発されているため、以降に書くセットアップの注意点は今後のバージョンで解消される可能性があります。
最新の情報は公式リポジトリを確認してください。

判定役は素のモデルでは務まらない

Switchyard の LLM classifier 方式は、判定用のプロンプトと構造化 JSON のスキーマを与えて、モデルに「このリクエストはどちらのティアで足りるか」を答えさせる仕組みです。
最初は、weak と同じローカルのモデル(Nemotron 3.5 Lightning)を判定役に兼任させました。
セットアップ時の単発テストは通ったので、そのまま数日チームで使ってもらいました。

結果、/v1/stats の配分はこうなっていました。

tiers.weak.calls    = 23     (7.6%)
tiers.strong.calls  = 279   (92.4%)

9 割以上が strong に流れていて、ルーターを挟んだ意味がありません。
原因は 3 つ重なっていました。

1 つ目は、判定材料にユーザーの依頼が入っていなかったことです。
classifier は元リクエストの要約 JSON を読みますが、この要約には max_request_chars(既定 16,000 文字)の上限があり、超えた分は末尾から切り捨てられます。
そして要約の先頭は Hermes の system prompt です。
前回見たとおりこれだけで 1.6 万トークン(約 5 万文字)あるので、毎回 system prompt の途中で切れて、肝心の依頼文が判定役に届いていませんでした。
stats の classifier prompt が平均 4,100 トークン(16,000 文字相当)で張り付いていたのが痕跡です。

2 つ目は、素のモデルに判定役の語彙がないことです。
フォーマットには従えても、「このタスクを軽いモデルで完遂できる確率」を見積もる感覚を持っていません。
同型の事象は次の記事に詳しく、素の Lightning を判定役に直結すると判定カテゴリ一致 17.3%、ほぼ全件が「判定不能」に落ちています。

https://dev.classmethod.jp/articles/dgx-spark-nemotron-lightning-switchyard-classifier-finetune/

3 つ目は、プリセットの保守性です。
profile: general の実装を読むと、ティア対応は SIMPLE→weak だけで、MEDIUM も COMPLEX も、判定不能時のフォールバックも、すべて strong です。
判定が少しでも曖昧なら strong に落ちる設計でした。

「依頼文を見ていない判定役が、語彙を持たないまま曖昧な判定を返し、曖昧なら strong に行く」という三段構えです。
1 つ目と 3 つ目は設定と変換層で直せますが、2 つ目はモデルの問題として残ります。

Capability Card と p_solve

判定専用に事後学習(LoRA SFT)したモデルを判定役に据えます。
前掲の記事にコーディング向けの judge を仕立てる手順があり、今回はそれと同じ手法で、このチームアシスタントのトラフィック(Backlog の検索、チーム RAG、Slack の依頼)向けに学習した judge を使いました。
ベースは Nemotron 3.5 Lightning、マージ後に NVFP4 で 21.6GB です。

この judge の判定は「このタスクは難しいか」ではありません。
「手元のエージェント構成でこのタスクをやり切れる確率はいくつか」という能力の予報として設計されています。

  • system prompt には Capability Card を入れる。エージェントが使える skill、ツール、権限を記述した文書で、判定はこの Card に対して行われる
  • 出力は {crux, primary_rule, capability_boundary, p_solve} の構造化 JSON。crux は「成功を左右する最難関の要件」、p_solve は「一発でやり切れる確率」
  • ルーティングは閾値方式。基準 0.70 に、判定の確信度合いに応じて 0.10 ずつ上乗せする(能力の範囲内 0.70、不確か 0.80、範囲外 0.90)。p_solve が閾値以上なら weak、未満なら strong
  • 環境の能力が変わったら、モデルを学習し直すのではなく Card を書き換える

最後の点がこの設計の要です。
第 3 回、第 4 回で skill は増えていくので、そのたびに再学習が必要な設計では運用が回りません。

判定専用の vLLM を立てる

judge を vLLM で serve します。
判定は短文で並列も低いので、コンテキストは 8K で足ります。

services:
  vllm-judge:
    image: vllm/vllm-openai:v0.27.1-aarch64
    ports:
      - "127.0.0.1:8002:8000"
    volumes:
      - ${HOME}/models/tachikoma-router:/model:ro
    command:
      - --model
      - /model
      - --served-model-name
      - judge
      - --max-model-len
      - "8192"
      - --gpu-memory-utilization
      - "0.22"
      - --kv-cache-memory
      - "4294967296"     # KV は固定 4GiB
      - --trust-remote-code
      - --moe-backend
      - marlin
      - --mamba-backend
      - flashinfer

メモリ設定で 1 つハマりました。
--gpu-memory-utilization 0.25 だけで起動すると KV cache に 12GiB 取られ、統合メモリの空きが 3GB を切ります。
そこで KV を --kv-cache-memory で 4GiB に固定したところ、今度は起動しません。

ValueError: Free memory on device cuda:0 (39.19/121.69 GiB) on startup is less than
desired GPU memory utilization (0.92, 111.95 GiB).

--kv-cache-memory を指定すると --gpu-memory-utilization が既定値 0.92 に戻り、KV は固定したのに起動時の空きメモリチェックだけが 0.92 基準で走って落ちるという挙動です。
両方を明示指定するのが正解でした。
この構成で judge の実消費は重み 16.3GiB と KV 4GiB に収まっています。

統合メモリではもう 1 つ注意点があります。
DGX Spark で vLLM を起動する際、他プロセスのモデルロード状態を動かしてはいけません。

AssertionError: Error in memory profiling. Initial free memory 82.25 GiB,
current free memory 92.36 GiB. This happens when other processes sharing
the same container release GPU memory while vLLM is profiling ...

これは同居していた Ollama が、アイドル 5 分でモデルを自動アンロードするタイミングと初期化が重なったものです。
dGPU なら他プロセスの RAM 解放は VRAM に影響しませんが、統合メモリでは直結します。

Switchyard を立てる

インストール

Getting Started with Switchyard の案内どおりに uv tool install --python 3.10 "nemo-switchyard[cli]" を実行すると、依存解決の段階で止まりました。
執筆時点の pip 版 0.2.0 では、ドキュメントの記述と実装に次の 4 点の差分があります。

  • Python は 3.12 以上が必要(ドキュメントは 3.10 と案内している)
  • ドキュメントにある routes.toml は Rust 版 switchyard-server(cargo)用の書式で、pip 版 CLI の switchyard serve--routing-profiles で YAML bundle を受け取る
  • YAML の読み込みに PyYAML が必要だが、パッケージの依存として宣言されていない
  • serve には server extra(fastapi、uvicorn)が必要

依存の 2 つは、実行してみるまで分かりませんでした。

ModuleNotFoundError: No module named 'yaml'
ModuleNotFoundError: No module named 'uvicorn'

まとめて解決するコマンドは次のとおりです。

uv tool install --python 3.12 --with pyyaml "nemo-switchyard[cli,server]"

judge の契約を Switchyard に話させる

ここで、classifier.model を judge に向けるだけでは動きません。

pip 版 Switchyard 0.2.0 の classifier は、profile ごとに固定された JSON スキーマ({recommended_tier, confidence, abstain, ...})を厳密にパースする実装です。
judge が返す verdict({crux, primary_rule, capability_boundary, p_solve})とは契約が違います。
判定プロンプトは差し替えられても、応答スキーマと閾値ロジックは差し替えられません

これは pip 版に限った制約です。
Rust 版の switchyard-server はルート定義に閾値(base_thresholdthreshold_step)を持てるので、この形の judge を設定だけで組み込めます。
今回は他の構築を pip 版の CLI で進めていたので、変換層を挟むほうを選びました。

そこで両者の間に、契約を変換する小さなプロキシ(shim)を挟みました。

shim は Python 標準ライブラリだけの 300 行ほどで、やることは 4 つです。

  1. 判定材料の復元:要約 JSON からメッセージ列を取り出し、system prompt を捨てて「最初の依頼、直近のやり取り、最新の依頼」を judge の 8K コンテキストに収まる形で組み直す
  2. judge の呼び出し:Card を system に、verdict のスキーマを response_format に渡して temperature 0 で呼ぶ
  3. 検証と閾値:verdict の整合(rule と boundary の対応、p_solve の値域)を検証し、閾値と比較して weak と strong を決める。無効な verdict や judge の障害は strong 側に fail-open
  4. 契約の変換:結果を Switchyard が期待する JSON に詰め直す。weak なら必ず SIMPLE、strong なら必ず COMPLEX に落ちる値を返す(前述の 3 つ目の原因はこれで無効化される)

判定の中心部はこれだけです。

STEPS = {"supported": 0, "uncertain": 1, "unmatched": 1, "unsupported": 2}
threshold = 0.70 + STEPS[verdict["capability_boundary"]] * 0.10
route = "weak" if verdict["p_solve"] >= threshold else "strong"

judge はテキストしか判定できないので、画像や PDF の添付を含む依頼は judge を呼ばずに strong へ直行させています。

routing-profiles.yaml

YAML bundle のスキーマはドキュメント化されていないため、wheel 内のローダー実装から読み解きました。
最終形は次のとおりです。

defaults:
  timeout_secs: 300

routes:
  switchyard:                      # inbound model id(Hermes からは model="switchyard" で呼ぶ)
    type: deterministic            # LLM classifier 方式
    profile: general
    fallback_target_on_evict: strong  # ティア障害時の逃げ先(必須項目)
    classifier:
      model: tachikoma-shim
      base_url: http://127.0.0.1:8003/v1
      api_key: local-noauth        # 認証なしでも必須項目(ダミー)
      fail_open: true              # shim や judge の障害時は strong に倒す
      max_request_chars: 200000    # 既定 16000 のままだと依頼文が判定役に届かない
      recent_turn_window: 4
    weak:
      model: accounts/fireworks/models/deepseek-v4-flash-0731
      base_url: https://api.fireworks.ai/inference/v1
      api_key: ${FIREWORKS_API_KEY}
    strong:
      model: accounts/fireworks/models/kimi-k3
      base_url: https://api.fireworks.ai/inference/v1
      api_key: ${FIREWORKS_API_KEY}

fallback_target_on_evict と classifier の api_key が必須であることは、起動エラーで知りました。

error: invalid route bundle: switchyard.fallback_target_on_evict must be a non-empty string
error: invalid route bundle: switchyard.classifier.api_key must be a non-empty string

weak と strong は単独のパススルー先としても自動登録されます。
/v1/models を引くと、ルート名の switchyard に加えて両ティアの実体と Fireworks 側のカタログが並ぶので、「このタスクは必ず strong で」というときは model="strong" を指定すれば固定できます。

weak に Fireworks の DeepSeek V4 Flash を選んだのには、判定側との整合という理由もあります。
この judge の学習で教師を務めたのが同じ V4 Flash なので、p_solve の較正が実行役の実力とそのまま対応します

strong に Kimi K3 を選んだ理由は、ネイティブに画像を扱えることです。
判定役はテキストしか読めないので、添付つきの依頼は judge を通さず strong へ直行させています。
その受け皿が画像を読めなければ経路として成立しません。
DeepSeek V4 はテキスト専用なので、weak と strong で同じモデル系列に揃える選択肢は最初から取れませんでした。

ただし生成は速くありません。
同じ 300 トークンの生成で weak の V4 Flash が 3.1 秒のところ、K3 は 14〜18 秒かかります。
実運用のエージェントの 1 ターンはツール呼び出しで何度もモデルを叩くので、strong 側の実測は平均 32.9 秒、単発の最大は 133 秒でした。
strong の選定では、能力だけでなく生成速度も体感を左右します。

NemoHermes の接続先の切り替え

Hermes の推論先を Switchyard に向けます。
今回の構築で最も手間取った工程です。

ハマりどころ: loopback ブリッジの許可ポート

Switchyard を localhost:4000 で立てて onboard し直したところ、sandbox 内の router がコンテナ自身の localhost:4000 に接続しようとして失敗しました。

[sandbox] [INFO] [openshell_router] routing proxy inference request (streaming)
  endpoint=http://localhost:4000/v1 method=POST path=/v1/chat/completions
[sandbox] [OCSF] NET:FAIL [LOW] inference.local:443

NemoClaw のソースを読むと、loopback URL を sandbox から届く host.openshell.internal に書き換えるブリッジ処理は、ポートが 11434、11435、8000 のいずれかのときだけ発動します。
4000 は対象外でした。

対処はポートの入れ替えです。
judge を 8002、Switchyard を 8000 に置きました。
書き換えが効くと、同じログの endpointhttp://host.openshell.internal:8000/v1 に変わります。

ハマりどころ: 待ち受けアドレスの二重化

onboard はホスト側から localhost:8000 を検証し、sandbox からは host.openshell.internal(Docker ブリッジの 172.18.0.1)の 8000 番にアクセスします。
つまり両方のアドレスで待ち受けが必要です。
0.0.0.0 にバインドすれば一度に済みますが、認証なしのルーターを社内 LAN に晒すことになるため見送りました。
ブリッジ IP にバインドし、loopback 側は socat で転送します。

switchyard serve --routing-profiles routing-profiles.yaml --host 172.18.0.1 --port 8000
socat TCP-LISTEN:8000,bind=127.0.0.1,fork,reuseaddr TCP:172.18.0.1:8000

Ollama を provider にしたときにこの問題が起きないのは、NemoClaw 自身が 0.0.0.0:11435 に認証プロキシを立てて解決しているためです。
custom endpoint では自前で面倒を見ることになります。

ハマりどころ: 設定変更が時間差でクラッシュを呼ぶ

コンテキストウィンドウを広げるとき、sandbox 内でこう叩きたくなります。

# これをやってはいけない
nemohermes team-assistant exec -- hermes config set providers.compatible-endpoint.models.switchyard.context_length 262144

コマンドは成功し、設定も反映されます。
ところが config.yaml の整合ハッシュ /sandbox/.hermes/.config-hash は更新されません。
起動スクリプトはハッシュ検証を先に行うので、次に gateway が再起動した瞬間に検証で落ちてクラッシュループに入ります

[SECURITY] Hermes config hash does not match persisted inputs
[SECURITY] HERMES_MCP_CONFIG_DRIFT: MCP intent cannot be matched to the persisted gateway state

厄介なのは、設定した直後は何も起きないことです。
壊れているのに動き続け、次の再起動で初めて表面化します。
正解はホスト側のコマンドで、こちらは設定と両方のハッシュを 1 つのトランザクションで更新します。

export PATH="$HOME/.local/bin:$PATH"   # openshell が PATH に無いと ENOENT になる
nemohermes team-assistant config set --key <dotpath> --value <value> --restart

復旧は rebuild しかありません(再起動や recover では直りません)。
しかもクラッシュループ中は state のバックアップが取れず rebuild 自体が中止されるため、docker update --restart=no でループを止め、docker cp で手動バックアップしてから rebuild --force で復旧することになります。

sandbox 内の設定ファイルを直接いじる系のコマンドは、ホスト側に同等の入口がないかを先に探す、という運用ルールにしました。

動作確認

起動バナーに、判定役と両ティアの割り当てが出ます。

  switchyard  ready  →  switchyard

  profiles  ▶ switchyard  (default)
                llm-classifier
                strong      accounts/fireworks/models/kimi-k3
                weak        accounts/fireworks/models/deepseek-v4-flash-0731
                classifier  tachikoma-shim
                profile     general

judge に差し替えてからは、判定を 1 件ずつ verdict のログで追えます。
これが素の classifier との一番の違いで、「なぜこちらに振ったのか」を crux が説明してくれます。

{"route": "weak", "latency_ms": 1353.5, "capability_boundary": "supported",
 "primary_rule": "SUP-1", "p_solve": 0.77,
 "crux": "The request asks for a list of in-progress issues in project TASKHUB
          grouped by assignee, which requires a read-only Backlog API call and
          formatting of the result."}

{"route": "strong", "latency_ms": 1813.8, "capability_boundary": "unsupported",
 "primary_rule": "LIM-1", "p_solve": 0.31,
 "crux": "The request asks for a 3-month cross-project issue trend analysis and
          a resource allocation plan for the next period. The main deliverable
          is deliberative analysis and a proposal, not a retrieval."}

課題一覧の依頼は Backlog skill の読み取りと整形で完結するので p_solve 0.77 で閾値 0.70 を超え、weak へ。
傾向分析とリソース配分の提案は、検索ではなく思考が本体なので能力範囲外と判定され、閾値 0.90 に届かず strong へ。
Switchyard 経由の E2E でも、前者は DeepSeek V4 Flash が 7 秒で、後者は Kimi K3 が答えました。

判定のレイテンシは、Card(約 2,600 トークン)を毎回読む分を含めて p50 で 1.8 秒、p95 で 2.5 秒です。
素の Lightning を判定役にしていたときの平均 1.7 秒と同水準で、判定の質だけが上がったことになります。

配分も明確に変わりました。
judge に差し替えてから 4 日間、チームの通常利用で記録された判定 156 件の内訳です(構築時の検証で叩いたぶんも含みます)。

素の Lightning 事後学習済み judge
weak 23 件(7.6%) 97 件(62.2%)
strong 279 件(92.4%) 59 件(37.8%)

boundary の内訳は supported 96、unsupported 45、unmatched 11、uncertain 3、検証に落ちたもの 1 でした。
「能力の範囲内(Backlog の検索や整形、Web 検索)は weak、思考が本体のタスクは strong」という設計どおりの割れ方です。

一方、金額での比較はできていません。
Switchyard の /v1/stats にはティアごとの cost_estimate がありますが、モデルの単価を設定していないため 0 のままです。
この記事で書いた配分の改善は、あくまで「どちらのモデルが何回呼ばれたか」であって、請求額の比較ではありません。

おわりに

ルーターを挟む価値は、当初考えていた「安くなること」ではありませんでした。
効いたのはどこに何が流れたかが数字で見えることです。
stats が 92.4% の偏りを見せてくれなければ、判定役が依頼文を読めていないことにも、素のモデルに判定の語彙がないことにも気付けていません。

判定役を事後学習したモデルに替えるのは、手間に見合うと感じています。
判定を「タスクの難易度当て」ではなく「このエージェント構成でやり切れるかの能力予報」として設計したことで、判定が 1 件ずつ根拠つきで追えるようになりました。
そして能力の変化には、モデルの再学習ではなく Capability Card の書き換えで追従できます。

入口を 1 つにまとめておく効果も大きいです。
運用を始めてから実行役のモデルは何度か入れ替えましたが、Hermes 側の設定は一度も触っていません。
model="switchyard" が変わらないので、差し替えはルート定義の数行とルーターの再起動で済みます。

今後試したいのは、weak をローカルで完結させることです。
weak に選んだ DeepSeek V4 Flash-0731 は、社内で DGX Spark 2 台にまたがって動かした実績があります。

https://dev.classmethod.jp/articles/dgx-spark-2node-deepseek-v4-flash-0731/

284B(active 13B)の重みは公式配布の FP8 と MoE FP4 で 155GiB あり、1 台の統合メモリには収まりません。
2 台を QSFP で直結して vLLM の tensor parallel で分けると、短いプロンプトのデコードで 76 tok/s、900K トークンの文脈でも 69 tok/s が出ています。
ここに weak を戻せれば、判定役と同じ理屈で、日常的な依頼はその中身も DGX の外に出なくなります。
ルーターを挟んでいるので、そのときの差し替えもルート定義の base_url を書き換えるだけです。

次回は、skill でエージェントに Backlog を操作させます。
書き込みを許しつつ削除は通さない設計と、実運用のフィードバックで skill を鍛えていく話です。

参考資料

この記事をシェアする

DevelopersIO 2026

関連記事