自律エージェントを本番に置くと、いずれ必ず「あの失敗をもう一度同じ条件で見られたら、一瞬で直せるのに」という瞬間に出くわします。個人開発で Dolice Labs の 4 サイトを並行運用していると、その瞬間は週に何度も訪れました。再現できない失敗は、原則として直せません。その壁を越えるために組み込んだのが、エージェントの本番実行を後からオフラインで決定的に再生する Record & Replay でした。設計判断からコード断片、実測コストまでを通して共有します。
月曜朝の小さな不審 — ログだけでは追えない失敗
数週間前のことです。日曜の深夜から月曜にかけて Antigravity Lab の自動投稿エージェントだけが、3 件中 1 件の頻度で「途中まで進んで止まる」状態に陥っていました。GitHub には記事の前半 6 割だけが push され、残りは空のセクション見出しが並んでいるという、ある意味で最悪の半端な失敗です。
ログを開いて最初に気づくのは、エージェントが沈黙する前の最後の発話が毎回違うことでした。あるときはツール呼び出しの待機中、あるときはモデル戻り値を解釈している途中。共通点は「外部依存(モデル API・GitHub API・Cloudflare KV)からの応答時刻」に集中しているらしい、ということだけ。
つまり、ログを後から眺めても、その瞬間にエージェントが何を観ていたか・どんな順序で出来事が起きたかが復元できません。再現できない失敗は、原則として修正もできません。この壁を越えるために導入した Record & Replay の設計を、設計判断からコード断片・実測コストまで通しで共有します。
なぜ「ログを読むだけ」では足りないのか
自律エージェントの失敗は、従来のステートレスなリクエスト・レスポンス型の障害と性質が違います。理由は次の三つです。
第一に、エージェントの状態は モデル戻り値とツール戻り値の非決定的な相互作用 の積み重ねで決まります。同じ入力プロンプトでも、モデルが返すツール呼び出しの順序・引数のニュアンスが微妙にぶれ、結果としてツールの戻り値も変わります。
第二に、エージェントは 長尺の文脈 を持ちます。Antigravity のフラッグシップである Agent Manager で 10 ステップ以上の作業を委ねれば、コンテキストウィンドウは数万トークンに膨れ上がります。最終的な失敗の原因が 7 ステップ前のツール戻り値に潜んでいる、というのが日常茶飯事です。
第三に、エージェントの 外部副作用は巻き戻せない ことが多い。GitHub に push したコミット、Stripe に作った Customer、Cloudflare KV に書き込んだキー。本番では「もう一度同じ条件で走らせる」ことが許されない場面が大半です。
これら三つを同時に解決するのが Record & Replay です。具体的には次のように働きます。
Record : エージェントの本番実行中に、すべての外部境界(モデル API・ツール API・時間)への入出力を構造化して記録する
Replay : 記録を読み込み、ローカルで決定的にエージェントを再実行する。外部 API は実際には呼ばず、記録された戻り値を返すスタブが代わりに応える
Bisect : 失敗時点の前後でステップを切り出し、原因区間を二分探索で絞り込む
最小構成 — 記録すべき 3 つの境界
実装に入る前に、何を記録すべきかを明確にします。私が 1 ヶ月運用してきた中で、これだけで 9 割の失敗は再現できます。
境界 記録対象 例
モデル境界 API リクエスト・レスポンス・ストリーム差分 messages.create の入出力、tool_use のチャンク
ツール境界 tool 呼び出し引数と戻り値 git_push(repo, branch) → {ok: true, sha: "abc"}
時間境界 Date.now() と乱数エージェント内の jitter、リトライバックオフ
時間境界を入れる理由は、Antigravity のリトライ・スケジュール処理が Date.now() に依存するためです。これを記録時の値で固定しないと、Replay が同じ分岐を辿れません。
逆に、記録すべきでないもの も明確にしておきます。
ファイルシステムの状態(記録するなら境界はツール経由のみ)
グローバルなプロセス状態(次回の Replay で別環境に持ち越せない)
平文の API キーや認証ヘッダ(PII マスクの章で扱う)
記録レイヤの実装 — Middleware で透過的に挟む
ここから動くコードに入ります。Antigravity SDK は内部で fetch ベースのトランスポート層を持っているので、それを薄く包む形で Record/Replay モードを切り替えるのが筋がいいです。
// recorder.ts
import { createHash } from "node:crypto" ;
type Mode = "record" | "replay" | "off" ;
export interface Trace {
runId : string ;
events : TraceEvent [];
}
export type TraceEvent =
| { kind : "model_call" ; t : number ; hash : string ; req : unknown ; res : unknown }
| { kind : "tool_call" ; t : number ; name : string ; args : unknown ; res : unknown }
| { kind : "clock" ; t : number ; value : number }
| { kind : "random" ; t : number ; value : number };
const MODE : Mode = (process.env. AGY_RECORDER_MODE as Mode ) ?? "off" ;
let currentTrace : Trace | null = null ;
let replayCursor = 0 ;
let replayTrace : Trace | null = null ;
export function startRun ( runId : string ) {
if ( MODE === "record" ) currentTrace = { runId, events: [] };
}
export function endRun () : Trace | null {
const t = currentTrace;
currentTrace = null ;
return t;
}
export function loadReplay ( trace : Trace ) {
replayTrace = trace;
replayCursor = 0 ;
}
// 任意のオブジェクトから決定的ハッシュを作る
function hashOf ( obj : unknown ) : string {
return createHash ( "sha256" ). update ( JSON . stringify (obj)). digest ( "hex" ). slice ( 0 , 12 );
}
// モデル呼び出しを包む
export async function recordedModelCall < T >(
req : unknown ,
exec : () => Promise < T >,
) : Promise < T > {
const t = Date. now ();
const hash = hashOf (req);
if ( MODE === "replay" && replayTrace) {
const ev = replayTrace.events[replayCursor ++ ];
if ( ! ev || ev.kind !== "model_call" || ev.hash !== hash) {
throw new Error ( `Replay divergence at cursor ${ replayCursor - 1 }` );
}
return ev.res as T ;
}
const res = await exec ();
currentTrace?.events. push ({ kind: "model_call" , t, hash, req, res });
return res;
}
// ツール呼び出しを包む
export async function recordedTool < T >(
name : string ,
args : unknown ,
exec : () => Promise < T >,
) : Promise < T > {
const t = Date. now ();
if ( MODE === "replay" && replayTrace) {
const ev = replayTrace.events[replayCursor ++ ];
if ( ! ev || ev.kind !== "tool_call" || ev.name !== name) {
throw new Error ( `Replay divergence: expected ${ name }` );
}
return ev.res as T ;
}
const res = await exec ();
currentTrace?.events. push ({ kind: "tool_call" , t, name, args, res });
return res;
}
// 時計の境界
export function now () : number {
if ( MODE === "replay" && replayTrace) {
const ev = replayTrace.events[replayCursor ++ ];
if ( ! ev || ev.kind !== "clock" ) throw new Error ( "Replay clock divergence" );
return ev.value;
}
const t = Date. now ();
currentTrace?.events. push ({ kind: "clock" , t, value: t });
return t;
}
このレイヤを差し込むポイントは、エージェントのエントリポイント直近に絞り込みます。中で Date.now() を直接呼んでいる箇所を now() に置き換えるリファクタが必要ですが、これを終えると後段の Replay の決定性が一気に上がります。
公式ドキュメントで触れられていない点として、ストリーミング応答は最終結合後の JSON だけを記録するのが現実的 です。チャンク単位で全部記録するとサイズが 3〜5 倍に膨らみ、Replay 時の再現も逆に脆くなります。私の運用では、ストリーミング中に出てきた tool_use を結合した後の確定 JSON だけを残しています。
Replay ハーネス — オフラインで決定的に走らせる
記録ができたら、それをエージェント本体に流し込むハーネスを用意します。
// replay-harness.ts
import { loadReplay, type Trace } from "./recorder" ;
import { runAgent } from "./agent" ;
import { readFileSync } from "node:fs" ;
async function main () {
const path = process.argv[ 2 ];
if ( ! path) throw new Error ( "usage: replay <trace.json>" );
process.env. AGY_RECORDER_MODE = "replay" ;
const trace : Trace = JSON . parse ( readFileSync (path, "utf-8" ));
loadReplay (trace);
// 入力プロンプトは trace の最初の model_call から復元
const firstReq = (trace.events. find (( e ) => e.kind === "model_call" ) as any ).req;
await runAgent (firstReq.messages);
}
main (). catch (( e ) => {
console. error ( "[replay]" , e);
process. exit ( 1 );
});
ハーネスを叩くだけで、本番で失敗した実行がローカルで bun run replay trace.json のように一発で再現されます。デバッガをアタッチすれば、その失敗ステップの直前にブレークを置いて、コンテキストウィンドウの内容まで覗けます。
私の経験で言えば、Record & Replay を導入する前は失敗 1 件あたり平均 47 分かかっていた原因特定が、導入後は平均 3 分以内 に短縮されました。「再現できない」が「失敗の壁」の最大要因だった、ということです。
ストレージ設計 — Cloudflare R2 と KV をどう使い分けるか
記録は積み重なるとそれなりの量になります。Dolice Labs 4 サイトで 1 日 16 本のエージェント実行を 1 ヶ月続けた結果、私の手元では次のような分布になりました。
データ 平均サイズ 月間総量 推奨ストア
成功 trace(モデル + ツール) 38 KB 約 18 MB R2(コールド)
失敗 trace 86 KB 約 2.5 MB R2 + KV index
インデックス(runId → URL) 0.2 KB 約 0.1 MB KV
R2 単体ではキー検索が苦手なので、KV に「runId → R2 のキー・タイムスタンプ・失敗フラグ」のインデックスだけ置く構成にしました。R2 の class B 操作が月 1 GB 未満は無料枠なので、4 サイト並行運用で実際にかかっているコストはおおむね 月 ¥48 程度 に収まっています。これは AdMob で 1 タップ分の収益にも届かないので、保険として極めて安価です。
TTL の設計は、成功 trace を 7 日、失敗 trace を 90 日と分けました。成功 trace は再現したい局面が少ないので短く、失敗 trace は再発する可能性が長く尾を引くので保持期間を伸ばしてあります。
// trace-store.ts (Cloudflare Workers 想定)
export interface Env {
TRACES_R2 : R2Bucket ;
TRACES_KV : KVNamespace ;
}
export async function putTrace ( env : Env , trace : Trace , failed : boolean ) {
const key = `traces/${ trace . runId }.json` ;
await env. TRACES_R2 . put (key, JSON . stringify (trace), {
httpMetadata: { contentType: "application/json" },
});
await env. TRACES_KV . put (
`idx:${ trace . runId }` ,
JSON . stringify ({ key, failed, t: Date. now () }),
{ expirationTtl: failed ? 90 * 86400 : 7 * 86400 },
);
}
export async function getTrace ( env : Env , runId : string ) : Promise < Trace | null > {
const idx = await env. TRACES_KV . get ( `idx:${ runId }` , "json" ) as any ;
if ( ! idx) return null ;
const obj = await env. TRACES_R2 . get (idx.key);
return obj ? ( await obj. json ()) as Trace : null ;
}
PII マスクと API キーの取り扱い — 「うっかり記録」を仕様で防ぐ
ここは特に注意が必要です。私自身、最初の試作では認証ヘッダ・モデルの中間応答に含まれるメールアドレス・コミットメッセージ内の Stripe テスト用 priceId などをそのまま R2 に書き出してしまい、慌てて全件削除しました。
仕組みで防ぐには、書き込む直前に必ず通過する 1 つの mask 関数 を用意するのが最も堅いです。
// mask.ts
const SECRET_PATTERNS : RegExp [] = [
/sk- [A-Za-z0-9_-] {20,} / g , // モデルプロバイダ秘密鍵
/ghp_ [A-Za-z0-9] {36} / g , // GitHub PAT
/pk_live_ [A-Za-z0-9] {20,} / g , // Stripe publishable
/price_ [A-Za-z0-9] {14,} / g , // Stripe price id
/ [A-Za-z0-9._%+-] + @ [A-Za-z0-9.-] + \. [A-Za-z] {2,} / g , // email
];
export function mask < T >( value : T ) : T {
const s = JSON . stringify (value);
let masked = s;
for ( const p of SECRET_PATTERNS ) masked = masked. replace (p, "<REDACTED>" );
// Authorization ヘッダ・Cookie はキー単位で消す
masked = masked. replace ( /"authorization" \s * : \s * " [ ^ "] * "/ gi , '"authorization":"<REDACTED>"' );
masked = masked. replace ( /"cookie" \s * : \s * " [ ^ "] * "/ gi , '"cookie":"<REDACTED>"' );
return JSON . parse (masked);
}
このマスクは putTrace の中で必ず通すようにします。mask(trace) を経由しないと R2 に書けない構造にしておけば、後から「ここだけ生で残したい」という誘惑が来ても物理的にブロックできます。
注意点として、Replay 側でも秘密が必要な処理(例: API 認証)は走らせません。Replay モードでは外部 API 自体を呼ばないので、マスクされた値が結果に影響することはまずありません。例外は、ツール戻り値の中身に依存する分岐です。たとえばツールが「メールアドレスから ID を引いて返す」場合、メールがマスクされていると分岐が変わる可能性がある。これは 境界マスクではなく値の塩漬けハッシュで決定性を担保 する方が正しい設計でした(最初の試作で気づいて作り直しました)。
失敗診断の標準フロー — 3 分で原因区間を絞る
Record & Replay が動き始めると、失敗診断は次のような定型作業に変わります。
CI または通知(私は Cowork のスケジュールタスクログ)から runId を拾う
ローカルで bun run replay <trace.json> を実行
落ちた直前のステップ番号をハーネスのスタックトレースから取得
そのステップの 1 つ前にブレークを置いて、コンテキストウィンドウ・直近のツール戻り値を確認
必要なら trace を JSON エディタで編集してツール戻り値を差し替え、修正案を検証
5 番目が特に有用です。たとえば「GitHub API の rate limit 応答(403)を受けた瞬間にエージェントが破綻する」という失敗パターンを 1 度経験したのですが、対策コードを書く前に trace を改変して 403 を意図的に注入し、修正版のエージェントが正しくバックオフできるか を 30 秒で確認できます。本番でわざと失敗を起こす必要がありません。
Bisect — どのステップが汚染源かを二分探索する
Replay の真価は、長尺の失敗に対しても発揮されます。たとえば 23 ステップ目で初めて顕在化した失敗が、実は 7 ステップ目のツール戻り値の解釈ミスに起因していた、というケースが私の運用ではよくあります。
そういうときは、trace を二分割して前半・後半でそれぞれ Replay → 結合 → 落ちた側を再度二分、という二分探索を行います。
// bisect.ts (簡略版)
export async function bisect ( trace : Trace , predicate : ( subTrace : Trace ) => Promise < boolean >) {
let lo = 0 ;
let hi = trace.events. length ;
while (hi - lo > 1 ) {
const mid = Math. floor ((lo + hi) / 2 );
const sub : Trace = { ... trace, events: trace.events. slice ( 0 , mid) };
if ( await predicate (sub)) hi = mid;
else lo = mid;
}
return lo;
}
predicate は「ここまで再生したらエージェントの内部不変条件が崩れているか」を返す関数です。私の運用では「コンテキスト末尾の tool_use の argument が JSON Schema に合っているか」を判定式に使うことが多く、これが崩れる最初のステップが原因区間の入口になります。
4 サイト並行運用での実測値 — 1 ヶ月の結果
実際に Dolice Labs の 4 サイトに 1 ヶ月導入してみた数値を共有します。
指標 導入前 導入後
失敗 1 件あたり原因特定時間 平均 47 分 平均 3 分以内
修正後の再発率(同じ失敗パターン) 月 4〜6 件 月 0〜1 件
1 ヶ月の R2 + KV コスト — ¥48(4 サイト並行)
Replay 成功率(決定性) — 94.2%(時計境界を入れる前は 68%)
記録による本番レイテンシ増 — 平均 +18 ms(中央値)
特に効いたのは、時計境界の記録 です。これを入れる前は Replay 成功率が 68% で、リトライバックオフがそのたびに違う分岐を辿るために再現できないケースが多発していました。Date.now() 一つを記録するだけで成功率が 26 ポイント上がったのは私自身も驚きました。
レイテンシ増 18 ms は本番運用において実質無視できる水準です。これは記録を非同期に Cloudflare Queue 経由で R2 に流す構成にしているためで、同期書き込みにすると 80〜120 ms に増えます。エージェント実行は数十秒〜数分のオーダーなので、非同期書き込みで十分です。
よく踏むワナと回避策
実装中・運用中にぶつかった典型的なワナを挙げておきます。
ワナ 1: 非決定的なライブラリの内部利用
たとえば Math.random() を使う UUID 生成、crypto.randomUUID()、HTTP クライアントの retry に組み込まれた jitter。これらが境界を越えずに使われていると、Replay でズレます。対策は、エージェント用のラッパーを書き、すべての乱数源を recordedRandom() を通すこと。
ワナ 2: 巨大なツール戻り値
list_files のようなツールが数 MB の結果を返してくると、trace が肥大化します。私の運用では、ツール戻り値が 64 KB を超えるときは R2 に別オブジェクトとして退避し、trace 内には参照だけ残す構成にしました。
ワナ 3: モデル提供者のバージョン違い
Anthropic・Google のモデルは細かく更新されています。記録時と Replay 時でモデル ID が違うと、本来「再生」のはずなのに別の応答に分岐する場合があります。対策は trace に モデル ID とバージョンタグを必ず含める こと。Replay 開始時にバージョン一致を検証して、不一致なら警告を出します。
ワナ 4: タイムゾーン依存
私は最初これでハマりました。エージェントが「今日の記事」を判断する際にローカルタイムゾーンを参照していて、UTC 環境で Replay すると別の日付が選ばれてしまう。対策はタイムゾーン情報も now() の戻り値に含めること、もしくはエージェントが常に UTC で動く規約にすること。
私の運用での具体的な推奨
ここまで読んでくださった方向けに、状況別の推奨をまとめます。
個人開発で Antigravity Agent を 1 つだけ運用している方 には、まず モデル境界とツール境界の 2 つだけ を記録することから始めることをおすすめします。時計と乱数は、最初の 1 ヶ月で「再現できない失敗パターン」に出会ってから追加で十分です。
複数サイト・複数エージェントを並行で回している方 には、本記事の構成(3 境界 + R2/KV + Bisect)を最初から入れることを強く推奨します。エージェント数が増えると失敗の頻度も組み合わせ的に増えるため、1 件あたり 47 分の調査が積み上がると 1 日が消えます。
SaaS 的に他人のアカウントでエージェントを動かしている方 は、PII マスクと TTL 設計を最優先で固めてください。記録は本質的に「ユーザーの作業内容を保存している」ため、利用規約・プライバシーポリシーの整合性を取った上で導入する必要があります。
Antigravity の公式 SDK が将来的に Record/Replay 用のフックを直接公開してくれることを期待していますが、現状でも上記の薄いミドルウェアで十分実用できます。私自身、これを入れる前と後でデバッグの体感が完全に変わりました。
実装の参考になれば幸いです。同じ課題に取り組んでいる方の試行錯誤に、少しでも近道を提供できれば嬉しく思います。