見出し画像

llama-swap を見て「launcher で同じことができるのでは」と言ったら、前提が 1 つ古かった — CodeRouter v2.9.1、モデル自動スワップを外部依存ゼロで自前実装した話

TL;DR: 前話(第 34 話)で E2E の配管が 4 CLI 全通し、grok の空応答も夜明け前に直した。区切りがついたので、前から気になっていた llama-swap を眺めていた。「Ollama 以外だと複数モデルの切替ができない。launcher はもう llama.cpp を起動できるんだから、同じことができるのでは」── 発端はその雑談だった。調べると、前提が 1 つ古かった。llama.cpp 本体に 2025-12 で router mode(`--models-dir` / `--models-max`、既定 4、LRU 退避)が入っていて、llama-swap のコア機能は本体に吸われつつあった。それでも自前実装を選んだ。理由は「外部ツールへの依存はセキュリティリスクを負い得る。実装できるものは自前で」── CodeRouter の依存最小主義そのままだ。設計書を先行させ(Phase 1=オンデマンド spawn + ロード保留 + TTL アンロード / Phase 2=メモリ会計+排他 swap)、7 論点をレビューで潰してから実装。新規 `coderouter/launcher_swap.py` は 431 行、`SwapManager` は per-model ロック+リース方式。`register_provider` の対になる `deregister_provider` を新設した ── 動的な TTL アンロードを作って初めて「外す手段が無い」ことに気づいた。実装班の自己申告テスト 31 件は緑だったが、壊す前提の敵対レビューが CRITICAL 1 + HIGH 2 + MEDIUM 3 の 4 系統を再現テスト付きで炙り出した ── bundled ルールの黙殺、デッドポートのリーク、auto-restart との二重監視、無駄 spawn。全部直して回帰 15 件追加、最終 1725 テストパス + ruff クリーン、クラウド(Linux/3.12)と Mac 実機で同数一致。実機キットは LFM2.5-350MALL PASS(5/5)、コールドスタート 2 秒、25 秒アイドルで自動アンロード。v2.9.1 として PyPI へ。外部依存は 1 個も増えていない。


あらすじ — 35 話目です

連作のタイムスタンプ。初コミットは 2026-04-19。本記事の作業日は 2026-07-12、初コミットから 85 日目です。前話(第 34 話)は同じ 07-12 の未明に grok の空応答を直した回で、これはその続き ── 夜が明けてからの話です。

  • 第 1〜9 話 (v1.8 → v2.2):「動く」→「壊れない」→「壊れても自分で直る」

  • 第 10〜17 話 (横断 / v2.3 → v2.5.3):Plugin SDK / Launcher / 低メモリガード ── 「動かせる」へ

  • 第 18〜21 話 (v2.5.4 → v2.6.0):上流の事故をフィルタで受ける / 敵が「故障」から「無駄」に

  • 第 22 話 (v2.7.0):AI レビュー軍団で 26,656 行を総ざらい、バグ 22 件

  • 第 23〜26 話 (v2.7.3 → v2.7.6):空応答フォールバックの実測検証 / 多言語ドキュメント / MTP 対応

  • 第 27〜30 話 (v2.7.7 → v2.7.10):外部エージェントを backend に ── grok / codex / antigravity、Phase 1 完結

  • 第 31 話 (v2.8.0→v2.9.0):agent_cli を plugin に切り出し ── 1,168 行を Core から消す

  • 第 32〜34 話:サブエージェント E2E / ext-codex の真犯人 / grok の「空の成功」を当日修正

  • 第 35 話 (本記事・v2.9.1)launcher にモデル自動スワップ ── llama-swap 相当を、外部依存ゼロで自前実装した

この連作の通低音はずっと「机上で動くはず → 実機が裏切る → 直す」でした。今回は少しだけ形が違います。実機に裏切られる前に、まず自分の前提が古くなっていたことに気づかされ、そのうえで実装した本人には見えないバグを別の視点が釣り上げる話です。


きっかけ — llama-swap を眺めていた

区切りがつくと、次に気になっていたものへ手が伸びる。E2E の配管が全部緑になったあと、開いていたのは llama-swap のリポジトリ(`github.com/mostlygeek/llama-swap`)だった。

言ったのはこういうことだ。「Ollama を通せば複数モデルの切替はできる。でも Ollama を挟まない生の llama.cpp では、モデルごとにプロセスを立て直す羽目になる。launcher はもう llama.cpp を起動・停止できるんだから、リクエストが来た瞬間に必要なモデルを立ち上げて、使い終わったら片付ける ── 同じことが launcher でできるんじゃないか」。

要は、llama-swap がやっている「オンデマンド起動とアイドルでの片付け」を、CodeRouter の launcher に持ち込めないか、という雑談だった。雑談のまま流すには惜しかったので、調査に回した。体制はいつもどおり、外部事情の調査と手元コードの調査を並列で走らせた。


調査 — 前提が 1 つ古かった

返ってきた一次情報で、最初の前提が崩れた。llama.cpp 本体(llama-server)に、もう router mode が入っていた

ggml-org の公式ブログ(2025-12-11)によれば、`--models-dir` でモデル置き場を指すか、モデルを指定せずに起動すると llama-server 自体が router 化する。`--models-max`(既定 4)で常駐上限を決め、超えたら LRU で退避する。llama-swap の核だった「複数モデルを 1 プロセスで束ねてオンデマンドに切り替える」機能は、本体に吸われつつあった。自分が「launcher でやれば新しい」と思っていたことは、視点が半年ぶんずれていた。

ただし本体の router mode も万能ではない。アイドル TTL でのアンロードや、常駐/排他といった細かい寿命制御はまだ弱く、feature request の段階にある。

llama-swap のほうも改めて見た。Go 製で、v217(2026-05)まで 159 リリースを重ねた成熟プロキシだ。`ttl` によるアイドル解放、`groups`(swap=排他 / persistent=常駐 / exclusive)、そして Anthropic の `/v1/messages` ネイティブ対応まで持っている。参照実装として敬意に値する出来で、実際このあとの設計では語彙(swap / groups / ttl)を意図的に踏襲している。

肝心の CodeRouter 側はどうか。結論は「土台は半分ある」だった。複数プロセスの起動・停止・ログ管理(`LauncherRegistry`)、provider の自動登録、`model_pattern` によるルーティングは、第 10〜17 話の launcher 回で既にできていた。足りないのは 4 点だけ ── ①リクエスト到着時のオンデマンド spawn、②ロード中に来たリクエストの保留、③アイドル TTL でのアンロード、④メモリ排他の swap。llama-swap の核心が、そのまま欠けているところに重なっていた。

見積りも出た。新規で 400〜700 行、それに並行処理の罠。調査時点の推奨は正直、こうだった ── 「llama-swap を下流に置くほうがコスパは良い」。


判断 — それでも自前実装

推奨は「採用」に傾いていた。それを覆したのは、コスパとは別の物差しだった。

決めたのはこの一言だ。「外部ツールへの依存はセキュリティリスクを負い得る。実装できるものは自前で実装したい」。CodeRouter がコア依存を 5 個(fastapi / uvicorn / httpx / pydantic / pyyaml)に絞り、sub-release を重ねても依存追加ゼロで通してきた ── その方針の延長線上にある判断だ。外部バイナリを 1 枚挟むと、token 認証や `model_dir` のトラバーサル防止といった、既に自前で持っている防御を、もう一度その外部プロセスとの境界でも整合させないといけない。守るべき境界が増える。

ここで論点を 1 つ足しておいた。llama.cpp の router mode は「新しい第三者依存」ではない。CodeRouter はもう llama.cpp を起動しているのだから、その本体機能を使うのはセキュリティ観点では自前実装と同格だ。だから設計には、逃げ道を最初から埋め込んだ ── router mode の寿命制御が成熟したら、そこへ委譲できる出口戦略を残す。「自前で作る」と「未来永劫自前で抱える」は別の話で、今は前者を選ぶが、後者に縛られないようにしておく。

「コスパで負けている見積りを、依存最小というセキュリティ側の価値で覆す」── これが今回いちばん重い意思決定だった。数字だけ見れば採用が正解で、その数字を承知のうえで自前を選んだ、という記録として残しておきたい。


先に穴を二つ塞ぐ — readiness gating と auto-restart

設計に入る前に、調査の副産物として既存の穴が 2 つ見つかった。swap を載せる前に、土台の穴を塞いでおく。

1 つ目。launcher は spawn した直後にその provider を登録していた。だがモデルのロードには時間がかかる。ロードが終わる前に来たリクエストは、まだ答えられないプロセスに当たって失敗する。ここに readiness gating を入れた。新しい status `"loading"` を設け、`/health` で応答可能を確認してから登録する。「起動した」と「応答できる」は別で、後者を確かめるまで表に出さない。

2 つ目。クラッシュしたプロセスが `error` のまま放置されていた。opt-in の auto-restart を足した。バックオフ付きで最大 3 回まで、意図的に止めた場合は再起動しない、既定は off。勝手に復活してほしくない場面もあるので、明示的に有効化したときだけ働く。

この 2 件でテストは 21 件。swap 本体の前に、受け皿の側を固めた格好だ。


SwapManager — そして「登録」に対になるもの

設計書は先に書いた。`docs/designs/launcher-model-swap.md` に、Phase 1(オンデマンド spawn + 保留 + TTL)と Phase 2(メモリ会計+排他 swap)を段階分割し、並行性設計・セキュリティ節・テスト計画まで載せる。レビューで 7 論点 ── TTL の粒度、ポート割当、groups の粒度、self-healing との責務境界、readiness 通知、非 GGUF の見積り、ルーティングの到達手段 ── を確定させてから、実装に渡した。設計書が固まる前にコードを書かない。

実装は新規ファイル `coderouter/launcher_swap.py`、`SwapManager` で 431 行。核は per-model のロック+リース方式だ。同じモデルに同時リクエストが来ても spawn は 1 回に収束するようロックで守り、streaming の最中はリースが立っていて TTL では追い出されない。加えて TTL の sweeper が、アイドルになったモデルを回収する。

ここで、動的な機能を作って初めて表に出た欠落があった。`register_provider` に対になる操作が存在しなかった

これまで launcher は provider を「登録する」ことしかしてこなかった。プロセスは立てっぱなしか、せいぜい手で止めるだけで、「登録の逆をやる」需要がそもそも無かったからだ。ところが TTL アンロードは、アイドルになったモデルを表から外す操作を要求する。外す手段が無い、と気づいたのはこの時だった。だから `deregister_provider` を新設した。

「対になる操作の欠落」は、静的なうちは顔を出さない。動的な機能(TTL アンロード)を作って初めて、片割れが無いことが顕在化する。 足すコードが、前から空いていた穴を照らし出した。

もう 1 つ、使い勝手のために auto_router のルールを自動注入するようにした。カタログに宣言したモデル名から、完全一致のルーティングルールを勝手に生やす。ユーザーはカタログを書くだけでよく、`{"model":"<name>"}` のリクエストが来れば勝手に起動して勝手に片付く。実装班の申告テストは 31 件、この時点では全部緑だった。


敵対レビュー — 実装者に見えない 4 系統

31 件が緑でも、そこで止めない。実装する視点と、壊す視点は別だからだ。壊すつもりで検証する敵対レビューに、再現テスト付きで回した。

出てきたのは CRITICAL 1 + HIGH 2 + MEDIUM 3 の 4 系統。実装した本人の自己申告テストでは 1 つも鳴らなかったものだ。

CRITICAL ── swap を有効にすると、bundled の auto_router ルール(画像は multi へ、等)が黙って全廃されていた。自動注入したルールの合成ブロックが、既存の bundled ルールを置換してしまっていた。有効化した瞬間に、これまで効いていた振り分けが静かに消える。いちばん怖いのは、エラーも出さずに機能が欠けることだ。

HIGH(2 件) ── ひとつは、TTL アンロードが汎用 provider を掃除せずデッドポートをリークしていた。エフェメラルポートだとアンロードのたびに死んだポートが積み上がり、際限なく溜まる。もうひとつは、auto-restart を有効にすると `SwapManager` と二重監視になり、固定ポートを奪い合う。先に塞いだ auto-restart が、swap と組み合わさって新しい競合を生んでいた。

MEDIUM(3 件) ── スキーマ緩和の横断影響、ルーティング先と無関係な無駄 spawn、カタログに一致しない model 名でのリース漏れ

一方で、並行性のコア(ロック・リース・readiness・shutdown)と、セキュリティ境界(カタログ外のモデルは起動できない・トラバーサル防止)は「健全」判定だった。危ういのは周辺の相互作用で、芯は据わっていた ── この切り分けが付いたことにも意味がある。

全部直して、回帰テストを 15 件足した。CRITICAL の対処に合わせて、司令塔判断でルールの優先順も改善している。swap ルールは `re.escape` の完全一致で、明示的に名指ししたときだけ発火する強いシグナルだ。だからこれを、コードフェンス密度などで推測する bundled ヒューリスティックより先行させた。合成が bundled を潰すのではなく、明示指定を先に見て、残りをヒューリスティックに委ねる並びにした。

最終は 1725 テストパス + ruff クリーン。クラウド(Linux / Python 3.12)と Mac 実機で、同じ数がぴったり一致した。


実機 — ALL PASS と、bash 3.2 の抵抗

机上が緑でも実機で確かめる。`_run/swap-test` の 5 フェーズ自動キットを、**LFM2.5-350M(Q4_K_M)**で回した。結果は ALL PASS(5/5)

  • コールドスタート:リクエスト → 自動 spawn → `/health` で readiness 確認 → provider 登録 → 応答まで、2 秒

  • ウォーム:0 秒。プロセスは 1 個のまま、再 spawn なし。

  • カタログ外の model 名:フォールスルー先で正常応答。リース保護も効いていた。

  • TTL アンロード:25 秒アイドルで `deregister` → swap-unload → ポート解放。

  • 再 spawn:アンロード後のリクエストで、また 2 秒で復帰。

コールドで 2 秒、ウォームで 0 秒、片付けは 25 秒後に自動。使う側がやることは、カタログを宣言するだけだ ── `launcher.swap.enabled` を立てて `models: [{name, backend, model_path, port}]` を並べれば、あとは勝手に起動して勝手に片付く。opt-in なので、宣言しなければ従来どおり何も変わらない。

小さな抵抗が 1 度だけあった。テストスクリプトがなぜか落ちた。犯人は macOS 標準の bash 3.2 で、`$MODEL_NAME発火` のように変数の直後に日本語が続くと、変数名の解析を誤って unbound variable を吐く。`${MODEL_NAME} 発火` に直したら、それだけで通った。1725 のテストを抜けたコードが、最後にシェルの古さに足を引っかけた ── それだけの話だ。


リリース — v2.9.1、依存は 1 個も増えていない

タグを push すると、GitHub Actions が Trusted Publishing(OIDC)で PyPI へ自動公開する。`coderouter-cli 2.9.1`(2026-07-12)の反映を確認した。CHANGELOG には、敵対レビューで潰した修正の中身まで書いた ── 「何を直したか」を後から追えるようにしておく。

自前実装を選んだ甲斐は、依存グラフに現れる。llama-swap 相当の機能を丸ごと足しておいて、コア依存は 5 個のまま、外部バイナリは 1 枚も増えていない


メタ教訓

連作を通しての学びに、今回の 3 つを足す。

  1. 土台が半分ある」なら、自前実装は非現実的ではなかった。コスパでは llama-swap 採用が上、という調査時点の見積りを、依存最小というセキュリティ側の価値で覆した ── ただし router mode が成熟したときの出口(委譲)は、設計に残した。作るが、抱え込まない。

  2. 実装するエージェントと、壊すエージェントを分けたのが効いた。実装者の自己申告 31 件では 1 つも鳴らなかった 4 系統(bundled 黙殺・デッドポートのリーク・二重監視・無駄 spawn)を、壊す前提の別視点が再現テスト付きで釣り上げた。

  3. 「対になる操作の欠落」は、動的な機能を作って初めて顕在化する。`register_provider` の片割れが無いことは、TTL アンロードという「外す」需要が生まれるまで、誰にも見えていなかった。

教訓: 前提は静かに古くなる。「新しいと思って作るもの」が、調べたら本体に取り込まれつつあった ── それでも自前を選ぶ根拠は、目新しさではなく、依存を増やさないことにあった。そして「動く」と「壊れない」は別で、実装した本人が緑にしたコードほど、壊す視点を外注する価値がある。作った機能は、前から空いていた穴(`deregister`)を照らすことがある。増やすときこそ、対になるものが揃っているかを疑う。


まとめ

  • リリース:v2.9.1(2026-07-12・PyPI 反映確認済み)

  • 主役:`launcher.swap` モデル自動スワップ Phase 1(オンデマンド spawn + ロード保留 + TTL アンロード)

  • 実装:新規 `coderouter/launcher_swap.py`、`SwapManager` 431 行、per-model ロック+リース

  • 新設 API:`deregister_provider`(`register_provider` の対になる操作)

  • 併載:readiness gating(新 status `"loading"`)/ opt-in auto-restart(バックオフ最大 3 回・既定 off)

  • 敵対レビュー:CRITICAL 1 + HIGH 2 + MEDIUM 3 を再現テスト付きで検出、全修正 + 回帰 15 件

  • テスト:最終 1725 パス + ruff クリーン、クラウド(Linux/3.12)と Mac 実機で同数一致

  • 実機:`_run/swap-test` 5 フェーズ ALL PASS(5/5)、LFM2.5-350M、コールド 2 秒 / ウォーム 0 秒 / 25 秒で自動アンロード

  • 依存:外部依存の追加 ゼロ(コア依存 5 個のまま)

自前実装かllama-swap採用か ── 数字は採用に、価値観は自前に傾いた。傾きを承知で自前を選び、出口だけは残した。それが v2.9.1 の背骨です。


次の話

候補は 2 つ。ひとつは今回 Phase 2 に送った、メモリ会計+排他 swap(複数モデルを RAM の予算内で入れ替える段)。もうひとつは、第 33〜34 話から持ち越している orchestration companion ── 弱いローカル orchestrator に非同期 Task をどう扱わせるか、という宿題です。どちらも、まだ着手していません。


CodeRouter は MIT ライセンスの OSS です: https://github.com/zephel01/CodeRouter
`pip install coderouter-cli` / `uvx coderouter-cli serve` で動きます。
起動は `uvx --from coderouter-cli coderouter serve --port 8088`、接続は `ANTHROPIC_BASE_URL=http://localhost:8088 ANTHROPIC_AUTH_TOKEN=dummy claude`。モデル自動スワップは `launcher.swap.enabled` の opt-in、宣言しなければ従来どおり何も変わりません。

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

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