請求は月末にやってくる
あなたはボットを出荷する。Claudeはうまく返ってくる。クライアントはご機嫌だ。最初の1か月は穏やかに過ぎる。ところがAnthropicの請求を開くと、小さなカフェからのトラフィックだけで200ドル超になっている。
ログを掘り下げる。1か月で60,000件のリクエスト。「日曜は営業していますか?」「住所はどこですか?」「配達は無料ですか?」— その類が何千回も繰り返されている。どれもすべて、400トークンのシステムプロンプト付きでClaude Sonnetにルーティングされていた。
これはモデルのコスト問題ではない。根本はアーキテクチャの問題だ。負荷が本質的に非一様なのに、モデル提供(サービング)が一様になっている。
ビジネス向けボットにおけるリクエストの複雑さは、通常は正規分布ではない。二峰性(bimodal)だ。Claudeの力が完全に無駄になる長い裾のFAQリクエストと、実際に必要とされる場面である苦情・イレギュラーケース・生成タスクの、狭いスパイクがある。これらのフローを分割しないと、「ローカルモデルで十分だったはずの」クラウド推論に対して支払い続けることになる。
「とりあえずOllamaを使えばいい」はなぜ機能しないのか
明らかな解決策は、すべてをOllamaに移すことだ。たとえばGPU上で llama3.1:8b や mistral:7b のようなモデルを使えば、単純なタスクに対しては、変動費ゼロで許容できる品質が得られる。
しかし問題は、オープンソースモデルが特定の状況で劣化することだ。長いコンテキスト(>3Kトークン)、出力形式の厳格な要件、多段の推論。RAGを使ったボットでは、これらは日常的に発生する。すべてをOllamaに寄せると、クライアントが気づくまさにその箇所で、品質が予測不能になる。
もう一つの考え—「複雑なリクエストだけClaudeに払えばいい」—は方向性としては正しいが、「複雑」とは何か。正式な分類器がないと、コード内で手作業で維持する条件分岐になり、規模に合わせて伸びず、トラフィックが変わるたびに壊れていく。
必要なのはルータだ。どのモデルがリクエストを処理するかを、どこかへ送る前に判断するコンポーネントである。
アーキテクチャ:1つのインターフェース、2つのティア
コアとなる要件:ルータは外部から見えないこと。FastAPIのエンドポイントの観点では、常に応答を返す単一の llm_client.complete() があるだけだ。リクエストがどこへ行ったかは実装詳細になる。
OllamaとClaudeの間で負荷分散を行うわけではない。階層(ヒエラルキー)がある。Ollamaが第1ティアで、Claudeはエスカレーション(引き上げ)だ。エスカレーションは3つのケースで発生する。ルータがそう判断したとき、Ollamaが無効なレスポンスを返したとき、またはOllamaが利用できないとき。
ルータ:エラーコストの非対称性
ルータは二値の「単純/複雑」分類器ではない。正しい捉え方は、「ルーティングの誤りに伴う期待コストを最小化する」ことだ。
複雑なリクエストをOllamaへ誤って振り向けた場合:品質劣化、リトライ、会話が壊れる可能性。B2Bではクライアントにとって本当の業務上の結果が生じる。
単純なリクエストをClaudeへ誤って振り向けた場合:少しのオーバースペンド(数セント)。
非対称性は明白だ。つまり、疑わしければクラウドへ、という具体的なルールが得られる。これで慎重になっているわけではない。各エラータイプの実コストを、正しく見積もっているだけだ。
意思決定ロジックは2層構造になっている。
まずハードルールが発火し、スコアリングの結果を上書きする。苦情、法的文脈、生成タスク—常にClaude。営業時間や住所を開くための明快なリクエスト—常にOllama。
ハードルールが発火しない場合にのみ、ソフトなスコアリングが動く。要因は、RAGコンテキストの量、形式要件、メッセージ長、対話における連続した確認質問の回数(回数の増加は、以前の回答が問題解決に役立っていないことを示すシグナルになる)。
ルーティングの閾値は意図的にずらしている:
target = (
ModelTarget.CLOUD
if signal.score > 0.35 or signal.confidence < 0.6
else ModelTarget.LOCAL
)
confidence < 0.6 — ルータが分類に十分な確信を持てない場合、リクエストはClaudeへ送られる。非対称性を明示的にコード化している。
本番環境で壊れる3つのこと
Ollamaの整形された出力。JSONを返すように明示的に指示していても、llama3.1:8bは定期的にそれをmarkdownのコードブロックで囲んだり、周囲にテキストを付け足したりする。本番ではこれを「レッジケース」とは呼べない。日常的に起こる。解決策は、複数のフォールバックパターンでパースすること、そして2回失敗したらClaudeへ自動エスカレーションすること。3回リトライでも4回リトライでもない。Ollamaでの2回目のリトライは、Claudeを1回呼ぶより遅い。
負荷が高いとコンテキストウィンドウが崩れる。Ollamaは最初のリクエストでnum_ctxをモデルに割り当て、そのセッションの中では動的に調整しない。サービスがデフォルトのnum_ctx=2048で起動しておき、RAGコンテキストが3,500トークンのリクエストが来たら、コンテキストは静かに(暗黙に)切り詰められる。エラーはなく、「何も分からない内容」についてのレスポンスが返ってくるだけだ。num_ctxは毎回明示的に渡す必要があり、実際のボリュームより上の余裕(headroom)を持たせるべきだ。
スパイク時のレイテンシ劣化。単一GPU上では、Ollamaはリクエストを並列化しない。キューに積むだけだ。突然のトラフィックスパイクではp95レイテンシが線形に増える。ルータはこのことを知らないため、ローカルへルーティングし続けてしまう。必要なのはエラーだけでなく、レイテンシに対する回路遮断(circuit breaker)だ。p95の閾値を超えたら、分類に関係なく、すべてのトラフィックを一時的にClaudeへ送る。これは別のコンポーネントとして実装すべきで、ルータのロジックに条件を追加してはいけない。そうすると、回路遮断の状態が分類と絡み合って混乱する。
観測性(Observability)
適切なログがないと、システムは不透明になる。コストは見えるが、何がそれを生んでいるのか分からない。
重要なのは、routed_to だけでなく actual_model もログに残すことだ。これらのフィールドはエスカレーションの際に乖離する。ルータの主要な健全性指標は、エスカレーション頻度である。増えているなら、トラフィックのパターンが変わったか、ローカルモデルが劣化したか、あるいは閾値の再調整が必要だということになる。
次に重要なシグナルは、品質の代理指標だ。手動のレスポンスラベリングではない。下流の挙動を見る。つまり、ユーザーが回答から2分以内にフォローアップの質問をする場合、最初の回答はおそらく問題を解決できていない。追加のインフラなしで測定できる。
数字
実例:カフェのためのTelegramボット。ルータをリリースしてから1か月観測した。
リクエストタイプ:トラフィック比率:モデル:FAQ、営業時間、住所、料金— 61%:Ollama。メニューの確認(clarifications)、材料(ingredients)— 18%:Ollama。書類に基づくRAG、生成— 9%:Claude。ケースの例外、苦情— 12%:Claude
コスト(導入前):234ドル/月。導入後:47ドル/月。クライアントの苦情による品質—変わらず:以前はClaudeに送られていたシナリオは、引き続きClaudeに送られている。
80%のコスト削減は、アーキテクチャの目的ではない。リクエストのコストを、複雑さの関数にして一定コストではなくしたことによる副産物だ。真の利益は、システムが「可読」になったこと。今では、各インタラクションタイプがいくらかかるのかが見えて、トラフィックが増えたときに何をすればよいかを正確に判断できる。



