TT Lab
はじめる
学ぶ 学習パス コース

AIエージェント — モデルではなくグラフ

やり直すのか、そしてどこまで使うのか

TT Labで続きを見る

一言でいうと

リトライは1行で有効になりますが、何をやり直すかを決めていないリトライは、同じ失敗を上限まで繰り返す装置にすぎません。そして、その繰り返しは、そのまま呼び出しバジェットを消費します。

なぜ必要なのか

エージェントをグラフで組むと、ある日、こんな振り返りを書くことになります。「決済ゲートウェイが30秒ほど不安定になり、その30秒の間に入ってきた案件が、すべて失敗で終わった。」

そこで、リトライを有効にします。LangGraphでは、ノード1つに1行で済みます。

graph.add_node("settle", settle, retry=RetryPolicy(max_attempts=3))

そして、翌週に同じ振り返りをまた書きます。2つのうちのどちらかです。

この3つ、何をやり直すか、同じリクエストが2回反映されないようにすること、どこまで使うかは、別々に学べばそれぞれ簡単ですが、まとめておかないと、必ず1つが抜けます。

デフォルトは思ったより少なくリトライする

RetryPolicyの引数をそのまま書き出すと、次のとおりです。このラボの環境のlanggraph 0.2.60で、自分で確認した値です。

RetryPolicy(initial_interval=0.5, backoff_factor=2.0, max_interval=128.0,
            max_attempts=3, jitter=True, retry_on=<기본 함수>)

目を向けるべきは、最後のretry_onです。デフォルトの関数の本体を開くと、次のようになっています。

def default_retry_on(exc):
    if isinstance(exc, ConnectionError):
        return True
    if isinstance(exc, (ValueError, TypeError, ArithmeticError, ImportError,
                        LookupError, NameError, SyntaxError, RuntimeError,
                        ReferenceError, StopIteration, StopAsyncIteration, OSError)):
        return False
    ...
    return True

つまり、デフォルトのポリシーは、ValueErrorをやり直しません。RuntimeErrorも、OSErrorも同じです。やり直すのは、ConnectionErrorと、5xxで終わったHTTPレスポンスのほうです。考えてみれば、合理的なデフォルト値です。ValueErrorやTypeErrorは、たいてい自分たちのコードの書き間違いで、そういうものは、100回やり直しても同じ結果だからです。

問題は、自分たちが作る例外が、たいていValueErrorやRuntimeErrorを継承することです。「ゲートウェイが一時的に使えない」をclass GatewayBusy(RuntimeError)にすると、ポリシーを付けても、一度もやり直しません。エラーも警告も出ません。そのため、ポリシーを付けたという事実だけを信じて通り過ぎると、数週間後に、同じ振り返りを書くことになります。

直す方法は、retry_onを自分で書くことです。例外クラスのタプルを渡すか、関数を渡します。

RetryPolicy(retry_on=(GatewayBusy,), max_attempts=3)
RetryPolicy(retry_on=lambda exc: isinstance(exc, GatewayBusy), max_attempts=3)

max_attemptsは、総試行回数であって、追加のリトライ回数ではありません。3なら、最初の1回とやり直し2回です。そして、3回すべて使っても失敗したら、最後の例外がそのまま上がってきます。リトライしたという痕跡が、例外に付くわけではありません。その痕跡は、自分で数えて残す必要があります。

やり直してよい失敗とそうでない失敗

ここが、実際に難しい場所です。失敗を種類に分けておかないと、リトライは2つのうちのどちらかに間違った方向へ行きます。何もやり直さないか、何でもやり直すかです。

基準は1つで十分です。同じリクエストをそのまま送り直したとき、別の答えが出る可能性があるか。

恒久的な失敗にリトライをかけると、コストが3倍になって終わりです。これは、言葉で信じることではなく、回数で見る必要があります。試行の記録を数えると、恒久的な失敗1件に試行が3行たまることが、そのまま見えます。その数値が、そのまま他人に説明する根拠になります。

やり直す前に冪等であること

リトライが安全であるためには、もう1つ条件が必要です。同じリクエストを2回送っても、1回だけ反映される必要があります。

タイムアウトで切れたリクエストが、特に危険です。レスポンスを受け取れなかっただけで、向こうではすでに処理されているかもしれません。このとき、そのまま送り直すと、2回請求されます。そのため、リクエストごとにキーを1つ付けて、受け取る側がそのキーで「すでに行った件か」を見ます。キーは、リクエストを作った側が決める必要があります。受け取る側が毎回新しく作ると、2つのリクエストが同じリクエストかどうかを知る手段がありません。

キーを何にするかが重要です。注文番号のように、その仕事1つを指す値である必要があります。時刻やランダムな値を使うと、リトライのたびに新しいキーになって、冪等性が失われます。

バジェットはリトライまで数える

最後がバジェットです。エージェントは、人が予想するよりはるかに多く呼び出します。1件を処理するのにツールを何回呼び出すかを決めておかないと、1件が他の件の分まで使い切ります。

ここでよく抜けるのが、リトライも呼び出しだという事実です。1件に3回まで試行すると決めておいて、10件を1つのバジェットの中で回すと、最初の2件が不安定になっただけで、あとの件は、試行すらできません。そのため、バジェットは「何件」ではなく、「ツールの本体が何回動いたか」で数えるほうが正確です。

そして、バジェットが尽きたときに何をするかが残ります。答えは、リトライではありません。断念して、結果を残します。例外をそのまま上げると、呼び出した側にはスタックトレースだけが残り、何ができて何ができなかったかは、誰にもわかりません。バジェット超過は失敗ですが、予想された失敗なので、結果の記録の1行である必要があります。

このラボは、TypesリファレンスのRetryPolicyと、Graph API overviewのノード設定を使います。ツールをどう呼び出して、結果をどう検証するかは、ツールのモジュールで別に扱い、ここでは、呼び出したあとに失敗したときだけを見ます。理由を見て引数を直してもう一度呼び出すのは、グラフの中で行うことで、ここで扱うのは、同じリクエストをそのまま送り直す枠のほうのリトライです。層が違います。

現場での姿

1つ目: ポリシーを付けたのにリトライされない。例外がValueErrorやRuntimeErrorの系統なので、デフォルトのretry_onが除外したのです。試行の記録が1行だけであることでわかります。

2つ目: カード拒否にも3回ずつ送る。retry_onを広く取りすぎたのです。ゲートウェイ側のレート制限に先に引っかかって、肝心にやり直すべき案件が押し出されます。

3つ目: 同じ案件が2回請求される。タイムアウトで切れたリクエストを送り直したのに、受け取る側に冪等キーがありませんでした。この事故は、たいてい経理の側が先に見つけます。

4つ目: バッチの後ろの部分がまるごと動かなかった。前のほうの数件のリトライが、バジェットを使い切りました。ログには「バジェット超過」も見えません。ただ何の記録もありません。

5つ目: 上限を上げてごまかす。max_attemptsを10に上げれば、その日は乗り切れますが、恒久的な失敗にかかるコストが10倍になります。上げるべきは上限ではなく、分ける基準です。

実務で本当に大切なこと

次のラボですること

/root/work/agbudget/budget.pyを、1ステップずつ育てます。まず、リトライのないグラフで、失敗が1回しか試行されないことを再現し、RetryPolicyを付けても、デフォルトのretry_onがその失敗をやり直さないことを、試行回数で確認します。続いて、retry_onを明示してやり直すようにし、恒久的な失敗が上限まで繰り返されるのを回数で見たあと、分ける関数を入れて、その数値を1に戻します。次に、冪等キーで同じリクエストが2回反映されないようにし、呼び出しバジェットを数えて、尽きたら断念しつつ結果を残し、最後に、複数の件が1つのバジェットを分け合うときに、前の件のリトライが後ろの件の分を食うことを確認します。採点ツールは、書かれたモジュールを実際に読み込んで、毎回異なるキー・金額・失敗の計画で動かし、試行回数を自分で別に計算した値と突き合わせます。