見出し画像

検出は当たっていたのに起動できなかった — CodeRouter v2.7.6 の MTP 対応は「テスト段階」だと思って使ってほしい話

TL;DR: v2.7.6 で Launcher に MTP (Multi-Token Prediction) / speculative decoding 対応を入れました。核心の設計は「メインの gguf と同じフォルダに置いてあれば勝手に使う」── `mtp_mode="auto"` で ①本体 gguf に nextn 層が内蔵されていれば `--spec-type draft-mtp`(別ファイル不要)→ ②なければ同フォルダから companion gguf を自動検出 → ③なければ通常起動、の 3 段フォールバックです。ところが実機で学んだのは別のことでした ── 検出が正しくても、起動できるとは限らない。Huihui-Ornith(nextn=1)は 12 秒でロードできたのに、Gemmable4-12b-mtp(nextn=4)は検出は完璧に動いたのに `llama-server` が SIGSEGV。ビルドを変えてもフラグを変えても落ちる、upstream 側の arch 対応がまだ揃っていない領域でした。そこで PR #60 で「auto の spec 起動が 180 秒以内に異常終了したら 1 回だけ spec なしで再起動」する自動フォールバックを追加して v2.7.6 として出荷。リリース自体は通常どおりですが、この記事でははっきり言っておきます ── MTP 対応は「テスト段階」だと思って使ってください。llama.cpp のアーキ別成熟度がバラつく現状で launcher にできる最善は「効く環境では効かせ、壊れる環境では壊さない」だ、という整理の話です。


あらすじ — 26 話目です

連作のタイムスタンプ。初コミットは 2026-04-19、前話 (v2.7.5) から間もなく。本記事の作業日は 2026-07-09、初コミットから 82 日目です。調査からリリースまで、今回も 1 日で終わりました。

この連作の通低音はずっと「机上で動くはず → 実機が裏切る → 直す」です。今回もその通りでした。ただし今回裏切ったのは自分のコードではなく、upstream (llama.cpp) の、アーキテクチャによってまだ揃っていない成熟度でした。


v2.7.6 で出したもの — Launcher の MTP / speculative decoding 対応

CodeRouter の Launcher は、llama.cpp や vllm の長い起動コマンドを画面操作で組み立てる道具です(第 15〜16 話)。今回はここに speculative decoding、とくに MTP (Multi-Token Prediction) を通す配線を足しました。

speculative decoding は、小さく速い「下書き (draft)」でトークンを数個先読みし、本体モデルで一括検証して当たった分をまとめて確定する高速化手法です。llama.cpp はこれを `--spec-type` で切り替えます。

先読みするトークン数は `--spec-draft-n-max`(既定値 3)で決まり、MTP では 2〜3 が推奨とされています。llama.cpp の PR #22673 では、3 draft トークンで採択率およそ 75%、デコードが2 倍超に速くなったという報告があります(計測は CUDA が中心で、バックエンドごとの成熟度にはばらつきがあります)。ローカル LLM は生成が遅いのが最大の弱点なので、効けば体感がはっきり変わる領域です。

──とはいえ、これを Launcher に通すのは「フラグを 1 個増やす」だけの話ではありませんでした。むしろ調べるほど、配布物の世界がカオスだと分かってきた。


背景 — 調査エージェントが持ち帰った「nextn 内蔵」という鍵

今回もまず、司令塔 (Fable 5) の下に Opus の調査エージェントを 2 体並列で走らせました。1 体は「llama.cpp の MTP / speculative decoding 仕様」を、もう 1 体は「CodeRouter 側の既存 launcher コード構造」を読ませる。ファイルを大量に読む仕事はサブエージェントに消化させ、主枠には結論だけ返す ── 第 22 話で作った体制の、今回は日常運用版です。

仕様調査が持ち帰ったいちばん重要な発見はこれでした。

nextn (MTP) 層が本体 gguf に内蔵されていれば、別ファイルは要らない。`--spec-type draft-mtp` だけでいい。

検出も明快で、GGUF メタデータの `{arch}.nextn_predict_layers > 0` を見るだけ(実体は `blk.N.nextn.*` という専用テンソル群)。たとえば GLM-4 MoE なら `glm4moe.nextn_predict_layers` が入っている。これが 0 より大きければ「このモデルは自前で draft ヘッドを持っている」と分かる。

当初、自分が立てていた課題設定はこうでした ── 「インデックス用の小さい draft gguf があれば指定、なければどうする?」。頭の中では「本体モデル + 別の draft モデル」という 2 ファイル前提だった。ところが調査の結論は「そもそも別ファイルが要らないケースがある」で、前提が 1 個ずれていた。

さらに厄介なのは、世の中の配布物が混在していることでした。

  • 本体 gguf に nextn を内蔵した変種(`-mtp.gguf` のような名前のことが多い)

  • MTP ヘッドだけ抽出した別 gguf

  • 昔ながらの小型 draft モデル

どれが来ても破綻しない設計にしないと、Launcher の「起動ボタン 1 つ」という約束が守れない。おまけに既知の地雷もありました ── nextn 内蔵モデル + `--split-mode tensor` はクラッシュする(llama.cpp issue #24309)。検出できても踏んではいけない組み合わせがある、ということです。Launcher はこの組み合わせを検出すると警告ログを出しますが、起動そのものはブロックしません ── 判断は最終的にユーザーに委ねます。


設計 — 「同じフォルダに置いてあれば使う」を既定にする

仕様が見えたので、ここからは設計判断です。この判断だけは人間(筆者)がやりました。決めたのは `mtp_mode="auto"`(既定)の 3 段判定です。

  1. 本体 gguf の nextn を検出 → あれば `--spec-type draft-mtp`。別 gguf は要らない。

  2. なければ、メイン gguf と同じフォルダから companion gguf を自動検出。判定条件は「名前に `mtp` / `draft` を含む、または quant・shard サフィックスを除いた名前プレフィックスをメインと共有する」「サイズがメインの 50% 未満」「arch が読み取れる場合はメインと一致」。見つかれば内部で `--spec-type draft-simple` と `--model-draft <companion のパス>` を組み立てます。

  3. どちらもなければ通常起動して、ログに一言だけ残す。

要は「メインモデルと同じフォルダに draft を置いておけば、あとは勝手にやる」。ユーザーに一番負担が少ない運用はこれだ、という判断です。パスを毎回コピペさせるのではなく、置き場所の慣習に寄せる。

もちろん逃げ道も用意しました。

  • 明示指定: `draft_model_path` で直接パスを渡せる。

  • 無効化: `mtp_mode="off"`。しかもこの off は旧コマンドとバイト一致にしました。後方互換 ── 今まで通りに起動したい人の生成コマンドが 1 バイトも変わらないことを、テストで固定しています。

  • ユーザーが `--spec-type` を自分で書いたら、自動検出は全面的に譲る。手動運用に割り込まない。

そしてガードも既存方針を踏襲しました。draft モデルを指定する `--model-draft`(エイリアス `-md` / `--spec-draft-model`)は、3 通りの綴りすべてを extra_args から拒否します。これは既存の `-m`(モデル指定)ガードと同じ考え方で、専用フィールドが唯一の経路という原則。自由記述欄と専用フィールドで二重に draft を指定できると、どちらが勝つかで事故る。だから塞ぐ。

実装で 1 個、罠がありました。共有ロジックを新規ファイル `coderouter/launcher_speculative.py`(`resolve_speculative` / `find_draft_companion`)に切り出したのですが、これを Web launcher と デスクトップ GUI の両方から使う必要があった。この 2 つの launcher は、なんと `_build_cmd` が別実装だったのです(第 15 話の 2 形態がそのまま別コードとして残っていた)。片方だけ直すと、もう片方で MTP が効かない ── 共通化しておかないと確実に将来ズレる場所でした。

実装は Opus に任せ、日英ドキュメントは Sonnet が書き、成果は PR #59(13 ファイル、+1199 / −17、新規テスト 41 件)。フルスイート 1605 passed / 8 skipped で通りました。机の上では、綺麗に通った。


実機テスト — 検出は当たり、起動は裏切った

ここからが山場です。机上で通ったので、実機に持っていきました。

成功例 — 設計どおりに動いた

まず Huihui-Ornith-1.0-9B-abliterated-MTP(nextn=1)。これは本体 gguf に nextn が内蔵された変種です。起動すると:

[launcher] MTP: nextn layers (1) detected in main gguf → --spec-type draft-mtp

検出が第 1 段で当たり、別ファイルなしで `--spec-type draft-mtp` が付き、12 秒でロード完了、listening。自動検出が設計どおりに動きました。ここまでは気持ちがいい。

失敗例 — 検出は完璧、起動が SIGSEGV

次に Gemmable4-12b-mtp(Gemma 系のコミュニティモデル、nextn=4)。検出は正しく動きました。nextn=4 をちゃんと読み、`--spec-type draft-mtp` を組み立てた。ところが `llama-server` がこう吐いて落ちました。

E llama_init_from_model: failed to initialize the context: Gemma4Assistant requires ctx_other to be set
[spec] failed to measure MTP context memory

そして SIGSEGV (exit code -11)。検出は当たっているのに、起動できない。

切り分け — ビルドでもフラグでもなかった

ここからは実際にやった順に書きます。

  1. launcher を抜いて `llama-cli` 直叩き → 同じクラッシュ。launcher の組み立てが悪いのではない。

  2. `-fit off`(メモリフィッティング無効化、issue #24343 のワークアラウンド)→ それでも即 segfault。

  3. リグレッションを疑う。issue #24795 に「b9553 では動く / b9702 以降で壊れた」とあった。→ b9553 を別ビルドして試しても同じクラッシュ

つまり、ビルドでもフラグでもない。ここで結論が出ました ── この gguf か、この arch 対応自体が怪しい

エラーメッセージ `Gemma4Assistant requires ctx_other` が示唆していたのは、「このモデルは assistant(draft 側)アーキテクチャで、単体では起動できない」可能性でした。Gemma4 系の MTP は本体内蔵ではなく、別ファイルの assistant gguf を必要とする方式で、配布されている物のパッケージングが疑わしい。関連する upstream の issue は #24343, #24350, #24443, #24758, #24795 ── どれもオープン/未確認で、まだ upstream 側が固まっていない領域だと分かりました。

決定打はこれです ── MTP を切って素の起動にしても、このモデルは落ちる。speculative フラグと無関係にクラッシュするなら、これは launcher では救えない。llama.cpp 側の問題です。

第 18 話で「上流が壊したものを下流は見た目しか直せない」と書きましたが、今回はもっと手前 ── 上流がまだ作りかけの領域でした。検出という自分の仕事は 100% 正しかった。にもかかわらず起動できない。「検出が正しくても、起動できるとは限らない」 ── これが今回いちばん腹落ちした学びです。


対応 — PR #60、起動クラッシュを自動でフォールバックする

Gemmable4 は救えない。でも、この経験から救える範囲を広げる設計は作れます。それが PR #60 です。

ルールは 1 つ。

`auto` で spec フラグを付けた起動が、180 秒以内に異常終了したら、1 回だけ spec フラグなしで自動再起動する。

実ログはこうなります。

[launcher] MTP startup failure detected (exit code -11); retrying without speculative decoding

speculative なしのコマンドで、もう一度立ち上げ直す。境界は明確に引きました。

  • 明示指定 (`draft_model_path`) や、ユーザー自身の `--spec-type` は自動リトライしない。ユーザーが意図して選んだものを勝手に外すのは越権です。自動フォールバックが効くのは「auto が推測で付けた」ときだけ。

  • `mtp_fallback_done` ガードで構造的にループ不可にしました。フォールバック後の起動がまた落ちても、二度は再試行しない。無限リトライは第 15 話の無限ループの親戚なので、最初から構造で塞ぐ。

テストは、exit code 11 を返すスタブバイナリでの統合テストを含めて 9 件追加。「実際にすぐ落ちるプロセス」を偽装して、フォールバックが確かに発火し、かつ 1 回で止まることを固定しました。これで累計 87 件

正直に書くと、Gemmable4 はフォールバック後の素の起動でも落ちました(= llama.cpp 側の問題で確定)。つまりこのモデル自体は最後まで動かせていません。でも収穫はありました ── フォールバックの動作そのものは、この実機ログで検証できた。落ちるモデルは「フォールバックが正しく発火するか」の最高のテストケースだったわけです。壊れた実機は、机上では絶対に手に入らないログをくれます。


リリース — リリースは普通に、スタンスは「テスト段階」

v2.7.6 として出しました(2026-07-09、PR #59 / #60 + release PR)。タグを push すると GitHub Actions が走り、PyPI へ Trusted Publishing で公開されます。リリースノート自体はいつも通りの新機能の書き方で、「テスト版」や「β」の但し書きは付けていません ── 機能そのものはテスト 87 件とフルスイート 1,605 件で固めてあり、launcher 側の実装に未完成の部分はないからです。

その上で、この記事でははっきり言っておきます。MTP 対応は「テスト段階」だと思って使ってください

未成熟なのは launcher ではなく llama.cpp 側のアーキテクチャ別の成熟度です。GLM 系や一部の内蔵 nextn モデルは今日から効く。Gemma4 系はまだ upstream が作りかけで落ちる。この状況で「MTP 対応しました!」と胸を張るのは不誠実だし、「まだ危ないから入れない」も機会損失。落とし所は、

効く環境では効かせ、壊れる環境では壊さない。

これが今の launcher にできる最善だ、という整理です。3 段フォールバックと起動クラッシュの自動リトライは、まさにこの「壊れる環境では壊さない」を担保するための仕組みでした。下流ルーターの誠実さは、直せる範囲を直し、直せない範囲を「まだ揃っていない」と正しく言えることにある ── 第 18 話の結論が、今回は「上流の未成熟」に対して再来した形です。

── 小ネタを 1 つ。CI の ruff が全角括弧で RUF001 を怒ってきました。GUI ラベルの「(空欄で自動検出)」の丸括弧が全角だったせい。日本語 UI を書いていると定期的に踏むやつです。半角に直して緑。

使う前に — llama.cpp のバージョンとビルド例

MTP を試す前提として、llama.cpp が新しいことが絶対条件です。`--spec-type` 系フラグに対応した最近のビルドが必要なのはもちろんですが、もっと分かりにくい罠があります ── nextn 内蔵 gguf は、古いビルドだと「MTPが使えない」ではなく「モデル自体がロードできない」

実際に踏みました。Linux 機(b9943)で動いていた Ornith 9B MTP の別 quant を、開発用 Mac の古いビルドでロードしたら:

llama_model_load: error loading model: missing tensor 'blk.32.ssm_conv1d.weight'

通常層が 0〜31 の 32 層のモデルで、blk.32 は MTP(nextn)用の追加ブロック。nextn を知らない古いビルドはこれを通常層だと思って `ssm_conv1d` を探しに行き、「テンソルが無い」で死にます。エラーメッセージからは MTP の M の字も見えないのが意地悪なところです。Mac を b9949 に更新したら同じファイルがそのまま 51.2 t/s で動きました。

確認とビルドの一例を置いておきます。

# 対応確認(2つとも)
llama-server --version                     # b99xx 台の新しめか
llama-server --help | grep spec-type      # draft-mtp が列挙されるか

Mac (Apple Silicon) — Metal は既定で有効、フラグ不要:

git clone https://github.com/ggml-org/llama.cpp && cd llama.cpp
cmake -B build
cmake --build build --config Release -j $(sysctl -n hw.ncpu)

NVIDIA (CUDA) — RTX 50 系(Blackwell)の例:

# CUDA 12.8 推奨。13.1 は Blackwell の MMQ カーネルが落ちて
# cuBLAS フォールバック(プロンプト処理 5〜6 倍遅)の報告あり
export PATH=/usr/local/cuda-12.8/bin:$PATH
cmake -B build \
  -DGGML_CUDA=ON \
  -DCMAKE_CUDA_ARCHITECTURES=120 \
  -DGGML_CUDA_FORCE_CUBLAS=OFF
cmake --build build --config Release -j $(nproc)

どちらの環境でも共通の注意が 1 つ: 更新時は `rm -rf build` してから。`CMakeCache.txt` に古いフラグが残ったまま再ビルドされる罠は、OS を問わず踏めます。

おまけ考察 — 16GB 機でも MTP は効くのか

「MTP は VRAM 潤沢なマシンの話でしょ」と思われがちですが、16GB クラスでも恩恵はあります。むしろ構造的には、帯域が細いマシンほど相対的な伸びしろが大きい。理由は、ローカル LLM のデコードが帯域律速だから。1 トークン生成するたびにモデルの重みをメモリから読み直していて、そこがボトルネックになる。draft で 2〜3 トークンをまとめて一括検証すれば、その 1 回の重み読み込みを複数トークンで償却できる。演算より前に、メモリ帯域の使い方で得をする構図で、これは 16GB の Mac やミニ PC こそ該当します。

ただし条件付きです。nextn 層の分だけ gguf もコンテキストもメモリが増える(12B なら数百 MB〜1GB 弱)ので、それでスワップに入ったら本末転倒 ── 帯域を稼ぐために足したものが、もっと遅いストレージ I/O を呼び込んで逆効果になる。16GB で使うなら、別 draft モデル方式よりメモリ追加が小さい内蔵 nextn 型を選び、ctx-size は控えめに、`--spec-draft-n-max` は既定の 2〜3 のまま。「効く環境では効かせ、壊れる環境では壊さない」は、性能の面でも同じ話でした。


開発体制 — 第 22 話の「軍団」が日常運用になった

最後に、メタな一貫性を 1 つ。第 22 話で作った「司令塔 (Fable 5) + Opus + Sonnet」の 3 層サブエージェント体制が、今回は特別なイベントではなく、普通の 1 機能を出す日常のフローとして回りました。

第 22 話では「20 体で 1 日」という規模が主役でしたが、今回分かったのは、この体制は非日常のイベントでなく、平常運転になるということでした。人間がやったのは設計判断だけ ── 「同じフォルダに置いてあれば使う」「テスト段階という温度感で伝える」という 2 つの意思決定。それ以外の、大量のファイルを読む・書く・直すは全部エージェントに流して、調査からリリースまで 1 日で畳めた。

第 22 話の教訓は「一人開発でもレビュー係は雇える」でした。第 26 話はその続きで、雇ったチームは、イベントのためでなく毎日の開発のために常駐させられる、です。


まとめ

第 26 話の教訓:

検出が正しくても、起動できるとは限らない。自分の仕事(GGUF メタデータから nextn を読む)が 100% 当たっていても、その先の upstream がまだ作りかけなら、実機は落ちる。だから launcher の仕事は「正しく検出する」で終わりではなく、「検出が当たった上で起動が失敗したとき、静かに素の起動へ戻す」までを含む。

机上で 1605 テストが緑でも、実機の Gemmable4 が SIGSEGV を返した瞬間に、設計の重心が「検出精度」から「失敗時の畳み方」へ移りました。連作の通低音「机上で動くはず → 実機が裏切る → 直す」は、今回は自分のコードのバグではなく、上流の未成熟をどう受け止めるかという形で現れた。直せない相手に対して、下流ができるのは「壊さないこと」だけです。

次回は、この MTP を「テスト段階」から一歩進める話になる予定です ── upstream の Gemma4 系 issue が閉じたら再検証するか、あるいは効くモデルを実運用で回して採択率と実デコード速度を実機で測るか。今回は「動く/落ちる」の二値までしか見ていないので、次は「どれだけ速くなったか」を数字で出したい。作る話から、測る話へ。


CodeRouter は MIT ライセンスの OSS です: https://github.com/zephel01/CodeRouter
`pip install coderouter-cli` / `uvx coderouter-cli serve` で動きます。
Launcher の MTP は `mtp_mode="auto"` が既定。メイン gguf と同じフォルダに draft を置くか、nextn 内蔵モデルを使えば、あとは自動です。

いいなと思ったら応援しよう!

zephel01 サーバー代とコーヒー代になります☕ 役に立ったら応援よろしくお願いします!