2014年から個人でアプリを開発してきて、累計5,000万ダウンロードを超えるまでに一番こたえたのは、派手なクラッシュではありませんでした。誰も見ていない深夜に、バックエンドの自律処理が一度だけ静かに失敗し、朝になってログを開いても「なぜそうなったのか」がもう分からない、という種類の障害です。
Claude Agent SDK で自律エージェントを本番に置くと、この問題がはっきり形を変えて現れます。エージェントは外部の状態に深く依存して動きます。モデルの出力は毎回わずかに違い、ツールが叩く API のレスポンスは時間とともに変わり、Date.now() は当然進み、リトライの分岐は乱数やレイテンシで変わります。つまり、失敗した実行をそのまま「もう一度走らせる」だけでは、二度と同じ失敗にたどり着けません。
両家の祖父がともに宮大工で、組み上げたものが何十年も保つのを近くで見て育ちました。長く保つものには必ず「後から検証できる」という性質が備わっています。エージェントの設計でも私が一番大事にしているのはここで、これからお伝えするのは、失敗を後から正確に再現するための設計、すなわち決定論的リプレイ(deterministic replay)とイベントソーシングを、実装と運用の両面から具体に落とした手順です。
なぜ「もう一度走らせる」では再現できないのか
再現を妨げる原因は、エージェント実行のなかに散らばった「非決定の境界(nondeterminism boundary)」です。コードのロジック自体は決定論的でも、外界に触れる瞬間だけは毎回違う値が入ってきます。本番で観察してきた限り、境界は次の4種類に集約できます。
第一にモデル出力です。temperature: 0 でも完全な再現は保証されず、ツール呼び出しの引数が1文字違うだけで分岐が変わります。第二にツールの入出力で、外部 API・データベース・ファイルの状態は時間とともに変化します。第三に時刻で、Date.now() や日付に基づく分岐は実行のたびに別の枝へ進みます。第四に乱数とリトライで、ジッター付きバックオフやサンプリングは実行ごとに揺れます。
ここで多くの人がオブザーバビリティ基盤(メトリクス・トレース・ダッシュボード)を入れて満足してしまいますが、それは「何が起きたかの統計」を見るものであって、「特定の1回をもう一度動かす」ものではありません。事後分析に本当に必要なのは後者です。発想を変えて、これら4つの境界を通過する値をすべて追記専用ログに記録し、再実行時にはログから読み戻すようにすれば、実行は決定論的に再現できます。これがイベントソーシングをエージェントに適用するという考え方です。
イベントログのスキーマを決める
まず、1回の実行を一意に識別する runId を発番し、その実行が境界を通過するたびに連番 seq 付きのイベントを追記します。イベントは「録る(record)」モードでは実値を書き込み、「再生(replay)」モードでは読み戻すという、対称な構造にしておくのが要点です。
// events.ts — 追記専用イベントの型と最小ストア
export type BoundaryKind = "model" | "tool" | "clock" | "random" ;
export interface AgentEvent {
runId : string ;
seq : number ; // 実行内で単調増加
kind : BoundaryKind ;
key : string ; // 呼び出しの論理キー(例: "tool:fetch_orders")
input : unknown ; // 録音時の入力(照合に使う)
output : unknown ; // 境界が返した実値
tokensIn ?: number ; // model イベントのみ
tokensOut ?: number ;
costUsd ?: number ;
ts : string ; // ISO8601。clock の真値ではなく記録時刻
}
export interface EventStore {
append ( e : AgentEvent ) : Promise < void >;
load ( runId : string ) : Promise < AgentEvent []>;
}
実運用では EventStore を追記専用に保つことが命綱です。途中で書き換えると再現性が壊れます。私は本番では1行1イベントの JSON Lines をオブジェクトストレージに置き、開発では単純な配列で持つ、という二実装で運用しています。スキーマには必ず kind と key を持たせてください。後述するリプレイで「同じ順序・同じ呼び出し」であることを照合する鍵になります。
録音と再生を切り替えるラッパー
非決定の境界を直接呼ぶ代わりに、必ず次の boundary() 関数を経由させます。録音モードでは本物の処理を実行して結果をログに書き、再生モードでは本物を呼ばずにログから返します。
// boundary.ts
import { AgentEvent, BoundaryKind, EventStore } from "./events" ;
export type Mode = "record" | "replay" ;
export class RunContext {
private seq = 0 ;
private cursor = 0 ;
private recorded : AgentEvent [] = [];
constructor (
public readonly runId : string ,
public readonly mode : Mode ,
private readonly store : EventStore ,
private replayLog : AgentEvent [] = [],
) {}
static async forReplay ( runId : string , store : EventStore ) {
const log = await store. load (runId);
return new RunContext (runId, "replay" , store, log);
}
// すべての非決定境界はこの1点を通す
async boundary < T >(
kind : BoundaryKind ,
key : string ,
input : unknown ,
real : () => Promise <{ value : T ; tokensIn ?: number ; tokensOut ?: number ; costUsd ?: number }>,
) : Promise < T > {
const seq = this .seq ++ ;
if ( this .mode === "replay" ) {
const e = this .replayLog[ this .cursor ++ ];
if ( ! e || e.kind !== kind || e.key !== key || e.seq !== seq) {
// 録音時と分岐がずれた = リプレイ前提が壊れている
throw new ReplayDivergenceError ( this .runId, seq, kind, key, e);
}
return e.output as T ;
}
const r = await real ();
const event : AgentEvent = {
runId: this .runId, seq, kind, key,
input, output: r.value,
tokensIn: r.tokensIn, tokensOut: r.tokensOut, costUsd: r.costUsd,
ts: new Date (). toISOString (),
};
await this .store. append (event);
this .recorded. push (event);
return r.value;
}
}
export class ReplayDivergenceError extends Error {
constructor ( runId : string , seq : number , kind : string , key : string , got : AgentEvent | undefined ) {
super ( `Replay diverged at run=${ runId } seq=${ seq } expected ${ kind }:${ key } got ${ got ?. kind }:${ got ?. key }` );
this .name = "ReplayDivergenceError" ;
}
}
ここで ReplayDivergenceError をわざと投げているのが、設計上いちばん効く部分です。再生中に録音時と違う順序・違う呼び出しが起きたら、その瞬間に「コードのロジックが変わったか、記録が不完全か」を教えてくれます。沈黙してそれらしい値を返してしまうより、ずれた地点を seq 単位で指してくれる方が、事後分析では圧倒的に速く真因に届きます。
モデル呼び出しとツールを境界に通す
あとはモデル呼び出しとツール実行を boundary() でくるむだけです。Claude へのリクエストは、録音時には実際に Messages API を叩き、再生時には記録済みのレスポンスをそのまま返します。
// model.ts
import Anthropic from "@anthropic-ai/sdk" ;
import { RunContext } from "./boundary" ;
const client = new Anthropic ({ apiKey: process.env. ANTHROPIC_API_KEY }); // 例: "YOUR_API_KEY"
// 概算コスト(Sonnet 4.6 の例。実値は料金ページの単価で更新してください)
const PRICE_IN = 3.0 / 1_000_000 ; // USD / input token
const PRICE_OUT = 15.0 / 1_000_000 ; // USD / output token
export async function callClaude ( ctx : RunContext , messages : Anthropic . MessageParam []) {
return ctx. boundary ( "model" , "claude:messages" , { messages }, async () => {
const res = await client.messages. create ({
model: "claude-sonnet-4-6" ,
max_tokens: 2048 ,
temperature: 0 ,
messages,
});
const tin = res.usage.input_tokens;
const tout = res.usage.output_tokens;
return {
value: res,
tokensIn: tin,
tokensOut: tout,
costUsd: tin * PRICE_IN + tout * PRICE_OUT ,
};
});
}
ツール側も同じ要領です。時刻も乱数も境界として扱い、エージェントのコードからは絶対に Date.now() や Math.random() を直接呼ばないという規律を徹底します。
// tools.ts
import { RunContext } from "./boundary" ;
export const now = ( ctx : RunContext ) =>
ctx. boundary ( "clock" , "now" , null , async () => ({ value: Date. now () }));
export const rand = ( ctx : RunContext ) =>
ctx. boundary ( "random" , "rand" , null , async () => ({ value: Math. random () }));
export async function fetchOrders ( ctx : RunContext , customerId : string ) {
return ctx. boundary ( "tool" , "tool:fetch_orders" , { customerId }, async () => {
const res = await fetch ( `https://api.example.com/orders?c=${ customerId }` );
return { value: await res. json () };
});
}
この規律さえ守れば、エージェントのループ本体(モデルに投げて、ツールを実行して、また投げる)はそのまま録音にも再生にも使えます。本番のコードと事後分析のコードが一本化されるため、「デバッグ用に別経路を書いたら本番と挙動が違った」という、いちばん厄介なすれ違いが構造的に起きなくなります。
失敗した実行を1回だけ再生する
運用上のゴールはこれです。深夜に runId=run_2026_0531_0203 が失敗したら、朝、手元のマシンでその実行だけをオフラインで再現します。
// replay.ts
import { RunContext } from "./boundary" ;
import { jsonlStore } from "./store" ;
import { runAgent } from "./agent" ; // 本番と同一のループ本体
async function replay ( runId : string ) {
const ctx = await RunContext. forReplay (runId, jsonlStore);
try {
const result = await runAgent (ctx); // ネットワークもLLMも一切叩かない
console. log ( "再生完了:" , result);
} catch (e) {
if (e instanceof Error && e.name === "ReplayDivergenceError" ) {
console. error ( "分岐ずれを検出。コード変更か記録欠落の可能性:" , e.message);
} else {
console. error ( "録音時と同じ地点で失敗を再現:" , e);
}
}
}
replay (process.argv[ 2 ]);
再生は外部に一切アクセスしないので、何度でも、好きなだけブレークポイントを張りながら走らせられます。失敗が temperature: 0 でも揺れるモデル出力に由来していたのか、特定の注文データの欠損だったのか、深夜という時刻の分岐だったのかが、コードを一行も変えずに切り分けられます。私の体感では、再現に2〜3日かけていた間欠的な障害が、リプレイ導入後はおおむね同じ日の午前中に切り分けられるようになりました。
同じログからコストを実行単位で配賦する
このログは事後分析だけでなく、コスト配賦(cost attribution)にもそのまま使えます。model イベントに tokensIn / tokensOut / costUsd を記録してあるので、実行を集計するだけで「どのジョブが、いくらのトークンを、いくら使ったか」が出ます。
// cost.ts
import { EventStore } from "./events" ;
export async function runCost ( store : EventStore , runId : string ) {
const events = await store. load (runId);
const model = events. filter (( e ) => e.kind === "model" );
const tokensIn = model. reduce (( s , e ) => s + (e.tokensIn ?? 0 ), 0 );
const tokensOut = model. reduce (( s , e ) => s + (e.tokensOut ?? 0 ), 0 );
const costUsd = model. reduce (( s , e ) => s + (e.costUsd ?? 0 ), 0 );
return { runId, calls: model. length , tokensIn, tokensOut, costUsd };
}
ダッシュボードを別建てで作らなくても、実行ログそのものが一次資料になります。私は壁紙アプリ群のバックエンド自動処理で、月初にこの集計を回して「想定の3倍トークンを食っている実行」を毎月1〜2件見つけています。多くはリトライがループしていたか、コンテキストに不要な履歴を積みすぎていたケースで、いずれもリプレイで該当 seq のモデル入力を開くと一目で原因が分かります。
本番投入で実際につまずいた点
公式のサンプルには書かれていない、運用してはじめて分かった注意点を共有します。
まず個人情報の扱いです。境界の input / output をそのまま記録すると、ツール出力に顧客データが入り込みます。append() の直前に key ごとのマスキング関数を通し、再現に不要な PII は伏字にしておきます。再現性に必要なのは「分岐を決めた値」であって、生の個人データそのものではないことが多いです。
次にログのスキーマ進化です。AgentEvent に項目を足すと、古い runId のリプレイが壊れかねません。イベントに schemaVersion を持たせ、リプレイ側は古い版を読めるアダプタを用意しておきます。宮大工が後の修繕を見越して継ぎ手を残すのと同じで、後から検証する人(多くは未来の自分です)への配慮を最初に組み込んでおくのが結局いちばん安く付きます。
最後に保持期間です。全実行を永久に残すとストレージが膨らむので、私は「成功した実行は7日、失敗した実行は90日」を既定にしています。失敗だけ長く残すのは、事後分析の対象が常に失敗側だからです。runId に日付を埋めておくと、ライフサイクルポリシーでの自動削除も素直に書けます。
導入の最小手順と私の推奨
すでに動いているエージェントへ後付けで入れる場合、私は次の順序を推奨します。一度に全部やろうとせず、境界を1種類ずつ閉じていくのが安全です。
RunContext と boundary() を導入し、まずモデル呼び出しだけを通す(影響範囲が最小で効果が最大)。
Date.now() と Math.random() の直接呼び出しを grep で洗い出し、clock / random 境界に置き換える。
外部 I/O のあるツールを1つずつ tool 境界でくるむ。べき等でない副作用(決済・送信)は録音時のみ実行し、リプレイ時はログ値を返す。
EventStore を JSON Lines の追記専用にし、schemaVersion を最初から持たせる。
失敗時の runId を通知に必ず含め、replay.ts で1コマンド再現できる導線を用意する。
推奨の指針として、temperature は録音・再生いずれでも 0 に固定してください。完全な再現は保証されませんが、録音時とリプレイ時で設定がぶれないことが分岐ずれの誤検知を減らします。また、ログの保持は「成功は短く、失敗は長く」を既定にし、迷ったら失敗側を優先して残すのが、事後分析の費用対効果として最も高いというのが私の結論です。逆に、全実行を均一に長期保持するのはストレージ費用に見合わないため避けることをおすすめします。
決定論的リプレイは、派手な機能ではありません。けれど、誰も見ていない深夜に一度だけ起きた失敗を、翌朝きちんと再現して原因に手が届く——その安心感は、長く運用するソフトウェアにとって確かな土台になります。次の一歩として、いま動かしている自律処理のなかで「Date.now() を直接呼んでいる箇所」を1つ探し、boundary("clock", ...) に置き換えてみてください。そこが、再現できる設計への最初の入り口です。
同じように夜間バッチの再現性に悩んでいる方の助けになれば嬉しいです。お読みいただきありがとうございました。