本文へスキップ
AI活用

AIエージェントの二重投稿を止める排他claim設計|Notionキューのstatus遷移 実装記録

Hirokuma
10分で読める
AIエージェントの二重投稿を止める排他claim設計|Notionキューのstatus遷移 実装記録

AIエージェントにSNS投稿を任せるとき、本当に怖いのは「投稿しないこと」ではなく「二重に投稿すること」です。そしてこれはプロンプトに「二重投稿しないでください」と書いても止まりません。私が運用しているSNS自動投稿システムでは、この問題を投稿キューのstatus設計と「claimしてから直後に読み直す」排他手順で止めています。この記事は、その実装記録です。

対象読者は、AIエージェントに定期実行の仕事(投稿、通知、レポート送信など)を任せようとしている実務者です。持ち帰ってもらいたい結論を先に置きます。外部へ副作用が出る操作は、「取りこぼしても通知して人間が拾える」形に倒し、「二重に実行される」可能性のほうを設計で潰す。リトライを増やす方向ではなく、リトライを我慢する方向に寄せるのがポイントです。

キューはNotion、実行はエージェント

前提の構成から説明します。私のシステムでは、Threads・Instagram・LinkedInへの投稿は次の流れで動いています。

  1. 執筆担当のエージェントが投稿文を書き、Notionの「投稿キュー」データベースへ下書きとして登録する
  2. 審査担当(critic)がルーブリックで機械審査し、通過したものだけが承認済みになる
  3. 定期実行される投下担当のエージェントが、予定時刻の来た承認済み行を拾って、対応するSNSの公開ツールを呼ぶ

キューをNotionに置いたのは、人間が見える台帳にしたかったからです。専用のDBやメッセージキューを立てれば技術的にはきれいですが、そうすると「いま何が予約されていて、何が失敗しているか」を確認するための管理画面を別に作ることになります。Notionなら、キューそのものが管理画面です。私が予約を直接編集することも、失敗行にコメントを残すこともできます。

ただし、この選択には代償があります。私の構成でエージェントがNotionに対して使えるのは読み取りと更新の操作だけで、SQLのSELECT FOR UPDATEのような行ロックも、トランザクションもありません。つまり「読んでから書くまでの間に、別の実行が同じ行を読んでいるかもしれない」という前提で設計する必要があります。

statusは5値。それぞれに「誰が動かすか」を固定する

投稿キューの行はstatusプロパティで次の5状態を遷移します。

  • draft: 執筆直後。またはcritic審査で差し戻された状態
  • approved: critic通過済みで、scheduled_at(予定時刻)待ち
  • posting: 投下担当がclaimした瞬間の中間状態
  • posted: 公開成功。permalinkposted_atが書き戻される
  • failed: 公開失敗。errorに機密を除いた失敗要約が残る

設計の肝は、状態の数そのものより「どの状態を誰が動かすか」を固定したことです。draftからapprovedへ動かせるのはcriticの審査結果だけ。approvedから先へ動かせるのは投下担当だけ。そしてfailedからapprovedへ戻す操作は、エージェントには存在しません(これは後述します)。

投下担当が拾う条件は、次の3つをすべて満たす行だけです。

status = approved
AND scheduled_at <= 現在時刻
AND posted_at が空

posted_atの条件を入れているのは二重の保険です。仮にstatusがどこかで壊れても、一度でも公開に成功した行にはposted_atが入っているので、この条件で必ず弾かれます。

claimの手順: 更新した「つもり」を信用しない

ロックがない環境での排他は、楽観的にやるしかありません。投下担当は対象行を1行ずつ、次の手順で処理します。

  1. 行のstatuspostingへ更新する(これがclaim)
  2. 直後に同じ行を読み直しstatus=postingかつposted_atが空であること、対象のページIDが一致することを確認する
  3. 確認できた行だけ、プラットフォームに対応する公開ツールを1回だけ呼ぶ
  4. 成功したらpermalinkposted_atstatus=postedを書き戻す
  5. 失敗したらstatus=failederrorを書き、同一実行内では再試行しない

この手順で設計が一度ひっくり返ったのが、手順2です。最初に考えたくなるのは「claimの更新が成功したら、それで所有権が取れた」という素直なモデルですが、更新した「つもり」を信用しないことにしました。更新リクエストの成否と、その時点の行の実際の状態は、別のものとして扱う。だからclaimの直後にもう一度読み、いま自分が期待した状態になっているかを目で確かめてから、はじめて外部への副作用に進みます。確認できなければ、その行には触らず次へ進むだけです。

「読み直したところで、その直後に別の実行が割り込む可能性はゼロではないのでは」という指摘はそのとおりで、これは厳密な相互排除ではありません。ただ、実行間隔が管理された定期実行どうしの競合では、claim→再読のわずかな窓まで重なるケースは実用上ほぼ潰せます。そして残ったリスクは、次の層(後述するサーバー側の上限)が受け止めます。厳密さを1層で完結させず、層を重ねて確率を落とす考え方です。

「リトライしない」を仕様にする

この設計でいちばん反直感的なのは、リトライへの態度だと思います。信頼性の話では普通、リトライは増やす方向に進みます。ネットワークは失敗するもの、失敗したらやり直せばいい、と。しかしこのキューでは逆に、リトライを2箇所で明示的に禁止しています。

1つ目はpostingのまま止まった行です。 claimしたあと、公開ツールの呼び出し結果を書き戻す前にエージェントの実行が落ちると、行はpostingのまま残ります。このとき実際に投稿が出たかどうかは、行を見ても分かりません。書き戻しに失敗しただけで、投稿自体は成功しているかもしれない。そこで「postingのまま15分を超えた行は再投稿せず、人間の確認が必要な行として通知だけする」というルールにしました。自動で再投稿すれば取りこぼしは消えますが、すでに出ていた場合は二重投稿になります。SNSの投稿は、出しそびれても後から人間が拾えますが、二重に出たものは取り消しても見られています。欠測のコストと二重実行のコストが非対称なので、欠測側に倒す。いわゆるat-most-once(多くて1回)の選択です。

2つ目はfailed行です。 公開に失敗した行を、エージェントが自動でapprovedへ戻すことを禁止しています。失敗の原因が一時的なものか、本文や画像の問題か、行を見ただけでは切り分けられないからです。原因不明のまま自動で列に戻すと、同じ失敗を延々と繰り返す行がキューに住み着きます。failedは「人間に引き渡した」という状態であって、「あとでもう一度やる」という状態ではない。この区別を仕様にしました。

プロンプトの禁止事項は「最初の層」でしかない

投下担当のエージェントには、スキルの手順書として「draftや未来時刻の行を公開しない」「stale行を再投稿しない」といった禁止事項も書いてあります。ただ、これはあくまで最初の層です。指示は破られる前提で、その先に2つの層を重ねています。

2層目が、ここまで書いたキューのstatus設計です。手順を多少読み違えても、拾う条件(approved・時刻到来・posted_at空)とclaim→再読の型に従うかぎり、壊れた行は素通りできません。

3層目が、公開ツールを提供するサーバー側の上限です。私のシステムでは、投稿はすべて自作のMCPサーバーを経由し、そこで全チャネル合算の頻度制限と媒体別の日次上限がかかります。キュー側の排他をすべてすり抜けたとしても、最後はサーバーが物理的に弾く。この層の実装は別の記事に書きました。

自作MCPサーバーに「投稿し過ぎない」制限を実装した記録|制限はプロンプトではなくサーバーに置くAIエージェントに投稿ツールを渡すと放っておくと公開し過ぎます。その制限をプロンプトではなく自作MCPサーバー側(Cloudflare WorkersのKV)に置いた実装記録。単純なカウントではなくreserve→commit/releaseの二段階にした理由と、失敗時に枠を戻す設計まで解説します。www.tentspace.net

審査の分離(critic)まで含めると、執筆→審査→claim→公開→上限の各段に別の防御がある形になります。criticの仕組みはこちらです。

Claude Codeのサブエージェントで公開前レビューを分離する|critic実装記録Claude Codeのサブエージェントで、公開前レビューを執筆エージェントから分離した実装記録。読み取り専用のcritic定義、判定基準のファイル固定、PASS/FAILの機械判定、修正2回上限と隔離まで、実際に運用している構成を解説します。www.tentspace.net

ハマりどころと運用の注意

実装してみて効いた細かい点をいくつか。

  • 中間状態を増やしすぎない。 一時は「審査中」「claim待ち」など状態を細かく刻むことも考えましたが、状態が増えるほど「誰がどこへ動かすか」の組み合わせが爆発します。5値でも運用は十分に回っています
  • errorに書く内容は要約に絞る。 失敗時の書き戻しにAPIレスポンスを生で貼ると、トークンや内部IDが台帳に残る事故につながります。機密を除いた要約だけを書く、と手順の側で決めておきます
  • 検証用の行を集計から隔離する。 開発中に作ったサンプル行がキューに残っていると、拾う条件に合致した瞬間に「テスト投稿が本番に出る」が起きます。サンプル行は命名規則で区別し、拾う対象から除外しています

まとめと次の一歩

外部に副作用が出る操作をAIエージェントへ任せるときの設計を、実運用中の構成でまとめました。

  • 状態遷移を少数のstatusに固定し、状態ごとに動かせる主体を1つに絞る
  • ロックがなければ、claim→直後再読の楽観的排他で「確認してから副作用」を徹底する
  • 欠測と二重実行のコストが非対称なら、自動リトライを我慢してat-most-onceへ倒す
  • プロンプトの禁止事項を1層目として、キュー設計、サーバー側上限と層を重ねる

このキューはいまも毎日動いていて、postingのまま止まる行がどのくらいの頻度で出るか、観測を続けているところです。数字がたまったら、staleルールの15分という閾値が妥当だったかを見直すつもりです。