Azure OpenAI ServiceからのHTTPステータスに応じて、Azure API Managementで再試行したりフォールバックしたりしたい

2023/07/31現在の情報に基づいています。将来の機能追加・変更に伴い、記載内容からの乖離が発生する可能性があります。

問い合わせ

最近Azure OpenAI Service (以下、AOAI) にご執心のいつもの人から、以下のような問い合わせが届いた。

現在AOAIを使って社内システムを構築中である。東日本リージョンが使えるようになったことは承知しているのだが、AOAIからの応答が500や429だった場合に、再試行したり、別のAOAIインスタンスにフォールバックするような仕組みを検討している。どのレイヤーで構成するのがよいか?

確かにAOAIを負荷分散する件は以下のエントリで記述したが、HTTPステータスに応じてフォールバック(というか別インスタンスに転送)する、という問い合わせははじめて受けた。

どうやら下図のようなことを実現したいらしい。

背景

現在のシステムアーキテクチャの主流は、

  • サービスはステートレスに
  • 例外は呼び出し側に返し、呼び出し側でコントロールできるように

なので、例えばAOAIで429(Rate limitに到達)が発生した場合には、呼び出し元アプリケーションで待機した上で再試行するようにコントロールすることが多数派。そのため、その意図をもうちょっと詳しく問い合わせ主に尋ねてみた。それによると

  • 中間層で対応するとスケールしづらいことは承知している
  • 社内利用なので、スケールについてはそこまで考慮する必要はない
  • 逐一外部ネットワークへのEgressが発生するのは待ち時間だけでなくコストも少々かかるが、これは看過できない

ということのようで、下図のような階層でAPI公開のスコープやアクセス管理を検討していたもよう。

実装案

どこまでインテリジェントなフォールバックをさせるか次第ではあるが、大きく分けて2通りの方法が考えられる。これらは特にAOAI固有の方式ではないため、別のサービスでも同様の考え方を適用できる。

方式方法短所・短所
APIM内で再試行&フォールバックbackendセクションでretryポリシーを使う– シンプルでAPIMで完結できる
– 複雑なロジックが必要な場合は少々厳しい
コントローラーとして振る舞うアプリケーション(上図ではComposite ApplicationやBusiness Application)で再試行&フォールバック通常のWebアプリケーションの実装– きめ細かいLogicを記述できる
– 実装コストが大きい
– 管理対象が増える可能性がある

後者の場合は通常のWebアプリケーションで、API呼び出し失敗時には例外処理の上、再試行&フォールバックするだけなので、詳細は省く。このエントリでは前者について記述する。

[1] AOAIへのアクセス

以下のYouTubeで類似の手法が紹介されているが、inboundセクションでBearerトークンを取得するためsend-requestポリシーを駆使している。

Azure OpenAI scalability using API Management

しかしながら、AOAIのアクセス方式をAzure AD認証にするのであれば、Managed Identityを使ってもっとスマートにできる。詳細は以下のエントリを参照。

API KeyとAzure AD認証のどちらが推奨か? (Azure OpenAI Serviceへの負荷分散)
https://logico-jp.io/2023/06/08/request-load-balancing-for-azure-openai-service/#apikey-or-rbac

[2] バックエンドサービス

Application GatewayなどL7 Load Balancerを挟んでいる場合と、直接APIMからAOAIを呼び出している場合で異なる。

a) L7 Load Balancerを挟んでいる場合

下図のイメージ。

L7 Load Balancerの負荷分散ルールに依存するが、純粋なラウンドロビンで負荷分散している場合、APIMからのリクエストは複数あるAOAIインスタンスのいずれかに均等に送信されるので、結果としてフォールバックできてしまう。そのため、HTTPステータスに応じて再試行することに集中すればよい。

以下では、

  • 呼び出し回数は最大3回(最初の1回は必ず実行するので再試行は2回)
  • HTTPステータスが300以上の場合に再試行(呼び出し先の変更があるか否かはL7 Load Balancer次第)

というルールでbackendセクションのポリシーを構成する。このとき、backendセクションは1個のポリシーしか配置できないため、以下のように構成する必要がある。

  • forward-requestポリシーはグローバルスコープで定義されているが、オーバーライドするために<base />を削除し、retryポリシーを追加
  • forward-requestポリシーでは、再試行時にリクエスト本文を保持するよう、buffer-request-body="true"を指定

上記ルールを実装した例が以下。

<!-- retryCount is specified in Named Values. -->
<policies>
    <inbound>
        <base />
        <set-variable name="retryCount" value="@(int.Parse("{{retryCount}}"))" />
        <set-variable name="maxRetryCount" value="@((int)context.Variables["retryCount"] -1)" />
        <authentication-managed-identity resource="https://cognitiveservices.azure.com" />
    </inbound>
    <backend>
        <retry condition="@(context.Response.StatusCode >= 300)"
               count="@((int)context.Variables["maxRetryCount"])" 
               interval="1" max-interval="10" delta="1" first-fast-retry="false">
            <!-- forward request and request body is stored for retry -->
            <forward-request buffer-request-body="true" />
        </retry>
    </backend>
    <outbound>
        <base />
    </outbound>
    <on-error>
        <base />
    </on-error>
</policies>

b) 直接APIMからAOAIを呼び出している場合

下図のイメージ。

この場合、実装方法は2つあるので、それぞれ説明する。

i) バックエンドサービスを個別に指定

明示的にバックエンドサービスを指定する。バックエンドサービスのベースURLは、set-backend-serviceポリシーを使ってオーバーライドできる。

バックエンドサービスの設定 / Set backend service
https://learn.microsoft.com/azure/api-management/set-backend-service-policy

以下はその例。

<policies>
    <inbound>
        <base />
        ...
        <set-backend-service base-url="Base URL" />
    </inbound>
    ...
</policies>

このとき、ベースURLを名前付き値 (Named Value) で作成して保持しておけば、URLが変わるときも名前付き値の値だけを変更すればよく、ポリシーの変更が不要になるので都合がよい。名前付き値については以下を参照。

Azure API Management ポリシーで名前付きの値を使用する / Use named values in Azure API Management policies
https://learn.microsoft.com/azure/api-management/api-management-howto-properties

ii) バックエンドサービスをプールとして指定

2024/01/23現在、Previewではあるが、API Managementでラウンドロビンで負荷分散できるようになっている。これを使うとL7 Load Balancerと類似のことがAPIMだけで可能になる。ただし、2024/01/23時点で利用可能な負荷分散機能はラウンドロビンのみなので注意が必要。また、現時点ではAzure Portal、Azure CLI、PowerShellで構成できず、Bicep、ARMまたはREST APIで構成する必要がある。

負荷分散プール (プレビュー) / Load-balanced pool (preview)
https://learn.microsoft.com/azure/api-management/backends?tabs=arm#load-balanced-pool-preview
Backend (REST API)
https://learn.microsoft.com/rest/api/apimanagement/backend?view=rest-apimanagement-2023-05-01-preview
Microsoft.ApiManagement service/backends 2023-05-01-preview
https://learn.microsoft.com/azure/templates/microsoft.apimanagement/2023-05-01-preview/service/backends

以下のエントリにも少々記載した。

この場合、inboundセクションでset-backend-serviceポリシーを使ってバックエンドプールを指定するが、backendセクションのポリシー設定はL7 Load Balancerを使う場合と同じ。

[3] backendセクションでのポリシーの構成

inboundセクションでset-backend-serviceポリシーを使って指定したバックエンドサービスのBase URLにリクエストを転送し、以下の条件によって再試行やフォールバックを構成する。

リクエスト転送先URLの個数3個
各URLでの試行回数3回(最初の1回は必ず実施するので、再試行は2回)
今回の例では、リクエスト転送先URLの個数は3なので、最大9回の呼び出しが発生する
200台が返る場合当該応答をAPIMから応答として返す
300以上が返る場合ループカウンタを増分
最大3回各URLに対して試行
3回目呼び出し後、リクエスト転送先URLを変更
429が返る場合ループカウンタを初期化
リクエスト転送先URLを変更
どのリクエスト転送先URLに対しても200台が返ってこなかった場合retryポリシーの継続条件をfalseにして、retryのループを抜ける

以下では、各ポリシーの構成について記述する。

a) 再試行

以下のドキュメントに記載の通り、再試行条件、最大試行回数、再試行間隔などを指定する。

再試行 / retry
https://learn.microsoft.com/azure/api-management/retry-policy

<retry
    condition="Boolean expression or literal"
    count="number of retry attempts"
    interval="retry interval in seconds"
    max-interval="maximum retry interval in seconds"
    delta="retry interval delta in seconds"
    first-fast-retry="boolean expression or literal">
    <!-- One or more child policies. No restrictions. -->
</retry>

この例では、以下の条件で再試行するよう構成する。max-intervalとdeltaを指定しているので、指数関数的に再試行間隔が広がる指数再試行アルゴリズムを選択している。

パラメータ名属性
condition再試行条件(do ... while()のような継続条件)
count試行回数
interval再試行の間の待機間隔
max-interval再試行の間の最大待機間隔
delta待機間隔の増分値
first-fast-retry最初の再試行をすぐに実行するか否か
b) 要求の転送

同じメッセージで再試行したいので、グローバルスコープのforward-requestポリシーをオーバーライドする。この設定はL7 Load Balancerの時と同じ。

要求の転送 / Forward request
https://learn.microsoft.com/azure/api-management/forward-request-policy

今回の例では以下のように設定した。

<forward-request buffer-request-body="true" />

そのほか、fail-on-error-status-code属性を使い、HTTPステータス400以上が返ってきたらon-errorセクションに流すこともできるが、今回は再試行が必要なので、指定していない。

上記内容を踏まえて設定したポリシーは以下の通り。{{}}で指定している値はNamed Valueを参照している。

<!-- URLs and retryCount are retrieved from Named values -->
<policies>
    <inbound>
        <base />
        <!-- URLs retrieved from Named values -->
        <set-variable name="URL" value="@(JArray.FromObject("{{URLs}}".Split(',')))" />
        <!-- # of URLs retrieved from Named values -->
        <set-variable name="urlCount" value="@(((JArray)context.Variables["URL"]).Count)" />
        <!-- Max # of retries for each URL -->
        <set-variable name="retryCount" value="@(int.Parse("{{retryCount}}"))" />
        <!-- Max # of retries in retry policy -->
        <set-variable name="maxRetryCount" value="@((int)context.Variables["retryCount"] * (int)context.Variables["urlCount"]) - 1" />
        <!-- Loop Counter for URLs -->
        <set-variable name="urlLoop" value="@(0)" />
        <!-- Invoked URL for visibility -->
        <set-variable name="OpenAI-Instance-Invoked" value="@{
            JArray jarray = (JArray)context.Variables["URL"];
            return jarray[(int)context.Variables["urlLoop"]].ToString();
        }" />
        <!-- Initialize backend service URL -->
        <set-backend-service base-url="@((string)context.Variables["OpenAI-Instance-Invoked"])" />
        <set-variable name="attempt" value="@(0)" />
        <set-variable name="continue" value="@(true)" />
        <!-- Bearer token for authentication, retrieved from Managed Identity -->
        <authentication-managed-identity resource="https://cognitiveservices.azure.com" />
    </inbound>
    <backend>
        <!-- Condition: HTTP Status >= 300 and continue == true -->
        <retry condition="@(context.Response.StatusCode >= 300 && ((bool)context.Variables["continue"]))" count="@((int)context.Variables["maxRetryCount"])" interval="1" max-interval="10" delta="1" first-fast-retry="false">
            <!-- forward request and request body is stored for retry -->
            <forward-request buffer-request-body="true" />
            <!-- Increment # of attempts -->
            <set-variable name="attempt" value="@((int)context.Variables["attempt"] + 1)" />
            <choose>
                <!-- In case of 429 -->
                <when condition="@(context.Response.StatusCode == 429)">
                    <set-variable name="attempt" value="@(0)" />
                </when>
                <!-- In other cases, no operation. -->
                <otherwise />
            </choose>
            <choose>
                <!-- If # of attempts can be divided by 3, URL should be changed. -->
                <when condition="@((int)context.Variables["attempt"] % (int)context.Variables["retryCount"] == 0)">
                    <set-variable name="urlLoop" value="@((int)context.Variables["urlLoop"] + 1)" />
                    <choose>
                        <!-- If at least one URL for trial exists -->
                        <when condition="@((int)context.Variables["urlLoop"] < (int)context.Variables["urlCount"])">
                            <set-variable name="OpenAI-Instance-Invoked" value="@{
                                JArray jarray = (JArray)context.Variables["URL"];
                                return jarray[(int)context.Variables["urlLoop"]].ToString();
                            }" />
                            <set-backend-service base-url="@((string)context.Variables["OpenAI-Instance-Invoked"])" />
                            <set-variable name="attempt" value="@(0)" />
                        </when>
                        <!-- If no URL for trial exists -->
                        <otherwise>
                            <set-variable name="OpenAI-Instance-Invoked" value="All URLs were called but no response." />
                            <set-variable name="continue" value="@(false)" />
                        </otherwise>
                    </choose>
                </when>
                <!-- In other cases, no operation. -->
                <otherwise />
            </choose>
        </retry>
    </backend>
    <outbound>
        <base />
    </outbound>
    <on-error>
        <base />
    </on-error>
</policies>

まとめ

APIMのポリシーを使うことで、再試行やフォールバックが可能であり、HTTPステータスごとに細かく制御することもできることがわかった。ただ、構成やロジックをできるだけシンプルにすべき、という原則には少々反する部分があるのも確か。なので、用法用量を守って正しく使うことが何よりも重要である。

またこの例ではリクエストごとに対応するようにするため、コンテキスト変数を使っているが、APIとして挙動を変えたい、といった場合には、API Managementの埋め込みCacheを使うとよりシンプルな構成にできる。以下のブログエントリではIntelligent load balancingを実装するために使われている。

Intelligent Load Balancing with APIM for OpenAI: Weight-Based Routing
https://techcommunity.microsoft.com/t5/ai-azure-ai-services-blog/intelligent-load-balancing-with-apim-for-openai-weight-based/ba-p/4115155

コメントを残す

このサイトはスパムを低減するために Akismet を使っています。コメントデータの処理方法の詳細はこちらをご覧ください