この記事では、ローカル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
ルーターはこれでようやく「速さ」だけでなく「良し悪し」を語れる材料を手に入れました。次回、その材料を意思決定に落とし込みます。