速度しか見ていなかったルーターに、回答の良し悪しを教えた ― LLM-as-judge と品質スコア

この記事では、ローカルLLMの回答をgemma3:12bがLLM-as-judgeで採点し、
スコアリングに品質評価を組み込む仕組みを解説します。
速度だけでなく「回答の良し悪し」でモデルを選びたい方向けです。

 

家庭内に組んだ分散AI推論クラスタの開発記、今回で12本目になります。前回は「使うときだけ Wake-on-LAN で起こして、終わったら自分で寝るマシン」を作りました。今回はその続きとして、(1) 寝ているマシンを OpenWebUI の画面から指名で起こせるようにし、(2) ルーターに今まで欠けていた「回答の品質を見る目」を入れた話です。とくに後半、gemma3:12b を審査員(LLM-as-judge)に仕立てて全モデルを採点させた結果が、自分の思い込みをいくつもひっくり返してくれて面白かったので、そこを厚めに書きます。

これまでのあらすじ

このクラスタは、複数のローカルマシン(CPU機・GPU機・常時起動の軽量機)に Ollama を載せ、Coordinator が「ドメイン分類 → モデル選択 → 適切なキューへ投入」を行う構成です。モデル選択のスコアは長らく「速さ(応答時間)」と「サンプル数(その組み合わせをどれだけ試したか)」だけで決めていました。

つまりルーターは「どのモデルが速いか」は知っていても、「どのモデルがその分野でいい答えを返すか」をまったく見ていなかった。これがずっと喉に刺さった小骨でした。

前半:寝ているマシンを画面から指名で起こす

前回、CPU機の moon は普段シャットダウンしておき、必要なときだけ Coordinator が Wake-on-LAN で起こす仕組みを入れました。ただ「起こすトリガー」は API を直接叩く必要があり、普段使いの OpenWebUI からは触れなかった。そこで OpenWebUI の Pipe Function を manifold 化し、モデルセレクタに2つ並べました。

  • 🧭 Coordinator (Auto Route) … 従来どおりの自動ルーティング
  • 🌙 Coordinator → moon … moon を指名。寝ていれば Coordinator が起こす

後者を選ぶと、ルーティング要求に worker:"moon" を添えて送るだけ。あとは Coordinator 側が WoL 送信とキュー投入をやってくれます。

route_payload = {"prompt": user_message}
if is_moon:                       # 🌙 を選んだとき
    route_payload["worker"] = "moon"
# /route は1回だけ呼ぶ。タスク投入はこの時点で完了している

悩んだのは起動待ちの「見せ方」です。moon の起動には実測でだいたい30〜50秒かかる。その間チャット画面が無言だと「固まった?」と不安になります。そこで、起動を待つ間 /workers を数秒おきに覗いて moon の登録を待ち、その様子を段階的にストリーム表示するようにしました。

🌙 moon を起こしています(Wake-on-LAN 送信済み)…
…まだ起動中(経過 32秒)
✅ moon の起動を確認しました。
⏳ 処理中…

ポイントは、タスク自体は最初の一回の要求で投入済みだということ。Coordinator は「起こせる猶予(wake_deadline)」付きで moon 専用キューに積んでくれているので、この待機表示は純粋に体験のためのもので、二重投入は起きません。実機では moon 停止状態から選択 → WoL → 約32秒で起動確認 → moon が回答、まできれいに流れました。

後半:ルーターに「品質の目」を入れる ― LLM-as-judge

方針:強いモデルに審査させる

品質を数値化する方法はいくつかありますが、今回は「手元でいちばん賢いモデルに採点させる」=LLM-as-judge を選びました。審査員役は、GPU機に載っている gemma3:12b。比較ベンチで各モデルが残した回答(comparison_results に貯まっている)を一つずつ読ませ、正確さ・有用性・完全性・日本語としての自然さの観点で1〜10点を付けさせ、0.0〜1.0 に正規化して保存します。

審査の指示はこんな具合です。出力を必ず JSON だけに絞らせるのがパース安定のコツでした。

あなたは厳格で公平なAI回答の評価者です。
以下のユーザー質問に対するAIの回答を、
(1)正確さ (2)有用性・質問への適合 (3)完全性
(4)日本語の質問なら日本語としての自然さ の観点で
総合評価し、1〜10の整数で採点してください。
出力は次のJSONのみ:
{"score": <1から10の整数>, "reason": "<30字程度の理由>"}

設計の2つの「やらない」

このバッチを作るうえで、最初から決めていた割り切りが2つあります。

① オンライン処理には絶対に入れない。 gemma3:12b は重く、ものによっては数分かかります。これをユーザー応答の経路に挟むと体験が崩壊する。なので採点は完全に非同期のバッチ(夜間 cron)として切り離しました。

② 採点は Coordinator の /route を通さない。 採点プロンプトには元の質問文がそのまま入ります。中に「最新の〜」みたいな語があると、ルーターの Web 検索自動判定が誤爆して検索ワーカーに飛びかねない。採点はユーザータスクではなくインフラ作業なので、分類を通さず審査員の Ollama を直接叩く設計にしました。

あわせて、冪等(未採点の行=quality_score IS NULL だけを対象にする)と増分(1回あたり上限件数)にしてあります。途中で止めても、再実行すれば残りだけを拾う。毎晩少しずつ消化して追いつく作りです。

SELECT cr.result_id, cr.model, cr.response, cs.domain, cs.prompt
FROM comparison_results cr
JOIN comparison_sessions cs USING (session_id)
WHERE cr.status = 'done'
  AND cr.quality_score IS NULL   -- 未採点だけ=冪等
ORDER BY cr.updated_at
LIMIT 60;                        -- 増分

つまずき:審査員に繋がらない

ここで一番手こずったのがネットワークでした。審査員の gemma3:12b は GPU機(WSL2 上の Ollama)にしかいません。採点バッチを動かす別マシンから叩くと、TCP は繋がるのに、リクエストを送った直後に Connection reset by peer で切られる。

症状の切り分け: refused なら誰も待ち受けていない。reset by peer は「入口(Windows の portproxy)までは届いたが、その先の転送に失敗している」サイン。今回は後者でした。

原因は WSL2 の宿命で、WSL2 の内部IPは再起動のたびに変わる。Windows 側の portproxy が古いIP宛に転送し続けていて、フロントの接続だけ成立してバックエンドで死んでいた、というオチでした。portproxy を現在のIPに貼り直し、WSL2 側の Ollama を LAN に公開(待ち受けを 0.0.0.0 に)したら、あっさり 200 が返るように。採点 cron が無言で失敗し得る箇所なので、いずれ「WSL起動時にIPを拾って portproxy を貼り直す」自動化を入れる予定です。

結果:思い込みがひっくり返る

配線が通ってから全量79件を流したところ、5分20秒(1件あたり約4秒)で完走。比較タスクの回答は短文が多く、心配していた「1件数分」は杞憂でした。これなら夜間 cron で余裕で回ります。

そして採点結果(quality は0〜1、ms は平均応答時間)。ドメインごとに、自分の予想とのズレが見えてきます。

code ドメイン:差がつかない

モデル quality 平均ms
qwen2.5:7b / gemma3:12b ほか 1.00
LFM2.5-1.2B-JP 0.94 6,838
llama3.2:3b 0.93 24,117
gemma3:1b 0.84 43,265

上位がほぼ満点で団子。比較タスクが「マージソートを実装して」系で、正直どのモデルも書けてしまうため差がつきません。ここでの学びはむしろ逆で、code では品質で選ぶ意味が薄く、速いモデルを選べばいい。1.2B の軽量モデルが0.94で6.8秒、というのが効いています(これを活かすにはベンチ課題の難易度を上げる必要がある、という宿題も見えました)。

general ドメイン:ここで「速い=良い」が崩れる

モデル quality 平均ms
gemma3:4b 0.90 15,700
gemma3:12b 0.83 175,808
LFM2.5-1.2B-JP 0.80 65,389
llama3.2:3b 0.53 33,802
llama3.2:1b 0.40 57,968

まさに quality_score を入れたかった領域です。llama3.2:3b は general で 0.53(平均34秒)、対して gemma3:4b は 0.90(平均16秒)。速くて、しかも品質も高い。これまでの速度オンリーのルーターでも gemma3:4b は選ばれ得ましたが、「品質という根拠」を持って選べるようになったのが大きい。事実誤認の多い回答に低い点が付く様子は、審査員がちゃんと仕事をしている証拠です。

japanese ドメイン:コスパ最強枠の裏取り

モデル quality 平均ms
gemma3:4b / gemma3:12b 0.95 5,795 / 30,570
gemma3:1b 0.86 1,943
LFM2.5-1.2B-JP 0.88 7,193
llama3.2:1b 0.42 4,647

gemma 系が強いのは順当として、注目は gemma3:1b が品質0.86で平均1.9秒。前々から「日本語で異常に速い」とは思っていましたが、品質も悪くないことが裏取りできました。1B でこの速さと質なら、軽い日本語タスクの第一候補にできます。

審査員のコメントが容赦ない

採点理由(reason)を眺めるのが地味に楽しい。審査員 gemma3:12b は事故をしっかり見抜きます。

とくに笑ったのが llama3.2:1b の japanese。「メールを丁寧に書き換えて」という依頼に対し、

メール添削依頼なのにドイツ語で回答。的外れな上、意味不明。 → 0.10

……たしかにドイツ語で返していました。速さ(4.6秒)だけ見ていたら、この事故は永遠に分からなかったわけです。

一方で、軽量な日本語特化モデル LFM2.5-1.2B-JP には「丁寧で内容は適切だが、汎用的なテンプレートに寄っている」と 0.80。短い挨拶寄りの回答を、過小でも過大でもなく評価できているあたり、審査員としての解像度は十分でした。

次回:このスコアをルーティングに組み込む

今回でデータは揃いました。残るはこのスコアを実際のモデル選択式に組み込むこと。速さ・キュー負荷・品質をどう配分するかが論点です。

ここで一つ気をつけたいのがコールドスタート。まだ採点されていない(=サンプルゼロの)モデルを品質0点扱いにすると、新しいモデルが永遠に選ばれなくなってしまう。なので未採点は「中立(0.5)」として扱い、探索の芽を潰さないようにします。

def calc_quality_score(stats):
    q = stats.get("quality_score")
    n = stats.get("quality_sample_count") or 0
    if q is None or n == 0:
        return 0.5     # 未採点は中立。新顔を締め出さない
    return float(q)    # 0.0〜1.0
今回入ったもの: ① OpenWebUI から moon を指名起動できる入口(WoLの起動待ちを進捗表示)/ ② gemma3:12b を審査員にした品質採点バッチ(非同期・冪等・増分)と、全モデル×3ドメインの品質スコア。
ルーターはこれでようやく「速さ」だけでなく「良し悪し」を語れる材料を手に入れました。次回、その材料を意思決定に落とし込みます。