0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

NeMo Switchyardをローカル(WSL2 + Ollama)で検証、ルーティングより先にモデルの安定性の限界にぶつかる

0
Posted at

NVIDIA NeMo Switchyard v0.2.0 実機検証記

本記事は個人環境での検証に基づく個人的な備忘録です。設定値やパスは一般化して記載しているため、実際の環境に読み替えてください。技術的な確認は Sonnet 5 と、記事の執筆は Opus 5 と共に進めています。

1. はじめに

2026年8月、NVIDIAのモデルルーティングライブラリ NeMo Switchyard が v0.2.0 へアップデートされました。エージェントのワークフローにおいて、各ステップを「その時に最も適切なモデル」へ自動的に振り分けることで、フロンティアモデル並みの精度を保ちつつコストを1/3近くまで削減できる、という触れ込みのOSSです。v0.2ではアーキテクチャが大きく変わり、Rust製サーバー / ライブラリが主軸になりました(旧Python実装は非推奨)。

手元にはWSL2上で動かしている Hermes Agent と、Windows側のOllamaという環境があります。「軽量モデルで済む作業は軽量モデルに、難しいところだけ大きいモデルに」というのはローカルLLM運用でまさに欲しかった機能なので、実際に組み込んで検証してみることにしました。

この記事で分かること:

  • NeMo Switchyard v0.2.0 の4つのルーティングアルゴリズムが、実機でどう動くのか(結論: ドキュメント通りに正確に動く)
  • 記事等で言及される「Tunable Router (Prefill Router)」を現行OSS版で探したものの、実装が見つからなかったという確認結果
  • そして本題 — Switchyardを実運用のエージェントに繋いだ結果ぶつかった、ルーティング機構とは全く無関係の壁について

先に結論を言うと、Switchyard自体は期待通りに動きました。しかし実運用のエージェントに繋いだ瞬間、「小型モデル × 大規模ツールセット」という、ルーティング以前の問題が表面化しました。その原因究明の過程(と、途中で立てた仮説がことごとく外れていく様子)が、この記事の主題です。

検証環境:

項目 内容
GPU NVIDIA GeForce RTX 5070 Ti(VRAM 16GB / 実効 16,303 MiB)
システムRAM 64GB
OS Windows 11 Pro + WSL2(Ubuntu 26.04 LTS、networkingMode=Mirrored)
推論ランタイム Ollama 0.32.9(Windows ネイティブ、:11434)
Switchyard switchyard-server v0.2.0(WSL2側、:4000、cargo でビルド)
efficient ターゲット nemotron-ja:4b(4B)
capable ターゲット nemotron-3.5-lightning:30b-a3b(30B MoE / 3B active)
クライアント curl および Hermes Agent(WSL2側)
トラフィック観測 mitmproxy 11.0.2

検証日は2026年8月13〜14日です。記事中の数値は、断りのない限りすべて自分の環境での実測値です。


2. NeMo Switchyardの概要

2.1 何をするツールか

  • オープンソースのモデルルーティングライブラリ。 Rust製のプロキシ兼ライブラリで、OpenAI Chat Completions / OpenAI Responses / Anthropic Messages の3形式を相互に翻訳しながら、複数のバックエンドへリクエストを振り分けます。宛先はローカルの推論サーバー(vLLM・NIM・Ollamaなど)に限らず、OpenRouter・OpenAI・Anthropicといったパブリックのモデルプロバイダも同列に扱えます。実際、公式ドキュメントのサンプルはOpenRouter経由でClaudeやGeminiを使う構成が中心で、「安いクラウドモデルと高価なクラウドモデルの使い分け」も「ローカルとクラウドの併用」も同じ仕組みで書けます(今回はローカル完結の構成で検証しています)。
  • ルーターは Tuning-Free と Tunable の2系統。 Tuning-Freeは学習済みルーターモデルを必要としない4種(random / llm_classifier / stage_router / escalation)。TunableはPrefill Routerと呼ばれ、LLMの residual stream から複雑度を推定する方式です(後述しますが、今回調べた範囲では現行OSS版に実装が見当たりませんでした)。
  • Hermes Agentとの「統合」表記について。 NVIDIA公式ブログには「Nous Research: Hermes に NeMo Switchyard を統合し〜」という記述がありますが、Hermes Agent公式ドキュメントの Provider Routing 機能にも、GitHubリリースノート(v0.19.0 / v0.20.0 を含む)にも "switchyard" の記述は見当たりませんでした(2026年8月時点)。パートナーシップの告知が先行している段階と見られるため、本記事では外付けのプロキシとして連携させる方針で検証しています。

成熟度について。 公式READMEの ## Maturity にはこう書かれています1

Switchyard is pre-alpha software that is evolving rapidly. The API and algorithms are expected to change significantly before we reach v1.0.

Warning
Experimental software. Not for production use.

(訳: Switchyardは急速に進化しているpre-alphaソフトウェアです。APIとアルゴリズムは、v1.0に到達するまでに大幅に変更されることが見込まれます。/ 警告 実験的ソフトウェアです。本番環境での使用は想定していません。)

本記事の内容も、この前提の上でお読みください。

2.2 検証構成の全体像

まず、今回組んだ構成です。オレンジ色の箇所が、Switchyardを使わない通常構成との差分になります。

図1: Switchyardを使う構成

図2(比較用): Switchyardを使わない通常構成

両図の差分は、リクエスト中の model フィールドを誰が決めるかです。

図2では、クライアントが model: nemotron-ja:4b と最終的なモデル名を自分で指定し、Ollamaはそれをそのまま実行します。使うモデルは固定です。

図1では、クライアントが指定するのは model: switchyard/stage というルート名であり、実在のモデル名ではありません。Switchyardがルーティングアルゴリズムを走らせて、この値を nemotron-ja:4b または nemotron-3.5-lightning:30b-a3b という実在のモデル名に書き換えてからOllamaへ転送します。Ollamaから見れば、どちらの図でも「具体的なモデル名を指定された普通のリクエスト」が届くだけで、Ollama自身は何も振り分けていません。

この「ルート名 → 実モデル名」の対応を定義するのが routes.toml で、3階層で構成されています。

schema_version = 1

# ① llm_clients: どのエンドポイントに、どの形式で話すか
[llm_clients.ollama]
format = "openai_chat"
base_url = "http://localhost:11434/v1"
api_key_env = "OLLAMA_API_KEY"
max_retries = 2

# ② targets: 実際に呼ぶモデル(idがupstreamへ送られる本当のモデル名)
[targets.efficient]
id = "nemotron-ja:4b"
llm_client = "ollama"

[targets.capable]
id = "nemotron-3.5-lightning:30b-a3b"
llm_client = "ollama"

# ③ routes: クライアントが指定する「モデル名」= アルゴリズムの入口
[routes.stage_route]
id = "switchyard/stage"          # ← クライアントはこの名前を model に指定する
type = "stage_router"
capable_target = "capable"
efficient_target = "efficient"
picker = "efficient_first"
confidence_threshold = 0.5

つまり クライアントから見た「モデル名」が、実体としてはルーティングアルゴリズムの入口になっているわけです。switchyard/stage 宛のリクエストを受けたSwitchyardは、stage_router アルゴリズムを走らせて efficient / capable のどちらのターゲットを使うかをリクエストごとに決定し、選ばれた方の id(実モデル名)でOllamaを呼びます。これが図1のオレンジ部分で起きていることです。


3. 環境構築

Rust製なので、まずツールチェーンを用意します。

curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh -s -- -y
sudo apt-get update && sudo apt-get install -y build-essential pkg-config libssl-dev

その後、サーバーバイナリをビルド。数分かかります。

cargo install --locked switchyard-server
$ switchyard-server --version
switchyard-server 0.2.0

WSL2からWindows側のOllamaへの到達性も確認しておきます。今回の環境は .wslconfignetworkingMode=Mirrored にしてあるため、WSL2内から localhost:11434 でそのままWindows側Ollamaに届きます。

$ curl -sS http://localhost:11434/api/tags | head -c 200
{"models":[{"name":"nemotron-3.5-lightning:30b-a3b", ...

設定ファイルを書いたら、まず --dry-run で検証してから起動するのが安全です。

export OLLAMA_API_KEY="ollama-local"
switchyard-server --config ~/switchyard/routes.toml --dry-run
# => server OK: switchyard/classifier, switchyard/escalation, switchyard/random, switchyard/stage

switchyard-server --config ~/switchyard/routes.toml \
  --host 127.0.0.1 --port 4000 \
  --routing-log-file ~/switchyard/routing.jsonl

4. ルーティング機能の実機検証

4.1 最小構成での疎通確認(passthrough)

まずターゲット1つの passthrough ルートで、プロキシ越しに正常応答するかを確認します。

curl -sS http://127.0.0.1:4000/v1/chat/completions \
  -H 'Content-Type: application/json' \
  -d '{"model":"switchyard/smoke","messages":[{"role":"user","content":"日本語で一言、自己紹介してください。"}]}'
{
  "choices": [{
    "message": {"role": "assistant", "content": "私はAIアシスタントです。"},
    "finish_reason": "stop"
  }],
  "model": "nemotron-ja:4b",
  "usage": {"prompt_tokens": 31, "completion_tokens": 177, "total_tokens": 208}
}

レスポンスの model フィールドが、ルート名ではなく実際に処理したモデル名になっている点に注目してください。どのモデルが応答したかがクライアント側から追跡できます。

tool callingも試しておきます。

curl -sS http://127.0.0.1:4000/v1/chat/completions \
  -H 'Content-Type: application/json' \
  -d '{"model":"switchyard/smoke",
       "messages":[{"role":"user","content":"東京の天気を教えて"}],
       "tools":[{"type":"function","function":{
         "name":"get_weather",
         "parameters":{"type":"object","properties":{"city":{"type":"string"}}}}}]}'
{
  "choices": [{
    "message": {
      "tool_calls": [{"function": {"name": "get_weather", "arguments": "{\"city\":\"Tokyo\"}"}, ...}]
    },
    "finish_reason": "tool_calls"
  }]
}

finish_reason: "tool_calls" が返り、正しくツール呼び出しが生成されています。ここが後で効いてくる伏線です。ツール1個ならこの4Bモデルは正常に動く、ということを覚えておいてください。

4.2 Tuning-Free 4アルゴリズムの検証

efficient(nemotron-ja:4b)と capable(nemotron-3.5-lightning:30b-a3b)の2ターゲット構成で、4つのアルゴリズムを順に検証しました。まず、それぞれが「何を根拠に」モデルを決めるのかを整理します。

アルゴリズム 判断材料 追加のLLM呼び出し 主な用途
random なし(設定した重みのみ) なし A/Bテスト、固定比率でのトラフィック分割
llm_classifier リクエストの内容をjudgeモデルが評価し、weakモデルで解ける確率(p_solve)を推定 あり(毎ターン、judge 1回) 実行前に難易度で振り分けたい場合
stage_router 直近のツール実行履歴から、エージェントが今どの段階(探索中/エラー復帰中/定型作業中)にいるかをスコアリング なし(デフォルト) エージェントの多段タスク。ターンごとに双方向で切り替わる
escalation 実行の軌跡をjudgeが評価。行き詰まりが連続した時点でstrongへ固定(latch) あり(latchするまで毎ターン) weakで走らせつつ、詰まった時だけ救済したい場合

大きな違いは2点です。ひとつは判断のタイミングで、llm_classifier は「これから実行するリクエストが難しそうか」を事前に予測するのに対し、stage_routerescalation は「実際にやってみた結果どうなっているか」を見て判断します。もうひとつはコストで、randomstage_router は追加のLLM呼び出しなしに動く一方、llm_classifierescalation は判定のたびにjudgeモデルを1回呼ぶぶんのレイテンシとトークンがかかります。

なお stage_routerescalation は、切り替えの方向性も異なります。stage_router はターンごとに efficient ⇄ capable を双方向に行き来しますが、escalation は一度strongへ latch すると、そのセッションでは戻りません(片道)。

random

重み [1, 1] で8回リクエストした結果:

call 1 -> nemotron-3.5-lightning:30b-a3b
call 2 -> nemotron-3.5-lightning:30b-a3b
call 3 -> nemotron-3.5-lightning:30b-a3b
call 4 -> nemotron-ja:4b
call 5 -> nemotron-ja:4b
call 6 -> nemotron-ja:4b
call 7 -> nemotron-3.5-lightning:30b-a3b
call 8 -> nemotron-3.5-lightning:30b-a3b

5:3とほぼ設定通りに分散。A/Bテスト用途としては期待通りです。

llm_classifier

judgeモデルにタスクの難易度を判定させ、p_solve(weakモデルが解ける確率)が閾値以上ならweakへ、未満ならstrongへ振り分ける方式です。

judgeに使うモデルは classifier_target で指定します。今回は capable と同じ nemotron-3.5-lightning:30b-a3b を判定役に兼任させました。

[routes.classifier_route]
id = "switchyard/classifier"
type = "llm_classifier"
mode = "capability"
classifier_target = "capable"   # ← judge = nemotron-3.5-lightning:30b-a3b
strong_target = "capable"       # ← nemotron-3.5-lightning:30b-a3b
weak_target = "efficient"       # ← nemotron-ja:4b
base_threshold = 0.5

judgeは構造化出力でスキーマ通りのJSONを返す必要があるため、指示追従性の高いモデルを充てるのが無難です(公式ドキュメントの例でも、judgeには Gemini Flash 級のモデルが使われています)。今回はここに小型モデルを置く構成は試していませんが、判定が壊れた場合の挙動は仕様として明記されており、パースできない応答やjudge呼び出しの失敗は、安全側に倒れて strong_target へフォールバックする設計になっています2

--routing-log-file のログを見ると、内部の動きが明快に追えます。

model: nemotron-3.5-lightning:30b-a3b | tier: 'classifier'  | tokens: 2106  ← 判定
model: nemotron-ja:4b                 | tier: 'weak'        | tokens: 38    ← 実応答

judgeへの問い合わせ → その結果で本番モデルを選択、という2段構えが正しく機能しています。

なお、base_threshold = 0.5 の設定で「これは難しいだろう」と用意したマージソートのバグ修正タスクも weak 判定になりました。judgeが壊れていたのではなく、judgeの判断が私の想定より正確だったということです。ルーティングの妥当性を評価する際は、テストケース側の難易度設計も含めて考える必要があります。

stage_router

これが最も面白いアルゴリズムでした。ツール実行の履歴から「エージェントが今どの段階にいるか」を推定し、同一タスクの途中でもターンごとにモデルを切り替えます

  • WRONG → capable: エラーの深刻度、進捗のない空回り、探索フェーズ
  • PROGRESS → efficient: 書き込みや編集が着実に成立している

最初、ツールエラーを1回だけ含む会話履歴を投げたところ、切り替わりませんでした。バグかと思いましたが、レスポンスヘッダを見ると理由が明白でした。

$ curl -sS -D - ... # エラー1回のみの履歴
x-model-router-selected-model: nemotron-ja:4b
x-model-router-rationale: fall-through selected nemotron-ja:4b (confidence 0.000)

ドキュメントを読むと、これは設計通りでした。スコアラーは「corroborative(裏付け重視)」に作られており、単一のシグナルだけでは約0.46にしかならず、デフォルト閾値0.5を意図的に超えないようになっています。「1回のコマンド失敗で高いモデルに切り替えるな」という思想です。

そこで、同じエラーを3回繰り返し・進捗ゼロという「裏付けのあるシグナル」を与え直すと:

x-model-router-selected-model: nemotron-3.5-lightning:30b-a3b
x-model-router-rationale: fall-through selected nemotron-3.5-lightning:30b-a3b (confidence 0.613)

閾値0.5を超えてcapableへ正しくエスカレーションしました。ヘッダに信頼度スコアまで出るので、挙動の説明可能性は高いです。

escalation

まずweakモデルで実行し、judgeが「この実行は行き詰まっている」と連続して判定した場合にstrongへ latch(固定)する方式です。confirmations = 2(デフォルト)なら、2ターン連続でescalate判定が出て初めて切り替わります。

こちらも当初は単発の難問(年齢当てパズル)を投げていて、5ターン試しても一切escalateしませんでした。原因を探るため、ビルド時に手元へ展開されるパッケージ済みプロンプトを直接読みに行きました。

cat ~/.cargo/registry/src/index.crates.io-*/switchyard-libsy-0.2.0/src/prompts/escalation/prompt.md

そこには判定基準が明確に書かれていました。

Judge the trajectory — is the agent making real progress toward the stated task — not the difficulty of the task itself.

つまりこのjudgeはタスクの難しさではなく、エージェントの実行軌跡が行き詰まっているかを見ています。「同じImportErrorで4回失敗、その間は無関係なファイルを編集」といった具体例まで列挙されており、単発の一問一答は対象外(ドキュメントの "When not to use" にも明記)でした。

そこで、同一エラーが3回連続するエージェント的な軌跡を与えたところ:

ターン 選択モデル 挙動
1 weak judgeがescalate判定、streak=1(未到達)
2 capable 2回連続でescalate、streak=2に到達し latch
3 capable latch済みのためjudge呼び出しをスキップ

3ターン目の処理時間が 30.5秒 → 2.4秒 に短縮されており、「latch後はjudgeを呼ばない」という仕様まで実測で確認できました。

教訓: ルーターが期待通り動かないとき、まずバグを疑う前に「そのアルゴリズムが何を入力として想定しているか」を確認すべきでした。cargo install でビルドすると ~/.cargo/registry/src/ 以下にjudgeプロンプトの原文が残るので、これが挙動理解の最良の一次情報になります。

4.3 Tunable Router(Prefill Router)は現行OSS版に見当たらなかった

NVIDIA Developer Blogでは、ルーターが Tuning-Free と Tunable に分類され、後者の Prefill Router について「学習時にLLMのresidual streamを抽出して複雑度を推定し、shared-trunk MLPが各モデルの正答確率を予測する」と説明されています。

魅力的な機能なので試そうとしたのですが、リポジトリの docs/routing_algorithms/ には random / llm_classifier / stage_router / escalation_router / overview しかなく、architecture.md にも "prefill" の記述がありません。念のためバイナリを直接調べました。

$ strings ~/.cargo/bin/switchyard-server | grep -i prefill
# (何もヒットしない)

v0.2.0のOSS実装には含まれていないと判断しました。設計上 residual stream へのアクセスが必要なので、仮に実装されてもOllamaの標準的なOpenAI互換APIでは原理的に情報を渡せず、vLLMの計装やNIMなど専用スタックが要ると思われます。ローカルRTX環境で試したい場合は、今後のリリースを待つ必要があります。

ここまでの結論: NeMo Switchyardのルーティング機能は、ドキュメント通りに正確に動作する。 では実運用に投入してみます。


5. 実運用テストで発生した問題

Hermes Agentの接続先を、Ollama直結からSwitchyardの switchyard/stage ルートへ切り替えました。

# ~/.hermes/config.yaml
model:
  default: switchyard/stage                 # ← ルート名を指定
  provider: custom
  base_url: http://localhost:4000/v1        # ← Switchyardを向ける

簡単な質問は問題なく通ります。

$ hermes -z "こんにちは。今のモデル名を教えてください。1文で。"
現在のモデル名は**switchyard/stage**です。

ところが、ツールを使う実タスクを投げたところ、応答が明らかにおかしくなりました。

$ hermes -z "現在のディレクトリにあるファイル一覧を確認して、拡張子ごとの個数を教えて"
I'm sorry— the **`run_background`** helper that would let you launch a command
in the background... isn't currently exposed as a callable tool in this environment.

| Option | What it does | How to invoke |
|--------|--------------|----------------|
| **Synchronous terminal run** (`terminal`) | ... | `terminal({command: "/usr/bin/swupd refresh", ...})` |

ファイル一覧を聞いただけなのに、run_background という存在しないツールが使えないことへの弁明と、代替手段の比較表が延々と返ってきました。質問には一切答えていません。同じ質問をOllama直結に戻して投げると、精度はともかく質問の趣旨には答えます。

このとき、Switchyardのデバッグログにこんな記録が残っていました。

routing decision selected_model="nemotron-ja:4b"                (confidence 0.000)
routing decision selected_model="nemotron-ja:4b"                (confidence 0.000)
routing decision selected_model="nemotron-3.5-lightning:30b-a3b" (confidence 0.762)  ← 切替

タスクの途中で、stage_routerがefficient→capableへ自律的に切り替えていました。 崩壊した応答とタイミングが一致しています。

そこで立てた仮説がこれです。

仮説①: stage_routerによる会話途中のモデル切り替えが、Hermes側のツール呼び出し文脈の一貫性を壊しているのではないか?

もっともらしく聞こえます。しかし、これは後に完全な誤りと判明します。


6. 仮説検証: mitmproxyによる生トラフィックキャプチャ

問題は、Switchyardのログが「どのモデルを選んだか」は記録するものの、実際にモデルへ渡ったJSON本文までは記録しないことでした。RUST_LOG=trace まで上げてもHTTP接続の詳細止まりです。

そこで、経路上に mitmproxy をreverse modeで挟み込み、生のリクエスト/レスポンスを丸ごと記録することにしました。

キャプチャ用アドオンは短いPythonで済みます。

# capture.py
import json, os, time
LOGFILE = os.environ.get("MITM_LOGFILE", "/tmp/mitm_capture.log")
TAG = os.environ.get("MITM_TAG", "proxy")

def _pretty(body):
    if not body: return "(empty)"
    try: return json.dumps(json.loads(body), ensure_ascii=False, indent=2)
    except Exception: return body.decode("utf-8", errors="replace")

def request(flow):
    with open(LOGFILE, "a") as f:
        f.write(f"\n===== [{TAG}] REQUEST {flow.request.method} {flow.request.path} =====\n")
        f.write(_pretty(flow.request.content) + "\n")

def response(flow):
    with open(LOGFILE, "a") as f:
        f.write(f"\n----- [{TAG}] RESPONSE status={flow.response.status_code} -----\n")
        f.write(flow.response.content.decode("utf-8", errors="replace") + "\n")
uv tool install mitmproxy

MITM_LOGFILE=~/capture/sy_to_ollama.log MITM_TAG="switchyard->ollama" \
  mitmdump --mode reverse:http://127.0.0.1:11434 --listen-port 8091 -s capture.py

あとはSwitchyardの base_url:8091 に、Hermesの base_url:8090 に向けるだけです。

結果: 仮説①は誤りだった

キャプチャされたOllamaからの生レスポンス(ストリーミング)を追うと、決定的な証拠が出てきました。モデルの出力末尾です。

data: {"choices":[{"delta":{"content":"\n}"},...}]}
data: {"choices":[{"delta":{"content":"\n</"},...}]}     ← "</"
data: {"choices":[{"delta":{"content":"parameter"},...}]}      ← "parameter"
data: {"choices":[{"delta":{"content":">"},...}]}         ← ">"
data: {"choices":[{"delta":{"content":"\n</tool_call>"},...}]}  ← "</tool_call>"
data: {"choices":[{"delta":{},"finish_reason":"stop"}]}        ← ★ "stop"
data: {"choices":[],"usage":{"prompt_tokens":18935,...}}

起きていたのはこういうことでした。

  1. モデルが、正式な tool_calls フィールドではなく <tool_call>{...}</tool_call> という生のタグ付きテキストを content にそのまま出力していた
  2. そのため finish_reason"tool_calls" ではなく "stop" になっている(= Ollamaのパーサーがツール呼び出しとして認識できていない)
  3. Hermes側は tool_calls が空なので、その壊れたテキストをそのままユーザーへの最終応答として表示していた

そして注目すべきは "prompt_tokens": 18935 です。

同じキャプチャからリクエスト側を解析すると、Hermes Agentは毎リクエストで27個のツール定義(約75,000文字)を送信していました。

messages: 2 | tools: 27 | approx chars: 74993

第4.1節の疎通確認では、ツール1個なら同じ4Bモデルが正常に tool_calls を返していたことを思い出してください。

モデル切り替えは無関係だった。「4Bという小型モデル」×「27ツール・約19,000トークンという巨大なツールセット」の組み合わせが、このモデルのツール呼び出し能力の限界を超えているとみられます。

stage_routerの切り替えタイミングと重なったのは偶然でした。実際、その後の再現試行では切り替えが起きないケースでも同じ崩壊が発生しています。


7. 追加検証: 何が根本原因なのか

原因の所在は分かりました。では、どうすれば安定するのか。3つの仮説を順に潰していきます。

7.1 言語(日本語 vs 英語)は関係あるか

nemotron-ja:4b は日本語対応モデルですが、ベースは英語圏のモデルです。「英語で指示すれば安定するのでは」という仮説を、同一プロンプトの日英版で各3トライアル、計6回検証しました。

言語 Trial 生タグ漏れ 症状
JA 1 <tool_call> 出力が空
JA 2 .mdmd を別カウントするなど不正確
JA 3 生のPythonコードをテキスト出力
EN 1 壊れたコマンド文字列
EN 2 別種 <|DSML|function type> が漏出
EN 3 生のJSON引数をテキスト出力

有意差なし。 言語に関わらず、ほぼ全トライアルで何らかの崩壊が発生しました。

むしろ興味深いのは英語Trial 2で、<tool_call> とは別種の漏れパターンが出たことです。<|DSML|function type> という、全角パイプ を含むNemotron内部の制御トークン風の文字列がそのまま content に流れていました。崩壊の「表れ方」が複数あるということは、特定タグのエスケープ漏れのような単純なバグではなく、ツール呼び出し制御トークンのfinalize処理全体が不安定という、より根の深い状況を示唆します。

7.2 モデルサイズを上げれば解決するか

次の仮説は素直なものです。「4Bが小さすぎるなら、大きくすればいい」。手元にある hermes3:8b(8B、英語中心の学習、context 128k)で、英語3回・日本語2回を検証しました。

結果は予想と真逆でした。

モデル finish_reason: "tool_calls" の発生
nemotron-ja:4b (4B) 6トライアル中、複数回成功
hermes3:8b (8B) 5トライアル中、0回

一度もツール呼び出しに成功しませんでした。しかも壊れ方が、nemotron系とはまた違います。

</UNIT_TEST> {"arguments": {"action": "list_files", ...}, "name": "ls"}

架空のタグに加え、namearguments の順序が逆転した非標準JSON。さらに日本語トライアルの1つでは、実際にディレクトリを見ずに架空の拡張子と個数(.py: 3.jpg: 2 など、実在しないファイル)を答えるという、崩壊よりタチの悪い幻覚まで発生しました。

仮説②も棄却。 決定要因はパラメータ数ではなく、Ollama上でのそのモデル固有のチャットテンプレート / tool-callingテンプレート実装が、この規模のスキーマにどれだけ耐えられるか、というモデル個別の事情である可能性が高い。

7.3 ツールセットを絞り込めば解決するか

残る本命の仮説です。原因が「27ツール・19,000トークン」なら、ツールを減らせばいい。Hermes Agentには専用コマンドがあります。

hermes tools list                    # 状態確認
hermes tools disable browser delegation session_search vision image_gen tts computer_use

ここで、mitmキャプチャから得た実測のペイロード内訳が役に立ちました。

カテゴリ 関数数 定義サイズ(文字)
terminal 2 6,877
session_search 1 6,480
browser 10 6,019
delegation 1 5,934
file 4 5,798
skills 3 5,533
memory 1 2,802
code_execution 1 2,383
clarify 1 1,924
todo 1 1,338
tts 1 915
vision 1 895
合計 27 46,898

session_search が単体で6,480文字と、10関数を含む browser カテゴリ全体に匹敵していたのは意外でした。カテゴリ数ではなく実バイト数で見る重要性が分かります。

7カテゴリを無効化し、27関数 → 13関数(ツール定義 46,898 → 26,655文字、-43%)にした状態で日本語3トライアル:

Trial tool_calls 生タグ漏れ 応答
1 なし 妥当
2 なし 妥当
3 なし 妥当

3/3で崩壊なし。 これは効いた、と思いました。

ところが — 1ツール戻すだけで再発する

検証のため session_search(6,480文字)を1つだけ戻して再測定すると、3トライアル中1回で生タグ漏れが再発しました。「サイズ閾値を超えたのか」「session_search 固有のスキーマが悪いのか」を切り分けるため、session_search を外してほぼ同サイズの別ツール delegation(5,934文字)に差し替えたところ、やはり1/3で再発

構成 崩壊率
13ツール 0/3
13 + session_search 1/3
13 + delegation 1/3

別々のツールで同じ崩壊率。「ペイロード総サイズが閾値を超えると確率的に崩れ始める」という説が強く支持されたように見えました。

段階的スイープで見えた、もっと厄介な実態

閾値をきちんと特定すべく、13ツールを基準に visionttsdelegationsession_searchbrowser の順で1つずつ累積追加し、各段階3トライアルのスイープを実施しました。

段階 累積ツール数 生タグ漏れ
+vision 14 0/3
+tts 15 1/3
+delegation 16 0/3
+session_search 17 0/3
+browser(フル) 27 0/3

きれいな閾値は出ませんでした。 15トライアル中、明確な生タグ漏れは1回のみ。フルの27ツール構成ですら0/3です。つまり先ほどの「1/3 vs 0/3」も、統計的にはノイズの範囲だった可能性が高い。

そして、grepで機械的に検出できる「生タグ漏れ」ばかり追いかけていたことで見落としていた事実に気づきます。出力内容そのものを読むと、軽度だが実質的な崩壊がほぼ全段階で頻発していました。

  • +vision Trial 3: ツールを呼ばず、生のPythonコードをテキスト出力
  • +tts Trial 3: Md (8) ... Mld (1) ... Mml (9) という意味不明な拡張子分類
  • +session_search Trial 1: 支離滅裂(「以下のようにするのは意味がありませんか?」)、Trial 3: 出力がほぼ空(.0 のみ)
  • +browser(フル) Trial 2: 「/mnt/c ではファイル一覧コマンドを直接実行できない」という事実に反する主張、Trial 3: 簡体字中国語に切り替わって応答

そしてこれらは、「安定した」と判断した13ツール構成の時点でも、程度の差はあれ存在していました(拡張子カウントの数字が毎回違う、など)。

仮説③も部分的に棄却。 ツールセットの絞り込みは最悪ケース(生タグ漏れ)の頻度を下げる効果があるかもしれないが、このモデル自体の精度・安定性に由来するとみられる軽度な崩壊は、ツールを13個まで減らしても解消しなかった。


8. 結論と考察

3つの対策仮説は、いずれも「これで解決」とは言えませんでした。

仮説 結果
① モデル切り替えが文脈を壊している ❌ 誤り(mitmproxyで否定)
② 英語で指示すれば安定する ❌ 有意差なし
③ モデルを大きくすれば安定する hermes3:8b は逆に悪化
④ ツールセットを絞れば安定する △ 緩和する可能性はあるが根本解決ではない

一方で、nemotron-3.5-lightning:30b-a3b(30B)を capable ターゲットとして使った検証では、この種の崩壊は一度も発生していません

この一見矛盾するデータ(30Bは安定、8Bは4Bより悪い)を最も整合的に説明できるのは、次の解釈だと考えています。

同一モデルファミリー内でのスケールアップ(nemotron-ja:4bnemotron-3.5-lightning:30b)では、スケール則に沿って安定性が改善しているように見える。一方でモデルファミリーをまたぐと、チャットテンプレート / tool-calling実装の相性という別の要因が効いてきて、パラメータ数だけでは順序が逆転しうる。

もっとも、これは3モデル・少数トライアルの結果から組み立てた仮説にすぎません。より多くのモデルで確かめないと、一般的な傾向として言えるものではない点はお断りしておきます。

hermes3:8bnemotron-ja:4b に負けたのは、8Bという規模の問題ではなく、Ollama上でのそのモデルのテンプレート実装が、この規模のツールスキーマを想定していないと推測されます。ただしテンプレートの中身まで追い切れてはいないため、あくまで実測結果からの推測です。

実務上の教訓

  1. efficient tier のモデル選定は、パラメータ数やベンチマークスコアで決めない。 自分の実際のツールセットで finish_reason: "tool_calls" が安定して返るかを個別に実測する。ベンチマークの多くはツール数個規模で測られており、27ツール・19,000トークンという条件を反映していません。
  2. モデル比較の第一歩としては、同一ファミリー内でのスケールアップが変動要素を絞りやすい。 ファミリーをまたぐと、パラメータ数以外にチャットテンプレートの実装という変数が同時に動きます。Ollamaの場合、テンプレートがGGUFに埋め込まれたものか、ModelfileのGoテンプレートとして定義されたものか、その記述がどこまでツール呼び出しを想定しているかで挙動が変わり得ます。「同一ファミリーなら安全」という話ではなく、あくまで比較の際に動かす変数を減らせる、という程度の意味合いです。結局のところ、どのファミリーであれ実測は避けられません。
  3. ツールセットの絞り込みは補助的な緩和策と考えておく。 今回の試行回数では効果を確証できませんでしたが、使っていないツールを外すこと自体に実害はないので、試す価値はあります。削減量を見積もる際は、カテゴリ数ではなく実バイト数で見ること。
  4. エージェントの不可解な挙動は、まず生トラフィックを見る。 今回、Switchyardのログだけを見ていたら「モデル切り替えのバグ」という誤った結論のまま終わっていました。mitmproxyを挟むのに要した時間は10分ほどで、それで仮説が根本から覆りました。

9. まとめ

NeMo Switchyard v0.2.0 の機能検証としては、4つのTuning-Freeルーターすべてが、ドキュメント記載の設計通りに正確に動作することを実機で確認できました。特に stage_router の「単一シグナルでは動かず、裏付けが揃って初めて切り替わる」という慎重な設計や、escalation の latch後にjudgeをスキップする最適化は、実測値(confidence 0.613、30.5秒→2.4秒)を伴って確認でき、完成度の高さを感じました。Tunable Router(Prefill Router)は現行OSS版では見当たらず、今後のリリース待ちです。

一方で、それを実運用のエージェントに繋いだ瞬間に見えたのは、少なくとも今回のようなローカル構成では、ボトルネックが賢いルーティング機構よりも手前にあるという現実でした。どれだけルーターが正確にモデルを選んでも、選ばれた先のモデルが27個のツール定義を前にツール呼び出しの体裁を保てなければ、システムとしては成立しません。

Switchyardの「efficient tierで安く済ませる」という価値提案は魅力的ですが、ローカル環境でそれを享受するには、まず**「自分のツールセット規模で確実にtool callingが成立する最小モデル」を実測で見つける**という前提作業が要る、というのが今回得られた最大の学びでした。

当初の仮説がことごとく外れ、最後は「ツールを減らせば直る」という期待も部分的にしか成立しないと分かる、という結末になりましたが、生トラフィックを覗いたことで思い込みを何度も修正できました。今回も勉強になりました。


参考リンク

検証条件の注記

  • 各条件のトライアル数は3回程度と少なく、確率的に発生する事象の頻度比較としては統計的な厳密性を欠きます。本記事の「崩壊率」は傾向を示すものとしてお読みください。
  • 検証は2026年8月13〜14日、switchyard-server v0.2.0(pre-alpha)時点のものです。
  • Hermes Agentの設定では image_gen / computer_use も有効でしたが、実際のリクエストにツール定義としては現れませんでした(プロバイダ設定に依存すると思われます)。本記事の「27ツール」はキャプチャで実測した数です。
  1. NVIDIA-NeMo/Switchyard README

  2. LLM Classifier Routing - Judge model compatibility

0
0
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?