ゲートウェイの保証はゲートウェイの性質ではない。呼び出し側が選んだトランスポートの性質であり、リアルタイムへ一歩近づくたびに弱くなる。素のリクエストレスポンス呼び出しは、モデルが実際に返したusageブロックから計測される。同じ呼び出しにstream: trueを付けると、ポリシーが何と言おうとプロンプトのtokensは推定になり、補完のtokensも推定になる。WebSocket接続はそもそもバックエンド間でload balancingできない。確立された瞬間、クライアントとバックエンドの間に1対1で固定されるからだ。第2回はインフラを築いた。本稿はその上にAPIを載せる。そして誠実なAPIとは、いま自分がどの保証を手放したかを各呼び出し側に告げるAPIだ。

コンシューマーチームが知るべきこと

正確に3つだ。base URL、クライアントクレデンシャル、そしてAzureにとってではなく自分たちにとって意味を持つモデル名。デプロイメント、リージョン、プロバイダー、そのどれが先週の火曜に変わったか。いずれも知らなくていい。それが契約のすべてであり、本稿にあるすべては、裏側が動き続けてもこの契約を真に保つために存在する。

API Managementのunified model APIはまさにこのために作られている。クライアントはOpenAI Chat Completions形式で話し、ゲートウェイが対象モデルの実際に使うバックエンド形式へ翻訳する。サポートされるバックエンドAPI形式はOpenAI Chat CompletionsとAnthropic Messages APIの2つで、個別に名指しする価値のある仕事を4つこなす。クライアント向け形式をバックエンドから独立して標準化し、プロバイダー横断で1組のガバナンスポリシーを適用し、プロバイダーをまたぐfailoverを構成し、そしてaliasでクライアント向けモデル名をバックエンドのモデル名から切り離す。

ここで最も重要なのがaliasで、第2回の直接の続きにあたる。gpt-4.1のバージョン2025-04-14NoAutoUpgradeで固定されたモデルデプロイメントは、どのアプリケーションもその名を直接呼ばない場合にだけ役に立つ。アプリケーションが呼ぶのはchat-defaultだ。aliasがchat-defaultをデプロイメントに対応付け、その対応が変わる日は、5チームをまたぐ協調リリースではなく、プルリクエストでレビューされるゲートウェイ構成の変更になる。

サポートチケットではなくここに書いておくべき注意が2つある。unified model APIはプレビュー中で展開の途上にあり、クラシックティアでの早期アクセスはAI Gateway Earlyリリースチャネル、つまりインスタンス単位の更新設定を通じて行われる。またサポートされる2つのバックエンド形式は、この会社が動かすすべてを覆っていない。Microsoft FoundryのMistralとLlamaのデプロイメントにはOpenAI互換のchat completionsサーフェス経由で到達し、unified APIはそれをOpenAI形式のバックエンドとして前面に立てられるが、これはネイティブなAnthropic翻訳とは別の統合経路だ。どのモデルがどの経路で届くかは、誰かが午前2時に形式の不一致をデバッグする前に書き留めておく価値がある。

ルーティングは優先度グループが先、重みが後

aliasの背後にあるのはbackendではなくbackend poolだ。API Managementのpoolはラウンドロビン、重み付き、優先度ベース、セッション対応の分散をサポートし、モデルトラフィックに有効なパターンは最初の2つの組み合わせになる。吸収できる限りを吸収すべきProvisioned Throughputデプロイメントの優先度グループと、その第1グループが使えないときにだけトラフィックを見る低優先度の従量課金デプロイメントのグループだ。グループ内では重みが負荷を分ける。

poolはポータル、REST API、またはARMかBicepのテンプレートで構成する。第2回はTerraformを選んだが、これはまさに同回がazapiの脱出ハッチとして正当だと述べたケースそのものだ。1つのリソース、生のARMボディが、azurermリソースの隣に座り、それらをIDで参照する。

resource "azapi_resource" "chat_pool" {
  type      = "Microsoft.ApiManagement/service/backends@2023-09-01-preview"
  name      = "chat-pool"
  parent_id = azurerm_api_management.gw.id

  # azurerm has no backend-pool schema yet. Revisit when it does.
  body = {
    properties = {
      description = "PTU first, PAYG overflow"
      type        = "Pool"
      pool = {
        services = [
          { id = azapi_resource.be_ptu_west.id,  priority = "1", weight = "1" },
          { id = azapi_resource.be_payg_west.id, priority = "2", weight = "3" },
          { id = azapi_resource.be_payg_north.id, priority = "2", weight = "1" },
        ]
      }
    }
  }
}

優先度1はPTUデプロイメントだ。すでに時間単位で支払い済みなので、そこが捌けるtokenはすべて追加コストゼロのtokenになる。優先度2は優先度1が受けきれないときにだけトラフィックを受け、2リージョン間の3対1の重みは好みではなくクォータを反映している。これはルーティングとして表現されたコストトポロジーであり、アーキテクチャ図では見えず、請求書では明白になる類のものだ。

circuit breakerと、どこまで信用してよいか

失敗し続けているpoolメンバーは、人間が判断しなくてもトラフィックを受けなくなるべきだ。API Managementはcircuit breakerをbackendのプロパティとして公開する。ルールが、一定間隔内の失敗回数または失敗率と、失敗として数えるステータスコード範囲を定義し、breakerがトリップするとゲートウェイはトリップ期間中そのbackendの呼び出しを止め、クライアントに503 Service Unavailableを返す。backendがRetry-Afterヘッダーを送っていれば、breakerはその値を受け入れてちょうどその長さだけ待てる。レート制限されたモデルエンドポイント相手には、固定値の当て推量よりはるかに良い。

circuitBreaker = {
  rules = [{
    name = "ptu-5xx"
    failureCondition = {
      count    = 3
      interval = "PT1H"
      statusCodeRanges = [{ min = 500, max = 599 }]
    }
    tripDuration     = "PT1H"
    acceptRetryAfter = true
  }]
}

この機能には、それについてどう推論してよいかを決める性質が2つある。第一に、トリップのルールは近似だ。ゲートウェイは分散していて、インスタンス間でbreakerの状態は同期されず、各インスタンスは手元の情報でルールを適用する。3回の失敗でトリップするよう構成したbreakerが、スケールアウトしたインスタンス全体では実際にはかなり多くを通すことがある。これは保護機構であってカウンターではなく、その正確なしきい値の上にSLO計算を組んではいけない。第二に、Consumptionティアでは利用できない。第2回のティア決定がコストの話ではなかった理由が、また1つ増えたことになる。

503は意図して設計する価値がある。ゲートウェイから503を受けた呼び出し側は、モデルが落ちているのか、breakerが開いているのか、インスタンス全体が不健全なのかを区別できず、この3つはそれぞれ異なるクライアント挙動に値する。outboundポリシーで、どのbackendを試したかを示すレスポンスヘッダーを足し、breakerの状態をログに残せば、不透明な503は、コンシューマーチームがチケットを開かずに行動できる情報になる。

streamingと、それが会計に課す代償

コンシューマーチームは例外なくstreamingを求めるし、それは正しい。最初のtokenまでの時間は人間が知覚する数字で、総レイテンシはダッシュボードが表示する数字だ。標準の答えは同じHTTPエンドポイント上のserver-sent eventsで、クライアントがstream: trueを設定するだけ。ゲートウェイのルーティングは何も変わらない。

変わるのはメーターだ。トークン制限ポリシーは通常、実際のusageから動く。estimate-prompt-tokens="false"ならモデルのレスポンスのusageセクションを読むため、リクエストが上限を超えたことは事後に検出され、ウィンドウがリセットされるまで後続リクエストがブロックされる。推定を有効にすると、呼び出しの前にAPI定義のスキーマからプロンプトのtokensを数え、わずかな精度と引き換えに、超過を知るためだけにバックエンドのクォータを燃やさずに済む。

streamingはこの選択肢を奪う。stream: trueが設定されると、ポリシー設定に関係なくプロンプトのtokensは常に推定になり、補完のtokensも推定になる。streamingされたリクエストをゲートウェイで実測から計測できる構成は存在しない。画像を受け付けるモデルではずれが積み上がる。streamingが有効なとき、または推定が有効なとき、ポリシーはバックエンドのカウントを取らずに画像のtokensを過大に数える。

これはstreamingを拒む理由ではない。数字が何を意味するかに正確であるべき理由だ。第5回がその数字をお金に変えるからだ。

  • 非streamingのHTTP: モデル自身のusageから計測。正確。
  • streamingのSSE: プロンプトと補完のtokensはゲートウェイで推定。レート制限には十分、課金には近似。
  • WebSocket: 後述。実質的に帯域外。

コントロールプレーンの答えは、より良い推定ではなく突合だ。ゲートウェイメトリクスはクォータを強制するリアルタイム信号であり続け、第5回で構築する月次のアトリビューションジョブが、プロバイダーの報告する正式なusageに対して補正し、その差分は独立したメトリクスとして追跡する。増え続けるずれはバグであり、数パーセントで横ばいのずれはstreamingの代償だ。プロダクトオーナーに発見される前に、チャージバック文書に書き込んでおく。

WebSocket、抽象化が終わる場所

リアルタイムの音声やspeech-to-speechのトラフィックはリクエストレスポンスに収まらず、API ManagementはWebSocket APIをサポートする。ただしその制約は鋭く、アーキテクチャを飾るのではなく変えてしまう。

WebSocket接続は複数のbackendへの分散もload balancingもできない。いったん確立されると、各接続はクライアントとbackendの間で1対1に維持される。本稿のあらゆるルーティング判断、優先度グループも、PTU優先のトポロジーも、重み付きのリージョン分割も、最初のハンドシェイクにだけ適用され、その後には何も適用されない。セッションの途中で劣化したbackendをゲートウェイがfailoverさせることはできない。セッションを終わらせ、クライアントに再接続させるしかない。この経路のクライアントにはバックオフ付きの再接続ロジックが必要で、キャパシティモデルはWebSocketセッションを、個別にルーティングできるリクエストの流れではなく、固定された予約として扱う必要がある。

さらに小さめの制約が3つ続く。onHandshake操作には適用できないポリシーの長いリストがあり、キャッシュ、CORS、ボディ操作、すべてのvalidate-*ポリシーが含まれる。グローバルスコープやプロダクトスコープから継承されたポリシーは、大声で失敗する代わりに実行時に黙ってスキップされる。set-headerポリシーは、ハンドシェイクリクエストではHostを含む特定の既知ヘッダーを変更できない。そしてTLS検証はHTTP APIより厳しい。WebSocketのbackendに対してゲートウェイは、サーバー証明書が信頼できること、そのsubject名がホスト名に一致することの両方を検証する。HTTP APIなら信頼だけでよい。証明書のsubjectが不一致のままHTTPで何年も動いてきたbackendは、WebSocket APIとして前面に立てた瞬間に失敗する。

実務上の帰結はこうだ。リアルタイム経路は自前のAPI、自前のプロダクト、自前のクォータを持ち、コンシューマー向けドキュメントには、チャットエンドポイントが持つfailover保証の対象外だと平明に書く。そうでないふりをすれば、その違いはインシデントレビューで発見されることになる。

session affinityと、それを必要としない理由

poolはCookieによるセッション対応をサポートする。クライアントがSet-Cookieの値を保存して返送し、後続リクエストが同じbackendへ届くようにする仕組みだ。これはステートフルなサーバー側APIのために存在する。典型はAssistants APIで、クライアントはセッションIDを保持し、レスポンスボディからスレッドIDを取り出し、正しい呼び出しに正しいCookieを送らなければならない。

動きはする。そして設計の時点で避ける価値のある制約でもある。session affinityを持つ呼び出しは再ルーティングできない呼び出しであり、WebSocket経路と同じカテゴリーに入る。failoverはなく、backendの負荷が、届いたリクエスト数ではなく、どのセッションがそこに着地したかで決まるキャパシティモデルになる。ステートフルなAPIが本当に必要な場面では、別のaliasに載せ、その弱い保証が、量の大半を占めるステートレスなトラフィックへ黙って継承されないようにする。

注視すべき失敗モード

  • 4つの意味を持つ503。breakerが開いた、poolメンバーが全滅した、ゲートウェイが不健全、あるいは本当に落ちているbackend。どれなのかを示すヘッダーかログの1行がなければ、その全部がチケットになる。
  • streamingのずれをバグとして扱う。これは文書化された挙動だ。失敗はずれ自体ではなく、推定値を請求書と書かれた場所に載せることだ。
  • クリティカルパスの中のプレビュー機能。unified model APIはプレビュー中だ。翻訳層としてそれが許されるのは、フォールバック、つまり同じプロダクトとポリシーの背後にあるプロバイダー別の直接APIが、インシデントの最中に設計されるのではなく、すでに存在しテスト済みである場合に限る。
  • WebSocket APIで黙ってスキップされるポリシー。onHandshakeでサポートされない継承ポリシーは実行時にスキップされる。グローバルだと思い込んでいたセキュリティ統制がここではグローバルでなく、誰も教えてくれない。

第4回が受け継ぐもの

本稿の終わりに、コンシューマーチームの手元には1つのエンドポイント、1つの形式、安定したモデルのaliasが残る。その背後にはルーティングとfailoverがあり、どの保証がどのトランスポートに適用されるかを文書化した宣言が付く。そのどれもが扱わないのは、いま答えを必要としない仕事だ。サポートチケットの夜間サマリーが、最初のtokenを待つ顧客と同じ同期経路に座り続ける理由はない。それはキューであり、次の話だ。

次に読む

これを大規模に運用する際のインフラおよびプラットフォーム側の話はercan.cloudのフィールドノートに、ハブはercanermis.comにある。

参考資料