処理の二重実行を防ぐ AI ワークフローの障害復旧
AI ワークフローの障害復旧は、失敗した API 呼び出しをリトライするだけでは不十分です。あるワークフローが、チケットを作成し、顧客レコードを更新した後、承認リクエストを送信する前にモデルとの接続を失うことがあります。これを再起動すると、2 つ目のチケットが作成されたり、更新が繰り返されたりしかねません。かといって処理を放棄すれば、ビジネスプロセスは未完了のまま取り残されます。
復旧経路は、ワークフローと一体で設計してください。副作用の前後で進捗を永続化します。各アクションには安定した冪等キー(idempotency key)を付与します。エラーのリトライは、再試行に成功の見込みがある場合にのみ行い、安全に繰り返せない変更については補償を定義します。以下の実装とテストは、これらのルールを実践に落とし込むものです。
通常のリトライが失敗する理由
モデル呼び出しが注目されるのは、その出力が確率的であり、プロバイダーが自社システムの外部に存在するためです。しかし、より厄介な障害はステップとステップの間で発生します。外部サービスがリクエストを受理した後、ワーカーがレスポンスを記録する前に、プロセスがクラッシュすることがあります。タイムアウトは、呼び出し側が待機をやめたことを示すにすぎません。リモート操作が実際に行われたかどうかについては、何も語らないのです。
このような曖昧さを「不明」な結果と呼ぶことにします。請求書を読み取り、モデルに分類を依頼し、承認タスクを作成し、メッセージを投稿する経理ワークフローを考えてみましょう。タスクサービスが変更をコミットする一方で、HTTP レスポンスが消失することがあります。ここで無条件にリトライすると、もう 1 つタスクが作成されてしまいます。逆にリトライを見送れば、最初のタスクが存在するにもかかわらず、ワークフローが行き詰まる可能性があります。
永続性を備えたワークフローツールは、障害後もプロセスが処理を継続できるだけの状態を保存します。LangGraph は、耐障害性のある実行と後からの検査のために、グラフの状態をスレッド単位でまとめたチェックポイントとして保存します。詳しくは永続化に関するドキュメントで説明されています。ただし、チェックポイントが解決するのは問題の一部にすぎません。外部への書き込みには、依然として、再実行時の挙動を定めた明確な契約が必要です。
リトライにも境界が必要です。Temporal のリトライポリシーのドキュメントは、間隔、バックオフ、最大間隔、最大試行回数、リトライ不可のエラーを区別しています。また、Temporal では Activity がデフォルトでリトライされる一方、Workflow Execution はリトライされない点も指摘されています。エンジンが異なれば制御方法も異なりますが、それでも判断は明示的であるべきです。HTTP クライアントが、あらゆる失敗を独自の判断で繰り返してよいわけではありません。
まず復旧の契約を定義する
まずは、ワークフローのステップを一覧化した表を作成しましょう。各ステップについて、入力、出力、副作用、再実行時の挙動、復旧アクションを記録します。そうすれば、放っておけば暗黙の前提のままになりかねない判断を、チームで見直せるようになります。
| ステップ | 副作用 | 再実行しても安全か | 復旧ルール |
|---|---|---|---|
| 請求書を読み取る | なし | はい | 上限付きバックオフでリトライ |
| 請求書を分類する | プロバイダーの使用と生成された出力 | たいていは安全 | 保存済みの結果があれば再利用する |
| 承認タスクを作成する | 別システムでの新規タスク | いいえ | 冪等キーを使い、キーで照会する |
| 承認者に通知する | メッセージの配信 | 常に安全とは限らない | プロバイダーのメッセージ ID を保存するか、アウトボックスを使う |
| 元帳エントリを記帳する | 財務記録 | いいえ | 安定したトランザクション参照と補償計画を必須とする |
副作用を伴うすべての呼び出しを、次のいずれかの結果に分類してください:
succeeded:結果に関する永続的な証拠をシステムが持っている状態です。failed:変更が発生しなかったという永続的な証拠をシステムが持っている状態です。unknown:リクエストは成功したかもしれませんが、呼び出し側はどちらの結果も証明できない状態です。
ステートマシンを単純化するために、unknown を failed と付け替えてはいけません。冪等キーを使って対象システムに照会するか、そのキーを尊重するエンドポイントへ同じリクエストを送信してください。どちらの手段も取れない場合は、ソフトウェアが結果を安全に確定できないため、その実行をオペレーターへ振り分けます。
AI ワークフローの障害復旧をステップごとに実装する
1. 実行に安定した識別子を与える
ビジネスイベントがシステムに入ってきた時点で、ワークフロー ID を 1 つ作成します。その ID は、プロセスの再起動、キューの再配信、手動での再開をまたいで維持してください。ワーカープロセスや、試行のたびに生成されるタイムスタンプから導出してはいけません。
副作用ごとに、別個のキーを導出します。実用的な形式は、ワークフロー ID、論理的なステップ、バージョンを組み合わせたものです:
workflow_id = "invoice:tenant-42:inv-1847"
approval_key = hash(workflow_id + ":create-approval:v1")
notification_key = hash(workflow_id + ":notify-approver:v1")
バージョンは、リトライ回数ではなく、アクションの契約に属するものです。オペレーターがリトライを 5 回クリックした場合、5 回すべての試行が同じキーを送信しなければなりません。バージョンを変更するのは、意図するビジネスアクションが変わったときだけです。
受信側のサービスは、最初の結果とともにキーを保存し、重複したリクエストに対してはその結果を返すべきです。受信側を変更できない場合は、キーとリモートオブジェクトの ID を自社のデータベースに記録するアダプターを追加します。そのアダプターは、データベースの一意制約で保護してください。制約を設けずに「これは存在するか」を事前に問い合わせるだけのクエリには、問い合わせと挿入の間に競合状態が生じます。
2. 判断と実行を分離する
モデルの出力を、直接の書き込みから遠ざけてください。まず、検証済みのスキーマの形で、モデルに提案アクションを求めます。その後、決定論的なアプリケーションコードが、権限、ビジネスルール、現在の状態を確認したうえでアクションを実行します。
def run_invoice_workflow(state, services):
if not state.classification:
proposal = services.model.classify(state.invoice_text)
state.classification = validate_classification(proposal)
services.checkpoints.save(state)
if not state.approval_task_id:
key = stable_key(state.workflow_id, "create-approval", version=1)
task = services.approvals.create_or_get(
idempotency_key=key,
payload=approval_payload(state.classification),
)
state.approval_task_id = task.id
services.checkpoints.save(state)
return state
再実行の際、ワークフローはモデルに別の提案を求める代わりに、受理済みの分類結果を再利用できます。ポリシー上、新たな判断が必要な場合は、その理由を記録し、新しいバージョンを作成します。ワーカーの再起動によって、判断が偶発的に変わってしまうことがあってはなりません。
3. 副作用の前後でチェックポイントを取る
副作用の前に検証済みの入力を保存し、リモートの結果が返ってきたらすぐにそれを保存します。最初のチェックポイントは意図を記録し、2 つ目は観測された結果を記録します。
ローカルの状態とアウトボックスのレコードが 1 つのデータベースを共有する場合は、トランザクションを使います。ビジネス状態とイベントを、同じトランザクション内でアウトボックスに書き込みます。配信のリトライは、別のパブリッシャーに任せられます。こうすることで、データベースはコミットされたのに、メッセージを発行する前にプロセスがクラッシュする、という隙間を避けられます。
グラフのチェックポイントには、ワークフローのバージョン、ステップの状態、モデルの結果またはその永続的な参照、冪等キー、リモートオブジェクトの ID、試行回数のカウンター、最後に分類されたエラーを含めるべきです。認証情報や不要な機密テキストは、チェックポイントの状態から除外してください。復旧に必要なのは識別子と判断であって、ワーカーが利用できるすべてのシークレットの写しではありません。
4. リトライの前にエラーを分類する
小さなエラー分類体系を作り、デフォルトを保守的に設定してください:
- 一時的なトランスポートエラー、レート制限、サービス利用不可は、上限付きの指数バックオフとジッターを用いてリトライします。
- 不正な入力、認可の失敗、ポリシーによる拒否、サポートされていない操作は、状況が変わるまでリトライしてはいけません。
- 「不明」な結果は、次の非冪等な書き込みを発行する前に整合を取ります。
- モデルのフォーマット失敗が繰り返される場合は、わずかな試行回数の予算を使い切った時点で一時停止します。ポリシーが許す場合は、診断のために不正な出力を保持します。
- 試行を使い切った失敗や分類できない失敗は、デッドレターキューまたはオペレーターのレビュー画面へ送ります。
Temporal は、間隔、バックオフ、試行回数の上限、リトライ不可のエラーに必要なポリシー制御を、公式のリトライポリシーリファレンスで公開しています。お使いのキューやオーケストレーターが異なる名称を用いているとしても、同等の値をコードや設定に反映し、それらをテストしてください。無制限のリトライポリシーは、恒久的な権限エラーを、騒がしく高コストなループへと変えてしまいかねません。
5. ロールバックのふりをせず、補償する
データベーストランザクションでは、すでに配信されたメールや、別の製品ですでに作成されたタスクをロールバックすることはできません。分散した変更については、許容できるビジネス状態を復元する補償アクションを定義します。Microsoft の Saga パターンのガイダンスは、後続のステップが失敗したときに、先行する変更を補償トランザクションで取り消せる、一連のローカルトランザクションについて説明しています。
補償はビジネス上の操作であり、必ずしも文字どおりの取り消しではありません。承認タスクを削除すると監査証跡が消えてしまう恐れがあるため、「理由を添えてタスクをキャンセルする」方が安全なこともあります。送信済みのメールは送信を取り消せないため、補償は訂正メッセージとインシデントマーカーの組み合わせになるかもしれません。元帳エントリには、削除ではなく、反対仕訳のエントリが必要になることもあります。
補償可能な各アクションについて、誰がそれを起動できるのか、それ自体が冪等かどうか、どのような証跡を記録するのか、補償が失敗した場合に何が起こるのかを明確にしてください。後続のアクションが先行のアクションに依存している場合は、補償を依存関係の逆順で実行します。技術的なエラーが起きるたびに自動で補償してはいけません。まず、元のアクションが成功したかどうか、そしてビジネスポリシーが取り消しを許容するかどうかを見極めます。
6. オペレーターによる復旧を明示的にする
ソフトウェアは、あらゆる結果を確定できるわけではありません。オペレーターには、ワークフロー ID、現在のステップ、意図したアクション、冪等キー、リモートの参照、最後のエラー、試行回数、利用可能な操作を一覧できる画面を提供します。オペレーターは、チェックポイントから再開する、リモートの状態と整合を取る、1 つのステップをリトライする、完了済みのステップを補償する、あるいは理由を添えて実行を終了する、といった対応が必要になることがあります。
各操作は、自動復旧で使われるものと同じアプリケーション関数を呼び出すべきです。手動のリトライが、冪等性や権限のチェックを迂回してはいけません。オペレーター、タイムスタンプ、理由、変更前の状態、変更後の状態を記録してください。これにより、緊急用のボタンが、監査可能なワークフローの遷移へと変わります。
正常系だけでなく、障害をテストする
デモがうまくいっても、復旧については何もわかりません。あらゆる境界で障害を注入し、そのうえでワークフローの状態と外部への作用を確認してください。
- 外部リクエストの前でクラッシュさせます。再開された実行は、アクションを 1 回だけ実行すべきです。
- 受信側がコミットした後、ワーカーがレスポンスを保存する前にクラッシュさせます。再開された実行は、整合を取るか、同じキーで再実行し、それでも外部オブジェクトを 1 つだけ生成すべきです。
- レート制限を 2 回返した後、成功させます。バックオフ、試行回数、最終的な完了を確認します。
- 恒久的な認可エラーを返します。ワークフローが、一時的エラー用のリトライ予算を消費せずに停止することを確認します。
- モデルの判断を保存した後、再起動します。再実行が、こっそり別の判断を生成するのではなく、受理済みの判断を使うことを確認します。
- 先行する 2 つの作用の後で、後半のステップを失敗させます。定義された補償の順序と監査記録を確認します。
- 補償アクションを失敗させます。実行が可視のまま残り、きれいなロールバックとして報告されないことを確認します。
- 同じキューイベントを同時に配信します。一意制約によって、論理的な実行が 1 つ、各副作用の写しが 1 つに保たれることを確認します。
否定的な結果に対するアサーションも含めてください。各テストの後で、承認タスク、メッセージ、元帳エントリの数を数えます。どのステップもリース期間を超えて running のまま残っていないこと、完了したステップがリモートの参照を失っていないこと、終了した実行が明示的な遷移なしに再開できないことを検証します。
よくある実装上の誤り
試行のたびにランダムな冪等キーを使うと、重複した処理が新しい処理として扱われてしまいます。状態を最後にしか保存しないと、クラッシュに備えた永続的な境界がなくなります。タイムアウトを失敗の証拠とみなすことも同じく危険です。リモートシステムがすでに変更をコミットしている可能性があるからです。
もう 1 つの誤りは、ワークフロー全体を 1 つの大まかなリトライポリシーで包んでしまうことです。読み取り、モデル呼び出し、メッセージ、財務上の書き込みは、同じリスクを共有しているわけではありません。ポリシーは個々のステップに属します。リトライには、経過時間と試行回数に基づく予算を設け、その後に可視の終了状態またはレビュー状態を続けるべきです。
補償をロールバックと呼んでよいのは、それが本当に以前の状態を復元する場合だけです。補償は失敗することもあれば、新しいレコードを作成することもあり、ビジネスプロセスを許容範囲内の別の状態に落ち着かせることもあります。インシデントの際には、オペレーター向けのラベルでその区別を明確にすべきです。
実践的な次のアクション
本番のワークフローを 1 つ選び、それが行いうる外部への書き込みをすべて列挙してください。各書き込みに対して、安定した冪等キー、unknown の結果経路、呼び出しの前後のチェックポイント、そして整合用のクエリまたは補償アクションのいずれかを追加します。そのうえで、上記の 8 つの障害テストを、本番以外の環境で実行します。
この取り組みは、オーケストレーションフレームワークを変更しなくても、復旧の抜け漏れを浮き彫りにします。AI ワークフローの障害復旧は、クラッシュがプロセス全体の場当たり的な再実行ではなく、テスト済みの状態遷移につながるようになったときに、初めて整ったといえます。
参考文献
- Temporal のリトライポリシーは、上記で論じたリトライ間隔、バックオフ、最大試行回数、リトライ不可のエラーの制御を裏づけています。
- Microsoft Azure Architecture Center:Saga パターンは、サービスをまたいだ部分的な完了の後に補償トランザクションを用いる手法を裏づけています。
- LangGraph の永続化は、復旧設計で用いたチェックポイント、スレッド、状態検査、耐障害性のある実行という概念を裏づけています。