NemoHermes の LLM ルーティングに使っている NeMo Switchyard の Classifier を TypeSafe の Jev に替えて計測してみた

NemoHermes の LLM ルーティングに使っている NeMo Switchyard の Classifier を TypeSafe の Jev に替えて計測してみた

NeMo Switchyard の Classifier を、生成モデルから判定専用モデル Jev に置き換えました。判定時間が短縮され、全シナリオで期待どおりに振り分けできたその過程を紹介します。
2026.09.20

はじめに

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

以前の記事で、Slack 常駐エージェント(NemoHermes)の前段に NeMo Switchyard を置き、事後学習した judge モデルでリクエストを weak と strong に振り分けました。

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

judge は 1 件の判定に中央値で 1.8 秒かかります。
Hermes は tool を呼ぶたびに Switchyard を叩くので、Slack の 1 発言で判定が 3 回から 4 回走り、その分が応答の待ち時間に乗ります。

今回はこの判定役を、TypeSafe の Jev に置き換えて試します。
Jev はテキストを生成しないモデルで、選択肢と確信度を直接返します。
先日、弊社のモリッシーさんが Switchyard に Jev を classifier として組み込むライブラリを実装して PR を出していたので、それを DGX Spark 上でビルドして、実際に運用しているエージェントと同じ構成で判定を比べました。

https://dev.classmethod.jp/articles/jev-support-for-nemo-switchyard/

なお、本記事の執筆時点で TypeSafe の API は early access(waitlist)で、返ってきたモデル名は jev-1.13.0 です。
Switchyard は PR ブランチ(switchyard-server 0.3.0、コミット b0ad365)をソースからビルドしています。
PR の内容はマージまでに変わる可能性があるので、最新の状態は PR #739 を確認してください。

TypeSafe と Jev

TypeSafe AI は、ソフトウェアの中で判断を下すためのモデルを作っている AI ラボです。
2026 年 9 月 15 日に System One モデルという分類と、その最初のモデル Jev を発表しました。

System One モデルは、自然言語の入力を読んで、生成テキストではなく型のついた判定と確率を返すモデルです。
公式ドキュメントでは、次の 3 つのプリミティブが用意されています。

  • Choice:与えた選択肢から 1 つを選び、確信度を添えて返す
  • Score:ルーブリックに沿って数値のスコアを返す
  • Noul:文の真偽を 0 から 1 の値で返す

出力はサンプリング層から直接取り出されるので、生成したテキストを JSON にパースする工程がありません。
公称値では、応答時間は 70 ミリ秒から 500 ミリ秒、入力トークンの単価は 100 万トークンあたり 0.042 ドル、出力は無料です。
API は 1 本で、POST /v1/systemonestate(判定させたいテキスト)と questions(プリミティブと選択肢)を送ります。

Switchyard の judge がやっていた「この依頼は weak で足りるか」は、選択肢が 2 つの Choice そのものです。
判定という軽い仕事のために生成モデルを回していた部分を、判定専用のモデルに替える、というのが今回の置き換えです。

置き換えで何が変わるか

実際に置き換えて運用するわけではありませんが、速度の面以外で変わる点を整理しておきます。

判定の入力が DGX の外に出ます。
前回の構成では、判定役だけがすべてのリクエストの中身を読むので、判定役をローカルに置いていました。
Jev は TypeSafe の API なので、判定に使うテキスト(依頼の冒頭と最新のユーザー発言)が TypeSafe に送られます。
実行役は最初から Fireworks に出しているので、外に出る情報の種類は増えませんが、出る先が 1 つ増えます。

判定の根拠が読めなくなります。
judge は verdict に crux(この依頼で一番難しい要件)を書くので、なぜ strong に振ったかを 1 件ずつ追えました。
Jev が返すのはラベルと確信度(と選択肢ごとの確率)だけです。

判定の基準を、学習ではなく文章で持ちます。
judge は Capability Card を system prompt に持つ事後学習済みモデルでした。
Jev では、選択肢ごとの説明文(criteria)がそのまま判定の基準になります。
基準を変えたいときは、設定ファイルの文章を書き換えます。

前述の PR について

PR は type_safe_classifier というルート種別を Switchyard に足すものです。
構成は 3 層に分かれています。

  • libsy(ルーティングの中核)には、HTTP に依存しない TypeSafeProvider という trait と、それを使う TypeSafeTaskClassifier が入る
  • 新しい crate switchyard-typesafe-client が、POST /v1/systemone を実際に叩く HTTP 実装を持つ
  • switchyard-runner(設定を読んでルートを組み立てる層)に、[type_safe_client] テーブルと type_safe_classifier ルートの設定が足される

判定の流れは次のとおりです。
リクエストの冒頭の依頼と最新のユーザー発言を平文にして state として送り、options に書いた選択肢と説明を Choice の criteria として渡します。
返ってきた確信度が base_threshold 以上なら、そのラベルに対応する models グループの先頭のターゲットに振ります。
確信度が閾値未満のとき、ラベルが解決できないとき、API が失敗したときは、どれも default_target に振ります。
この classifier は判定の失敗でリクエストをエラーにしません。

API キーは環境変数からしか読みません。
設定ファイルに書く欄が無く、ログにも出ません。

ビルドする

Jev の対応は Rust 版の switchyard-server に入っています。
DGX Spark(aarch64、Ubuntu 24.04)に rustup を入れて、PR のブランチをビルドします。

curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh -s -- -y --profile minimal
. ~/.cargo/env

git clone --branch feature/typesafe-classifier-routing --depth 1 \
  https://github.com/cm-morrissey/Switchyard.git
cd Switchyard
cargo build --release --locked -p switchyard-server
ls -la target/release/switchyard-server

20 コアの DGX Spark で、ビルドは 36 秒で終わりました。
できたバイナリは 23MB です。

routes.toml を書く

Rust 版の設定は TOML です。
llm_clients(接続先)、targets(モデル)、routes(振り分け)の 3 段で書きます。

routes.toml
schema_version = 1

[type_safe_client]
api_key_env = "TYPESAFE_API_KEY"
# base_url = "https://api.typesafe.ai"   # 既定値
# model = "jev-latest"                    # 既定値

[llm_clients.fireworks]
format = "openai_chat"
base_url = "https://api.fireworks.ai/inference/v1"
api_key_env = "FIREWORKS_API_KEY"

[targets.weak]
id = "accounts/fireworks/models/deepseek-v4-flash-0731"
llm_client = "fireworks"

[targets.strong]
id = "accounts/fireworks/models/kimi-k3"
llm_client = "fireworks"

[routes.switchyard]
id = "switchyard"
type = "type_safe_classifier"
default_target = "strong"
base_threshold = 0.6
question = "Slack の社内アシスタントがこの依頼に答えるとき、どのモデル階層が必要かを選んでください。"
context_window = 262144
tool_calling = true
vision = true

[routes.switchyard.options]
weak = "短い質問、用語の確認、翻訳・要約、Backlog や社内 wiki の検索や引用のような、手順が明確で完了条件がはっきりした依頼。"
strong = "設計判断や方針の相談、壁打ち、複数の資料を突き合わせる調査、コードの設計やレビュー、正確さの誤りが高くつく依頼など、深い推論や多段の作業が必要な依頼。"

[routes.switchyard.models]
weak = ["weak"]
strong = ["strong"]
any = ["strong", "weak"]

default_target は strong にしました。
判定に失敗したときに安いモデルへ流すと、失敗が利用者から見えなくなります。
judge のときも、判定に失敗したら strong に流す(fail-open)設定だったので、そこは揃えています。

options の説明文は日本語で書きました。
Slack に来る依頼は日本語なので、判定の基準も同じ言語で書いたほうが、あとで読み返して直しやすいと考えたためです。
Jev が日本語の criteria をどう扱うかは、次の節の結果で確かめます。

起動して疎通する

キーを環境変数に入れて、まず --dry-run で設定を検証します。

export FIREWORKS_API_KEY="..."
export TYPESAFE_API_KEY="..."
./target/release/switchyard-server --config routes.toml --dry-run
# server OK: switchyard

ドキュメントによると、[type_safe_client] を書き忘れた設定は、この検証の段階で type_safe_classifier のルートが組み立てられずに止まります。
起動時は、経路の記録を JSONL に残すオプションを付けました。

./target/release/switchyard-server --config routes.toml \
  --host 127.0.0.1 --port 8011 --routing-log-file routing.jsonl

疎通は OpenAI 互換の chat/completions に、model をルート id にして投げます。

curl -sS http://127.0.0.1:8011/v1/chat/completions \
  -H 'Content-Type: application/json' \
  -d '{"model":"switchyard","messages":[{"role":"user","content":"idempotent を一言で説明して"}]}'

応答の modelaccounts/fireworks/models/deepseek-v4-flash-0731、つまり weak でした。
サーバーのログには判定の結果が 1 行出ます。

Model selected algorithm="type_safe_task_classifier" target=accounts/fireworks/models/deepseek-v4-flash-0731 confidence=1.0

judge と同じ依頼で判定を比べる

チームの Slack に来る依頼を模した 13 個のシナリオを用意しました。
weak を期待するものが 8 個(挨拶、Backlog の状況確認、用語の説明、要約、社内 wiki の検索、会議 URL の確認、カスタム絵文字の有無、翻訳)、strong を期待するものが 5 個(技術の壁打ち、コードの設計レビュー、複数資料の突き合わせ、構成の比較設計、非技術の壁打ち)です。

比較のために、同じ Rust 版 switchyard-servertype = "llm_classifier" を使い、従来の judge(vLLM で動く事後学習済み Nemotron Lightning)を判定役にした構成も立てました。
llm_clientstargets は同じで、違うのは routes の判定役だけです。
計測はどちらも 13 シナリオを 3 回ずつ、実行役の生成は max_tokens=16 で切っています。

judge の結果

judge は 39 件中 36 件で期待どおりに振りました。
判定込みの応答時間の中央値は 2.31 秒、judge の verdict 単体は中央値 1.47 秒です。
冒頭に書いた運用中の 1.8 秒より短いのは、今回のシナリオが 1 往復の短い依頼で、実運用の会話(直近の数ターンと tool の結果を含む)より judge の入力が短いためです。

外れた 3 件は「会議 URL の確認」が 3 回のうち 2 回、「Backlog の状況確認」が 3 回のうち 1 回、strong に振られたものです。
temperature は 0 ですが、判定は回によって揺れます。
verdict の crux を読むと、会議 URL の回は「カレンダーを読む手段が Capability Card に無い」と見ていました。
これは judge の誤りではなく、Card に書かれた能力どおりの判定です。

Jev の結果

Jev は 39 件すべてで期待どおりに振りました。
判定込みの応答時間の中央値は 1.17 秒で、judge より 1.1 秒ほど短くなっています。

応答時間には実行役の生成が含まれるので、判定役の違いを見るには生成時間を引く必要があります。
/v1/stats には実行役の呼び出し時間と要求全体の時間が別々に出るので、その差(判定と変換にかかった時間)を比べました。

判定役 判定と変換の時間(中央値) 期待どおり
judge(llm_classifier) 1.55 秒 36/39
Jev(type_safe_classifier) 0.27 秒(strong)から 0.42 秒(weak) 39/39

判定そのものの時間を見るために、TypeSafe の API を直接叩く計測も 13 シナリオ × 10 回で行いました。

シナリオ 期待 Jev の選択 confidence 判定時間(中央値) 入力トークン
挨拶 weak weak(10/10) 0.99 0.492 秒 505
Backlog の状況確認 weak weak(10/10) 1.00 0.530 秒 510
用語の説明 weak weak(10/10) 1.00 0.512 秒 502
要約 weak weak(10/10) 0.98〜1.00 0.515 秒 692
社内 wiki の検索 weak weak(10/10) 1.00 0.510 秒 519
会議 URL の確認 weak weak(10/10) 0.99 0.484 秒 507
カスタム絵文字の有無 weak weak(10/10) 0.99 0.531 秒 500
技術の壁打ち strong strong(10/10) 1.00 0.510 秒 588
コードの設計レビュー strong strong(10/10) 1.00 0.488 秒 599
複数資料の突き合わせ strong strong(10/10) 0.99 0.512 秒 571
構成の比較設計 strong strong(10/10) 0.99〜1.00 0.516 秒 582
非技術の壁打ち strong strong(10/10) 0.99 0.522 秒 544
翻訳 weak weak(10/10) 1.00 0.521 秒 521

130 回の判定はすべて成功し、期待と一致しました。
判定時間は中央値 0.514 秒、p95 でも 0.576 秒です。
入力トークンは 500 から 692 で、1 回あたり約 0.000023 ドル、130 回で 0.003 ドルでした。

Python から 1 回ずつ叩いたこの計測は、毎回 TLS の接続を張り直しています。
Switchyard は接続を再利用するので、先の表の strong の 0.27 秒のほうが Switchyard から見た判定の時間に近く、モリッシーさんの記事の 0.25 秒から 0.28 秒と同じ水準です。

confidence は全シナリオで 0.98 以上でした。
judge が回によって揺れた「会議 URL の確認」も、Jev は 0.99 で weak に置いています。
Jev は Capability Card を持たないので、「その手段がエージェントにあるか」は見ていません。
criteria に書いた「手順が明確で完了条件がはっきりした依頼」に当てはまるかだけで判定しています。
どちらが正しいかは、エージェントに実際にカレンダーを読む手段があるかで決まります。

壁打ちの 2 件は、どちらも confidence 0.99 以上で strong でした。
criteria の strong 側に「壁打ち」と書いたので、そのまま拾われています。
judge の運用では「完了条件が無い依頼は weak がやり切れるかを予報しにくく、weak に寄りやすい」という指摘がチームから出ていました。
今回の 13 シナリオでは judge も壁打ちを strong に振りましたが、判定の基準を文章で持てるほうが、この種の依頼の扱いを直しやすいのは確かです。

使ってみて分かった制約

画像つきの依頼は、Jev には「(image)」という文字に置き換えて渡されます。
Jev は画像入力に対応していないので、PR 側で画像ブロックをプレースホルダに落としています。
画像の中身を見て振り分けることはできませんが、画像が添付されている事実は判定に使えます。

判定の根拠は JSONL には残りません。
--routing-log-file の記録には選ばれたモデルとトークン数が残り、ラベルと確信度はサーバーの INFO ログにだけ出ます。
PR のレビューでも、確率分布の保持と判定レイテンシの記録は今後の課題とされています。
運用で「なぜこちらに振ったか」を追う必要があるなら、当面はログを別に集める必要があります。

また、Jev は現時点では early access です。
API キーは waitlist からの案内で配布されており、料金や上限は今後変わる可能性があります。

おわりに

判定役を judge から Jev に替えて、ルーターが判定に使う時間は中央値 1.55 秒から 0.3 秒前後になり、13 シナリオ × 3 回の振り分けは 39 件すべて期待どおりでした。
判定 1 回のコストは約 0.000023 ドルで、コストが置き換えを止める理由にはなりません。

代わりに、判定の入力が TypeSafe に出ること、判定の根拠(crux)が読めなくなることを受け入れる必要があります。
前回の記事で判定役をローカルに置いた理由はこの 1 点目だったので、実際に置き換えるには、判定の入力を外に出してよいかの判断が残ります。

ルーターを 1 段挟んでおいたので、判定役の差し替えは設定ファイルのルート 1 つと環境変数 1 つで済みました。
判定の基準が Capability Card の学習から criteria の文章に移ったことで、基準の変更は設定ファイルの編集になります。

判定の速さと、入力を DGX の外に出さないことは、今回の構成では両立しません。
両立させる案として、判定だけを学習させた小さなローカルの分類器を判定役にする試みも進めています。
judge の出力から根拠(crux)を外すと判定が何秒縮むかと合わせて、次の記事で比べます。

参考資料

この記事をシェアする

関連記事