ホームラボ分散AI推論基盤「Coordinator」シリーズ第20回。今回はMacBook Air M1に日本語特化のELYZA-JP-8Bと思考型のQwen3:8bを増強した記録――のはずだったのですが、増強の検証ベンチがesxiノードで2晩前から続いていた隠れ障害を炙り出すという展開になりました。
この記事で分かること:
- hf.co直接pullしたGGUFモデルが日本語の質問に英語で回答する原因と、OllamaのModelfileによる解決方法
- Qwen3のthinkingモードはOllama APIでどう返ってくるのか(実機確認の結果)
- GPU非搭載扱いのワーカーに7〜8Bモデルを即接続できる設計上の抜け道
- systemdの旧unit残存による二重Worker障害――登録もハートビートも正常に見えるのに、ベンチだけが2倍遅くなる――の切り分け手順
発端: 「1Bモデルばかりで回答に変化がない」
前回M1 MacBook Air(16GB)をワーカーとしてクラスタに参加させた際、登録モデルはllama3.2:3bの1つだけでした。当初は他の常時起動ノードと同じ1Bクラス(LFM2.5-1.2B-JP、llama3.2:1b、gemma3:1b)を追加する計画だったのですが、ここで立ち止まりました。
1Bクラスはai-coreとesxiですでに飽和しています。夜間ベンチは毎晩同じ顔ぶれの小型モデルが似た回答を返すだけで、品質採点もcodeドメインはほぼ1.00に張り付き。同じものを増やしても冗長なだけで、面白みがない。
そこで方針転換です。M1はgemma3:12bを7.68 tok/sで回せる程度のMetal性能があるので、「常時起動の中型モデル枠(7〜8Bクラス)」としてモデルファミリーを散らすことにしました。候補は日本語特化のELYZA(Llama-3-ELYZA-JP-8B)と、思考型のQwen3:8b。既存の小型モデル群とは回答の毛色が明確に違う2つです。
GPU_MODELS外を選ぶと即接続できる
うちのCoordinatorはモデル名でキューの振り分け先を決めています。
GPU_MODELS = {"gemma3:12b", "qwen2.5:7b", "phi4-mini:3.8b", "gemma3:4b"}
def task_tier(model):
return "gpu" if model in GPU_MODELS else "cpu"
このセットに入っていないモデルは自動的にcpu tier扱いになり、cpu_inference能力しか持たないM1ワーカーにもそのまま流せます。つまりGPU_MODELS外の7〜8Bを選べば、ルーティング設計を一切いじらずに即日接続できる。ELYZAもQwen3もセット外なので、この抜け道がそのまま使えました。
ELYZAが英語で回答してくる
pullと実測は順調でした。
ollama pull hf.co/elyza/Llama-3-ELYZA-JP-8B-GGUF:Q4_K_M # 4.9GB
ollama pull qwen3:8b # 5.2GB
# M1での実測
# ELYZA-JP-8B: 12.27 tok/s
# qwen3:8b: 11.97 tok/s
ところがワーカー経由の一気通貫テストで事故が起きます。「敬語で自己紹介の例文を一つ作ってください。」という日本語の依頼に対して、ELYZAが全文英語で回答してきたのです。日本語特化モデルとしては本末転倒です。
原因はモデルの取得経路にありました。ELYZA-JP-8BはLlama-3ベースで、公式の推奨利用方法は日本語のシステムプロンプト(「あなたは誠実で優秀な日本人のアシスタントです。」)が前提です。ところがhf.co経由でGGUFを直接pullするとこのSYSTEMが付いてこないため、素のLlama-3の癖で英語に寄ってしまう。うちのワーカーは/api/generateにpromptしか渡さない設計なので、対処はモデル側にSYSTEMを焼き込むのが正解です。
cat > ~/coordinator/modelfiles/elyza-jp-8b.Modelfile << 'EOF'
FROM hf.co/elyza/Llama-3-ELYZA-JP-8B-GGUF:Q4_K_M
SYSTEM """あなたは誠実で優秀な日本人のアシスタントです。特に指示が無い場合は、常に日本語で回答してください。"""
EOF
ollama create elyza-jp-8b -f ~/coordinator/modelfiles/elyza-jp-8b.Modelfile
FROMは既存blobを共有するのでディスクは増えません。これで敬語の例文が綺麗な日本語で返るようになりました。
副次的ですが重要なポイントがもう一つ。hf.co/elyza/Llama-3-ELYZA-JP-8B-GGUF:Q4_K_Mという長い名前をelyza-jp-8bという短縮名に整理できたことです。うちの統計テーブル(model_domain_stats)はモデル名がキーなので、統計が貯まってから名前を変えると過去実績と分裂します。貯まる前のこのタイミングで確定させるのが肝心でした。
Qwen3のthinkingはAPIでどう返るか
Qwen3には回答前に思考トレースを生成するthinkingモードがあり、Ollamaでは既定で有効です。「1+1は?」に対してeval count 337トークン・28秒という挙動を見て、思考部分が回答に混入しないか不安になったので、ワーカーと同じ叩き方で確認しました。
curl -s http://localhost:11434/api/generate \
-d '{"model":"qwen3:8b","prompt":"1+1は?","stream":false}'
# → thinkingフィールド有無: True
# → response: 「1+1は2です。…」(思考トレースの混入なし)
結果、thinkingは別フィールドに分離され、responseはクリーンな回答のみ。ワーカー無改修で投入できます。思考時間はduration_msに乗りますが、それは「Qwen3は考える分だけ遅いが質はどうか」という特性としてベンチにそのまま現れるので、むしろ好都合です。
crontab v2: フォールバック判事と6モデルベンチ
モデル構成が確定したところでcrontabを更新しました。ポイントは2つです。
1つ目はquality採点のフォールバック判事。うちのLLM-as-judge採点は毎晩22:00にRTX 3070 Ti上のgemma3:12bで行いますが、RTXマシンは常時起動ではないため、寝ている晩は採点が止まっていました。M1にもgemma3:12bがpull済み(7.68 tok/s)で、採点スクリプトはルーティングを通さずOllamaを直叩きする設計なので、未登録のまま判事として使えます。RTXが寝ていてM1が起きている時だけ動く3段条件で03:00に仕込みました。
# rtx停止中かつmacbookair稼働中のみ・同じgemma3:12bなので採点基準は一貫
0 3 * * * ! curl -sf http://192.168.0.196:11434/api/tags > /dev/null 2>&1 && curl -sf http://192.168.0.43:11434/api/tags > /dev/null 2>&1 && JUDGE_OLLAMA_HOST=http://192.168.0.43:11434 JUDGE_MODEL=gemma3:12b BATCH_LIMIT=20 venv/bin/python3 quality_scorer.py >> quality_scorer.log 2>&1
cron既定のdashでも!はPOSIX準拠で動きます。M1は7.68 tok/sと遅いのでBATCH_LIMITは控えめの20に。
2つ目は夜間ベンチへの新モデル組み込み。ELYZAとQwen3はM1しか保持していないので、ベンチに含めること自体が毎晩のスモークテストを兼ねます。日本語ドメインでは「elyza-jp-8b vs LFM2.5-1.2B-JP」という日本語特化モデル対決が毎晩観測できるようになりました。
検証ベンチが異常値を吐いた
ここまでが本来の作業です。仕上げに6モデルベンチを手動で1回流したところ、想定外の数字が出ました。
done 9005ms LFM2.5-1.2B-JP (ai-core)
done 28393ms elyza-jp-8b (macbookair) ← 想定通り
done 58217ms qwen3:8b (macbookair) ← thinking込みで想定内
done 83598ms llama3.2:3b (ai-core)
done 276106ms llama3.2:1b (esxi) ← !?
error - gemma3:1b (esxi) ← Read timed out (300秒)
新モデル2つは完璧。問題はesxi担当の1bクラスです。普段なら数秒〜十数秒で終わるはずのllama3.2:1bが276秒、gemma3:1bに至ってはワーカーの300秒タイムアウトでerror。1Bモデルがこの遅さになるのは「遅い」ではなく性能崩壊です。
ところがesxiにログインしてみると、load averageは0.00、メモリも余裕、ollama psも空。一見何も起きていない。唯一の異変はps auxにありました。
hogehoge 2599 ... /home/hogehoge/venv/bin/python worker.py ← Jun07から残留
hogehoge 2098 ... /home/hogehoge/venv/bin/python worker.py ← Jun07から残留
hogehoge 6477 ... /home/hogehoge/venv/bin/python worker.py ← systemd正規
worker.pyが3重に起動していました。3プロセスとも同じworker_idで同じRedisキューをBRPOPするため、ベンチの2タスクが同時に別プロセスへ渡り、非力なi5-8265UのvCPU上で並行生成して相互にCPUを食い合っていた、というわけです。野良2匹をkillして一件落着――
killしても蘇る
――のはずが、再走ベンチでまた2タスクが同時にrunningになりました。うちのワーカーはBRPOPで1タスクずつ取る逐次処理なので、単独プロセスなら2タスク同時runningは構造上あり得ません。消費者がまだ2匹いる。
ここからの切り分けはRedisに聞くのが最短でした。BRPOPでブロック中のクライアントは全員CLIENT LISTに写ります。
redis-cli CLIENT LIST | grep brpop
# → esxi(192.168.0.41)からの接続が2本。しかも同秒誕生(age=1452)
esxi側で接続の持ち主を特定すると――
sudo ss -tnp | grep 6379
# → pid=14710, pid=14711 ← killした2098/2599でも正規の6477でもない新PID!
killしたはずのプロセスが消え、別のPIDが2本、同時刻に生まれている。つまり何かが自動的にプロセスを再生産しています。systemd unitの全数調査で真相が判明しました。
systemctl list-units --all | grep -iE "worker|coordin"
coordinator-worker.service loaded active running ← 正規(6/7のsystemd化で作成)
ollama-worker.service loaded active running ← 初期構築時の旧unitが残存!
systemctl cat ollama-worker.service coordinator-worker.service | grep -E "ExecStart|Restart"
ExecStart=/home/hogehoge/venv/bin/python /home/hogehoge/worker.py # 両方まったく同じ
Restart=always # 両方とも
同じworker.pyを起動するunitが2つ、両方Restart=alwaysで共存していました。esxiをsystemd化した日に新unitを作った際、初期構築時の旧unit(moonと同じ命名のollama-worker.service)の無効化が漏れていたのです。プロセスをkillしてもsystemdが2系統とも即座に蘇生させる。最初に見つけた「Jun07からの残骸」も手動起動の残骸ではなく、旧unitのMain PIDだった――killしたから別PIDで復活した――と考えると全部辻褄が合います。
根治は一行です。
sudo systemctl disable --now ollama-worker.service
sudo systemctl restart coordinator-worker.service
# → プロセス1本・BRPOP接続1本に。Ollama直叩きも2.8秒で即答
再走ベンチはllama3.2:1bが19秒、gemma3:1bが34秒(コールドロード込み)。276秒の悪夢から平常運転に戻りました。
統計は2晩分汚染されていた
後日談として、夜間ベンチの過去実績を確認すると、esxi系1bクラスの平均は131〜140秒。ベンチ導入以降の2晩はずっと二重Worker状態で走っていたことになり、クリーンな値は一度も記録されていませんでした。うちのルーティングは平均durationをスコアに使うため、汚染レコード(error残骸含め計24件)はDELETEして再蓄積することにしました。根治後の19秒/34秒が、事実上の初の正常ベースラインです。
教訓: Reaperの死角・第2号
うちのクラスタには死んだワーカーや固まったタスクを自動再投入するReaperがいて、以前の記事では「登録済みなのにBRPOPしていないゴーストWorker」という死角を塞ぎました。今回はその逆です。
- ghost_pending: BRPOPする者がいない → v6.3で検出可能に
- 今回: BRPOPする者が多すぎる → 登録・ハートビート・タスク処理がすべて「正常に見える」ため、既存のどのトリガーでも検出不可能
多重起動は障害として沈黙します。タスクは(遅いながら)doneになり、エラーも出ない。今回見つかったのは「新モデルのベンチが想定より遅い」という性能の違和感からでした。機能追加の検証が思わぬ障害を発見する――運用あるあるですが、検証ベンチには障害検知器としての価値があると実感した一件です。
運用チェックリストには次の項目を追加しました。
- Workerノード追加・unit改名時は
systemctl list-units --all | grep -iE "worker"で同一スクリプトを起動するunitが1つだけであることを確認する - 「2タスク同時running」は逐次処理ワーカーでは構造的にあり得ない → 消費者多重のシグナルとして
redis-cli CLIENT LIST | grep brpopで接続元を数える - hf.co直接pullのモデルは公式推奨SYSTEMの有無を確認し、必要ならModelfileで焼き込む。短縮名は統計が貯まる前に確定させる
まとめ
M1 MacBook Airは「常時起動の中型モデル枠」として、日本語特化のelyza-jp-8b(12.3 tok/s)と思考型のqwen3:8b(12.0 tok/s)を獲得しました。RTXが寝ている夜はgemma3:12bフォールバック判事としても働きます。そして増強の検証ベンチが、2晩沈黙していたesxiの二重Worker障害を炙り出し、Reaperの新しい死角が1つ記録されました。
今晩からの夜間ベンチでは「elyza-jp-8b vs LFM2.5-1.2B-JP」の日本語対決が毎晩観測されます。品質スコアが貯まったら結果はまた記事にします。
シリーズの過去記事は全記事まとめからどうぞ。