レッスン4: 副作用と冪等性: 再開時に再実行しても安全なツールはどれか
学習目標:
- 再開時のリプレイが、既定で at-least-once(少なくとも1回)の実行セマンティクスになり、exactly-once には決してならない理由を説明する
- ツール操作が冪等かどうかを判断し、2回走った瞬間に実害を生む副作用を見抜く
tool_use_idをキーにした副作用台帳を設計・実装し、再開時の宙ぶらりんな呼び出しが、実際に実行するかどうかを決める前に台帳を確認するようにする
前提: レッスン2とレッスン3を読み、checkpoint.jsonに残った宙ぶらりんなpendingToolUseの調整ルール(レッスン3)を理解していること。このシリーズの第7コース「Agent Harness Fundamentals: Loops and Control」のHIGH_IMPACTツール集合と実行前の承認ゲートを知っていること | 前: レッスン3 << | 次: レッスン5 >>
再開が与えるのは at-least-once: レッスン3が積み残した高影響ツール
レッスン3では、checkpoint.jsonからpendingToolUseを読み出し、それを使って宙ぶらりんな呼び出し——クラッシュがツール実行と台帳への書き込みのあいだに落ちたもの——をループへ引き戻す方法を学びました。そのときの調整ルールはこうでした。読み取り専用ツールはそのまま再実行する。判断のつかない高影響ツールにはis_errorのtool_resultを追加し、ループが詰まった状態から抜けられるようにして、問いは人間に返す。これは誠実なフォールバックであり、同時に未解決の問題でもあります。「判断がつかない」とは、そのタスクが自力では先へ進めないということです。つまり、高影響ツールでクラッシュが起きるたびに、誰かが見張っていなければなりません。
根っこにあるのはこれです。再開はその性質上、at-least-onceの実行セマンティクスを与えます。ツールが本当に成功した後、その結果がmessagesに書き戻される前、あるいはチェックポイントにコミットされる前にプロセスが死ぬことはありえます。そしてその時点で、「このツールは走ったのか、走らなかったのか」はcheckpoint.jsonだけでは答えられない問いになります。read_fileのような読み取り専用ツールなら、わからなくても何も失いません。もう一度読んでも結果は同じです。しかしsend_emailやcreate_ticket、あるいは送金となると、わからないことがそのままインシデントになります。再実行すれば受信者に同一のメールが2通届くかもしれず、重複したチケットがどこからともなくシステムに現れるかもしれません。
これは新しい問題ではありません。このコースの前半で、エージェントはステートフルであり、エラーは積み重なる1という点を確認しました。1回だけ起きるはずだった副作用を2回実行してしまうことは、その「積み重なり」が取る具体的な形の一つです。エラーは「1回余分に走らせた」で止まりません。その余分な副作用の上に乗って、下流へ転がっていきます。このレッスンでは、レッスン3が開けたままにした穴を塞ぎます。まず冪等性という概念を導入し、次に再開ループに副作用台帳を取り付けて、「判断がつかない」を「判断がつく」に変えます。
冪等とは何か: 1回でも10回でも、同じ効果
冪等とは、1回実行しても何回実行しても、最終的な効果が同じになる操作のことです。ここで問題にしているのは効果——その操作が外の世界(ファイル、データベース、受信箱)に残す最終状態——であって、各呼び出しが文字通り返す値ではない点に注意してください。
ツールが冪等かどうかを判断するには、質問が一つあれば足ります。「この操作が気づかれないままもう1回走ったとして、外の世界には何かが余分に増えるか、あるいは違う状態になるか?」次の小さな例をその質問にかけてみると、違いはすぐに現れます。
readFileContentは、そもそも副作用がまったくないという理由で冪等です。「残るもの」と呼べるものが何もありません。setLineも冪等ですが、こちらは実際に状態を変更します。ただし変更の仕方が上書きです。1回呼べば42行目はXになり、10回呼んでも42行目はやはりXのままです。最終状態が呼び出し回数によって変わりません。appendRowとsendEmailが冪等でない理由は、どちらも同じです。効果が累積的だからです。呼び出すたびに外の世界へ本当にもう一つ足されるので、呼び出し回数が最終状態にそのまま現れます。
この境界線を覚えておいてください。上書き型の書き込みはたいてい冪等で、追記型の書き込みはたいてい冪等ではありません。読み取りや「先に確認してから、動くかどうかを決める」型の操作はたいてい冪等で、条件なしの単純な挿入はたいてい冪等ではありません。次のセクションの副作用台帳は、冪等でなく、しかも設計変更では冪等にできない操作を捕まえるためにこそ存在します。
副作用台帳: どの副作用がすでに起きたかを書き留める
レッスン2では、ループの走っている現場——messages、各種カウンター、まだ台帳に書かれていないツール呼び出し——をチェックポイントに保存し、クラッシュしてもその場から拾い直せるようにする方法を学びました。しかしチェックポイントが答えるのは「ループのどのステップまで到達したか」であって、「そのステップの副作用が実際に起きたか」ではありません。通常運転中はこの二つはほぼ歩調を合わせて動きますが、その隙間にクラッシュが落ちた瞬間に食い違います。レッスン3が高影響ツールの調整を未解決のまま残さざるをえなかったのは、まさにこれが理由です。
この隙間を塞ぐには、副作用台帳が要ります。どの副作用がすでに起きたかを、チェックポイントとは別に、ディスク上に書き留めるのです。構造は単純で、tool_use_idをキーにしたマップです。
台帳を書くタイミングは非常に重要です。ツール関数が実際に成功して結果を返した、その瞬間に書きます。しかもレッスン2の「地点B」のチェックポイント(ツール結果がmessagesに入った後に行われる定型の書き込み)よりも一拍早く書きます。理由は直接的です。「ツールが成功した」から「地点Bのチェックポイントの書き込みが終わった」までの狭い窓の中にクラッシュが落ちた場合、地点Bのチェックポイントはこれが起きたことを記録する機会を得ていません。再開時に真実を教えてくれるのは、より早く書き終えていた台帳だけです。台帳の書き込み自体も、レッスン2とレッスン3の.tmp+renameによるアトミックな書き込みを使わなければなりません。理由は同じで、中途半端に書かれた台帳ファイルは、台帳がまったくないよりも危険だからです。実際には完了しなかった副作用を、あなたに信じ込ませてしまいます。
台帳が手元にあれば、再開時の調整ルールはレッスン3の「判断がつかない」から「判断がつく」へ格上げされます。checkpoint.jsonから取り出したpendingToolUseのidを、台帳で引くのです。ヒットした場合、その副作用は本当に起きているので、台帳に保存されたresultを取り出してtool_resultを埋め、二度と実行しません。ミスした場合、この呼び出しは開始すらしなかったか、成功しないまま途中で死んだかのどちらかなので、実行しても安全です。このルールはすべてのツールに当てはまります。ただ、冪等なツールにとってはこの照合がどちらに転んでも影響がない、というだけのことです。実際にこれに依存するのは、繰り返されると実害を生む操作のほうです。
ここには単独で述べる価値のある洞察が一つあります。tool_use_idはすでに冪等キーである、ということです。モデルがツールを名指しするたびに、その呼び出しは "A unique identifier for this particular tool use block"2(この特定のツール使用ブロックに対する一意な識別子)を伴います。これは公式仕様のidフィールドの定義そのままです。同じ名指しが再開時のリプレイによってもう一度見えたとしても、idは変わりません。台帳が「この呼び出し」と「さっきのあの呼び出し」を同一の出来事として認識できるのは、まさにこの性質のおかげです。あなたが独自の重複排除の仕組みを考案する必要はありません。
2段のゲート: 承認は「やるべきか」を、台帳は「もうやったか」を問う
このシリーズの第7コース「Agent Harness Fundamentals: Loops and Control」では、runToolUsesに承認ゲートを取り付けました。高影響ツールが実際に走る前に、これから何が起きるかを表示し、人間の確認を待ち、そのうえで初めて通す、というものです3。このゲートが止めるのは「これはやるべきか」という問いです。このレッスンの副作用台帳が止めるのは別の問い、「これはもうやったか」です。二つのゲートは違うことを問いますが、置かれる場所は同じです。どちらも、モデルがツールを名指しした後、ツールが実際に走る前の瞬間にねじ込まれます。どちらも、確認が済むまでツール関数を実行させません。
この二つを重ねると、runToolUsesはこうなります。
この順序は交渉の余地がありません。冪等性ゲートが先に来なければならないのです。理由は明白で、この呼び出しがすでに台帳にあるなら、後から「やるべきか」と尋ねても意味がありません。すでに終わっているからです。しかも尋ねられた人を混乱させるだけです——システムはこれを明らかに完了しているのに、なぜ私に確認を求めてくるのか、と。再開時の宙ぶらりんな呼び出しに対して、最初の問いは常に「これは起きたか」であり、それが片付いて初めて「これはやるべきか」の出番が来ます。
ツール設計層での冪等性: 火消しではなく、原因を直す
副作用台帳はハーネス側のバックストップです。ツール自体が冪等に設計されていようがいまいが、台帳はtool_use_idを使って重複実行を止められます。しかしバックストップはあくまでバックストップであり、より良い投資は原因を直すことです。ツールを変更できるなら、そもそも冪等になるよう設計し、台帳の出番が来ないようにするのです。
その変更の最も一般的な形は、「作成する」を「存在を保証する」に変えることです。
ensureTicketを1回呼んでも10回呼んでも、そのタイトルに一致するチケットはシステムにちょうど1枚だけ残ります。最終状態が呼び出し回数によって変わらない——これが冪等の定義そのものです。同じ考え方はファイルの書き込みにも当てはまります。ファイル全体を書くwrite_fileは本質的に冪等で、繰り返し呼んでも残る内容は同じです。追記型のappend_fileはそうではなく、呼び出しごとにファイルが1セクションずつ伸びていきます。上書きを選べるなら、追記を選ばないことです。
原因を直すことと火消しをすることは、二択ではありません。役割分担です。冪等に設計できるツールはツール層で解決し、すべての呼び出しに台帳経由の遠回りをさせずに済ませるべきです。そして、業務ロジックの問題として本当に「重複排除してまとめる」ことができない操作——たとえば、異なる時刻に本当に起きた2件の送金で、2つの別個の出来事として認識されるべきであり、設計の工夫で1件に畳み込むことができないもの——については、台帳が唯一のバックストップです。
呼応: もう一つのガードレールと、それが割に合うタイミング
このコースは、信頼性はモデルの適応力を、リトライロジックや定期的なチェックポイントのような決定論的なセーフガードと組み合わせることから生まれる1、という点を出発点にしました。副作用台帳はそのガードレールの一つです。それはモデルに「自分はもうこれをやったか」を判断させません——それは最初からモデルの知覚できる範囲の外にありました。台帳は、ディスク上に書かれた確たる証拠を使って、ハーネスにその判断をモデルの代わりに下させるのです。
同時に、程度の感覚も持っていてください。エージェントが持つツールがすべて読み取り専用なら、このレッスンの台帳はおそらく元が取れません。副作用台帳それ自体が一層の複雑さであり、それを追加するに値する理由は、重複した副作用という現実のリスクを実際に食い止めることです。複雑さは、成果を明らかに改善する場合にのみ追加すべきです3。判定基準は前のレッスンと同じです。まず自分のツール集合の中に、冪等でない高影響な操作があるかを見ること。あるならゲートは取り付ける価値があります。ないなら、急いで書く必要はありません。
まとめ
- 再開は at-least-once の実行セマンティクスをもたらします。ツールが本当に成功した後、結果が台帳に書かれる前にプロセスが死ぬことがあり、チェックポイントだけでは、その宙ぶらりんな呼び出しが走ったかどうかを教えてくれません。レッスン3が高影響ツールの調整を未解決のまま残さざるをえなかった根本原因はここにあります。
- 冪等の定義は、1回実行しても何回実行しても最終的な効果が同じになる操作、です。上書き型の書き込み(
set_config、write_file)はたいてい冪等で、追記型の書き込み(append_log、send_email)はたいてい冪等ではありません。
- 副作用台帳は、どの副作用がすでに起きたかを
tool_use_idをキーにしてディスク上に記録します。このキーは、モデルがツールを名指しする際に伴う一意な識別子であり2、再開時に同じ名指しがリプレイされても変わらないため、本質的に冪等キーになっています。書き込みは.tmp+renameのアトミックな書き込みで、ツールが成功したその瞬間に行ってください。
- 再開時の調整ルールはこう格上げされます。
pendingToolUse.idが台帳でヒットしたら、保存済みの結果を再利用して二度と実行しない。ミスしたら、安全に実行する。
- 承認ゲートは「これはやるべきか」を、副作用台帳は「これはもうやったか」を問います。両者は互いを補完し、どちらも実際の実行の手前に位置し、冪等性ゲートが先に来ます。
- 火消しより原因を直すほうが優れています。ツールを本質的に冪等に設計すれば(「作成」より「存在を保証」、追記より上書き)、すべてに台帳が必要になるわけではなくなります。信頼性はモデルの適応力と決定論的なセーフガードの組み合わせから生まれますが1、セーフガードもまた複雑さであり、成果を明らかに改善する場合にのみ追加すべきです3。
>> レッスン5: 巻き戻しとフォーク: チェックポイントの第二の価値