手元のマシンで動かしていたバッチを、Managed Agents API でクラウドのワーカーに移したときの話です。最初の数日は快適でした。手元のCPUを占有せず、夜間に勝手に走り、朝にはレポートができている。ところが一週間ほどで、妙なことに気づきました。同じ入力のはずなのに、昨日と今日でレポートの構成が違うのです。
エフェメラルワーカーは、起動するたびに新しい環境で立ち上がり、終わると消えます。これは設計上の利点ですが、裏を返すと「手元では当たり前だった前提」がすべて消えるということです。ローカルにあった設定ファイル、環境変数、キャッシュ、入力データのちょっとした状態。そうした暗黙の文脈が、クラウドでは引き継がれません。
このバッチでは、各サイトの週次レポートに加えて、AdMob のメディエーション実績を集計するジョブも回しています。App Store と Google Play のリリースの合間に、手を止めずに集計が進んでほしい。だからこそ、結果が日によってぶれるのは見過ごせませんでした。私はこの「ぶれ」を、運用を止めずに潰していきました。
ここでは、クラウドに出したバッチの結果がぶれる原因を切り分け、再現性を入力契約・環境スナップショット・シード固定の三層で守る設計を共有します。個人開発で複数サイトのレポートを自動生成している、実用本位の組み立てです。
まず「ぶれ」の原因を4つに分ける
再現できない、という症状はひとくくりにすると直せません。私は原因を4つに分けて、ひとつずつ潰しました。
環境差です。エフェメラルワーカーのランタイムやライブラリのバージョンが、起動のたびに微妙に違うという落とし穴があります。
暗黙コンテキストの欠落です。手元では参照できていたファイルやデータが、クラウドには渡っていません。
モデルの更新です。gemini-3.5-flash のような別名は、裏側のモデルが静かに差し替わることがあります。
モデル本来の非決定性です。温度パラメータやサンプリングのゆらぎが残ります。
この4つは対処法がまったく違います。環境差はスナップショットで、暗黙コンテキストは入力契約で、モデル更新はバージョン固定で、非決定性はシードと温度の固定で押さえます。混ぜて考えると、どれも中途半端にしか直りません。
入力契約: 暗黙の文脈を明示的な引数に変える
最も効いたのは、入力契約をJSONで固定したことでした。クラウドのワーカーは「手元にあるはずのもの」を一切前提にできません。だから、ジョブに必要なものをすべて1つの契約オブジェクトに書き出し、それだけを渡します。
// 入力契約 — このジョブが必要とするものを、すべて明示する
interface BatchInputContract {
contractVersion : "1" ; // 契約自体のバージョン
task : "weekly-report" ;
// データは参照ではなく、内容そのものか不変なポインタで渡す
inputs : {
siteId : string ;
periodStart : string ; // ISO 8601、曖昧な「先週」を許さない
periodEnd : string ;
datasetUri : string ; // 不変オブジェクトストレージ上の固定URI
datasetSha256 : string ; // 取得後に検証するハッシュ
};
// モデルを別名ではなく固定バージョンで指定する
model : {
id : "gemini-3.5-flash" ;
pinnedVersion : string ; // 例: "gemini-3.5-flash-002"
temperature : 0 ;
seed : number ;
};
// プロンプトテンプレートもバージョンで固定する
promptTemplateId : string ; // 例: "weekly-report@7"
}
要点は3つあります。期間を「先週」のような相対表現で渡さないこと。データを参照ではなく、ハッシュで検証できる不変URIで渡すこと。モデルを別名でなく固定バージョンで指定すること。これだけで、同じ契約からは同じ結果が出る確率が大きく上がりました。
特にデータのハッシュ検証は重要でした。datasetUri が同じでも、その中身が差し替わっていれば結果は変わります。ワーカー側で取得直後に datasetSha256 を照合し、一致しなければジョブを止めます。「いつのまにか入力が変わっていた」という最も気づきにくいぶれを、ここで弾けます。
環境スナップショット: 何の上で動いたかを成果物に刻む
入力を固定しても、その上で動く環境が毎回違えば結果はぶれます。エフェメラルワーカーでは環境を固定しきれない場面もあるので、せめて「何の上で動いたか」を成果物に刻んでおきます。
import { createHash } from "node:crypto" ;
// 実行環境のダイジェストを作る
function environmentDigest () : { digest : string ; detail : Record < string , string > } {
const detail : Record < string , string > = {
runtime: process.version, // Node のバージョン
platform: `${ process . platform }-${ process . arch }` ,
// 依存の固定スナップショット(lockfile のハッシュ)
lockfileSha: process.env. LOCKFILE_SHA ?? "unknown" ,
// ワーカーイメージのタグ(Managed Agents が注入)
workerImage: process.env. AGY_WORKER_IMAGE ?? "unknown" ,
tz: Intl. DateTimeFormat (). resolvedOptions ().timeZone,
};
const digest = createHash ( "sha256" )
. update ( JSON . stringify (detail))
. digest ( "hex" )
. slice ( 0 , 16 );
return { digest, detail };
}
タイムゾーンを含めているのは、実害があったからです。手元は日本時間でしたが、クラウドのワーカーはUTCで立ち上がりました。「今日の日付」を使う処理が、日付の境界をまたいで前日のデータを拾うことがありました。ワーカーのタイムゾーンを明示し、日付は契約の periodStart / periodEnd から計算するように変えて、この種の境界事故をなくしました。
manifest: 入力・環境・出力を1つに束ねる
入力契約と環境ダイジェストが揃ったら、それらと出力を1つの manifest にまとめて成果物の隣に置きます。再現性とは「同じ入力・同じ環境なら同じ出力」が確認できる状態のことです。manifest はその確認を可能にする台帳です。
interface RunManifest {
runId : string ;
contractHash : string ; // 入力契約全体の sha256
environment : ReturnType < typeof environmentDigest>;
modelResolved : {
requested : string ; // "gemini-3.5-flash"
served : string ; // API が実際に応答したバージョン
};
outputSha256 : string ; // 生成物のハッシュ
startedAt : string ;
finishedAt : string ;
}
async function writeManifest ( m : RunManifest , store : ObjectStore ) {
// 成果物と同じ場所に manifest.json を置く
await store. put ( `reports/${ m . runId }/manifest.json` ,
JSON . stringify (m, null , 2 ));
}
modelResolved.served を必ず記録するのが肝でした。gemini-3.5-flash という別名で要求しても、API が実際に応答したバージョンは応答メタデータに含まれます。これを記録しておくと、「先週と出力が違う」と気づいたとき、最初に確認すべき「モデルが裏で変わったのか」が一目で分かります。実際、再現できない出力の原因の多くは、入力でも環境でもなく、このモデルの静かな差し替えでした。
非決定性をどこまで許すか
温度を0にしてシードを固定しても、生成モデルの出力が完全に決定的になるとは限りません。クラウド側の並列実行やルーティングによって、わずかなゆらぎが残ることがあります。ここで「完全な再現」を目指すと、コストばかりかかって割に合いません。
私は、再現性を「バイト単位で同一」ではなく「意味のある差分がない」で定義し直しました。レポートの数値・結論・構成が同じであればよく、言い回しの揺れは許す。manifest の outputSha256 が一致すれば理想ですが、一致しない場合でも、抽出した主要指標が一致するかを別途チェックします。
// 厳密一致が崩れても、意味的な再現性を検証する
function semanticMatch ( a : ReportMetrics , b : ReportMetrics ) : boolean {
return a.totalClicks === b.totalClicks
&& a.topQuery === b.topQuery
&& Math. abs (a.ctr - b.ctr) < 0.001 ; // 表示桁の丸め差は許容
}
この線引きをしてから、本番運用が現実的になりました。週次レポートで「再現できない」と判定される割合は、対策前の約15%から1%未満に下がりました。残る1%は本当にデータ側が変わっていたケースで、これはむしろ正しく検出できているということです。
本番運用で最後まで残った注意点は、ワーカーの並列度を上げると非決定性がわずかに増えることでした。同じジョブを別ワーカーに分散させると、ルーティングの違いで応答が揺れます。私は、再現性が重要なジョブは並列度を1に固定することを推奨します。速度と再現性のどちらを取るかを、ジョブ単位で選べるようにしておくと、運用が楽になります。
再実行を安全にする
再現性を整えると、副産物として再実行が安全になります。あるジョブが失敗したとき、同じ契約ハッシュをそのまま投げ直せば、前回と同じ条件で走ります。これは冪等性の土台です。
私は manifest の contractHash をキーにして、成果物の保存先を決めています。同じ契約ハッシュの成果物がすでに存在すれば、再実行はそれを上書きするのではなく、世代として並べて残します。こうしておくと、「先週の出力」と「再実行した出力」を後から並べて比較でき、ぶれの有無をその場で確認できます。
ここで注意したいのは、再実行の前に必ず datasetSha256 を再検証することです。失敗の原因がデータ側の変化だった場合、ハッシュが変わっているので、同じ契約でも検証で止まります。止まること自体が「入力が変わった」という正しい知らせになります。再実行を雑に成功させないことが、結果的に再現性を守ります。
クラウドに出すかどうかの判断
最後に、そもそもクラウドに出すべきかという判断です。エフェメラルワーカーは便利ですが、再現性を守るためのこうした作り込みが必要になります。手元のCPUに余裕があり、入力が小さいバッチなら、無理にクラウドへ出さず手元で回すほうが安いし速いこともあります。
私がクラウドに出しているのは、手元では時間がかかりすぎるか、複数を同時に走らせたいバッチだけです。そのうえで、上で述べた入力契約・環境スナップショット・manifest の三点を必ず付けます。この三点は、後から「なぜ結果が変わったのか」を問い直せる土台です。再現性は速度を犠牲にする制約ではなく、クラウド実行を信頼できるものに変える投資だと考えています。
クラウドにバッチを移したばかりで結果のぶれに悩んでいる方の、切り分けの助けになれば幸いです。