【Azure OpenAI】ChatGPTのレイテンシをどうにかしたい

# ChatGPT呼び出しの速度が安定しない
どうにもこうにもレイテンシが安定しないです。LLMの特徴なのか複雑な指示や長文回答を要求すると遅くなるみたいですし、それだけでなく基盤が繁忙なのか突然リクエストが遅くなったり500エラーが返ったりすることもありますよね。
(**ただ安定するときはとても安定しているので、もっと安定すれば今回の検証はそもそも不要かもです。というか、不要になったらいいなぁ。**)

なお、Azure OpenAI ServiceはSLAはあるものの、[レイテンシに関するSLAはありません。](https://learn.microsoft.com/ja-jp/azure/ai-services/openai/faq#azure-openai----api-----sla---------------)ぬぐぐ。

なので、アプリに組み込むのであればタイムアウト＆リトライが必須ですよね。でもそういえばAzure OpenAIはマルチリージョンでリソースが作れるので、それで何とかできないなか？という気持ちでマルチリージョン並列呼び出しを試してみました。

コストが掛け算で増えていきますけど、もともとが(gpt-35-turboなら)低額なので、許容できるのであれば検討には値するかなと思います。
また、以下のコードには例外ハンドリングもタイムアウトリトライもいれていないとりあえず動作のみの検証になります。

# マルチリージョン並列呼び出し
ようは、東アメリカと南西アメリカ、フランス、イギリス、日本で**同じリクエスト**を並列で実行し、最初に回答が返ってきたものを使う。これだけです。

以下のようなリージョン単位でのChatGPT呼び出し関数を定義します。Pythonです。なお、Pythonのopenaiのライブラリですが、`openai.api_key="XXXX"`のあたりからスレッドセーフ感を感じません。[.NETのライブラリはスレッドセーフみたい](https://learn.microsoft.com/ja-jp/dotnet/api/overview/azure/ai.openai-readme?view=azure-dotnet-preview#thread-safety)なんですが、pythonライブラリがそうでなさそう（きちんと調べていないです）ので、HTTPのリクエストを直接呼び出す方式でやりました。

以下のように並列呼び出ししたいリソース単位でメソッドを定義します。このコードだとエンドポイント`AOAI_ENDPOINT_1`キー`AOAI_API_KEY_1`モデル名`AOAI_MODEL_1`を使っています。同じように`_2`や`_3`で各リージョンのインスタンスを指定します。コルーチンしたいので、aiohttpをつかって、POSTリクエストを処理します。
```python
import aiohttp

async def call_chatgpt_at_xxx (messages,temperature=0):
    url = f"{settings.AOAI_ENDPOINT_1}openai/deployments/{settings.AOAI_MODEL_1}/chat/completions?api-version=2023-05-15"
    headers = {
    "Content-Type": "application/json",
    "api-key": settings.AOAI_API_KEY_1
    }
    data = {
    "messages": messages,
    "temperature": temperature,
    }

    async with aiohttp.ClientSession() as session:
        response = await session.post(url, headers=headers, json=data)
        response = await response.json()

    return response['choices'][0]['message']['content']
```

できたChatGPT呼び出し関数をタスクとして定義します。とりあえずテスト用のプロンプトをいれます。その上でタスクを同時実行し、最初の回答が返ってきたら残りをキャンセルして、最初の回答を返す感じです。以下のコードだと、東アメリカと東日本の2リージョンでの並列実行です。。
```python
import asyncio
・・（略）・・

    messages =  [  
    {'role':'system', 'content':'あなたはAIアシスタントです'},
    {'role':'user', 'content':'今日の格言を教えてください'},
    ]

    tasks = [
        asyncio.create_task(call_chatgpt_japan(messages),name="EAST JAPAN RESOURCE!"),
        asyncio.create_task(call_chatgpt_eastus(messages),name="EAST US RESOURCE!"),
    ]

    # 一番最初に完了したタスクだけを取得する
    done, pending = await asyncio.wait(tasks, return_when=asyncio.FIRST_COMPLETED)

    # 未完了のタスクはキャンセルする
    for task in pending:
        task.cancel()

    # 完了したタスクから結果を取得する
    for task in done:
        first_response = task.result()
        first_name = task.get_name()
        return first_response,first_name
```

実装はこれだけですが、動作確認のため、ログを出して動かしてみた結果は以下となりました。

# 2リージョン並列呼び出しの結果
```:1回目
EAST JAPAN RESOURCE!
「成功は、行動を起こす者にのみ訪れる」
0.48秒

EAST US RESOURCE!
「成功は、行動を起こす者にのみ訪れる」
1.30秒
```
```:2回目
EAST JAPAN RESOURCE!
「成功は、行動を起こす者にのみ訪れる」
0.30秒

EAST US RESOURCE!
「成功は、行動を起こす者にのみ訪れる」
1.61秒
```

タイミングがよかったのか、いずれもそんなにレイテンシ遅くなく返ってきてますが、2リージョン並列呼び出しにすることで、1回目で約0.8秒、2回目で約1秒短縮できそうですね。いや、たまにもっと遅くなったりするんですよ。なので、レスポンスが重要ならマルチリージョンで呼びだして最初の回答を使うのはありかもですね。

# まとめ

Azure OpenAI のChatGPT呼び出しをマルチリージョン並列呼び出しにすることで、たまたまどこかのリージョンがリクエスト集中でパツンパツンになっていても影響の少ないリージョンの低レイテンシの回答を使うことができそうです。

ただやってみて思いましたが、

- **利用するライブラリがスレッドセーフでない場合は、自前で呼びだし関数を作成しないとならない**
- **自前の関数に、自前のタイムアウトも設定しないとならない**
- **例外ハンドリングがめんどくさそう、特に全リージョンタイムアウトするようなケースなども考えないとならない**
- **コストが並列した数の掛け算になる。**
あたりが実際にやる場合には、検討/考慮ポイントですね。。

やっぱり基本に返って、
- **タイムアウトの設計とリトライでカバー**
- **ストリームを使うことでユーザビリティをカバー**
- **UI/UXでカバー**
 
でできるならそっちの方が良さそうです。が、1つの検討材料として見てもらえたらなと思います。