こちらの記事は「Medley Summer Tech Blog Relay」の7日目の記事です。
株式会社メドレーの人材プラットフォーム本部内、SREグループでエンジニアを務めている斎藤(@bbrfkr)です。
今回は最近LLMを使っている中で感じていたモヤモヤを改善するため、個人ではありますが実験的に開発しているAI Router「cobaiter」についてお話しします。
メドレーでは生成AI利用のガイドラインが社内で展開されており、各部門の業務ではそのガイドラインに沿って利用をしています。
モチベーション
LLMには、実際に支払う金銭的なコストと、応答が返ってくるまでの推論時間というコストの2つがあります。ですが、1つのLLMだけを使い続けていると、この両方をうまく最適化できているとは言い難い状況でした。簡単な質問にも高性能で高価なモデルを使ってしまったり、逆に難しい質問へ非力なモデルを当ててしまったりと、モデル選択が固定的であるがゆえの無駄が生まれます。実際、モデルの切り替えが面倒だから、いつの間にかClaudeのOpusにずっとリクエストを投げてたとかありがちなんですよね…
そこで少し調べてみたのですが、プロンプトの内容に応じて最適なモデルを選び出す「ルーティング」を行うプロジェクトは、探した範囲ではあまり見当たりませんでした。加えて、自分の使い方に合わせてルーティングの挙動を細かくチューニングしたいという目的もあったため、既存のツールを探すよりも自前で開発してしまおうと考えました。そして生まれたのがcobaiter(Context Based AI Router)です。ローカルLLMも使えればコミット & push & PR作成などはローカルで賄えるので、個人では現在、このルーターを活用中です!
設計とアーキテクチャ
cobaiterは、プロンプトやこれまでの会話コンテキストをもとに、与えられたモデルリストの中から回答に最適なモデルを選出します。最適かどうかの判断には、プロンプトおよびコンテキストを次の2つの観点で評価した連続値を使います。
- 質問がそのモデルの得意領域にどれだけ合致しているか(relevance)
- 回答の難易度(difficulty)
さらに各モデルには能力レベルを表す「tier」を設定し、tierが高いモデルほど難易度の高い問題に対応できる一方、応答速度は遅くなるという前提でペナルティを与えます。同様に、モデルの利用料金(cost)に応じてもペナルティを与え、性能と速度・コストのバランスを取った選出を行います。
また、1回の会話の中で回答に一貫性を持たせるため、一度あるモデルを選んだら、しばらくは同じモデルを使い続けるようにしています。ただし、会話が進むにつれてコンテキストが変化し、回答難易度や質問分類が変わった場合には、しきい値を基準にモデルを再選出できるようにもしています。
cobaiter自体はOpenAI API互換のプロキシとして動作し、あくまでモデル選出のみを担当します。実際に推論を行う有効なモデル群はLiteLLM側に持たせ、cobaiterはそのLiteLLMの手前に立つリバースプロキシとして動作する前提で設計しました。
システムコンポーネント図
シーケンス図
Get Started
cobaiterはOpenAI互換プロキシとして動作します。エージェント側はモデル名を意識せず、仮想モデルcobaiter-autoを呼ぶだけで動作します。
cobaiterは、OSSで提供されるLLMゲートウェイ「LiteLLM」のリバースプロキシとして動作します。そのため、利用前にLiteLLMを構築しておく必要があります。また、実際に呼び出すモデル群はLiteLLM側に設定しておく必要があります。cobaiterはモデル選出のみを担当し、モデル管理は行いません。
さらに、会話状態やモデルレジストリの永続化にはValkeyを使います。
起動はDocker Composeで行えます。
# .env と litellm_config.yaml を編集
cp .env.example .env
# valkey + litellm + cobaiterのセットを起動
docker compose up -d
# 正常起動確認
curl localhost:8000/healthz
モデルリストはコードにハードコードせず、models.yamlという外部ファイルで管理します。
models:
- model: bbrfkr-llm-general # LiteLLM が公開する実モデル名を指定
description: 汎用の対話・推論・文章作成向け
task_examples: # 代表的なタスク文(relevance判定はこちらを優先して使う)
- この文章を要約してほしい
- 旅行のプランについて相談したい
cost: 0 # 相対コスト(USD/Mtok目安、ローカルは0)
tier: 2 # 能力レベル(大きいほど高性能/低速)
context_window: 262144
multimodal: true
supports_tools: true
is_local: true
fallback_chain: # このモデルが使えなかった時にfallbackするモデル群
- bbrfkr-llm-general-no-think
ここで指定するdescriptionやtask_examplesは、あくまでそのモデルの「用途」だけを表す自由文です。difficultyやcost、回答スピードについてはここには含めません。これらは別の軸として評価しており、混ぜてしまうとembeddingによる分離が鈍ってしまうためです。
呼び出し方はOpenAI互換なので、既存のエージェント実装をほぼそのまま流用できます。
$ curl -s localhost:8080/v1/chat/completions -d '{"model":"cobaiter-auto","messages":[{"role":"user","content":"こんにちは。"}]}' | jq '.choices[0].message.content'
"こんにちは!何かお手伝いできることはありますか?"
細かいですが、レスポンスヘッダのx-cobaiter-routeを見れば、その回答が新規に選出されたものなのか、これまでのモデルを固定継続しているのか、文脈の変化による再選出なのか、あるいはフェイルオーバーによるものなのかが、わかるようになっています。
# 最初の1回目は新規会話なので、routeは「classifier-select」と新規選出になる
$ curl -s localhost:8080/v1/chat/completions -d '{"model":"cobaiter-auto","messages":[{"role":"user","content":"こんにちはー。"}]}' -v > /dev/null
...
< x-cobaiter-model: bbrfkr-llm-general-no-think
< x-cobaiter-route: classifier-select
< x-cobaiter-conversation: fp:5c2fce8a62713b9d4d5793c80d094ec2
...
# 同じプロンプトに対しては、routeは「pinned」になり、固定される
$ curl -s localhost:8080/v1/chat/completions -d '{"model":"cobaiter-auto","messages":[{"role":"user","content":"こんにちはー。"}]}' -v > /dev/null
...
< x-cobaiter-model: bbrfkr-llm-general-no-think
< x-cobaiter-route: pinned
< x-cobaiter-conversation: fp:5c2fce8a62713b9d4d5793c80d094ec2
...
# 会話が変わると、routeは「classifier-select」になり、再選出になる
$ curl -s localhost:8080/v1/chat/completions -d '{"model":"cobaiter-auto","messages":[{"role":"user","content":"今日の晩御飯はカレー。"}]}' -v > /dev/null
...
< x-cobaiter-model: bbrfkr-llm-general-no-think
< x-cobaiter-route: classifier-select
< x-cobaiter-conversation: fp:a5009cb274b64ee10e8f72c6b7381e4f
...
ルーティング簡易検証
ルーターの中身をおさらいすると、生成系LLMによる高度な分類を行うのではなく、relevanceとdifficultyという2つの軸だけで判断しているのがポイントです。
relevanceは、プロンプトから作られたダイジェストと各モデルのtask_examples(未設定ならdescription)とのembedding類似度(top-2-mean cosine)、つまり似た用語・文章かで算出します。
difficultyは、要約や翻訳、タイトル生成といったメタタスク語が含まれる場合は決定的に低難度と判断し、それ以外は「易しいタスク集合」「難しいタスク集合」との類似度比(relevanceと同様のアルゴリズム)から算出します。
この2軸(relevanceとdifficulty)は、
capability_fit = 1 − max(0, difficulty − tier/maxTier)
を求めることで、そのモデルがコンテキストに対してどの程度回答可能かを、0〜1の連続量で算出し、
suitability = relevance × capability_fit
を求めることでさらに、cost/tierのペナルティで再ランキングするという、決定的なコードによって評価されます。
これにより、embedding呼び出しは1リクエストあたり基本1回で済むため、1回のLLM呼び出しに占めるルーティングに必要なレイテンシは40-50ms程度に収まりました。
内部実装の確認として、以下のような質問を、cobaiter経由でAIに質問してみました。
- 簡単な挨拶
- 「こんにちは。」
- IT分野に関する質問
- 「jqコマンドにおいて、.model と .choices[].message.content を両方表示するクエリ教えてください」
- 数学的な質問
- 「ゲーデルの不完全性定理を証明してください」
- 物理学的な質問
- 「量子計算において、量子ビットを複製することができないことを証明してください」
cobaiterの背後のLiteLLMには以下のモデルが待ち構えている状態で検証してみます。
- bbrfkr-llm-general
- 一般質問回答用で、thinkingあり
- 裏側はローカルモデルで「gemma4 26B A4B」
- bbrfkr-llm-general-no-think
- 一般質問回答用で、thinkingなし
- 裏側はローカルモデルで「gemma4 26B A4B」
- bbrfkr-llm-coding
- コーディング質問回答用で、thinkingあり
- 裏側はローカルモデルで「Qwen 3.6 35B A3B」
- bbrfkr-llm-coding-no-think
- コーディング質問回答用で、thinkingなし
- 裏側はローカルモデルで「Qwen 3.6 35B A3B」
- gpt-5.5
- OpenAIでお馴染みのモデル
- claude-sonnet-5
- Claudeでお馴染みのモデル
結果は以下のようになりました。
$ curl -s localhost:8080/v1/chat/completions -d '{"model":"cobaiter-auto","messages":[{"role":"user","content":"こんにちは。"}]}' | jq '{ model: .model }'
{
"model": "bbrfkr-llm-general-no-think"
}
$ curl -s localhost:8080/v1/chat/completions -d '{"model":"cobaiter-auto","messages":[{"role":"user","content":"jqコマンドにおいて、.model と .choices[].message.content を両方表示するクエリ教えてください"}]}' | jq '{ model: .model }'
{
"model": "bbrfkr-llm-coding"
}
$ curl -s localhost:8080/v1/chat/completions -d '{"model":"cobaiter-auto","messages":[{"role":"user","content":"ゲーデルの不完全性定理を証明してください"}]}' | jq '{ model: .model }'
{
"model": "bbrfkr-llm-general"
}
$ curl -s localhost:8080/v1/chat/completions -d '{"model":"cobaiter-auto","messages":[{"role":"user","content":"量子計算において、量子ビットを複製することができないことを証明してください"}]}' | jq '{ model: .model }'
{
"model": "gpt-5.5"
}
簡単な挨拶には、ローカルモデルでもthinkingが必要ない「bbrfkr-llm-general-no-think」に流れ、クイックな回答が期待できます。jqコマンドの使い方については、一般的な質問というよりはIT分野の質問であるため、ローカルモデルかつコーディング回答用の「bbrfkr-llm-coding」に流れました。「ゲーデルの不完全性定理」のような既知の数学的な質問には、ローカルモデルでthinkingありの「bbrfkr-llm-general」が選出され、量子力学の質問のような専門的な物理学の質問には、強力なクラウドモデルである「gpt-5.5」が選出されていることがわかります。
概ね、期待どおりではないでしょうか。
また実装の確認として、いくつかの自動テストを用意しています。
- 事前定義したembedding空間を使う、外部通信なしのユニットテスト
- 実際のモデルを使うテストケース
tests/fixtures/routing_cases.yaml
詳細はテストケースを見ていただきたいのですが、実モデルを使うテストケースで確認できたこととして、「こんにちは」という一言は、ローカルの軽量汎用モデル(bbrfkr-llm-general-no-think)に着地し、コーディング系モデルへは流れません。一方、「このPythonコードのバグを直してください」「コードレビューをお願いします」「マイクロサービス間の通信方式で悩んでいる」といったタスクはコーディング系モデル(もしくは高tierの汎用モデル)に着地し、汎用の軽量モデルは避けられます。「英文を日本語に翻訳して」「京都旅行のプランを考えたい」であれば汎用モデルに着地し、コーディング系モデルは避けられます。
特に確認したかったのは境界ケースです。「このAPIの料金体系について教えてください」という質問には"API"というコーディング寄りの語が含まれますが、実際の意図(料金相談)に沿って汎用モデル側へ正しく着地します。また「マクロ経済政策が為替レートに与える影響について多角的に論じて」というタスクはdifficultyこそ高く出ますが、ドメインとしては汎用のままとなり、コーディング系モデルには流れません。
difficultyとrelevanceによるドメイン判定が、独立して機能していることが、こうしたケースから確認できました。これらの境界ケースをあえてテストに含めているのは、扱うドメインを今後増やしていく前提で、ドメインが増えてもrelevanceの分離が壊れないかを継続的に見張るためです。
まとめ
今回は「1つのLLMに固定して使い続けると、簡単なタスクにも高性能・高コストなモデルを充ててしまう」というモヤモヤから、会話の内容に応じてモデルを自動選出する自前ルーター cobaiter を作りました。
判定ロジックには生成系LLMを使わず、embeddingと決定的なヒューリスティックだけでrelevanceとdifficultyを算出しているため、レイテンシとコストを抑えつつ、自分の好みに合わせて細かくチューニングできます。また、1つの会話内ではモデルを固定し、文脈が実質的に変わったときだけヒステリシス付きで再選出することで、回答の一貫性と柔軟性を両立させました。モデルの用途やコスト、tierといった情報はすべて外部設定ファイルで管理しているため、コードを変更せずにチューニングできる点も気に入っています。
ちなみに残念なところは、cobaiterだとどうしてもLLM APIをコールする必要があるので、subscription契約で完結しないこと。ただ、LiteLLMには課金額制限をつけられるので、1か月で合計30ドルまでしか使えないように制御することでなんとかしています。 ![]()
今後の課題としては、対応ドメイン(description/task_examples)の拡充、フェイルオーバーやクレジット枯渇まわりの実運用検証などが残っています。コードは公開しているので、興味があれば触ってみてください。
採用情報
メドレーでは医療ヘルスケアの課題を解決するため、さまざまな領域のスペシャリストを探しています!
ご興味がありましたらぜひお声がけください。
8日目は @huku_developer さんです。お楽しみに!
