Azureのバッチ推論の経路は、APIコールにはまったく見えない。JSONLファイルをアップロードし、jobを作成し、最大24時間後に出力ファイルを回収する。つまり、ゲートウェイポリシーが前に立てるリクエスト単位のトランザクションが存在しない。第3回が築いた保証のすべて、トークン制限、テナント単位の計測、ルーティング、circuit breakingは、API Managementを通るリクエストに適用される。バッチは構造上そのどれも持たない。本稿は、そもそも同期経路に載せるべきでなかった仕事を移し、それによってモデルへの第二の扉が開くという事実に向き合う。

リクエストパスの外に属するもの

この会社の5つのアプリケーションが生む仕事のうち3種類は、そこが最も置きやすい場所だったという理由だけで同期経路に載っている。基準は「遅いかどうか」ではなく、誰が待っているかだ。

  • サポートチケットの夜間サマリー。数万件の項目、待つ人間はおらず、結果は朝のレポートまでにあればよい。完璧なbatch候補だが、いまは会話の途中にいる顧客と同じTPM quotaを取り合っている。
  • 小売のナレッジ検索向けドキュメント取り込み。バースト性があり、アップロードで発火し、分単位の遅延なら許容できる。batch jobではなくqueueだ。分単位のレイテンシは成立するが、時間単位では成立しない。誰かがドキュメントをアップロードしたのは、それを見つけられると期待してのことだからだ。
  • プロンプト変更後の再スコアリング。コーパスに対して走り、ユーザーは一切おらず、完走するより途中でキャンセルされることの方が多い。明示的なキャンセルの筋書きを備えたbatchだ。

3つの形があり、ちょうど3つの機構に対応する。分単位にはworker付きのqueue、時間単位にはbatch job、人間が見ているすべてには同期ゲートウェイ。これらを混同した結果が、マーケティングのjobがカスタマーサービスのアシスタントを落とすという第1回のインシデントだ。

queueと、そこに入れてはいけないもの

分単位の仕事はAzure Service Busが運ぶ。重要な設計判断はすべて、どのブローカーを使うかではなく、メッセージに何を入れるかにある。

プロンプトをメッセージに入れてはいけない。Service Busは個々のメッセージプロパティを32 KBに、ヘッダーの累計、つまりユーザープロパティとシステムプロパティの合計を64 KBに制限しており、超過は静かな切り詰めではなくシリアライズ例外になる。ボディの内側でも、1 MBを超えるペイロードはエンティティのサイズquotaに対して二重に数えられる。答えはclaim-checkパターンだ。ドキュメントはBlob Storageへ行き、メッセージはblob参照、テナントID、モデルalias、相関IDを運ぶ。メッセージは小さいまま、queueの深さは意味のあるメトリクスのままで、ペイロードはworkerがストリーミングしたい場所にすでに置かれている。

worker poolの形を決める制約がさらに2つある。単一のqueue、topic、またはsubscriptionが受け付ける同時receive要求は5,000件までで、それを超えるreceiveはserver busyエラーで拒否される。これはスループットではなくレシーバー数の上限であり、タスクごとにレシーバーを開くworkerは想定より早くそこへ到達する。またnamespaceが許容する同時AMQP接続は5,000件なので、workerでのコネクションプーリングは最適化ではなく、規模が出た時点で必須になる。

dead-letterの扱いは、LLMのqueueが普通のqueueと分かれる場所だ。モデルがコンテンツフィルターによるブロックを返して失敗したメッセージは、workerがクラッシュして失敗したメッセージと同じではなく、リトライしてよいのは後者だけだ。モデルが拒否で答えたら、workerはメッセージをcompleteし、拒否を結果として記録する。abandonするのはインフラ障害のときだけだ。さもなければ、決して成功しないメッセージの配信カウントが上限まで積み上がり、dead-letter queueは本物の障害と区別のつかない項目で埋まる。

KEDAでworkerをスケールさせる

workerは第2回がプロビジョニングしたAKSクラスターに住み、queueが空のときに走っているべきではない。KEDAはAKSのアドオンとして利用でき、ワークロードをゼロまでスケールさせる。デプロイメントにはScaledObjectを、job型の仕事にはScaledJobを駆動し、認証はMicrosoft Entra Workload IDを通じてワークロードから切り離される。

apiVersion: keda.sh/v1alpha1
kind: ScaledObject
metadata:
  name: ingest-worker
spec:
  scaleTargetRef:
    name: ingest-worker
  minReplicaCount: 0
  maxReplicaCount: 20
  pollingInterval: 15
  cooldownPeriod: 120
  triggers:
    - type: azure-servicebus
      metadata:
        queueName: doc-ingest
        messageCount: "20"      # target backlog per replica
      authenticationRef:
        name: keda-workload-identity

アドオンには3つの制限があり、これが1回目で動くか3回目で動くかを分ける。同じワークロードにScaledObjectとHorizontal Pod Autoscalerを組み合わせてはいけない。KEDAは内部でHPAを使うため、両者は競合する。HPAが先に存在するとScaledObjectの作成は失敗し、ScaledObjectが先に存在するとHPAはそれでも作られ、スケーリング挙動が奇妙になる。クラスターごとに許される外部メトリクスサーバーは1つだけなので、KEDAアドオンが唯一の存在でなければならず、複数のKEDAインストールはサポートされない。つまり、あるチームがプラットフォームのものと並べてHelmで自前インストールする道は塞がれている。そしてAKS Standardでは、KEDAアドオンより先にworkload identityを有効化すること。順序を誤ると、KEDAオペレーターのPodは正しい環境を拾うために再起動が必要になる。

有用な既定値を挙げる。絶対的なqueueの深さではなくレプリカあたりのバックログでスケールし、maxReplicaCountはクラスターのキャパシティではなくモデルのquotaから決める。それぞれが429を受け取る20のworkerは、受け取らない5つより悪く、queueはメッセージがどれだけ待とうと気にしない。

batch経路と、それが第二の扉である理由

時間単位の仕事はGlobal-Batchモデルデプロイメントへ行き、そのAPIの形こそが本稿の要点だ。傍受すべきリクエストが存在しない。ファイルがアップロードされ、jobが作られ、jobが完了すると出力ファイルが現れる。この仕組みは、正確に記述する価値があるほど具体的だ。

入力はJSONLで、1行に1つのリクエストオブジェクト、すべての行がcustom_idを運ぶ。

{"custom_id": "ticket-88412", "method": "POST", "url": "/v1/chat/completions", "body": {"model": "batch-summarize", "messages": [{"role": "system", "content": "Summarize the ticket in two sentences."}, {"role": "user", "content": "..."}]}}
{"custom_id": "ticket-88413", "method": "POST", "url": "/v1/chat/completions", "body": {"model": "batch-summarize", "messages": [{"role": "system", "content": "Summarize the ticket in two sentences."}, {"role": "user", "content": "..."}]}}

レスポンスはファイルが定義した順序では返らない。だからこそcustom_idは便利なのではなく必須なのだ。レスポンスを入力へ結合し直す唯一の手段だからだ。model属性はGlobal Batchデプロイメントを名指ししなければならず、すべての行に同じデプロイメント名が現れる必要がある。第二のデプロイメントを狙うなら第二のファイルと第二のjobが要る。つまり「このバッチを今日安い方のモデルへ回す」は、ルーティングの判断ではなく提出時の判断になる。Microsoft自身のガイダンスも、多数の小さなファイルではなく大きなファイルを提出せよ、だ。

batch = client.batches.create(
    input_file_id=file_id,
    endpoint="/chat/completions",
    completion_window="24h",
    # 1209600 to 2592000 seconds, 14 to 30 days, before the output file expires
    extra_body={"output_expires_after": {"seconds": 1209600, "anchor": "created_at"}},
)

jobはその後validatingin_progressfinalizingcompletedと進み、作成の24時間後を指すexpires_atと、completed、failed、totalを刻み続けるrequest_countsを運ぶ。完了ウィンドウは24時間で、その内側で終わらないjobは継続せず期限切れになる。出力ファイル自体にも期限があり、14日から30日の間で設定できるウィンドウで消える。結果が来四半期もまだそこにあると仮定するパイプラインは、起こるのを待っているデータ損失だ。

キャパシティもここでは働き方が違う。batch jobはenqueued tokenのquotaを消費し、それを超えるほど大きなjobは、前のjobの後ろに並ぶのではなく拒否される。一部のリージョンではfail-fast挙動がサポートされ、複数のbatch jobを指数バックオフ付きでキューに入れられるため、1つの完了が次を自動的に始動させる。それがなければ、リトライループは提出側の仕事であり、各アプリケーションではなくcontrol planeに属する。

会計を誠実に保つ

ここからが居心地の悪い部分だ。batch経路はAPI Managementを通らないため、llm-token-limitはそれをスロットルせず、llm-emit-token-metricはそれを計測せず、第5回がこれから構築するテナント単位のアトリビューションには、社内最大のワークロードと同じ大きさの死角ができる。

答えは2つあり、その差は既定に流されるのではなく意図して決める価値がある。

  • アプリケーションにbatch jobを直接提出させ、計測されない第二の扉を受け入れる。最も単純だが、第1回が書かれた理由そのもの、誰にも帰属させられない請求書を静かに再導入する。
  • control planeを唯一のbatch提出者にする。アプリケーションはcontrol planeへbatchリクエストをポストし、control planeがテナントを検証し、モデルaliasをGlobal Batchデプロイメントへ解決し、JSONLを書き、jobを提出し、提出をテナントに記録し、完了までポーリングし、出力を返す。同期トラフィックにとってはゲートウェイが唯一の扉であり続け、非同期トラフィックにとってはcontrol planeが唯一の扉になる。

後者の方が手間はかかるが、このシリーズの前提を真に保つのはこちらだ。しかも会計は、第3回のstreamingのケースより固い地面に着地する。完了したbatch jobは自らのrequest_countsを報告し、出力ファイルはレスポンスごとのusageを運ぶ。つまりbatchの支出は正確に帰属でき、同期経路のstreamingトラフィックより精度が高い。asyncは近似を意味すると誰かが決めつける前に知っておく価値のある、心地よい逆転だ。

注視すべき失敗モード

  • 成功しえないリトライループ。コンテンツフィルターの拒否は結果であって障害ではない。それをリトライするとquotaを燃やし、配信カウントを膨らませ、正しく回答済みのメッセージで満ちたdead-letter queueに行き着く。
  • quotaを超えてスケールしたworker。KEDAはバックログだけを見て平然とmaxReplicaCountまでスケールする。それがモデルデプロイメントのTPMの許容を超えると、余分なレプリカは429を生むだけで、queueの掃けは速くならない。
  • 静かに期限切れになるbatch job。24時間のウィンドウは、誰かのコードの例外ではなくexpiredステータスで終わる。jobステータスへのアラートがなければ、朝のレポートはただ欠け、最初に気づくのはそれを読む人だ。
  • 古びて消える出力ファイル。結果ファイルの期限は14日から30日の間だ。保持が必要なものは、後でではなく提出の時点で、プラットフォームが所有するストレージへコピーする。
  • 利便性で再び開く第二の扉。Global Batchデプロイメントへの直接アクセスを持つ1チームが、アトリビューションモデルを崩す。第1回のネットワークルールとアラートはここにも適用され、そしてここが最も見落とされやすい経路だ。

第5回が受け継ぐもの

ゲートウェイを通る同期トラフィック、ゼロまでスケールするworker付きのqueueに載る分単位の仕事、そしてcontrol planeがGlobal Batchデプロイメントに対して提出する時間単位の仕事。3つの経路、3つのレイテンシプロファイル、そして品質の異なる3種類の利用データが、同じ問いに流れ込む。どのチームが何を使ったのか。その問いが次のテーマであり、「どのチーム」をそもそも答えられるものにするアイデンティティモデルも一緒だ。

次に読む

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

参考資料