AI 支援開発

LLMコンセンサスゲートにおいて無回答は不一致ではない

あるモデルが応答に失敗した際に、それを不一致としてカウントすると、リトライバジェットが汚染され、システム障害が判定と化してしまいます。この2つの状態を分離する方法。

この記事は英語の原文をAIモデルが翻訳したものです。表現が原文と異なる場合があります。 英語の原文を読む

2モデル合意ゲートは単純なアイデアです。2つの独立したLLMに同じ質問をし、両者が合意した場合にのみ実行し、それ以外はすべて人間に回すというものです。これは機能します。そして私は以前、正しくなければならない判断において、なぜ合意が単一モデルに勝るのかについて書きました。この投稿は、そのパターン自体に内在する失敗モードに関するものです。スクレイピングされた用語候補を分類するパイプラインにおいて、私たちの不一致率は数週間で18パーセントから68パーセントに上昇し、増え続けるアイテムが「モデルが合意できない」という理由で自動化から永久に除外されるようになりました。それらのアイテムの大部分は、まったく意見が不一致だったわけではありませんでした。モデルの1つが単に回答しなかっただけで、コードが沈黙を不一致としてカウントしていたのです。

コンセンサスゲートには2つではなく4つの結果がある

コンセンサスゲートの素朴なメンタルモデルには、モデルが合意するか、しないかという2つの結果があります。実際の状態空間はより大きく、その追加の状態こそが、まさに信頼性が損なわれる箇所です。

  1. 両方のモデルが回答し、判定が同じ。実行しても安全です。
  2. 両方のモデルが回答したが、判定が異なる。真の不一致であり、再試行または人間の確認に値します。
  3. 一方のモデルは回答したが、もう一方は回答しなかった。意見は1つであり、2つではありません。何も比較されていません。
  4. どちらのモデルも回答しなかった。このバッチの完全な停止です。

コードを書いているとき、状態2と3は似ているように感じられます。なぜなら、どちらの場合も処理を続行できないからです。しかし、それらは正反対のことを意味します。不一致はデータに関するシグナルです。つまり、その項目は真に曖昧であり、質問を繰り返しても収束する可能性は低いということです。無回答はインフラストラクチャに関するシグナルです。タイムアウト、レート制限、コンテンツフィルター、部分的なパースなどが考えられます。項目自体は、ごく簡単なものである可能性があります。

ほとんどの実装では、完全な停止は騒々しく明白であるため、状態4をエンコードします。状態3は、暗黙のうちに状態2にまとめられてしまうものであり、そのまとめこそがバグなのです。

コード内で無回答が不一致になる仕組み

この現象は通常、マップのルックアップで発生します。各モデルは判定のバッチを返し、それらを項目ごとにインデックス化して、比較します:

gv, gok := geminiVerdicts[item]
ov, ook := openaiVerdicts[item]
if !gok || !ook {
    return Judgment{Agreed: false} // missing answer, "not agreed"
}
if gv != ov {
    return Judgment{Agreed: false} // real disagreement
}

どちらのパスも同じstructを生成します。下流では、Agreed == falsedrop-disagreeとして記録され、リトライカウンターが増加し、3回試行に失敗すると、そのアイテムは枯渇済みとしてマークされ、自動ポピュレーションから削除されます。システムは、ログでさえも、枯渇したアイテムが3回曖昧だったのか、3回不運だったのかを伝えることはできません。

2つの詳細が、これを見た目以上に悪化させています。第一に、部分的なパースロスは一般的です。15個のアイテムのバッチを判定するように要求されたモデルが、どこにもエラーを出さずに14個を返すことがあります。欠落したアイテムは、自身の過失なく状態3に陥ります。第二に、一方的な失敗は時間的に相関しています。プロバイダーの調子が悪い夜には、バッチ全体が一度に状態3に陥り、その中のすべてのアイテムがリトライを消費します。

真の犠牲者はリトライバジェット

私たちのゲートは、各アイテムに3回のアテンプトを与えました。3回の真の不一致は、「モデルがこれについて収束しないため、人間が確認すべきである」という妥当な定義です。リトライバジェットは、あいまいさのためのフィルターです。

無回答をそのバジェットに含めてカウントすると、フィルターが何を選択するかが静かに変わってしまいます。2回の不安定な夜と1回の真の不一致に遭遇したアイテムは、3回あいまいだったアイテムと同じ状態で使い果たされます。さらに悪いことに、持続的な片側障害は、インフラストラクチャの障害を直接データの判定に変換します。障害中に処理されたすべてのアイテムは、枯渇に向かって一歩進み、そのような夜が3回続くと、パイプラインは実際には評価しなかった何百ものアイテムについて何かを「結論付けた」ことになります。

それが、私たちがついに数値を確認できるようになったときに、その数値が示したことでした。不一致とラベル付けされたアイテムをリトライすると、約3分の1の確率で結果が変わりました(最初の「不一致」の後にリトライされたアイテムのうち、181件は後に合意による拒否に、139件は合意による承認に解決され、577件は不一致のままでした)。その不安定さの一部は、境界線上の入力に対する真のモデルの揺らぎでした。しかし、そのうちの不明な割合は、状態2の名前をかぶった状態3であり、修正前はログ形式によって両者は区別できませんでした。コードは、各モデルが何を言ったかではなく、固定の説明文字列を保存していました。

修正: 無回答を記録し、カウントしない

変更は小さく、3つの部分からなります。

まず、比較後も真理値が残るように judgment 構造体を拡張します:

type Judgment struct {
    Agreed    bool
    Oneside   bool   // at least one model gave no verdict
    GeminiOut string // raw verdict, "" means no answer
    OpenAIOut string
}

早期リターンする前に、モデルごとのフィールドを埋めます。回答がない場合にショートサーキットするのが自然な箇所は、その情報が失われる直前の箇所でもあるため、順序が重要です。まず埋めてから、リターンします。

第二に、無回答には独自の判定を記録し、リトライカウントから除外します。我々の枯渇ルールは、アイテムのログ行全体で max(attempt) >= 3 となっていました。attempt = 0 で無回答の行を書き込むことで、枯渇に寄与することなく、すべての監査クエリでその行が可視になります。次の実際のリトライは、引き続きその番号を max(attempt) + 1 として計算するため、本来の試行シーケンスは途切れません。

第三に、Oneside を広義に定義します。Oneside は、バッチ全体が返ってきた限り、どちらかのモデルがない場合(両方がない場合を含む)に true になるべきです。両方のモデルが同じアイテムをドロップしたバッチは、不一致ではなく、依然として非比較となります。全面的な障害パスは、どちらのモデルも何も返さなかった場合のために取っておき、その場合はロギングを完全にスキップします。なぜなら、応答が全くない夜に、アイテムごとのトレースが一切残るべきではないからです。

2モデルゲートの4つの結果 2つのモデル 一致: 実行 不一致: カウント attempt = max + 1 欠落: 記録 attempt = 0 3ストライク: 人間キュー // 実際の内容の不一致のみがリトライ予算を消費する

ポイズンピルと、3部構成のエスカレーションガード

枯渇状態から無回答を除外すると、新たな危険が生じます。もし特定の入力が、あるプロバイダーを確実に沈黙させる場合(コンテンツフィルターが典型的な原因です)、そのアイテムは永久にリトライされるようになります。それは毎晩母集団に再投入され、2回のLLMコールを消費し、そして決して解決されません。キューにポイズンピルが発生したのです。

その修正に対する修正は、永続的な一方的な失敗を、通常のアウテージ中にエスカレーションが発動しないようにするガードを設けた上で、カウントされるパスにエスカレーションして戻すことです。

  • 無回答の行を、全期間ではなく、スライディングウィンドウ内でカウントします。私たちは14日間を使用しました。1年間、月に一度不安定な夜があったアイテムはポイズンピルではありませんが、全期間のカウントではいずれにせよ最終的にエスカレーションされてしまうでしょう。
  • エスカレーションする前に、バッチを確認します。もし今夜、バッチのほとんどが一方的である場合、それはアイテムのプロパティではなく、プロバイダーのインシデントです。バッチ全体のエスカレーションを抑制し、単なる無回答として記録します。抑制は安全な方向に作用します。最悪のケースは、もう一晩リトライが続くことです。
  • エスカレーションを行う際には、記録される判定を真の不一致と同一に保ち、すべての下流コンシューマー(枯渇クエリ、ダッシュボード、ドレイン予測)が以前とまったく同じように動作するようにします。そして、別の理由列に固定のマーカー文字列を入れます。後でアイテムを読む人間は、「プロバイダーがこれに対して失敗し続けたため、自動化から外れました」ということがわかる必要があります。なぜなら、それに対する正しい対応は、真の曖昧さに対する正しい対応とは異なるからです。

3回試行のバジェットに加えて3ストライクのエスカレーションを行うことで、ポイズンピルの最悪ケースの生存期間は6夜に制限され、その制限期間中、何が起こったかについて嘘をつくことに時間は費やされません。

違いがわかるようになると何が変わるか

観測できるメリットはすぐに現れます。夜間の統計が「disagreed」と「one-sided」に分割され、パイプラインは初めて、ある週の不調がモデルの品質の問題だったのか、インフラの問題だったのかという問いに答えられるようになります。変更前は、その問いは単に難しいだけでなく、データからは答えられないものでした。ログの行には、本来エビデンスがあるべき場所に定数文字列が保存されていたためです。各モデルの生の判定結果を記録するには2列分のコストがかかりますが、ある種の推測を完全に排除します。

述べておく価値のある、微妙な会計ルールもあります。それは、統計は分類器が見たものではなく、実際に記録されたものに基づいて報告する、というものです。one-sidedとして到着した項目が、計上対象のパスにエスカレーションされた場合、その項目は今夜のレポートの「disagreement」の列に属します。なぜなら、データベースが現在その項目についてそのように示しているからです。集計と保存された行に乖離が生じると、将来のすべてのデバッグセッションは、それらを一致させることから始めることになります。

独自のゲートのためのチェックリスト

  • 4つの結果 (agree、differ、one-sided、outage) をコード内で明示的に列挙する。もしそのうちの2つが同じstruct値を共有しているなら、それらはすでに統合されてしまっています。
  • 早期リターンの前に、モデルごとの生の判定結果を入力して保存する。回答がない場合は "" で問題ありませんが、定数的な説明文字列は不適切です。
  • 無回答を枯渇ルールから除外する。max(attempt) ルール下での attempt = 0 の行は、カウントせずに記録するための安価な方法です。
  • 「正常なバッチ内で両方のモデルがこの項目を逃した」場合を、disagreement (不一致) や outage (停止) ではなく、one-sided (一方のみ) として扱う。
  • ポイズンピルを制限する: スライディングウィンドウ内でN回の one-sided (一方のみの) 失敗が発生した後にエスカレーションし、バッチ全体が失敗している場合はエスカレーションを抑制し、エスカレーションされた行には明確な理由をマークする。
  • 集計は記録に従うようにする。統計は、実行された分岐からではなく、書き込まれたものから計算されるべきです。

これらはどれもLLMに特有のものではありません。信頼性の低い投票者がいる投票システムには、反対、欠席、通信不能の間の同じ3方向の区別があります。LLMパイプラインでは、タイムアウトと不一致の両方が「今夜は完了できない比較」という同じものとして到着するため、これを見逃しやすくなります。