Skip to content

ステージルーター

ひとつのモデル id に、プロンプトのテキストではなく会話の直近の tool-result 履歴から、強力なティアと効率的なティアをターンごとに選ばせます。

Updated View as Markdown

[[models]] エントリは通常ひとつの宛先を指定します。ステージルーターは 2 つ(強力な ティアと効率的なティア)を指定し、どちらが各ターンを担当するかを会話に決めさせます。 エラー、行き詰まった調査、探索的な読み取りは上へ。計画が固まった後の定型作業は下に 留まります。

ステージルーターは、エントリの [models.router] テーブルが取りうる type のひとつです。 auto は同じルーターを上流のプリセットで動かすものです。ほかの type と、その選び方は ルーティングの概要にあります。

これはオプトインで、既定では何もしません。[models.router] テーブルを設定しなければ、 shunt はこれまでどおりリクエストの model id だけを見てルーティングします。

何を読むか

リクエストボディの構造化されたプロトコルメタデータだけです。

  • tool_use.name — アシスタントが呼び出したツール
  • tool_result.is_error — 結果が失敗として返ってきたかどうか

どちらも Anthropic Messages ワイヤフォーマットの中核フィールドです。ルーターはシステム プロンプトも、ユーザーのテキストも、ツールの引数も読みません。したがって、プロンプトの形状を フィンガープリントして Claude Code のリリースごとに壊れるプロキシの failure mode を引き継ぎ ません。

tool_result ブロックはツール名を持ちません。唯一の手掛かりは tool_use_id → tool_use.id であり、この結合が抽出器全体が立つ唯一の一点です。

shunt はリクエストの messages 配列を 2 回走査します。1 回目はアシスタント メッセージを recent_turn_window が届く範囲まで後ろから走査し、最近として数える tool_use の id を集めます。2 回目は両方のロールを前から 1 度だけ走査し、 tool_use.id → tool_use.name を作りつつ、すべての tool_result をそれが答えた tool_use_id に紐づけて記録します。名前は走査中ではなく走査後に解決するので、自分の 呼び出しより前に現れた結果もそのまま突き合わせられます。突き合わせに使う id を除けば、 読むのは上の 2 つのフィールドだけです。

完了したツール呼び出しが 1 つもなければ抽出は何も返さず、ターンはピッカーの デフォルトになります。空の会話が「efficient」と採点されることはありません。

ツールの語彙

スコアラーが知りたいのは、ある呼び出しが観測したのか、変更したのか、計画したのかです。 プロバイダー中立なルーターはそれを推論するしかありませんが、shunt は Claude Code の実際の ツール名を知っています。プロバイダー中立なルーターより優位に立てるのはまさにここで、次の 14 個を直接マッチします。

分類 ツール
観測(Observe) Read、Glob、Grep、NotebookRead、WebFetch、WebSearch、BashOutput
変更(Mutate) Edit、NotebookEdit、Write
計画(Plan) TodoWrite、Task、EnterPlanMode、ExitPlanMode
未分類 Bash、Skill、KillShell とすべての mcp__* ツールを含むその他すべて

未分類のまま残った名前は [models.router.tool_semantics] で自分で分類できます。4 つの リスト(observe、mutate、plan、new)が上の表の後に適用されます。表がすでに知って いる名前は付け替えられず、Read を mutate に入れるのは上書きではなく起動エラーです。 リストを編集すると、次のロードでそのルーターのセッションピンが破棄されます。

ExitPlanMode を含むすべての Plan 呼び出しは計画活動として数えられ、これは探索として 読まれるため、下ではなく上へ押します。

Bash は意図的に未分類です。git status は観測、rm -rf は変更、cargo test は落ち着いた 状態のシグナルですが、それらを分けるのは input.command だけです。コマンド文字列を分類する ことこそ、この設計が避けている脆い推論です。

深刻度

Claude Code は失敗を等級なしの真偽値でしか報告しないため、shunt 自身が libsy の 0.0 / 0.3 / 0.7 / 1.0 という深刻度スケールへ対応づけます。

条件 severity
ウィンドウ内に失敗結果なし 0.0
ウィンドウ内に失敗結果あり 0.7(hard)
ウィンドウ内の Task の失敗 — コマンド 1 つの失敗ではなく、委譲されたサブエージェント実行まるごとの崩壊 1.0(critical)
直近 2 件の完了結果がどちらも失敗し、どちらもウィンドウ内 — 回復できていないセッション 1.0(critical)

どう決めるか

シグナルの採点は NVIDIA-NeMo/Switchyard のスコアラーである switchyard-libsy が行います。キャリブレーション済みの部分なので、ここでは再実装 しません。その依存がどこから来たのか、そしてその裏付けとなる公開ベンチマーク結果は Switchyard 統合にあります。

ディメンション

シグナルは 2 つの軸に射影されます — 何かがおかしいか、そしてエージェントが 生産しているか。

ディメンション 意味 押す方向
severity ウィンドウ内の最大エラー深刻度、[0, 1] Capable
spinning 読み・計画・書き・編集のいずれもない深いターン — 純粋な空転 Capable
exploring 読むか計画するが、書きも編集もしない深いターン Capable
production_intensity 直近のツール操作に占める書き+編集の割合 Efficient

spinning と exploring はどちらも turn_depth >= 8 を要求します。書き込みのない 序盤のターンは停滞ではなく通常の探索だからです。両者は同じ「生産していない」状態を 調査活動の有無で分割するので、発火するのは高々 1 つであり、生産軸が二重計上される ことはありません。

スコア

最大に達したディメンション 1 つが 0.10 のシグナル単位 1 つを寄与し、severity は hard 上限の 0.7 で正規化されて同じ重みに揃えられます。

raw   = 0.10 × (severity / 0.7 + spinning + exploring − production_intensity)
score = tanh(5.0 × raw)          // 符号: + capable, − efficient
confidence = |score|

ゲイン 5.0 がレンジを使える幅に広げます。これがなければ信頼度は ±0.20 付近で 頭打ちになり、意味のあるしきい値には決して届きません。

設計は相互補強的です。単一の軸だけで判定を決め切ることはできません。

一致するシグナル raw confidence 0.5 のデフォルトを超えるか
1 つが飽和 0.10 tanh(0.5) ≈ 0.46 いいえ
2 つが相互補強 0.20 tanh(1.0) ≈ 0.76 はい

0.5 というデフォルトはここから来ています。誤ったシグナル 1 つだけではターンを 動かすのに足りず、一致するシグナル 2 つなら足ります。

判定

pick_tier は 3 つのルールを順に走らせ、最初に発火したものが勝ちます。

  1. ハード昇格 → capable。critical な深刻度、コンパクションされたコンテキスト、 または直近のウィンドウで同じ失敗が繰り返された場合。スコアラーを飛ばし、 confidence: 1.0 を報告します。
  2. スコアラー。 中央値まわりの曖昧な帯の外なら符号に従います。
  3. Fall-open。 帯の内側なら libsy が PickOutcome::ConsultClassifier { default_tier } を返し、ターンはピッカーの デフォルトティアに着地します。

fall-open するのは API と戦うことではなく、API が文書化した fall-open の経路そのもの です。shunt は既定ではジャッジを走らせません。 だからそのホットパスには、2 回目の モデル呼び出しも、2 つ目の資格情報も、ルーターがもっとも自信のないターンで増える レイテンシもありません。下のジャッジのフォールバックは、 そのターンだけをジャッジに委ねるためのオプトインです。

かつては 4 つ目がありました。テストが通った落ち着いたターンをハード降格する ルールですが、アップストリームが削除しました。テストの成功は今では libsy 自身の capable hold を解除するだけで、shunt はその hold を使いません。

ルール 1 の 3 つの引き金のうち、今日の shunt で発火するのは 2 つです。critical な 深刻度は深刻度の表の 2 経路を通じて発火し、コンパクションされたコンテキストは トランスクリプトではなくリクエストヘッダーである x-claude-code-context-compacted から 届きます — だからこそこの引き金は、ツール活動がまったくないターンでも発火できます。 繰り返し失敗側が休眠したままなのは、libsy が同じ失敗かどうかを結果テキストと自前の エラー表の照合で判定するためで、Claude Code が送るブール値の is_error ではそれを 支えられないからです。shunt 自身の「直近の完了結果 2 つがどちらも失敗」ルールは severity を経由してすでにルール 1 に届きます。

下りが上りより難しい理由

ここまではターンを単独で判定します。そのうえで shunt は、その判定がセッションの ピン留めされたティアを動かすことを許す前に、独自のゲートをかけます。ティアの切り替えは 無料ではなく、長い Claude Code セッションではそのコストが具体的だからです。

  • プロンプトキャッシュはモデルごとのキーです。切り替えると温まったプレフィックスを 捨て、新しいティアでフルのキャッシュ書き込みを支払います。
  • Codex の継続はモデルをシグネチャにハッシュするため、切り替えは入力全体の再送を強制し、 shunt が稼いだばかりのアップロード削減をそのまま返上します。
  • プロバイダーをまたぐ切り替えは、セッションのプールされた接続とスティッキーな アカウントスロットも捨てます。

毎ターン独立に採点すると、長いセッションはルーティングしないときより高くつき えます。2 つの方向を意図的に非対称にしているのはそのためであり、ヒステリシスが shunt の 統合における枝葉ではなく中核である理由でもあります。

方向 必要条件
効率的 → 強力 スコアラーが実際に下した判断が confidence_threshold(0.5)を超えること。安いティアで賄えないターンは、失われるキャッシュプレフィックスより高くつきます。
強力 → 効率的 スコアラーが実際に下した判断、かつ min_dwell_turns の経過、かつ deescalate_threshold(0.75)。

fall-open、曖昧なターン、ツール活動のないターンは picker の既定値であって証拠ではないため、 どちらの方向にもティアを動かせません。

信頼度ゲートに免除はありません。かつては tests_passed ショートカットのための免除が ありました。信頼度をまったく報告しないショートカットだったからです。そのルールが アップストリームから消えたことで、証拠として数えられるすべてのソースが信頼度を報告 するようになり、信頼度を報告しないターンは信用せずに保留されます。その後釜である libsy の capable_hold — 以前の昇格がまだ保持されているという意味 — も証拠では ありません。sticky のターンと同じ考え方であり、それを押すのは libsy のステートフルな 分類器だけで、shunt はそれを呼びません。

capable_hold_turns(既定値 0)は、シグナルがティアを押し上げたあと強力なティアをさらに 数ターン保持します。復旧そのものが静かで落ち着いたように読めてしまう場面で役に立ちます。 保持されたターンはルートソース capable_hold として報告され、これも証拠ではないのでピンを 動かせません。

引き継ぐモデルに理由を伝える

[models.router.handoff_notes] は、シグナルがティアを動かしたターンに限って、アップスト リームへ送るリクエストに短いシステムブロックを追加します。上げるときは escalation_note、 下げるときは deescalation_note を付けるので、到着したモデルは一から始めるのではなく引き 継いだのだと分かります。Claude Code の attribution ブロックは先頭のシステムブロックで、 触れません。ノートは末尾に入ります。

既定(only_on_wrong_signal_escalation = true)では、escalation_note はシグナル主導の 上昇、つまりルートソース override と dimensions に限られます。固定のまま続いたターン、 シグナルのないターン、count_tokens プローブにはノートが付きません。引き継ぎが起きて いないターン — セッションの最初のターンと、すでに固定されているティアを確認しただけの ターン — も同じです。

切り替えのたびにプロンプトキャッシュミスを 1 回払います。 system 配列がキャッシュ されたプレフィックスの一部だからです。切り替え自体がすでに手放すモデル別プレフィックスに 上乗せされるコストであり、だからこのテーブルは明示的に有効化するまでオフで、 only_on_wrong_signal_escalation の既定値も狭い側になっています。

セッションのピン留め

判断は (広告されたモデル id, セッション id, エージェント id) 単位でピン留めされ、2 つの id は x-claude-code-session-id と x-claude-code-agent-id から読まれます。どちらの id も SHA-256 のプレフィックスをキーにします — 2 つともハッシュされ、保存されません。同じセッションでルーター付きモデルを 2 つ使えば、ティアは 互いに独立します。

サブエージェントは自分専用のピンを持ちます。 Task の子は親のセッション id に自分の エージェント id を添えて送るため、独立したエントリーを読み書きします。子の失敗が親を エスカレートさせることはなくなり、子のターンが親の dwell に数えられることもなくなりました。 Claude Code はこのヘッダーをオプトインなしにすべての委譲ターンで送るので、デフォルトの shunt デプロイでもクライアントを変更せずにそのまま機能します。ストアは親セッション 4096 件と、それとは別枠の子ピン 4096 件でそれぞれ上限に達し、挿入パス上で追い出しを行う ためバックグラウンドタスクはありません。トリムは自分が増やしたスコープにしか届かないため、 幅の広いファンアウトがそれを待っている親を追い出すことはありません。

コンパクションはエスカレートし、その状態が保たれます。 Claude Code はコンテキスト コンパクション後の最初のターンにだけ x-claude-code-context-compacted を一度付けます。 shunt はそのターンを compacted として採点し、これはスコアラーのハードオーバーライドに達する ため、コンパクション直後の履歴に読むべきツール活動がまったくなくても強力なティアへ ルーティングされます。さらにそのフラグをセッションのピンにラッチするので、後続のターンも 同じ結論になります。このヘッダーは、クライアントが CLAUDE_CODE_GATEWAY_HINT_HEADERS=1 を設定したときにだけ shunt に届きます (クライアント環境変数を参照)。同じ変数は x-claude-code-request-class も送り、その場合は上のエージェント id の規則に代わって、 どのターンが委譲された作業かをこの値が決めます。

  • セッションヘッダーを送らない呼び出し元はステートレスにルーティングされ、そうした リクエストのコンパクションフラグはそのターンだけに効きます。
  • count_tokens のプローブは何も記録しません。Claude Code は 1 ターン遅れた履歴でそれを 送るため、記録するプローブは古い履歴にピンを動かさせてしまいます。セッションがすでに ピン留めされていれば、プローブと後続のターンは同じピンを読むので同じティアに届きます。 まだピン留めされていないセッションでは、プローブは自身の履歴で採点されるため、後続の ターンとは別のティアに届くことがあります。
  • ピンは設定リロードを生き延びます。ただしピンは自分が作られたルーターテーブルの フィンガープリントを覚えているので、あるルーターを編集するとそのルーターのピンだけが破棄 されます。そのフィンガープリントは設定構造体をドット参照ではなく分解束縛で読むため、 あとからキーが増えたときは、古いピンが黙って生き延びる代わりにコンパイルが壊れます。
  • ピンは session_ttl_seconds で失効します。再起動をまたいで永続化されるものはありません。 コンパクションのラッチはそれを保持するピンと一緒に解除されます — この失効時、または リロードがルーターテーブルを変えたときです。

委譲された作業は、このルーターから完全に離れることもできます。 [models.subagents] オーバーレイはルーターが走る前に子を振り分ける ので、その子がここで採点されたりピン留めされたりすることはありません。

クライアントから見えるもの

選ばれたティアはアップストリームにのみ伝わります。レスポンスはクライアントが要求した id を そのまま報告するので、Claude Code の /model 表示と --resume のモデル復元は動き続けます。

POST /v1/messages     {"model": "claude-auto", ...}
  → アップストリーム受信 {"model": "claude-opus-4-8", ...}
  → クライアント受信     {"model": "claude-auto", ...}

決定はレスポンスヘッダー 2 つで報告されるので、推測する必要はありません。

ヘッダー 値
x-gateway-routed-model ルーターが選んだターゲット(claude-opus-4-8)
x-gateway-route-source その理由(override、dimensions、sticky、no_signal、fall_open、capable_hold、llm-classifier)

7 つのうち 6 つはシグナルのラベルです。llm-classifier は、下の ジャッジのフォールバックを設定したエントリで、ジャッジが 決めたターンにだけ現れます。設定しなければ、決着しなかったターンは従来どおり fall_open として報告され、ジャッジは呼ばれません。スコアラーはもう 1 つ ambiguous というラベルを定義していますが、これはレスポンスには載りません。ほかの ルーターの type は自分のラベルを使い、それぞれの type のページに載っています(random、 random_session、noop、LLM classifier の classifier_* ラベルなど)。[models.subagents] オーバーレイも自分のラベル (by_type に一致したときは subagent_type、その target へのフォールバックなら subagent、llm_classifier 形式では classifier のラベル)を使います。

ルーターも [models.subagents] オーバーレイもそのターンを決めなかった場合に限り、両方とも付きません。そうすることで「効率ティアに ルーティングされた」と「そもそもルーター経由ではない」が区別できます。 count_tokens のプローブには付けません。まだピン留めされていないセッションでは、 プローブは 1 ターン遅れた自身の履歴で採点されるため、そのティアが測定対象のターンの ティアであるとは限らないからです。

GET /routes は設定済みのルーターを routers にすべて載せ、その algorithm と targets を、そしてこの type については 2 つのティアのターゲットと、シグナルが何も決めなかった ときに picker が戻るティアを示します。ジャッジを設定したルーターは、その id を judges としても示します。メトリクスを有効にすると 3 つのカウンターが出力 されます。すべてのルーター type を対象とする shunt.router.decisions と、 shunt.stage_router.decisions、shunt.stage_router.flips です。ジャッジを設定すると 4 つ目として shunt.router.judge_calls が加わります。ティア切り替えが高価な 事象であり、決定カウンターだけでは移ってきたばかりのセッションと元からそこにいた セッションを区別できないため、flip は別に数えます。

設定する

[[models]]
id = "claude-auto"                     # /model に出すには claude/anthropic で始める必要があります
display_name = "Auto (stage router)"

[models.router]
type = "stage_router"
capable_target = "claude-opus-4-8"
efficient_target = "claude-sonnet-4-6"
# picker = "efficient_first"
# confidence_threshold = 0.5
# recent_turn_window = 3
# min_dwell_turns = 3
# deescalate_threshold = 0.75
# session_ttl_seconds = 3600
# capable_hold_turns = 0

どちらのターゲットも通常の公開モデル id なので、それぞれが通常のルーティングラダーで解決 され、自分のフェイルオーバーチェーン、アカウントプール、アダプター、effort、 service_tier をそのまま保ちます。ターゲット自身がルーターである場合は起動時エラーなので、 解決は構造的に 1 ホップです。

すべてのキーは設定リファレンスに記載されています。意図的に 除外したものを含む設計記録は ADR-0004 にあり、[models.router] の判別子と残りのアルゴリズムについての記録は ADR-0005 にあります。

ジャッジのフォールバック

シグナルはほとんどのターンを決めますが、すべてではありません。任意の [models.router.classifier] テーブルは ジャッジ を指定します。シグナルで決着しなかったターンに限り、どちらの ティアが必要かを尋ねるモデルです。

[models.router.classifier]
target = "claude-haiku-4-5"    # 尋ねるだけで、提供はしません
base_threshold = 0.5           # タスクを効率ティアに留める p_solve の下限

このテーブルがなければ何も変わりません。決着しなかったターンは従来どおり picker の 既定値へ fall-open します。あれば、そのターンだけがジャッジに回ります。ピンがすでに ターンを押さえているセッションは判定されず、count_tokens のプローブも判定されず、 リクエストが認証を通る前にジャッジ呼び出しが行われることもありません。 classify_trigger = "user_turn" はさらに対象を絞り、直近のメッセージが人間のユーザー ターンであるターンだけにします。そのためツールの継続ターンはジャッジ呼び出しを払わず、 セッションのピンに乗ります。

ジャッジは通常の公開モデル id で、呼び出し元のものではなく 自分のルートの 資格情報で 実行されます。予約済みの x-shunt-* スロットと cookie、authorization、x-api-key、 anthropic-beta は呼び出しの前にすべて取り除かれます。passthrough のアップストリーム に解決されるジャッジのターゲットが起動エラーになるのはこのためです — passthrough は 呼び出し元の資格情報をそのまま転送するという意味ですが、いま取り除いたのがまさにその 資格情報なので、送るものが残りません。auth = "none" のターゲットは問題ありません。 このモードはそのエンドポイントが資格情報をまったく必要としないという意味なので、認証なしで 動くローカル・セルフホストのジャッジはエラーではなくサポートされた構成です。呼び出しはそのターゲット自身のアカウントプールのクォータを消費するので、 ジャッジには専用の [[models]] エントリを与えてください。

ジャッジに問うターンでは、認証の対象はエントリ全体です — 要求された id に 2 つのティアの ターゲットとジャッジを、それぞれのフェイルオーバーチェーン全体まで含めて対象にします。 そのため応答ターゲットがすべて passthrough のエントリに資格情報を注入するジャッジを 加えると、そうしたターンはクライアントの資格情報を要求するようになります。認証に失敗した リクエストやポリシーが拒否したリクエストは、ジャッジ呼び出しを 1 回も行いません。ジャッジ に届かないターンは、このテーブルが存在しなかったときと同じく、実際に解決されたチェーンだけ で認証されます — シグナルが自力で決めたターンと、 [models.subagents] オーバーレイがルーターより先に振り向けたターンです。

[models.router] の 6 つのキーがすべての内部呼び出しに上限を課します — judge_timeout_ms(既定 30000、ヘッダーとボディを覆うエンドツーエンド期限)、 judge_max_response_bytes(65536)、セッションごとの max_judge_calls(8。 ジャッジ呼び出しを送るときに差し引くので、同時に走るターンでも超えられません)、 そして 3 つの gated_* です。gated_* は escalation や advisor のエントリが 留め置くターンにだけ上限を課すので、ステージルーターでは何も制限しません。上限を超えるとアップストリーム呼び出しはキャンセルされます。

ジャッジが決めたターンは x-gateway-route-source: llm-classifier として報告され、他の 決定と同じようにセッションをピン留めします。タイムアウト、サイズ超過や解釈できない応答、 アップストリームエラー、予算切れ — すべての失敗は fall_open に解決されるので、ジャッジ が落ちていてもコストはレイテンシであって可用性ではありません。各呼び出しは決着した結果 とともに shunt.router.judge_calls で数えられます。

制限

  • ターン途中のエスカレーションはありません。 レスポンスヘッダーがコミットされた後、 shunt はターンを再実行しないため、ターン開始時に選ばれたティアがそのターン全体を担当 します。
  • このジャッジが決めるのはティアだけです。 ジャッジを使うほかの type はそれぞれ自分の テーブルを持ちます。llm_classifier、 composite、advisor は [models.router] に直接設定します。判定が出るまでターンを留め置くのは llm_classifier の mode = "escalation" と advisor だけで、 このジャッジはそうしません。
  • [server.codex_endpoint] は対象外です。独自のルーティングテーブルを使います。
Navigation

Type to search…

↑↓ navigate↵ selectEsc close