Antigravity Agent でユーザー対応や運用判断を自動化する仕事を続けていて、最終的にどうしても残るのが「ここで AI に独走させるのは早い」という領域です。私自身、2014年から個人でアプリ開発を続けて累計 5,000 万ダウンロードに到達した範囲のなかでも、AdMob の収益化条件変更や App Store の規約改定の局面では、Agent が拾った変更案をそのまま本番設定に反映させたことが一度もありません。確率的なアクションは小さな失敗を 99 回避けても、1 回の判断ミスがレビュー停止や返金フローの破綻を引き起こす業界がほとんどです。
そのギャップを埋めるのが Human-in-the-Loop(HITL)の承認パイプラインで、ここではあえて「承認ボタンを置けば終わり」とは捉えず、リスク階層・データモデル・レビュアー UI・監査ログ・段階的自動化の全体を一本の運用システムとして設計する話に踏み込みます。実装は Antigravity Agent + PostgreSQL + 軽量な Web レビュー UI を前提にしますが、Agent SDK を別実装に置き換えても考え方は流用できます。
確率的な行動を本番にそのまま流せない理由
エージェントの出力は仕様上、信頼度の異なる行動の集合です。同じプロンプト同じ文脈でも、内部の探索や呼び出すツールの可用性で結果が揺れます。揺れの幅は数 % であっても、運用に乗ったときの期待損失は驚くほど非対称です。私の運営アプリで言えば、AdMob の規約に触れる広告ユニットを 1 つでも誤って有効にしてしまうと、最悪のケースで配信停止が数日続き、月間 7 桁の機会損失に直結します。逆に手堅い判断を Agent が 100 回成功させても、それは「事故が起きなかった」という形でしか観測できません。
本番でエージェントを走らせる前提として、私は次の 4 つを必ず仕分けします。
取り返しがつく変更か(30 秒以内にロールバック可能か)
外部に伝播する変更か(ストア・決済・メール・通知)
規約・契約・コンプライアンスに触れるか
撤回コストが顧客側に発生するか
このいずれかに該当するアクションは、信頼度がいくら高くても自動実行のレールに乗せません。「ロールバック可・伝播なし・規約セーフ・撤回コストなし」が揃って、はじめて自動化候補になります。HITL の役割は「全部止める」ではなく、自動化候補にならない領域だけを人間のキューに送ることです。
HITL を「承認ゲート」ではなく「リスク階層」として設計する
承認パイプラインを設計するとき、最も陥りやすいのが「全アクションを承認待ちにする → 承認疲れで形骸化」という落とし穴です。個人開発でアプリの不具合監視を Antigravity Agent に任せはじめた当初、私はこれをやって 3 週間で破綻させました。レビュー件数が 1 日 300 件を超え、自分が中身を読まずに承認するようになり、結果として承認ゲートが純粋なノイズに変わりました。
そこで採用したのが、アクションを 4 階層に分けるアプローチです。
L0 自動 : レビュアー宛のメモ作成、内部 Slack 通知。即時実行 + 監査ログのみ。
L1 ソフト承認 : ストア説明文のタイポ修正、リリースノートの草案投入。信頼度 0.85 以上で自動承認、それ未満は人間レビュー。
L2 ハード承認 : 価格変更、地域別配信のオン/オフ。信頼度に関わらず人間レビュー必須、ただし UI は 1 クリック。
L3 二重承認 : 規約改定への対応、新規プラン追加、返金ルール変更。2 名の独立承認、4 時間以上のクールダウン。
この段階分けが効くのは、L1 を導入できると人間レビューの母数が一気に下がるためです。私の運用では L1 ソフト承認を入れた直後にレビュー件数が 1 日 300 件 → 約 80 件になり、3 か月運用したあとの直近では 40 件前後で安定しています。承認の質が上がったのではなく、判断が必要なものだけを残せた結果です。
承認キューの中核データモデル
承認パイプラインを「動くもの」にするには、まずデータモデルを腐らない形で置く必要があります。私は PostgreSQL の単一テーブルにキューを集約しつつ、状態遷移を厳格にコード側で縛る構成を推奨します。
CREATE TABLE agent_actions (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
idempotency_key TEXT NOT NULL UNIQUE ,
agent_run_id UUID NOT NULL ,
level TEXT NOT NULL CHECK ( level IN ( 'L0' , 'L1' , 'L2' , 'L3' )),
confidence NUMERIC ( 4 , 3 ) NOT NULL ,
payload JSONB NOT NULL ,
payload_hash TEXT NOT NULL ,
diff_summary TEXT NOT NULL ,
state TEXT NOT NULL DEFAULT 'pending'
CHECK ( state IN ( 'pending' , 'approved' , 'rejected' , 'executed' , 'rolled_back' , 'expired' )),
reviewer_id UUID,
reviewed_at TIMESTAMPTZ ,
reason TEXT ,
expires_at TIMESTAMPTZ NOT NULL ,
executed_at TIMESTAMPTZ ,
rollback_token TEXT ,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW ()
);
CREATE INDEX idx_actions_state_level ON agent_actions( state , level , expires_at);
CREATE INDEX idx_actions_agent_run ON agent_actions(agent_run_id);
ポイントは 3 つあります。第一に idempotency_key を必須にしておくと、Agent が同じ判断を別ランで再提出したときに重複承認が発生しません。本番で私がいちばん怖いと感じたのは、ネットワーク再試行で同一アクションが 5 件キューに積まれるパターンでした。第二に expires_at を最初から持たせ、承認されないまま 24 時間放置されたアクションは自動的に expired に倒します。古い情報を承認することのほうが、未承認で流すよりも害が大きい局面が多いためです。第三に rollback_token を実行時に必ず発行します。これは「30 秒で戻せるか」を物理的に保証するための土台で、ない設計はそもそも本番に乗せないほうがよいと考えています。
状態遷移は次のように書きます。許可された遷移を関数の入口で弾く形にしておくと、レビュー UI 側のバグや競合更新で不整合が混入しにくくなります。
type State = 'pending' | 'approved' | 'rejected' | 'executed' | 'rolled_back' | 'expired' ;
const TRANSITIONS : Record < State , State []> = {
pending: [ 'approved' , 'rejected' , 'expired' ],
approved: [ 'executed' , 'rolled_back' ],
rejected: [],
executed: [ 'rolled_back' ],
rolled_back: [],
expired: [],
};
export async function transition (
actionId : string ,
to : State ,
reviewer : string ,
reason ?: string ,
) {
return db. transaction ( async ( tx ) => {
const row = await tx. query <{ state : State }>(
`SELECT state FROM agent_actions WHERE id = $1 FOR UPDATE` ,
[actionId],
);
if ( ! row.rows. length ) throw new Error ( 'action not found' );
const cur = row.rows[ 0 ].state;
if ( ! TRANSITIONS [cur]. includes (to)) {
throw new Error ( `illegal transition: ${ cur } -> ${ to }` );
}
await tx. query (
`UPDATE agent_actions
SET state = $2, reviewer_id = $3, reviewed_at = NOW(), reason = $4
WHERE id = $1` ,
[actionId, to, reviewer, reason ?? null ],
);
});
}
FOR UPDATE で行ロックを取るのは、複数レビュアーが同じ案件を同時に開いて二重承認する事故を防ぐためです。私は最初これを省略していて、デプロイ直後の数時間で 3 件の二重承認を経験しました。本番でレビュアーが 2 名以上いるなら必須の対処です。
信頼度スコアによるルーティングと自動昇格
L1 アクションが効率に効くのは、Agent 側がスコアを真面目に返してくれる場合だけです。Antigravity Agent では、ツール呼び出し結果に対して自己評価を出力させ、それを confidence フィールドに保存します。経験上、単純なロジット平均を返させるとスコアの分布が貼り付き、ルーティングが機能しません。次の 3 種類のシグナルを線形結合した複合スコアを推奨します。
ツール呼び出しの戻り値が事前条件を満たしたか(バイナリ)
過去 30 日の類似アクションの撤回率(正規化済み)
Agent 自身の自己評価(calibration を別途学習させて補正)
実装としては次の形になります。
type ActionLevel = 'L0' | 'L1' | 'L2' | 'L3' ;
type Route = { route : 'auto' | 'review' ; reviewers : number };
export function routeAction ( action : {
confidence : number ;
level : ActionLevel ;
domain : string ;
}) : Route {
if (action.level === 'L0' ) return { route: 'auto' , reviewers: 0 };
const threshold = thresholdFor (action.domain); // 後述のドメイン別校正
if (action.level === 'L1' && action.confidence >= threshold) {
return { route: 'auto' , reviewers: 0 };
}
if (action.level === 'L3' ) return { route: 'review' , reviewers: 2 };
return { route: 'review' , reviewers: 1 };
}
閾値の 0.85 は固定値ではなく、月次で校正します。私は撤回率が直近 30 日で 1.0% を超えた領域があれば、その領域の閾値を 0.90 に引き上げる運用にしています。逆に 0.2% を下回って 60 日経過したカテゴリは閾値を 0.80 まで下げ、より多くを自動に倒します。AdMob 関連の判定だけは規約変更リスクが高いため、撤回率の数値に関わらず閾値を 0.95 に固定しています。これは個人開発で AdMob を主要収益源にしている前提で引いた線で、収益構造が違えば線も変わります。
レビュアー UI を「3 秒で判断できる」形に削る
ここを軽視すると承認パイプラインが瞬時に形骸化します。私は当初、Agent の思考プロセスをそのまま JSON で表示していて、レビュー時間が 1 件 90 秒以上かかっていました。1 日 80 件のレビューは現実的に回らず、私自身が承認スタンプ機械と化す結果になりました。
改善後の UI は、判断に必要な最小限の情報だけを 1 画面に置き、それ以外は折りたたみに退避させます。
< ApprovalCard action = { a } >
< Header level = { a.level } confidence = { a.confidence } expiresIn = { a.expiresAt } />
< DiffSummary text = { a.diffSummary } /> { /* 必ず日本語の 1 文 */ }
< ImpactBadges payload = { a.payload } /> { /* 影響範囲、推定 DAU 影響 */ }
< PrimaryActions onApprove = { onApprove } onReject = { onReject } />
< details >
< summary >詳細 (raw payload, trace, related runs)</ summary >
< RawPayload payload = { a.payload } />
< TraceView runId = { a.agentRunId } />
</ details >
</ ApprovalCard >
DiffSummary を Agent に必ず日本語 1 文で生成させ、レビュアーが 1 文だけで 90% 以上のケースを判断できる状態を作るのが鍵です。私は Agent に「中学生でも判断できる粒度で書く」とプロンプトを書いていて、これだけで 1 件あたりのレビュー時間が約 90 秒から 12 秒前後まで縮まりました。承認の質と所要時間は反比例しません。むしろ判断材料を絞ったほうが、ヒューマンエラーが減ります。
ImpactBadges には影響範囲を 2 〜 3 個に限ったバッジを並べます。私のアプリの場合は「対象 DAU の推定値」「ストア表示への露出有無」「直近 7 日の関連エラー件数」の 3 つです。数字を出すことで判断の解像度が上がり、Approve ボタンの心理的コストが軽くなります。
監査ログと「なぜ承認したか」の記録
ここからは少し慎重に書きます。AdMob・App Store・Google Play などの外部プラットフォームで何らかの違反指摘を受けたとき、最終的に問い合わせ窓口が要求するのは「誰がいつ何を承認したか」の時系列です。承認ログを後付けで整備しようとすると、もれなく失敗します。
私の運用では agent_actions の状態遷移をすべて別テーブル agent_action_events に追記専用で残します。イベント単位の不変ログを持つと、3 か月後に「あの判断は誰の責任か」を問われたときに、すぐに ID で引けます。
CREATE TABLE agent_action_events (
id BIGSERIAL PRIMARY KEY ,
action_id UUID NOT NULL REFERENCES agent_actions(id),
event_type TEXT NOT NULL ,
actor TEXT NOT NULL ,
payload JSONB NOT NULL ,
occurred_at TIMESTAMPTZ NOT NULL DEFAULT NOW ()
);
CREATE INDEX idx_events_action ON agent_action_events(action_id, occurred_at);
加えて、承認時の reason フィールドを「短くてもよいので必ず人間が書く」ルールにしています。テンプレート文の選択肢を出すと、レビュアーは反射でクリックして実質的に空欄になります。30 文字程度でいいので自由記述を求めるほうが、後から「なぜ承認したか」を辿れる確率が上がります。個人運営でも複数人運営でも、ここの結論は同じです。
監査ログを書き出す側で気をつけたいのが、PII(個人を特定できる情報)を入れないことです。承認ペイロードに顧客メールが入ってしまうと、ログのバックアップ全体に保管要件が及びます。私は payload を保存する前にメールアドレスを sha256(email + pepper) でハッシュ化しています。レビュアー UI 側は復号する必要がない設計にしておくと、運用負荷も下がります。
失敗パターンと回避策
ここでは私自身が踏んだ落とし穴を、対処と一緒にまとめます。本番で同じことを繰り返さないよう、失敗を残しておく価値が高い領域です。
第一に、承認後にペイロードを再生成する設計は危険です。承認時の payload と実行時の payload がズレると、レビュアーは A を承認したつもりで B が本番に出るということが起きます。承認時に確定したペイロードを payload_hash で固定し、実行時にハッシュ不一致なら即時 rolled_back に倒します。
第二に、二重承認 (L3) で 2 名が同じ会社・同じ立場の人に固定されると、実質的に独立承認になりません。私は個人運営なので、家族メンバーや外部顧問など、文脈の異なる 2 名を「外部判断者」として明示的に用意しています。本番で規約改定対応を 1 人で承認するのは、個人的にお勧めしないアプローチです。
第三に、expires_at を伸ばす運用は禁物です。承認が遅れた案件を「もう少しだけ待つ」という名目で 48 時間に伸ばすと、すぐに 72 時間、96 時間と伸びます。期限切れになった案件は Agent に再生成させるほうが、コンテキストが新しい分だけ判断が正確になります。
第四に、承認 UI でキーボードショートカットを安易に有効化すると、a(approve)の連打事故が起きます。私は Approve だけは 1.5 秒の hover を要求する設計に変えました。Reject 側は即時で問題ありません。
第五に、ロールバックトークンを「あとで実装する」と先送りしないことです。私は最初の 2 週間ロールバック手段を持たずに走らせて、結果として L2 のアクションを全部止めるしかない事故を踏みました。事前にロールバック手順を確認できないアクションは、L1 にも L2 にも乗せず、L3 で扱うのが妥当な判断です。
段階的な自動化への移行(卒業条件の運用)
HITL は永遠に人間が承認し続けるための仕組みではなく、自動化に至る滑走路として位置づけます。L2 の領域を L1 に格上げし、L1 の領域を L0 に格上げしていく卒業条件を、私は次のように設定しています。
直近 60 日で 100 件以上の判断履歴がある
撤回率が 0.5% 未満で安定している
規約・契約に触れない領域であること
30 秒以内のロールバック手段が実装されている
この 4 条件を満たして、はじめて L2 → L1、または L1 → L0 への昇格を検討します。卒業判断は四半期に 1 回、人間が机上でやります。Agent には判定させません。理由は単純で、自分の昇格判断を自分でやる Agent は、撤回率を過小評価する方向にバイアスが出やすいためです。
逆に、撤回率が悪化した領域は降格させます。AdMob のポリシー更新時期に L1 の判定を一時的に L2 に降格させたことが 2025 年に 2 回ありました。降格自体は事故ではなく、設計通りに動いた結果です。
承認パイプラインを 6 か月運用したあとで振り返ると、いちばん効いたのは「すべてを承認する」設計ではなく「承認しないでよいものを増やす」設計でした。1997 年に独学でインターネットに触れた頃、当時のオンラインメンターから教わった「人に開かれた自然な言語」という考え方を覚えていて、レビュー UI を 1 画面に削り、判断ロジックを 4 階層に整理する作業は、その延長線上にある気がしています。
次のアクションとしては、自分のドメインで最初の 1 か月だけでもよいので agent_actions テーブルを作り、L2 相当のアクションを手動承認で 30 件ほど流してみるのが現実的です。撤回率の分布が見える状態を作れれば、L1 の閾値を引く根拠が手に入ります。お読みいただきありがとうございました。