前回の記事では、gemma3:12b を審判役にして各モデルの回答品質を採点する仕組み(LLM-as-judge)を実装した。ただその時点では採点データをルーティングに使っていなかった。今回はその「脳みそを繋ぐ」作業と、並行して気になっていた Web 検索の誤爆対策、そして「いつか壊れる前にやっておきたい」DB バックアップを一気に整えた。
品質スコアをルーティング式に組み込む
これまでのスコアリング式は3項だった。
speed × 0.4 + domain × 0.4 + queue × 0.2
speed は「速いほど良い」、domain は「このモデルでこのドメインの回答実績が多いほど良い」、queue は「今 Worker が暇なほど良い」という指標だ。品質スコアが加わったので4項に拡張した。
speed × 0.25 + domain × 0.15 + queue × 0.25 + quality × 0.35
重みの設計思想は以下の通り。
- quality を最大ウェイト(0.35)に。「良い回答を返すモデルを優先する」が今回の本命。
- domain を削減(0.4→0.15)。domain_score はサンプル数の対数で「実績があるモデルを優先する探索性」の役割だったが、quality がその役割を引き継ぐため小さくした。ただゼロにはしない。採点済みモデルに固定化しないよう探索性を残す。
- speed と queue を均等(各0.25)。速さと現在の負荷を同等に扱う。
実装変更は coordinator_api.py の3箇所のみ。
まず get_model_stats() の SELECT に quality カラムを追加する。
SELECT avg_ms, sample_count, quality_score, quality_sample_count
FROM model_domain_stats
WHERE model=%s AND domain=%s
次に未採点モデルのコールドスタート対策として calc_quality_score() を新規追加。
def calc_quality_score(stats: dict) -> float:
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) # DB側は 0.0–1.0 正規化済み
未採点モデルを 0.5(中立)にするのがポイントだ。0 にすると新しいモデルが永遠に選ばれなくなる。0.5 にしておけば、採点データが溜まるまでは「まあ平均くらい」として扱われ、実績が蓄積されれば自然に正しい位置に落ち着く。
最後に score_worker_model() に quality 項を追加する。
quality_score = calc_quality_score(stats)
total = (
WEIGHT_SPEED * speed_score
+ WEIGHT_DOMAIN * domain_score
+ WEIGHT_QUEUE * queue_score
+ WEIGHT_QUALITY * quality_score
)
/route/preview で確認する
デプロイ後、3ドメインで /route/preview を叩いて動作確認した。
general ドメイン(「Pythonの機械学習ライブラリを説明して」)では、旧式だと速いだけで選ばれていた llama3.2:3b(quality 0.53)が下位に沈み、gemma3:4b(quality 0.90)が上位に来た。これが今回の変更で一番嬉しい部分だ。「速いけど回答がイマイチ」なモデルを自動的に避けるようになった。
japanese ドメインでは gemma3:1b が上位に来た。前回の採点で「avg 1.9秒 / quality 0.86」というコスパ最強の結果が出ていたが、それがルーティングにも反映された形だ。
code ドメインは quality がほぼ均一(0.84〜1.00)なので速度支配が維持された。「コードは速さで選ぶ」という設計意図どおりで、変に崩れなくて良かった。
Web 検索の誤爆を直す
v5.2 で実装した Web 検索自動判定は「今日・最新・現在・天気」などのキーワードにヒットしたら自動的に Web 検索ルートに回す仕組みだ。便利なのだが、副作用があった。
- 「最新のソートアルゴリズムを実装して」→ コード要求なのに Web 検索へ
- 「現在の Python の仕様を学んだ」→ 勉強の振り返りなのに Web 検索へ
- 「現在の HTTP を説明して」→ 知識の説明依頼なのに Web 検索へ
対策はシンプルで、「ネガティブキーワード」リストを用意して、ポジティブキーワードにヒットしてもネガティブキーワードが含まれていれば Web 検索を抑制する。LLM を使わないゼロコストのフィルタだ。
WEB_SEARCH_NEGATIVE_KEYWORDS = [
# コード・実装コンテキスト
"実装", "コード", "プログラム", "スクリプト", "関数", "クラス",
"implement", "code", "coding", "program", "script",
# 学習・勉強コンテキスト
"学んだ", "勉強", "復習", "練習", "入門", "チュートリアル",
"learned", "study", "tutorial", "practice",
# 説明・解説依頼
"説明して", "解説して", "とは何", "とはなに",
"explain", "describe", "what is", "what's",
]
注意点として「教えて」は広すぎてネガティブキーワードには入れなかった。「最新ニュースを教えて」まで抑制してしまうためだ。ネガティブキーワードはコンテキストが明確なものだけに絞るのが肝要。
実装は classify_web_search() を以下のように更新するだけ。
def classify_web_search(prompt: str) -> bool:
prompt_lower = prompt.lower()
hit_kw = next((kw for kw in WEB_SEARCH_KEYWORDS if kw in prompt_lower), None)
if hit_kw is None:
return False
neg_kw = next((kw for kw in WEB_SEARCH_NEGATIVE_KEYWORDS if kw in prompt_lower), None)
if neg_kw is not None:
log.debug(f"[web_search_classify] positive={hit_kw!r} cancelled by negative={neg_kw!r}")
return False
log.debug(f"[web_search_classify] keyword={hit_kw!r} → web_search")
return True
DB バックアップを整える
comparison_results と model_domain_stats はこの基盤の「脳」だ。今や quality_score も含まれている。これを mars(Nextcloud サーバー)に毎日バックアップする仕組みを作った。
スクリプト本体(pg_backup.sh)はシンプルだ。
DATE=$(date +%Y%m%d_%H%M%S)
FILENAME="coordinator_${DATE}.sql.gz"
PGPASSWORD="${PGPASSWORD_VAL}" pg_dump \
-h "${DB_HOST}" -U "${DB_USER}" "${DB_NAME}" \
| gzip -9 \
> "${BACKUP_DIR}/${FILENAME}"
scp "${BACKUP_DIR}/${FILENAME}" "${MARS_USER}@${MARS_HOST}:${MARS_BACKUP_DIR}/"
# 7日超の古いファイルを削除(ローカル・リモート両方)
find "${BACKUP_DIR}" -name "coordinator_*.sql.gz" -mtime +7 -delete
ssh "${MARS_USER}@${MARS_HOST}" \
"find ${MARS_BACKUP_DIR} -name 'coordinator_*.sql.gz' -mtime +7 -delete"
AI-Core → mars の SSH 接続は公開鍵認証にしておく必要がある。cron はパスワードを入力できないためだ。設定は ssh-keygen で鍵を生成し、ssh-copy-id hogehoge@192.168.0.1 で mars に登録するだけ。
cron を整理する
今回の作業でAI-Core の crontab に2行追加した。
# quality採点バッチ(毎日 22:00 / rtx3070ti 起動中のみ実行)
0 22 * * * curl -sf http://192.168.0.196:11434/api/tags > /dev/null 2>&1 && \
JUDGE_OLLAMA_HOST=http://192.168.0.196:11434 JUDGE_MODEL=gemma3:12b BATCH_LIMIT=60 \
/home/hogehoge/coordinator/venv/bin/python3 /home/hogehoge/coordinator/quality_scorer.py \
>> /home/hogehoge/coordinator/quality_scorer.log 2>&1
# pg_dump バックアップ(毎日 02:00)
0 2 * * * /home/hogehoge/coordinator/pg_backup.sh >> /home/hogehoge/coordinator/pg_backup.log 2>&1
quality 採点は22:00にした。rtx3070ti(gemma3:12b が載っているGPUマシン)は普段使いのPCなので夜22時頃なら起きているが、深夜3時には落ちていることが多い。curl -sf で接続確認してから実行するガードを入れることで、rtx3070ti が停止中でも空振りで終わり、低品質な llama3.2:3b でのフォールバック採点が走らない。
バックアップは02:00。採点(22:00)のあとにバックアップが走るので、最新の quality_score が含まれた状態で保存される。
まとめ
今回で v6.2 の実装が一通り完成した。品質スコアを「採点するだけ」から「ルーティングに使う」までつなぎ、ついでに長年気になっていた Web 検索の誤爆と DB の無防備さも解消した。
現時点での Coordinator のスコアリングは以下のデータを統合して最適モデルを選ぶ。
- 過去の速度実績(avg_ms)
- 過去の回答品質(LLM-as-judge / 0.0〜1.0)
- 現在のWorker 負荷(CPU / GPU / VRAM / 処理中タスク数)
- そのモデルでのドメイン別実績量(探索性の担保)
「速度しか見ていなかった」状態から、かなり賢くなった。次は RAG 基盤に手をつけていく予定だ。