前回、LiteLLMのUIで追加したモデルが401を返す事故を書いた。原因はUI追加経路では os.environ/ 参照が解決されないことで、解決の過程でDBのモデル行をYAMLへ移設した。そのとき行った後始末の1つに「DBに残った同名の行を削除する」があり、そのときは脇道として片付けたのだが、放置したら何が起きたかを掘っていない。
本稿の問いはこれ1つだ: config.yamlとUI追加 (DB) の同名モデルは、プロキシ内部でどう併存するのか。 答えから言うと、両者は「同じ名前の設定」ではなく別々のidを持つ2つのdeploymentとして同一のmodel_groupに並び、デフォルトのルーティングが両方へリクエストを振り分ける。config.yaml側を削除してもDB行は生き続け、DB行を消すまでモデルは消えない。
この記事を読み終えた時、手元に残るもの:
- 同名併存が「設定の上書き」でなく「deploymentの追加」になる仕組み (同期diffがid基準であることのソース該当箇所)
- 同名併存が実際にトラフィックを分岐することの実測 (api_baseの異なる2deploymentへの振り分け)
- yaml側を消してもDB行が生き残ること、そして消し方がUI削除しかないことの実測
環境と計測方法
- LiteLLM Proxy 1.83.10 (公式Docker image
main-stable。稼働環境と同一versionの/app/litellmソースを実読) - postgres +
STORE_MODEL_IN_DB: True(UIで追加したモデルはDBに保存される) - 実挙動は隔離した検証用スタックで測った: 本番と同構成のプロキシに、APIの向き先 (api_base) だけ違う2つのモック上流 (
/aと/b) を用意し、どちらのdeploymentにリクエストが届いたかを上流側のログで数える。api_keyの値はどの出力にも出さない (本文中のSQLも値を出さない列設計にしてある)
検証はすべて廃棄可能なコンテナで行い、稼働中のゲートウェイには書き込んでいない。
前提: モデル一覧には2つの源泉がある
litellm proxyのモデル定義は、2つの経路からrouterに載る。前回の記事の構図をIDの視点で引き直す。
経路A: config.yaml (起動時読み込み)
起動時、config.get("model_list") がそのまま litellm.Router(model_list=...) へ渡る。このとき os.environ/ 参照は解決される。ここまでは前回書いた通り。
今回追加で押さえるのはidの由来だ。config.yaml側のモデルに明示的な model_info.id を書いていない場合、idは _generate_model_id(model_group, litellm_params) で作られる — model_nameとlitellm_params全体から作る決定的なハッシュだ。同じ内容からは常に同じidが生成される。
経路B: DB行 (UI追加)
UIの「Add model」(POST /model/new) で追加したモデルは暗号化されてDBに保存され、反映時は復号だけを行う経路でrouterへupsertされる。こちらのidはDB行のmodel_id (UUID) で、get_model_info_with_id が db_model: true のフラグと一緒に付ける。
つまり同名のモデルを両方の経路で定義すると、config側は「paramsのハッシュ」、DB側は「行のUUID」という異なるidを持つ2つの定義になる。
同期のdiffはid基準 — 同名では削除されない
「DBに残った同名行を削除した」とき、なぜ削除が必要だったのか。idの視点で読むと見える。
プロキシは起動時やモデル追加・削除のたびに add_deployment を呼び、DBの現在値とrouterの状態を同期する。この中の _delete_deployment (proxy_server.py) がやっていることを要約すると:
-
合集を作る — DB行のid全部と、config.yamlのモデルid全部を1つのリストにまとめる (config側は
os.environ/を解決してからidを生成する) -
routerと比較する — routerが今持っているdeploymentのidのうち、合集に存在しないものだけを
delete_deployment(id=...)で削除する
比較の単位が一貫して model_info.id であり、model_nameは削除判定に一切関与しない。だから「config.yamlに同名を書いたからDBの古い定義は上書きされる」ことは起きない。idが違う2つは、同期のどのタイミングでもそれぞれ「まだ存在するもの」として扱われ、両方ともrouterに残る。
上書きに見える操作が1つだけある。Router.upsert_deployment はidが同一のdeploymentを見つけたら置換する。だがこれはidが同じ場合 (DB行の再保存など) の話で、config側 (ハッシュid) とDB側 (UUID) が同じidになることはない。
実測1: 同名併存は「2行のモデル一覧」として見える
隔離スタックで確かめる。config.yamlに echo-dupe (上流はモックの /a) を定義して起動し、UI経路で同名 echo-dupe (上流 /b) を追加した直後の /model/info がこれだ。
echo-dupe | id: 278739dd1678e3f1... | db_model: False | api_base: .../a
echo-dupe | id: e6027d1a-036d-4cbc-... | db_model: True | api_base: .../b
同じ model_name の行が2つ並ぶ。idの形式も違えば出自のフラグ (db_model) も違う。UIのモデル一覧ではこの2行が同じ名前で並んで見えるので、「設定が二重に見える」状態そのものだ。
実測2: トラフィックは2つのdeploymentに分岐する
見た目だけなら「重複行の表示バグ」で済む。そうではないことを、上流で数える。
追加前 (config側だけ) に echo-dupe へ6リクエスト送ると、6件とも /a に届く — deploymentが1つなので分岐のしようがない。UI追加後に10リクエスト送ると:
/a に 3件
/b に 7件
10走で3:7なのはランダム振り分けの揺らぎで、比率に意味はない。意味があるのは両方に届いたことだ。同名のmodel_groupにdeploymentが2つあると、デフォルトのルーティング戦略 (simple-shuffle) はグループ内のdeploymentから選んでリクエストを投げる。つまり同名併存は「死んだ古い定義が残る」のではなく、生きている第2の経路に毎リクエストが振り分けられる。
ここが実運用で効く。片方のdeploymentだけが壊れている場合 (前回の401のように、DB側だけkeyがリテラルのまま等)、同じモデル名へのリクエストが安定して失敗するのではなく、一部だけ失敗する。10回中7回成功する断続的な401は、恒常的な401より原因の切り分けが難しい。「同じkeyなのに一部だけ落ちる」観測の読み方は前回書いたが、同名併存はそれとは別の、プロキシ内部で作られる断続失敗の機構だ。
実測3: config.yaml側を消してもDB行は生き残る
次に、yaml側を消すケース。config.yamlから echo-dupe を削除してプロキシを再起動した。
other-model | db_model: False | api_base: .../b # yamlに残した別モデル
echo-dupe | db_model: True | api_base: .../b # DB行だけが生き残った
再起動後も echo-dupe は /model/info にいて、リクエストは4件とも /b (DB行の上流) に届いた。同期diffの合集からyaml側のidが消えても、DB行のidは合集に残っているため、対応するdeploymentは削除されない。「YAMLに書いていないのにモデルが消えない」のはこの機構で、YAML側の一覧を見て運用している人には状態が見えにくい。
反対方向の削除は即座に効く。UIの削除 (POST /model/delete にidを指定) でDB行を消すと、再同期で合集からidが外れ、deploymentが削除され、echo-dupe へのリクエストは400 (モデル不在) になった。同名併存を解消する手段は、DB行の削除しかない。
確認と後始末のクエリ
併存に気づくかどうかが実害を決める。DBを直接見るときは、値を出さない列設計で。
select model_name, model_id, (litellm_params ? 'api_key') as has_api_key
from "LiteLLM_ProxyModelTable";
model_name の重複があれば、config.yamlの model_list と突き合わせて同名がないか確認する。前回記事の os.environ/% 検出 (api_key のリテラル残存チェック) と併せると、「解決漏れ」と「併存」の両方を1クエリずつで拾える。
運用方針としては前回と同じ結論になる: モデル定義はconfig.yamlに一元化し、UIからの追加はしない。一元化していてもUIで1度でも追加するとDB行は独立に生きるので、併存の監視はDB側で行う (yaml側の一覧だけ見ていると気づけない)。
限界
- 読んで実測したのは1.83.10 (2026-09時点のmain-stable)。
_delete_deployment/upsert_deployment/_generate_model_idの構造が将来のversionで変わる可能性がある - ルーティングの実測はデフォルトの
simple-shuffleのみ。least-busy等を明示した場合の振る分け方は別の挙動になりうる (本稿では「同名があると分岐する」ことだけを主張する) - 実測はモック上流2面の隔離スタックによる。実プロバイダーでの障害時の挙動 (retryが反対側のdeploymentに切り替わるか等) は踏んでいない
- DB保存時の暗号化・鍵管理の詳細は前回同様スコープ外
2源泉の構造 (config.yaml = 起動時・解決つき・決定的id / DB = 追加時・復号のみ・行id) を1回押さえると、「UIで直したのに反映されない」「yamlから消したのに消えない」「一部のリクエストだけ失敗する」という別々の症状が同じ土台で読めるようになる。
モデルを追加したら実タスクで使い倒して選定に使う手順は、LLMはベンチマークで選ばない — 自業務の実タスクで60分比較する手順にまとめてある。前回の401記事 (LiteLLMのUIで追加したモデルだけ401になる) と合わせて読むと、UI追加経路の落とし穴が一望できる。