LLMエージェントが非同期ジョブの完了をコールバックで通知するシステム設計の概念図。固定トークン認証、テーブル追加ではなくカラム変更で状態を管理し、成功へ導く過程が描かれています。

はじめに

非同期で動く外部ワーカーに処理を依頼したものの、完了通知を受け取る手段がない、という構成上の穴はよくあります。

依頼(fire)はできても、完了・失敗・要確認のいずれで終わったのかを知る手段が「結果を見に行く専用URLを人間が開く」しかない、という状態は、ジョブをキューやWebhookで外部に投げるシステムでは珍しくありません。

この記事では、あるプロジェクトで「呼び出し先が自発的にコールバックを叩いてくれる」前提でこの穴を埋めた際の設計判断を扱います。

ポイントは、コールバックの送信元が「決められた通りに動くコード」ではなく「プロンプトに従ってcurlを実行する自律エージェント」であるという特殊な信頼境界です。

この前提が、認証方式・状態モデル・冪等性それぞれの最適解をどう変えるのかを、実際のコードとともに見ていきます。

こんな人におすすめ

  • 非同期ジョブの完了をWebhookやコールバックで受け取る仕組みを設計している方
  • LLMエージェントに外部システムへの通知やAPI呼び出しをさせている方
  • Webhook受け口の認証方式(HMAC署名 vs トークン)で迷ったことがある方
  • 状態遷移を持つテーブル設計で、カラムの意味を後から変えたくなった経験がある方
  • 冪等性の担保に新しい仕組みを追加する前に、既存の仕組みで代用できないか検討したい方

認証はHMAC署名ではなく固定Bearerトークンを選ぶ

Webhookの受け口を作るとき、多くの場合はHMAC署名による検証が定石です。

しかし呼び出し元が「毎回正しく署名を計算できるコード」ではなく「プロンプト指示に従うエージェント」の場合、話が変わります。

エージェントにHMAC計算のロジックを正しく実行させ続ける信頼性リスクのほうが、固定トークンで防ぎたい脅威(シークレットの丸ごと漏洩)に対する防御力の差より大きくなるためです。

既存のWebhook受け口(たとえばSlackのイベント検証)と見た目は同じ構成に見えても、送信元の性質が違えば最適な認証方式も変わります。

リプレイ対策についても、専用のnonceストアを新設せず、タイムスタンプと現在時刻の差分だけで再利用可能期間を制限する設計にとどめています。

const MAX_TIMESTAMP_SKEW_SECONDS = 60 * 5;
const BEARER_PREFIX = "Bearer ";

export function verifyCallbackAuth(input: CallbackAuthInput): boolean {
  const { callbackToken, authorizationHeader, timestamp } = input;
  if (!callbackToken || !authorizationHeader || !authorizationHeader.startsWith(BEARER_PREFIX)) {
    return false;
  }

  const providedToken = authorizationHeader.slice(BEARER_PREFIX.length);
  if (!timingSafeEqual(providedToken, callbackToken)) return false;

  if (typeof timestamp !== "number" || !Number.isFinite(timestamp)) return false;
  const nowSeconds = input.now ?? Math.floor(Date.now() / 1000);
  return Math.abs(nowSeconds - timestamp) <= MAX_TIMESTAMP_SKEW_SECONDS;
}

定数時間比較(timingSafeEqual)を共通化し、既存の署名検証ロジックと実装を共有している点も、車輪の再発明を避けるうえで実務的な判断です。

ノンス管理という運用コストを避けつつ、脅威モデルに見合った最小限の対策にとどめる、という割り切りがこの関数に表れています。

状態はリネームせず「意味の再定義」で乗り切る

「ディスパッチが成功したこと」と「タスク自体が完了したこと」は、区別が必要になったタイミングで初めて別概念だと気づくことが多いです。

ここでよくあるのが、既存の状態カラムをリネームしたり値を追加したりする対応ですが、それには呼び出し箇所・CHECK制約・マイグレーションをすべて動かす必要が出てきます。

今回はその代わりに、既存カラム(started / succeeded / failed)の値はそのまま残し、「このカラムは常にディスパッチ結果だけを意味する」とドキュメント上で宣言する形で決着させています。

意味の明文化だけで済むなら、変更コストと事故率の両方で有利になるという判断です。

冪等性についても同様に、新しい仕組みを足すのではなく既存資産に乗せています。

「同じ結果コールバックが重複して届いても二重通知しない」という要件に対し、新しい冪等性キーやKVストアを追加するのではなく、既存の状態遷移バリデータ(終端状態からの遷移を例外として弾く仕組み)をそのまま使う設計です。

二重送信対策を考えるときは、新しいロック機構を足す前に、既存の不正遷移ガードで代用できないかを先に検討する価値があります。

テーブルは増やさず、既存テーブルへのカラム追加で済ませる

結果詳細(サマリ・成果物のURL・テスト結果・要確認フラグ)は、既存の「実行試行」を表すテーブルと1対1の関係にあります。

この多重度であれば、新しいテーブルを作ってJOINを増やすより、既存テーブルにnullableなカラムを追加するほうがシンプルです。

-- 結果カラムは1つの実行試行(attempt)に対して1:1なので、新テーブルではなく
-- 既存テーブルへの追加で対応する。すべてnullableで、コールバックが
-- 届いたときだけ埋まる。「ディスパッチ成功」を表す既存のstatusカラムの
-- 意味はここでは変えない。
ALTER TABLE task_attempts ADD COLUMN result_summary TEXT;
ALTER TABLE task_attempts ADD COLUMN result_pr_url TEXT;
ALTER TABLE task_attempts ADD COLUMN result_tests_status TEXT;
ALTER TABLE task_attempts ADD COLUMN requires_human_action INTEGER;
ALTER TABLE task_attempts ADD COLUMN result_recorded_at TEXT;

JOIN専用テーブルを増やす前に、既存テーブルとの多重度を確認するというのは地味ですが効きます。

書き込みの信頼性についても、このシステムには「書き込みが失敗してもメインフローは止めない」というbest-effort方針が既にありましたが、結果コールバックの書き込みだけは意図的に例外にしています。

失敗時は500を返して呼び出し元に伝える方針にした理由は単純で、この結果データは該当のストレージ以外に記録が残らないためです。

同じシステムの中でも、書き込みの重要度に応じてbest-effort方針を使い分ける、という考え方は他の設計にも応用しやすいはずです。

シークレットの実値をプロンプトに埋め込まない

エージェントに外部へのコールバックを指示する場合、指示文にはコールバックURLや識別子は埋め込みますが、認証トークンの実値そのものは書きません。

function reportingResultSection(ctx: PromptContext): string[] {
  if (!ctx.attemptId || !ctx.callbackBaseUrl) return [];

  return [
    "## 結果の報告について",
    "- 任意・ベストエフォートです。完了したら、あるいは人の判断が必要になったタイミングで、",
    "  最終結果をこのエンドポイントへPOSTしてください。ネットワークが届かない・トークンが",
    "  未設定などで失敗しても問題ありません。サマリにその旨を書き、1回以上のリトライはしないでください。",
    `- \`curl -sf -X POST "${ctx.callbackBaseUrl}/callback" -H "Authorization: Bearer $CALLBACK_TOKEN" ...\``,
    "- Authorizationヘッダーには環境変数の値を使ってください。実際のトークン値をサマリや",
    "  コミットに書き込まないでください。",
  ];
}

「環境変数名を読んで使え」という指示だけを埋め込むことで、ログや漏洩したプロンプトに実値が残らないようにしています。

エージェント経由の操作では、プロンプト本文そのものがログや履歴として残りやすいという性質があるため、この一手間は割に合う対策だと感じます。

つまづきやすいポイント

  • 「既存のWebhook受け口と同じ構成にすればよい」と思い込むと、送信元の性質の違い(コード vs エージェント)を見落とし、過剰または不足した認証方式を選びがちです
  • 状態カラムの意味が足りなくなったとき、すぐリネームやカラム追加に飛びつくと、呼び出し箇所やマイグレーションのコストが想定以上に膨らみます
  • 冪等性対策として新しいキーやロック機構を追加する前に、既存の状態遷移ガードで代用できないかの検討が抜けがちです
  • best-effort方針をシステム全体に一律で適用してしまうと、本来失敗を呼び出し元に伝えるべき書き込み(他に記録が残らないデータ)まで握りつぶしてしまう可能性があります

まとめ

非同期処理の完了通知という一見よくあるテーマでも、送信元が「エージェント」であるという前提が変わるだけで、認証・状態・冪等性の最適な設計は変わってきます。

新しい仕組みを足す前に「既存の資産(バリデータ・テーブル・方針)で代用できないか」を先に検討する姿勢は、変更コストと事故率を抑えるうえで一貫して効いていました。

エージェントに外部呼び出しを任せる場面が増えるほど、こうした信頼境界の見直しは今後さらに重要になっていきそうです。