SiftoのAI基盤設計:複数LLMをちゃんと扱うためにやったこと — OpenRouter・コスト分析まで

技術

はじめに

LLM を使ったアプリは、最初の一歩だけを見るとかなり簡単です。ひとつの provider を決めて、その API を叩き、必要な機能をつなげていけば、とりあえずは動きます。実際、初期の段階ではそのやり方でまったく問題ありません。むしろ最初から大げさな抽象化を入れるより、そのほうが速いです。

ただ、その状態で開発を進めていくと、すぐに別の問題が見えてきます。たとえば、事実抽出にはこのモデルが良いが、要約には別のモデルのほうが安定する、といった具合に、用途ごとに向いているモデルが違ってきます。さらに、ひとつの provider だけでは選択肢が足りなくなり、他社のモデルも使いたくなります。そこに OpenRouter のような横断的なモデル供給源まで加わると、今度は「選べること」自体が新しい複雑さになります。

Sifto でもまさにそこで詰まりました。単に LLM を呼べるだけでは足りず、複数 provider をまたいで、用途ごとにモデルを切り替え、しかも Usage やコストまで追えるようにしないと、運用が成立しなくなったのです。そこで LLM 周りを単発の実装ではなく、ひとつの基盤として組み直しました。この記事では、その設計をまとめます。

結論から言うと、Sifto の LLM 実装は、複数 provider と OpenRouter を横断しながら、用途別モデル選択、OpenRouter モデルの動的同期、Usage の収集、そしてコスト推定と分析までを、一貫した構造で扱えるように設計しています。狙いは単純で、新しいモデルを試しやすいことと、運用時に比較しやすいことを、同時に満たすことです。

まず、何がそんなに面倒なのか

Sifto では、ひとつのモデルですべてを処理しているわけではありません。事実抽出を行う facts、要約を行う summary、ユーザーからの質問に答える ask、ダイジェスト生成の digest、検証系の check など、用途ごとに別のモデルを持てるようにしています。これは品質とコストを両立するためには自然な設計です。どの処理にも同じモデルを使うより、用途ごとに適したモデルを選んだほうが、精度もコスト効率も良くなりやすいからです。

ところが、この時点で設計をちゃんとしていないと、すぐに破綻します。まず、モデル選択 UI が膨れ上がります。provider が増えるたびに候補が増え、しかも用途ごとに設定したいとなれば、単純な select ボックスでは手に負えなくなります。次に、API key の管理が provider ごとに必要になります。OpenAI だけならまだしも、Anthropic、Google、Groq、Mistral、Alibaba、xAI、Z.ai、そして OpenRouter まで扱い始めると、どの provider が使える状態なのか、それに応じてどのモデルを選べるのかを、常に意識しなければいけません。

さらに厄介なのが、同名モデルの衝突です。OpenRouter を導入すると、直に使っている provider の model ID と、OpenRouter 経由の model ID が同じ名前で存在することがあります。UI 上では同じように見えるモデルでも、実際にはコストも経路も違う。この違いを内部で区別できないと、Usage 集計もコスト分析も壊れます。要するに、「使えるモデルが増える」こと自体が、そのまま運用の難しさになるわけです。

Sifto ではこの問題を、闇雲に条件分岐を増やして対処するのではなく、構造を4つの層に分けることで整理しました。直接接続 provider を定義する静的 catalog、OpenRouter から同期する動的 catalog、用途ごとのモデル選択ロジック、そして API 側で Usage とコストを正規化する層です。この4つを分けておくことで、どこに何の責務があるかがはっきりし、拡張しても全体が壊れにくくなります。

provider は2種類ある。ここを分けないと全部濁る

Sifto には、大きく分けて2種類の provider があります。ひとつは、各社 API に直接接続する provider です。もうひとつは OpenRouter です。ここを同じものとして雑に扱うと、あとでかなり痛い目を見ます。

直接接続する provider には、anthropicopenaigooglegroqdeepseekalibabamistralxaizai があります。これらは shared/llm_catalog.json を中心にした静的 catalog で管理しています。この catalog には、どの provider がどの API key header を使うか、どの model ID がどの provider に属するか、用途ごとの default model は何か、価格はいくらか、structured output や tool calling のような capability を持つか、といった情報が入っています。言ってしまえば、ここは「安定運用枠」です。自分たちが明示的に採用し、仕様を把握し、継続的に使う前提のモデル群を定義する場所です。

一方で OpenRouter は、同じように扱っているように見えて、実態はかなり違います。Sifto では OpenRouter を単なる“経由地”としてではなく、独立 provider として扱っています。これはかなり意識的な設計です。OpenRouter をただの裏口のように扱ってしまうと、設定画面では別扱い、Usage では曖昧、コスト計算では特別扱い、という中途半端な構成になりがちです。そうではなく、Sifto では OpenRouter に対しても API key を持ち、用途別にモデルを選び、Usage と Analysis で他 provider と並べて見られるようにしています。つまり、運用上は「ふつうの provider」として扱うわけです。

ただし、OpenRouter は静的 catalog ではありません。ここが最大の違いです。OpenRouter のモデル一覧は固定ファイルから読むのではなく、OpenRouter models API から取得します。その結果を DB に snapshot として保存し、その snapshot をもとに Sifto 側の動的 catalog を組み立てます。つまり、Sifto の中では OpenRouter は provider でありながら、そのモデル群は外部から定期的に流れ込んでくる動的な資源です。この構造にしておくことで、OpenRouter の新しいモデルを取り込みつつ、アプリ内部では一定の安定性を保てます。

静的 catalog と動的 catalog を分けた理由

この設計でかなり重要なのが、静的 catalog と動的 catalog を明確に分けていることです。直接接続 provider のモデルは、shared/llm_catalog.json に載っている静的 catalog に属します。ここには idprovideravailable_purposesrecommendationbest_forcommentcapabilitiespricing といった属性があり、どのモデルをどの用途でどう使うかを明示的に管理しています。これは要するに、「自分たちの責任で採用しているモデル一覧」です。

OpenRouter の動的 catalog は、それとは目的が違います。OpenRouter models API から取得したモデルのうち、text generation に使えるものを採用し、embedding、moderation、reranker、speech/transcription のような対象外モデルは除外します。採用したモデルは snapshot として保存し、そこから Sifto の chat model catalog に変換します。このとき、Sifto 上の provideropenrouter ですが、元の提供元は provider_slug として保持します。たとえば openaigoogleanthropicbytedance-seed などです。つまり、Sifto の内部では「OpenRouter という provider の中に、上流 provider 情報を持ったモデル群がある」という構造になります。

価格についても、OpenRouter のモデルは OpenRouter API が返す pricing をそのまま採用します。description も英語を保持しつつ、日本語訳があればそちらを優先します。このへんも地味ですが重要です。動的 catalog を「とりあえず一覧表示できればいい」ではなく、Usage や設定にそのまま使えるデータとして整えているからこそ、あとで UI や分析に自然につなげられます。

同名モデル問題は alias で解決する

OpenRouter を真面目に扱おうとすると、同名モデル問題は避けて通れません。たとえば openai/gpt-oss-120b のような model ID は、直接接続 provider 側にも、OpenRouter 側にも存在し得ます。このとき、内部で単純に model ID だけをキーにしていると、どちらのモデルなのか区別できなくなります。設定画面で選んだモデルがどちらを指しているのか曖昧になりますし、Usage ログの集計やコスト計算ではもっと深刻です。同じ名前のはずなのに価格が違う、provider も違う、という状況が起こるからです。

そこで Sifto では、OpenRouter の動的 catalog に内部 alias を導入しています。たとえば OpenRouter 経由の openai/gpt-oss-120b は、内部的には openrouter::openai/gpt-oss-120b という ID を持ちます。UI 上はあくまで元の model ID を表示し、alias を見せません。見た目は同じでも、内部では別物として扱うわけです。

この alias 設計には3つの意味があります。ひとつは、settings 上で直接接続 provider 版と OpenRouter 版を別々に選べること。もうひとつは、Usage / Analysis で別 provider として集計できること。そして最後に、OpenRouter snapshot 価格を使って正しく再計算できることです。見た目の名前を保ったまま、運用上必要な差異は内部で保つ。ここがかなり効いています。

モデル選択ロジックは“全部同じ”にしない

Sifto では用途ごとにモデルを持てます。factssummaryfacts_checkfaithfulness_checkdigest_cluster_draftdigestasksource_suggestion などが代表例です。基本原則としては、まずユーザー設定で明示的に選ばれた model を優先し、それがなければ provider ごとの default model に落とす、という2層構造です。ただし、実際の fallback 条件は用途によって変えています。ここを全部同じにすると、使い勝手も運用も変なことになります。

まず、model から provider を判定する際は API 側の catalog を使います。通常の model であれば CatalogProviderForModel(model) で provider を引けますし、openrouter::... という alias で始まるものは必ず openrouter として扱います。これにより、設定された model がどの provider に属するかを、API 側で一意に判断できます。

ask と、それ以外の process-item 系では考え方を分けています。ask は API から worker を直接呼ぶ処理で、ユーザー体験に直結します。一方、facts や summary、check のような process-item 系は Inngest / Worker 経由のバックグラウンド処理に近い流れです。この違いがあるので、同じ fallback 戦略を適用するのは筋が悪い。

ask では settings.ask_model を最優先にします。その model の provider に対応する API key があれば、それを使います。もし key がなくて使えない場合だけ、利用可能 provider の ask default model に落とします。ここで重要なのは、settings.digest_modelsettings.summary_model には落ちないこと、そして実行時に選ばれた Ask model が失敗したときに、別 model へ自動で切り替える仕組みは持たないことです。Anthropic service 内の個別 fallback はあっても、Ask 全体として他 provider へ自動退避するわけではありません。これは「ユーザーが明示的に選んだ Ask の挙動を、勝手に別 provider へ変えない」ためです。少し厳しく見えるかもしれませんが、対話系ではそのほうが予測可能です。

一方、facts / summary / check 系は Inngest 側で runtime を解決します。まず用途ごとの override model を確認し、override が指定されていれば、その model の provider を確定します。その provider に対応する API key をユーザーが持っていれば実行し、持っていなければその場で失敗します。ここでは別 provider へ自動で逃がしません。つまり、明示的に選んだ model があるときは、その選択を尊重します。

逆に、model が未指定の場合だけ、実装側で定義した cost-efficient provider の固定優先順に従って、使える key を持つ provider の default model を選びます。この fallback 候補には OpenRouter も含まれます。ここでいう cost-efficient provider 順 は、単純なリアルタイム価格順ではありません。実装側が「この用途ならまずここから試す」という形で定義した、固定の優先順です。つまり、単価だけでなく、品質や安定性も含めた運用上の判断を、設計に埋め込んでいます。

Worker は stateless に寄せる

実行レイヤーである Worker は、できるだけ stateless に寄せています。DB は持たず、API から渡された model と API key でそのまま実行し、response に含まれる usage を計算して返す。責務はあくまで「与えられた条件で実行すること」です。設定の解決や provider の判定、価格の正規化などは API 側で行い、Worker 側には持ち込まないようにしています。

OpenRouter の実装では OpenAI 互換 transport を使っています。base URL は https://openrouter.ai/api/v1/chat/completions です。ここでも alias の設計が効いていて、OpenRouter alias は実行直前に本来の model ID に解決しつつ、usage メタでは alias を維持して返します。この「実行時には元の ID に戻すが、ログ上は alias を残す」という動きが、後段の価格正規化ではかなり重要です。実行は OpenRouter に通すが、Usage 集計とコスト計算の時点では「これは OpenRouter モデルだ」と分かる状態を保てるからです。

コストは“請求額”ではなく“比較可能な推定値”として扱う

Sifto の LLM cost は、請求 API から取得した実額ではありません。usage token を基準に、catalog 価格で再計算した推定値です。この方針はかなり意識的なものです。保存する項目としては、provider、model、pricing_model_family、pricing_source、input_tokens、output_tokens、cache_creation_input_tokens、cache_read_input_tokens、estimated_cost_usd などがありますが、重要なのは「最終的な cost は API 側で正規化して決める」という点です。

なぜそこまでやるのかというと、Worker が返す usage 情報だけでは、比較可能なデータにならないからです。provider ごとに usage のフォーマットが微妙に違いますし、OpenRouter の価格は静的 catalog ではなく snapshot ベースで動きます。さらに、同名モデルでも OpenRouter 版と直接接続 provider 版では扱いを分ける必要があります。こうした事情を考えると、個々の実行結果が返してくる数字をそのまま保存しても、あとでまともに比較できません。

そこで API 側では、non-cached input、output、cache read、cache write を catalog の価格で再計算します。これにより LLM UsageLLM Analysis に出るコストは、常に「その時点の Sifto 側の catalog 解釈に基づく estimated cost」になります。これは単なる見積もりではなく、比較可能な Usage データを作るための正規化層です。個々の provider の都合に引きずられず、アプリ全体として同じ物差しで比較するための仕組み、と言ったほうが近いかもしれません。

OpenRouter と直接接続 provider の model ID が重複している場合でも、worker は alias を usage の model に残し、API 側は openrouter::... を OpenRouter モデルとして catalog lookup します。そのうえで OpenRouter snapshot 価格を使って estimated_cost_usd を再計算します。結果として、表示名が同じモデルでも、直接接続 provider 版と OpenRouter 版を Usage / Analysis 上で分離できます。ここまでやって初めて、「同じ名前だけど別の経路で使ったモデル」をちゃんと比較できます。

UI も、モデル数が増える前提で作り直した

モデル数が少ないうちは、Settings 画面に select を置いておけば済みます。でも OpenRouter を入れた瞬間、そのやり方は死にます。候補が増えすぎて、どこに何があるのか分からなくなるからです。そこで Sifto では、OpenRouter 導入後に Settings のモデル選択 UI をモーダル方式に変えました。用途ごとに「選択」ボタンを置き、provider フィルタとフリーテキスト検索を使って候補を絞り、モデル行をクリックして確認ステップを挟んだうえで確定する、という流れです。大量モデルを前提にした設計へ切り替えたわけです。

OpenRouter Models 画面も専用に用意しています。ここでは同期結果を一覧表で見られるようにしており、最終同期時刻、fetched / accepted 件数、手動同期、provider フィルタ、フリーテキスト検索、列ソート、詳細モーダル、日本語訳 description の確認といった機能を持っています。カード UI にせず、1モデル1行のテーブルにしたのも意図的です。モデル数が多い状態では、一覧性のほうが重要だからです。

Usage / Analysis 画面では、すべての用途で保存された usage を共通の llm_usage_logs に集約しています。OpenRouter も通常 provider と同じように集計され、provider 表示は OpenRouter として出ます。これにより、「どの用途でどのモデルをどれくらい使ったか」「どの provider にコストが寄っているか」といった分析が、provider 横断で見られるようになっています。

OpenRouter の同期は“取ってきて終わり”ではない

OpenRouter を動的 catalog として扱う以上、同期まわりも運用の一部です。Sifto では手動同期と日次同期の両方を持っています。手動同期は OpenRouter Models 画面から実行でき、POST /openrouter-models/sync を叩いて最新モデル一覧を取得し、DB に snapshot を保存し、動的 catalog を更新します。その後、description の日本語訳は非同期で進みます。

この翻訳処理にも少し工夫があります。OpenRouter API から取ってきた英語 description のうち、description_ja がないものだけを対象に、OpenAI 系の翻訳モデルを使って1件ずつ日本語化し、成功したものから随時 DB に保存していきます。一括バッチでまとめて処理すると、途中で失敗したときに全部止まるし、時間もかかりすぎる。なので、1件ずつ進めて、途中成功分を確実に残す方式にしています。api 側に OPENAI_API_KEY がなければ、英語 description のまま使います。このあたりも「理想的に全部揃っていなくても、とりあえず運用は回る」ようにしています。

日次同期では Inngest の sync-openrouter-models を使い、最新モデル一覧の取得、snapshot 保存、日本語 description の補完、動的 catalog 更新、新規モデル追加差分の通知までを行います。前回の成功 snapshot と比較して新規 model ID が増えていれば、push や email で通知します。つまり OpenRouter は「たまに手動で更新する一覧」ではなく、継続的に監視・取り込み・通知する対象として扱っています。

まとめ

Sifto の LLM 基盤は、単に provider の数が多い、という話ではありません。直接接続 provider を静的 catalog で管理し、OpenRouter を独立 provider かつ動的 catalog として統合し、用途ごとに異なるモデル選択ロジックを持たせ、Worker が返す usage を API 側で正規化して比較可能なコスト / Usage データへ揃える。さらにそれを Settings、OpenRouter Models、LLM Usage、LLM Analysis といった UI まで一貫してつないでいます。

要するに、Sifto の LLM 基盤は、実験モデルを増やしやすい構成と、運用時に比較・分析しやすい構成を両立させるための設計です。LLM 周りは「とりあえず API を叩く」だけならすぐできます。でも provider を増やし、用途ごとに使い分け、コストまで見ようとした瞬間に、雑な設計は全部しわ寄せになります。Sifto ではそのしわ寄せを、基盤側できちんと引き受けるようにしました。

リポジトリの紹介

最後に、Sifto 自体の実装に興味がある方は、GitHub リポジトリも見てもらえると嬉しいです。
この記事で触れた LLM 周りの構成だけでなく、RSS 取得から本文抽出、事実抽出、要約生成、ダイジェスト生成まで含めて、アプリ全体の実装を公開しています。

リポジトリはこちらです。

GitHub - enjoydarts/sifto: ユーザーが登録したRSSフィードおよび単発URLを自動収集し、本文抽出・事実抽出・要約を行い、毎朝Digestメールとして配信するサービス。
ユーザーが登録したRSSフィードおよび単発URLを自動収集し、本文抽出・事実抽出・要約を行い、毎朝Digestメールとして配信するサービス。 - enjoydarts/sifto

コメント

タイトルとURLをコピーしました