Microsoft自身のポリシードキュメントは、score thresholdが0.2を超えるとcache mismatchが起こりうると述べている。これは、呼び出し元が尋ねていない質問にゲートウェイが答えるようになる、ということの丁寧な言い方だ。このシリーズの他の機構はすべて騒がしく失敗してきた。429、503、期限切れのbatch job。semantic cacheは、流暢で整った、自信に満ちた、しかし他人のプロンプトに属するレスポンスを返すことで失敗する。第5回は支出を帰属可能にした。本稿はそれを小さくする。そして、それを実現する機構をコストのレバーではなく正しさの境界として扱う。

キャッシュが動く前に実際に必要なもの

API Managementのsemantic cachingには前提条件のリストがあり、それが後から覆せないインフラ選択を決めてしまう。だからポリシーの後ではなく、前に置くべき話だ。

  • embeddings用の2つ目のモデルデプロイメント。chat completionのデプロイメントはコンシューマーの呼び出しを捌き、別建てのembeddingsデプロイメントは、キャッシュがプロンプトをベクトルに変えるために使う。つまりキャパシティの線が1本、quotaが1つ、監視対象が1つ増える。
  • API ManagementからモデルAPIへのmanaged identity認証。ポリシーの要求事項で、embeddings-backend-authsystem-assignedでなければならない。keyベースの選択肢はここにはなく、これは良い制約だ。
  • RediSearchモジュールを有効化したAzure Managed Redis。API Managementインスタンスのexternal cacheとして構成する。

罠は最後の項目にある。RediSearchはキャッシュの作成時にしか有効化できず、既存のキャッシュへ後から追加できない。セッションやrate-limitの状態のためにすでにRedisインスタンスを運用しているプラットフォームは、semantic cachingを単にオンにはできない。モジュール付きでプロビジョニングした新しいキャッシュが必要で、その決定が記録される場所は第2回のTerraformだ。「キャッシュを有効化する」に半日を見積もったスプリントの最中にこれを知るのが、ありふれた結末だ。

ポリシーのペア

lookupはinboundに、storeはoutboundに置き、それぞれポリシーセクションごとに1回しか登場できない。どちらもglobal、product、API、operationの各スコープと、classic、v2、consumption、self-hostedの各ゲートウェイで使える。

<inbound>
  <base />
  <llm-semantic-cache-lookup
      score-threshold="0.05"
      embeddings-backend-id="embeddings-backend"
      embeddings-backend-auth="system-assigned"
      ignore-system-messages="true"
      max-message-count="6">
    <vary-by>@(context.Subscription.Id)</vary-by>
  </llm-semantic-cache-lookup>
  <rate-limit calls="10" renewal-period="60" />
</inbound>
<outbound>
  <llm-semantic-cache-store duration="60" />
  <base />
</outbound>

これらの属性のうち4つは、定型文ではなく判断だ。

score-thresholdは、受信プロンプトがキャッシュ済みのものとどれだけ近く一致すべきかを0.0から1.0のスケールで定義し、低い値ほど高いsemantic similarityを要求する。これは初見で大半の人が想定する向きの逆で、方向を間違えると保守的なキャッシュが無節操なキャッシュに変わる。Microsoftのガイダンスは、0.05あたりの低い値から始め、望むhit対missの比率へ向けてチューニングすることだ。そこには、0.2を超えるとmismatchが起こりやすくなること、そしてセンシティブなユースケースはより低く保つべきだという明示的な警告が付いている。

ignore-system-messages="true"は、類似度を評価する前にsystemメッセージを取り除く設定で、推奨されている。響き以上に重要だ。ユーザーの質問が同一でsystemプロンプトが異なる2つのアプリケーションは、これがなければキャッシュには別物に見えるし、systemプロンプトを編集した単一のアプリケーションは、意味上の理由が何もないのに自分のキャッシュ全体を無効化してしまう。

max-message-countは、ダイアログの残りメッセージ数が指定値を超えたらキャッシュをスキップする。長い会話は、semantic matchが最も信用できない場所そのものだ。最後のメッセージの意味が、embeddingが一度も見ていないターンに依存するからだ。

vary-byは分離の境界であり、なぜそれがセキュリティコントロールなのかが次のセクションの主題だ。

vary-byはパーティショニングの気配りではない

vary-byがなければ、そのAPIのすべての呼び出し元でキャッシュは1つに共有される。顧客の詳細を含むカスタマーサービスアシスタントのプロンプトはキャッシュ済みのcompletionから答えられ、別のテナントから来た意味的に類似したプロンプトも、その同じエントリから答えられうる。ドキュメントは率直に述べている。cache entryへのユーザー間アクセスは、特定のユーザーまたはユーザーグループの識別子でvary-byを指定して制御せよ、と。

このプラットフォームでの最低ラインはsubscription IDで、これはキャッシュの境界を第5回が築いたテナンシーの境界にぴったり揃える。データを混ぜてはならないエンドユーザーにアプリケーションが仕えている場合、正しい値は呼び出し元がヘッダーに設定できる何かではなく、検証済みtokenのclaimだ。第5回のvalidate-jwtがtokenをoutput-token-variable-nameへ書き込むのは、まさにこのためだ。未検証のヘッダーを読むvary-byは、根本原因が設定ファイルというクロステナントのデータ漏えいだ。

細かいパーティショニングの代償はhit rateの低下で、このトレードは現実のものだ。それでも正しい既定だ。決して漏らさず20パーセント節約するキャッシュは、45パーセント節約していつか誤った顧客の答えを返すキャッシュに勝る。

いま追加した依存関係

キャッシュはすべての呼び出しのリクエストパスに座る。つまりRedisはいまやゲートウェイのクリティカルパス上にある。Microsoftの推奨は具体的で、そのまま従う価値がある。キャッシュが利用できない場合にバックエンドが圧倒されないよう、rate-limitまたはrate-limit-by-keyポリシーをcache lookupの直後に置け、というものだ。

その障害を最後まで考えてみる。平常時は、トラフィックの相当な割合がモデルに一切届かない。Redisが利用不能になれば、そのリクエストのすべてが即座に本物のcompletion呼び出しになり、しかもキャッシュ前提の定常状態に合わせてサイズされたquotaへ向かう。キャッシュの停止はプラットフォームを劣化させるのではなく負荷を倍増させ、それを停止からスロットリングへ変換するのがrate limitだ。これはデータベースの前に置くあらゆるキャッシュと同じ教訓だが、ここでは忘れやすい。キャッシュがキャパシティの依存関係としてではなく、コスト最適化として導入されたからだ。

embeddingsデプロイメントにも同じ思考が要る。すべてのlookupは受信プロンプトをembedするので、embeddingsモデルには本番のプロンプト量とプロンプト長に足るキャパシティとコンテキストサイズが必要だ。proof of concept向けにサイズされたembeddingsデプロイメントは、キャッシュの有無を問わず全トラフィックのボトルネックになる。hitかmissかが判明する前にlookupは起こるからだ。

cache hitが第5回の数字に何をするか

hitはタダではないし、completionでもない。embeddings呼び出し1回といくらかのRedis時間を消費し、completion tokenは一切消費しない。つまり第5回のchargebackモデルに必要なのは書き直しではなく、3つの小さな変更だ。

  • embeddingsの支出はプラットフォームのコスト項目になる。missを含むすべてのリクエストで発生し、completionには帰属できない。リクエスト数に応じた按分でテナントに課すか、プラットフォームが吸収するか。どちらかを選んで書き残すこと。照合から抜け落ちたままにしてはならない。
  • hit rateは正当なメトリクスのdimensionだ。低カーディナリティであり、支出の隣、ダッシュボードの上に置くべきものだ。落ちていくhit rateは、たいてい誰も告知しなかったプロンプトテンプレートの変更だからだ。
  • 節約は支出ではなく回避コストとして報告する。キャッシュのおかげで請求が30パーセント下がったテナントは、低くなった数字が新しいベースラインだと思い込む。課金されたtokenの隣に回避されたtokenを示すことがその会話を誠実に保ち、プラットフォームが支払っているRedisインスタンスの正当性を示す材料にもなる。

そもそもキャッシュしないという判断

semantic cachingが向くのは、量が多く、ばらつきが小さく、パーソナライズされていないプロンプトだ。この会社の、製品ドキュメントに対するリテールのナレッジ検索は理想に近い。それ以外は違う。だからポリシーはグローバルに適用するのではなくAPIごとにスコープすべきで、productスコープとAPIスコープをサポートしているのはまさにそのためだ。

レスポンスが現在時刻やライブな状態に依存する場所ではスキップする。60秒のdurationは鮮度の好みではなく正しさのウィンドウだからだ。tool-callingのフローでもスキップする。そこではモデルの出力は読むためのテキストではなく行動の指示であり、古びた指示は誤った行動だ。vary-byで分割できる範囲を超えてパーソナライズされたものもスキップする。そして長いマルチターンのダイアログは、similarity scoreが気づいてくれることを願うのではなく、max-message-countを設定して対象外として扱う。

注視すべき失敗モード

  • 逆向きに設定されたthreshold。高いほど緩い。hit rateを最適化するチームが0.4へ上げ、比率は改善し、mismatchは、質問と何の関係もない答えをユーザーが報告するまで見えない。
  • 欠けたvary-byエラーも警告もなく、APIのすべてのテナントでキャッシュが共有される。
  • 遅れて発覚するRediSearch。モジュールは既存のキャッシュへ追加できないため、答えは新しいキャッシュと移行であり、それも設定変更で済むと想定していた作業のさなかにだ。
  • Redisが落ち、バックエンドが溢れる。lookupの直後のrate limitがなければ、キャッシュの停止はトラフィックスパイクとしてモデルに到達する。
  • 小さすぎるembeddingsデプロイメント。すべてのリクエストで呼ばれるため、hitもmissも等しくスロットリングされ、症状はキャッシュが物事を遅くしているように見える。
  • キャッシュを静かに空にするプロンプトテンプレートの変更。請求書で気づくのではなく、hit rateにアラートを張る価値がある。

第7回が受け継ぐもの

キャッシュが答えるのは繰り返しだ。モデルが学習していない情報を必要とする、はるかにありふれたケースには何もしてくれない。それがretrievalの問題で、この会社のリテールのナレッジ検索はすでにそれを待っている。次回はAzure AI Searchと専用のベクトルデータベースを、実際に決め手となる条件で比較する。

次に読む

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

参考資料