前回の宿題 ― 「唯一正解した3B」をどう使うか
第33回で、古典的な早口言葉「にわにはにわにわとりがいる」(正解=庭には二羽鶏がいる)をローカルLLM 11台に解かせた。数詞の「二羽」まで辿り着けたのは、容量でもアーキでもなく純国産フルスクラッチの sarashina2.2-3b(3B)ただ1台だった。8B の elyza-jp-8b(日本語“追加”学習)ですら二羽は 0%。しかも best-of の judge(gemma3:12b)は、誤答の qwen3:8b に満点を付け、唯一の正解 sarashina を 0.80 に落として順位を反転させた。judge 自身が「二羽」を知らないからだ。
その後の本番マルチターン観測で、この穴の性質がはっきりした。8B は正解を注入しても、ユーザーが明示訂正しても、最後まで「にわとり=鶏の一語」に潰し続けた。毎ターン「にわ=庭=てい(音読み)」のような事実として誤ったルールを新造して自分の誤読を正当化する。理屈を作る能力が誤読の温存に使われる。これは容量を増やしても、RAG で事実を供給しても埋まらない。埋めるには「その答えを知っているモデルへ routing する」しかない。日本語ネイティブ出自でしか解けない知識 ― 私はこれを B2 と呼んでいる ― の受け皿を作るのが、今回の宿題だった。
作る前に立ちはだかった2つの制約
「sarashina を worker として登録すればいい」だけの話ではなかった。2つの制約が設計を縛った。
制約1:3B を素朴に広告すると、8Bの主経路を奪う
この基盤のルーターは、実績のないモデルには DEFAULT_SCORE=0.3 を与え、速度重み WEIGHT_SPEED=0.4 で速いモデルを優遇する。Phase 1 で痛い目を見たのはここで、実績が付いた小型モデルは実績ゼロの8Bを構造的に押しのけて汎用ルートを奪う。だから小型を1つでも汎用プールに広告すると、汎用推論がそちらに吸われる。
sarashina は 3B。普通に sanity_models に載せて広告した瞬間、二羽以外の全ての一般質問まで弱い3Bに流れ始める。sarashina は絶対に汎用プール(select_best_worker_model の対象)に出してはいけない。
制約2:全部日本語のこの環境で「日本語ネイティブ知識」を自動判定すると誤爆する
「B2 かどうか」を自動分類したくなる。だが、この基盤の入力はほぼ全部日本語だ。「日本語ネイティブ出自でしか解けない問い」を機械判定しようとすると偽陽性の巣になり、一般的な日本語の技術質問まで弱い3Bへ誤送してしまう。それは8Bより品質が下がる。第35回で書いた web検索の「います⊃いま」部分文字列誤発火と、まったく同じ構造の罠だ。権威ドメインは狭い(早口言葉・語呂・難読漢字・古典など、学習データ出自が効く領域)。狭いものを広いネットで獲ろうとしてはいけない。
設計 ― 既にある RAG 経路の鏡写しにする
制約1の答えは、意外なところに既にあった。RAG worker だ。route_to_rag → tasks:rag という専用キュー → 独立した worker_rag.py が消費。この worker_rag.py は /worker/register を叩かない。汎用の worker レジストリにも select_best_worker_model にも一切入らない。”rag-worker” は DB の表示文字列にすぎない。これをそのまま鏡写しにすればいい。
そこで tasks:authority という専用キューを新設し、api 側に route_to_authority() を route_to_rag の鏡写しで足した。
def route_to_authority(req: RouteRequest, domain: str) -> dict:
"""task_type='authority' のとき tasks:authority へ積む(明示指定のみ・v6.9.5)。
web_search / rag と同じく単発・注入なし・reaper対象外。
実処理は .43 の独立 consumer worker_sarashina.py(models=[] で register)が行う。"""
task_id = str(uuid.uuid4())
# ... PG に sarashina-worker/authority で pending 行を作り ...
r.lpush(QUEUE_AUTHORITY, json.dumps({
"task_id": task_id,
"task_type": "authority",
"prompt": req.prompt,
"worker": "default",
}))
return {"task_id": task_id, "worker_id": "sarashina-worker",
"model": "authority", "domain": domain, "queue": QUEUE_AUTHORITY}
そして .43 に worker_sarashina.py という独立 consumer を立てた。worker_rag.py の流儀そのままで、汎用の worker_base は使わない(あれは models を汎用プールに広告してしまう)。肝は register の1行だ。
def register(r_conn):
payload = {
"worker_id": cfg["worker_id"], # "sarashina-worker"
"host": cfg["worker_host"],
"models": [], # ★制約1: 必ず空
# = select_best_worker_model が絶対に選ばない(汎用プール非汚染)
"supports": cfg["supports"], # ["authority"]
...
}
models=[] で登録するから、ルーターは sarashina を汎用推論の候補として構造的に選べない。3B が8B主経路を奪う心配が消える。sarashina に触れるのは tasks:authority 経由だけになる。
制約2の答え ― 自動分類はしない
偽陽性が怖いので、v1 では自動分類器を入れなかった。パイプライン擬似モデルと同じ「誤爆回避のため明示オプトインのみ」の型だ。ユーザーが「これは native 日本語の言語問題だ」と分かっているときだけ、OpenWebUI のモデルセレクタで専用の擬似モデルを選ぶ。route() 側の分岐はこうなっている。
if req.task_type == "authority":
if AUTHORITY_ENABLED:
return route_to_authority(req, domain)
# 無効時は通常扱いへフォールスルー(8B/カード/RAGで応答・機能OFFで質問を落とさない)
log.info("[route] authority requested but AUTHORITY_ENABLED=0 → normal routing")
req.task_type = None
フラグ AUTHORITY_ENABLED は既定OFF。OFF のときは req.task_type=None に正規化して通常ルーティングへ落とす ― 機能を切っても質問を落とさない。reaper(孤児タスク回収スレッド)の除外句にも 'authority' を足した。web_search / rag と同格の単発タスクで、これを忘れると reaper に刈られる。
実装の途中で踏んだ2つの穴
穴その1:モデルタグが 404 ― hf.co/mmnga/ が抜けていた
worker を立てる前に、sarashina を warmup しようとして 404 が返った。私が既定に入れていたタグ sarashina2.2-3b-instruct-v0.1-gguf:Q4_K_M が、実機に存在しないタグだったからだ。ollama list で確認すると、実タグは前半にリポジトリ名が付いていた。
$ ollama list | grep -i sarashina
hf.co/mmnga/sarashina2.2-3b-instruct-v0.1-gguf:Q4_K_M 4f06c3a02d13 2.1 GB
hf.co/mmnga/ というプレフィックス込みが正しいタグだった。GGUF 系はタグ表記が素直でないことがある、という第33回の pull 教訓が、今度は起動時に牙を剥いた。worker/plist の既定値を実タグに直して解決。
穴その2:同時 resident の計測を、間違った条件でやっていた
.43 は M1 16GB。8B×2 は載らないと Phase 1 で分かっていたが、8B+3B なら載るはず ― これを実測で確かめようとした。ところが片方を warmup するたびに ollama ps は1行しか出ない。Ollama が新しいモデルを載せる前に古い方をアンロードしていた。「16GBに入らないのか」と一瞬諦めかけた。
数字が矛盾を教えてくれた。qwen3:8b 単独が 10GB もある。おかしい。実機の8B worker は num_ctx 爆発対策(worker_base v6.4)で num_ctx=8192 に固定しているはずで、10GB は既定 32768 でロードしたときの値だ。私の手動 warmup が options.num_ctx を渡していなかったから、Ollama が既定の 32768 でロードして KVキャッシュごと膨れていた。本番の worker とは別条件を測っていたのだ。「計測してから信じる」を、私自身が誤った条件で計測していた。
num_ctx を渡して測り直すと、あっさり2行並んだ。
$ curl ... -d '{"model":"qwen3:8b","options":{"num_ctx":8192}}' ...
$ curl ... -d '{"model":"hf.co/mmnga/sarashina...","options":{"num_ctx":8192}}' ...
$ ollama ps
NAME SIZE PROCESSOR CONTEXT
hf.co/mmnga/sarashina2.2-3b... 4.0 GB 100% GPU 8192
qwen3:8b 6.2 GB 100% GPU 8192
6.2GB + 4.0GB = 10.2GB < 16GB。2行とも 100% GPU で同時 resident 成立。8B側はコード変更不要(既に8192固定)で、手当ては sarashina 側を num_ctx=4096 に固定するだけだった(権威タスクは短文なので4096で十分・footprintを最小化して8Bフロアを圧迫しない)。.43 のメモリは必ず num_ctx を渡して測る、という教訓が1つ増えた。
本番 green ― そして正直な効果測定
api にパッチを当て、AUTHORITY_ENABLED=1 を有効化し、.43 に worker を配備した。起動ログは期待どおり。
[worker_sarashina] Sarashina(authority) Worker starting.
[worker_sarashina] Redis OK / Ollama OK
[worker_sarashina] [register] worker_id=sarashina-worker models=[] supports=['authority']
[worker_sarashina] Waiting on queues ['tasks:authority'] ...
Coordinator 側から /workers を見ると sarashina-worker が models: [] で登録されている ― 制約1(汎用プール非汚染)が実機で裏取りできた。
そして二羽を投げた。ここは正直に書く。同じプロンプトを4回投げた分布はこうだった。
- 1回目:数詞到達。「庭には二羽の鶏がいます/2羽が存在する」。8B が正解注入でも一度も届かなかった数詞に、sarashina は素の1発で辿り着いた。狙いどおりの回。
- 2回目:「庭には二羽鶏がいる」に触れつつ主眼は「には の繰り返しで強調」=部分的。
- 3回目:「庭に鶏がいる」止まり。真ん中の「にわ(二羽)」を落とす縮約。
- 4回目:一見
response=None。だがこれは中身の問題ではなかった(後述)。
4回目の None は肝を冷やしたが、切り分けたら濡れ衣だった。worker のログは 531文字を done で書いており、PG を直接見ると確かに 531文字入っていた。私が回した検証ループの sleep 10 が、このタスクの生成11秒に間に合わず running を読んだだけ ― 障害ではなくタイミングだった。ここでも「worker が言うこと」と「API が返すもの」の食い違いを、PG という一次情報で潰した。
SELECT status, length(response) FROM tasks WHERE task_id='0e43...';
status | resp_len
--------+----------
done | 531
結論はこうだ。配管は完全に green。効果は限定的だが実在する。 8B が構造的に外し、正解注入でもマルチターン訂正でも一度も届かなかった数詞層に、sarashina は素の1発で当てる回がある。ただし当たりは3〜4回に1回で、聞き方にも左右される(「解釈してください」と聞くと解説モードに逃げる傾向があり、数詞に届いた1回目は素直な入り方だった)。過信できない駒だ ― でも、8B が能動的に間違える領域では、これが現状で最良の駒でもある。best-of も合意も、ユーザーの明示訂正すら救えなかった穴に、routing だけが橋を架けた。
最後に名前を変えた ― 「権威」は大げさだった
擬似モデルの表示名を当初「日本語権威 (sarashina)」にしていた。実際に使ってみると「権威」は偉そうで違和感がある、と本人(=私)が感じた。設計上の意味は「日本語ネイティブ知識の担当に振る」であって権力の話ではないので、看板を実態に寄せて「🎌 Coordinator → 日本語特化 (sarashina)」に変えた。
ここで大事なのは、変えたのは看板(pipes() の表示名)だけで、内部の task_type="authority" は据え置いたことだ。task_type は api 側と tasks:authority キューが握っている契約で、そこを変えると経路が全部ズレる。「看板は自由・配管の名前は固定」の切り分けは、こういう改名のときに効く。
まとめ
3B の国産モデルにしか解けない問いのために、汎用プールを一切汚さない専用の通り道を、既存の RAG 経路を鏡写しにして作った。models=[] の独立 consumer と、明示オプトインだけの発火。過大評価も過小評価もせず、当たりは4回に1回だと実測で正直に測った。「知識の非代替性」は、大きいモデルでも合意でも訂正でも埋まらない ― 埋めるのは、それを知っている駒への routing だけだ、という第33回の結論に、実装で決着をつけた回だった。