LLMのリージョン障害、手動対応やめました

LLMのリージョン障害、手動対応やめました

はじめに

こんにちは、エンジニアのikkiです。最近はQANT スピークの安定運用まわりの開発に取り組んでいます。 今回はその中の1つの開発トピックに関して書いてみます。

弊社では複数のプロダクトでVertex AI(Gemini)をLLM基盤として利用しています。その中には単純なテキスト生成だけでなく、電話に関連するサービスでは音声データを直接Geminiに投げて音声文字起こしを行うといった、マルチモーダルな活用も含まれています。

こういったサービスを運用していく中で、リージョン障害に何度も悩まされました。

本記事では、手動でのリージョン切替対応をやめて動的リージョン切替の仕組みを構築した経緯と、音声入力特有の落とし穴について紹介します。

手動リージョン切替の限界

リージョン障害のたびにサービスが止まる

Vertex AIは利用するリージョンを指定してリクエストを送ります。そのため、特定のリージョンに障害が発生すると、LLMを使う機能が一斉に影響を受けます。

当初は障害を検知したら、ハードコードされたプライマリリージョンを手動で別リージョンへ書き換えて切り替える運用をしていました。切り替え先は、手元のツールから複数のリージョンに対してリクエストを投げ、レスポンスを見比べて決めていました。しかし、この運用にはいくつかの問題がありました。

状況がコロコロ変わる

リージョンのステータスは想像以上に細かく推移します。ツールで各リージョンの調子を確認して「ここが良さそうだな」と判断し、設定を変更してCIを待ち、本番環境に反映する。という一連の対応をしている間に、元のリージョンは復旧して、逆に切り替えた先のリージョンがその頃には調子を崩していることもあります。

正直、手動対応ではこの変化の速さに追いつけません。さらに弊社では複数のプロダクトがVertex AIを利用しているため、障害のたびにそれぞれで同じ対応が必要となり、対応漏れのリスクもあります。

リトライでは解決できないユースケース

ここで「リトライすればよいのでは?」と思う方もいるかもしれません。 たしかに、各種SDKには429や5xx系のエラーに対するexponential backoffによるリトライが組み込まれています。

ただ、全てのユースケースにおいてリトライをすれば良いというわけではありません。特に今回扱うような音声系プロダクトでは電話応対中にLLMを呼び出しています。

電話はリアルタイムな体験であり、リトライによって10-20秒待たされることはそのまま通話体験の大きな劣化に繋がります。そのため、同一リージョンでの複数回リトライは入れておらず、失敗したら即座に別のモデル・別のリージョンへfailoverする設計にしています。

なお、Vertex AIにはglobal endpointも提供されています。リージョン指定なしで高い可用性を得られますが、どのリージョンで処理されるか制御できないため、データ所在地の要件があるプロダクトでは採用しづらいです。こういった背景もあり、regional endpointを使いつつ自前でfailoverする方式を選びました。

動的リージョン切替の仕組み

こういった背景もあり、各リージョンのヘルスチェックを定期的に行い、その結果を返すCloud Functionを用意しました。 仕組みは至ってシンプルで、Cloud FunctionがVertex AIの各リージョンに実際にリクエストを送り、結果をFirestoreに保存するだけです。

さらに、この情報を取得するクライアントをnpmパッケージとして提供し、各プロダクトは簡単にリージョンのヘルス情報を取得できるようにしています。 あとはその情報をもとに、プロダクト側でリージョン切り替えのロジックを組めば良いというわけです。

一例として、下記のような軸でリージョンの切り替え先を決めています。

  1. モデル対応: そのモデルがそのリージョンで利用可能か(Geminiはモデルによって利用可能なリージョンが異なる)
  2. 許可リージョン: プロダクトとしてそのリージョンの使用を許可しているか(データ所在地の要件等)
  3. ヘルス状態: 現在そのリージョンが健全か

疑似コードにするとこんなイメージです。

// モデルが利用可能なリージョン × プロダクトの許可リージョン
const candidates = intersect(
  modelAvailableRegions[model],
  project.allowedRegions
);

// 候補の中からヘルスチェック結果をもとに最適なリージョンを選択
const bestRegion = await healthCheckClient.getBestRegion(candidates);

ヘルスチェック基盤自体が障害を起こしてサービスを止めてしまっては本末転倒なので、クライアントはfail-open設計にしています。Cloud Functionへの接続に失敗した場合は例外を投げず、プライマリリージョンをそのまま使い続けるようにしています。

text-to-textが健全でも音声入力は落ちている

この仕組みを構築する以前、手動でリージョン障害へ対応していた頃に気づいた問題があります。

テキストは通るのに音声だけタイムアウトする

障害対応中、あるリージョンにテキスト入力のリクエストを投げると正常に返ってくるのに、音声入力のリクエストだけがタイムアウトする、という事象に遭遇しました。

実際の障害対応時のスクリーンショットです。まずtext-to-textで各リージョンの調子を確認したところ、ヨーロッパ方面が使えそうに見えました。

各リージョンを確認した様子

しかし切り替えても復調せず、音声を乗せたリクエストで試したところ、ヨーロッパ方面が軒並みFAILになっていました。

STTで試したところヨーロッパが全滅している様子

テキストで確認すると問題なさそうに見えるのに、音声文字起こしだけが実質的に使えない状態です。

モダリティ別にヘルスチェックする

こういった経験から、動的リージョン切替の仕組みではヘルスチェックを textToTextspeechToText で分離する設計にしました。

interface RegionHealth {
  region: string;
  overallStatus: 'healthy' | 'degraded' | 'unhealthy';
  checks: {
    textToText: CheckResult;   // テキスト生成チェック
    speechToText: CheckResult; // 音声文字起こしチェック
  };
}

各チェックではVertex AIに実際にリクエストを送り、レスポンスタイムや連続失敗回数をもとにステータスを判定しています。テキスト生成のみを行うプロダクトならtextToTextの結果を、音声文字起こしを行うプロダクトならspeechToTextの結果を参照してリージョンを選択します。「テキストがhealthyでも音声はunhealthy」という状況で、適切なリージョンへフォールバックできるようになりました。

まとめ

今回は、Vertex AI(Gemini)を本番運用する上で避けて通れないリージョン障害への対応について書いてみました。特に電話のようなクリティカル性の高い仕組みでは、リージョン切替の遅延が致命的な影響を与えるため、今回の対応によってより安定した運用が可能になったと考えています。

RightTouchではLLMをサービスで本番運用しているプロダクトが複数あります。こういったプロダクトの開発に興味がある方や、一緒に安定稼動について考えたい方がいればぜひ一度お話ししましょう!