Antigravity Background Agent を本番事業に組み込んで 4 ヶ月を超えたあたりから、これまでにはなかった種類の問い合わせが自分の中から増えました。「先月のこの日、エージェントが Remote Config の値を変えた判断の根拠は何だったか」というような、当時の文脈を後追いで再現するタイプの問い合わせです。
廣川政樹として 2014 年からアプリ事業を 1 人で続けていて、累計 5,000 万 DL に達する壁紙アプリの運用や AdMob 経由の収益最適化を、複数のエージェントが連鎖して回しています。エージェント A が Remote Config を書き換え、エージェント B がその値を読んで広告フィルを再配分し、エージェント C が翌朝のレポートをまとめる、という流れです。1 つでも判断の根拠が消えると、月次のレポートが組み立てられなくなりました。
国際芸術賞 17 冠の作家活動とは別軸で、個人開発の裏側を支えるインフラ整備を続けてきました。意思決定ログ自体は派手ではない領域ですが、長期で運用するなら最初に整えたほうが良いというのが、4 ヶ月運用を超えてからの実感です。
6 ヶ月後を見据えるとログの粒度が変わる
短期のデバッグだけが目的なら、Cloud Logging の標準テキスト出力で足ります。私自身、最初の数週間はそうしていました。問題が起きたのは、3 ヶ月以上前のエージェントの判断を再現しようとしたときでした。
テキストログには「Remote Config の adSlot_A を 0.65 に更新しました」という結果しか残っていません。なぜ 0.65 だったか、どの観測値を見たか、どのプロンプトを使ったか、どのモデルを呼んだか、という文脈が完全に欠落していました。3 ヶ月前のエージェントの記憶はもう蒸発しています。これを後追いで埋めるには、当時の判断材料そのものを構造化して保管しておく必要があります。
私が落ち着いた指針は次の 3 つです。
- 結果ではなく 判断材料 を残す(観測値・前提条件・スコア)
- プロンプトとモデルバージョンを 不変な ID で紐付ける
- PII を最初に剥がしてから書く(後付けの匿名化はほぼ機能しません)
構造化スキーマの 4 層モデル
ログのスキーマを次の 4 層に分けています。1 リクエストあたり 12 KB ほどに収まるサイズ感です。
type DecisionLogV2 = {
// 第 1 層: identity(誰がいつ何を呼んだか)
ts: string; // ISO8601、ミリ秒精度
trace_id: string; // ULID(時系列ソートが効く)
agent_id: string; // "remote-config-tuner@v17"
parent_trace_id?: string;
// 第 2 層: inputs(何を観測したか)
inputs: {
metric_window: { from: string; to: string };
observed: Record<string, number | string>;
feature_flags: Record<string, boolean>;
};
// 第 3 層: reasoning(どう考えたか)
reasoning: {
prompt_hash: string; // プロンプトの SHA-256
model: string; // "gemini-3-pro-2026-04"
candidates: Array<{ label: string; score: number; rationale: string }>;
chosen: string;
};
// 第 4 層: action(何を変えたか)
action: {
target: string; // "remote_config.adSlot_A"
before: unknown;
after: unknown;
rollback_token: string; // 巻き戻し用
};
};
agent_id にバージョンを必ず埋めるのが、3 ヶ月後の自分への一番のプレゼントになります。「あのときのエージェントは v15 だった」と分かれば、当時のプロンプトテンプレートも Git タグから引き出せます。
prompt_hash は SHA-256 を入れておきます。プロンプトの全文を 1 リクエストごとに保存すると R2 の保管コストが跳ね上がるため、ハッシュだけ残しておいて、別の不変テーブルに「ハッシュ → 全文」のマッピングを置く構成です。これで重複保管を 28% ほど削減できました。
Cloudflare Workers から R2 へ流す書き込み層
Antigravity Background Agent から呼ばれる API は Cloudflare Workers に乗せています。書き込み層の構成は次の 3 段階です。
Step 1: Workers 側で構造化ログを生成
async function emitDecisionLog(env: Env, log: DecisionLogV2) {
const masked = maskPII(log); // 後述
const line = JSON.stringify(masked) + "\n";
// 即時 R2 ではなく、Durable Object のバッファに流し込む
const id = env.LOG_BUFFER.idFromName("global");
const stub = env.LOG_BUFFER.get(id);
await stub.fetch("https://buf/append", { method: "POST", body: line });
}
毎リクエスト R2 に書きにいくと、PUT API のコストが無視できなくなります(1 万リクエストで $0.36 程度)。Durable Object でバッファして 60 秒ごと、もしくは 1 MB たまったら 1 ファイルにまとめてフラッシュする方式に落ち着きました。
Step 2: Durable Object でバッファして flush
async flush() {
if (this.buf.length === 0) return;
const key = `decision/${dateShard()}/${ulid()}.ndjson`;
await this.env.LOG_BUCKET.put(key, this.buf.join(""), {
httpMetadata: { contentType: "application/x-ndjson" },
customMetadata: { schema: "DecisionLogV2", count: String(this.buf.length) },
});
this.buf = [];
}
dateShard は 2026/05/28/04 形式の文字列を返します。Polars で読むときの prefix scan が一気に効きます。
Step 3: R2 ライフサイクルで保管コストを抑える
R2 の Object Lifecycle で、90 日経過したオブジェクトは Infrequent Access に移動、180 日で削除します。月次レポートは 90 日以内に必ず作るので、後ろ半分は安いクラスで保管する設計です。この構成で月額のストレージ料金は ¥320 程度に収まっています(私のアプリ事業の 1 日あたりログ量 70 MB 規模での実測値)。
PII マスキング 12 パターン
App Store Connect や AdMob から取得した値には、購入トークン・端末識別子・国コード・IP の下位ビットなどが混ざります。後でマスキングするのは現実的ではないので、書き込み層で剥がします。私の場合は次の 12 パターンを正規表現で除外しています。
const PII_PATTERNS: Array<[RegExp, string]> = [
[/ghp_[A-Za-z0-9]{36}/g, "[GH_PAT]"],
[/sk_(live|test)_[A-Za-z0-9]{24,}/g, "[STRIPE_KEY]"],
[/AIza[A-Za-z0-9_\-]{35}/g, "[GOOGLE_API_KEY]"],
[/idfa:[A-F0-9\-]{36}/gi, "[IDFA]"],
[/aaid:[a-f0-9\-]{36}/gi, "[AAID]"],
[/receipt:[A-Za-z0-9+/=]{40,}/g, "[STORE_RECEIPT]"],
[/email:[^\s@]+@[^\s@]+\.[^\s@]+/g, "[EMAIL]"],
[/\b\d{1,3}(?:\.\d{1,3}){3}\b/g, (m) => maskIP(m)],
[/Bearer\s+[A-Za-z0-9_\-\.]+/g, "Bearer [REDACTED]"],
[/("password"\s*:\s*)"[^"]*"/g, '$1"[REDACTED]"'],
[/("authorization"\s*:\s*)"[^"]*"/gi, '$1"[REDACTED]"'],
[/jwt:[A-Za-z0-9_\-\.]{40,}/g, "[JWT]"],
];
IP は完全に潰すと地域分布が分からなくなるので、私の場合は下位 2 オクテットだけマスクしています。ヨーロッパ圏のユーザー比率は、IP の上位 2 オクテットから推定するレベルで十分です。GDPR の最小化原則とも整合します。
落とし穴を 2 つ挙げておきます。1 つ目は、エージェントが LLM 応答に含めてくる「説明文」の中に偶然 API キー風の文字列が混じることがあり、これも上の正規表現でカバーしています。2 つ目は、receipt を保管するときに originalTransactionId だけは別ハッシュテーブルに残しておかないと、後で重複検知ができなくなる点です。本番でハマったポイントなので強くお勧めします。
6 ヶ月後に判断を再現するクエリパターン
ここまで揃うと、R2 に積んだ NDJSON を Polars で直接スキャンできます。私が普段使う 4 種類のクエリを置いておきます。
import polars as pl
# パターン 1: 特定エージェントの 1 日分を時系列で読む
df = (
pl.scan_ndjson("s3://bucket/decision/2026/01/14/*.ndjson")
.filter(pl.col("agent_id") == "remote-config-tuner@v15")
.sort("ts")
.collect()
)
# パターン 2: 同じ trace_id を起点にエージェント連鎖を組み立てる
chain = (
df.lazy()
.filter(pl.col("trace_id").is_in(seeds))
.join(df.lazy(), left_on="trace_id", right_on="parent_trace_id", how="left")
.collect()
)
# パターン 3: 採用されなかった候補(chosen 以外)を抽出
explored = (
df.lazy()
.explode("reasoning.candidates")
.filter(pl.col("reasoning.candidates.label") != pl.col("reasoning.chosen"))
.select(["ts", "agent_id", "reasoning.candidates"])
.collect()
)
# パターン 4: 同一 prompt_hash でモデルだけ変えた場合の判断差分
shift = (
df.lazy()
.group_by(["reasoning.prompt_hash", "reasoning.model"])
.agg(pl.col("reasoning.chosen").mode().alias("dominant_choice"))
.collect()
)
特に効いたのはパターン 3 でした。エージェントが「採用しなかった候補」も残しているので、後から「あの判断は妥当だったか」を逆検証できます。プレミアム契約の解約防止策を 1 月に調整したとき、別案のスコアが拮抗していたことが 3 ヶ月後に判明し、A/B として再評価する根拠になりました。
4 ヶ月運用で見えた落とし穴
- ULID ではなく UUID v7 を採用すべきだった場面がある。Postgres と連携するエージェントが入ると、UUID v7 のほうがソート性能が出ます。途中で乗り換えるのは大変なので、最初に決めておきたい部分です。
before/after の unknown 型を正直に守る。型を厳しくしすぎると、Remote Config のように値の構造が突然変わったときに書き込みが落ちます。スキーマは「ログの書き手」より「ログの読み手」に優しく作るのが鉄則だと、私は考えています。
- R2 のリスト API で迷子になる。1 日 70 MB 規模でも、シャードキーを
2026/05/28/04 のように時間まで切っておかないと、後で list が重くなります。私の場合は時間単位までシャーディングして、結果的に Polars の scan_ndjson の速度も上がりました。
私が今後変えていく予定の点
スキーマは V2 で安定しましたが、次は agent_id ごとの「コスト属性」を入れる予定です。1 回の判断に消費した Antigravity Credits・LLM トークン数・Workers CPU 時間を同じレコードに含めると、月次のコスト最適化が桁違いにやりやすくなる見込みです。すでに 4 サイト並行運用しているため、サイトごとのコスト按分にも使えます。
意思決定ログ自体は派手ではない領域ですが、エージェント運用が 6 ヶ月を超えた頃から効いてくる土台です。同じように個人で AI エージェントを長期運用する方の参考になれば幸いです。お読みいただきありがとうございました。