Managed Agents API でまとまった量の処理を回し始めて、最初に痛い目を見たのは「途中で 1 件こけると、前半の成功分まで巻き添えにしてやり直していた」ことでした。
200 件の記事メタデータを 1 件ずつエージェントに整形させるバッチを組んだとき、137 件目で API が 503 を返しました。スクリプトはそこで例外を投げて停止し、私はつい python batch.py を再実行しました。すると 1 件目から走り出します。前半 136 件分の推論コストが、そのまま二度払いになりました。
クラウド側で実行が完結する Managed Agents は、手元の CLI エージェントと違って「途中状態がプロセスのメモリではなくサービス側にある」ぶん、再開の設計を自分で用意しないと、こうした取りこぼしが静かにコストへ変わります。本稿は、その再開の仕組みを少しずつ足していった記録です。コードは執筆時点(2026年6月14日)の公開プレビュー挙動に基づきます。
何が「やり直し」を生んでいたか
素朴なバッチは、だいたいこういう形をしています。
import os
from google import genai
client = genai.Client(api_key=os.environ["YOUR_GEMINI_API_KEY"])
def run_batch(items):
results = []
for item in items:
op = client.agents.run(
agent="managed-default",
input=item["payload"],
)
result = poll_until_done(op) # 完了までポーリング
results.append(result)
return resultsこのコードには、再開の観点で次の 3 つの穴があります。
- 進捗がメモリ上の
resultsにしか無いことです。プロセスが死ねば、どこまで進んだかの記録ごと消えます。 client.agents.run()の呼び出しに識別子が無いことです。同じitemを二度投げれば、サービス側は素直に二度実行します。クラウド実行はここが手元と決定的に違う落とし穴で、後述の冪等キーで回避します。- 失敗の種類を区別していないことです。
503(一時的)も、入力不正の400(恒久的)も、同じ例外として全体を止めます。本来、前者は待って再試行、後者はスキップして記録、という別々の扱いが要ります。
この 3 点は、本番運用に乗せるバッチほど効いてきます。順番に塞いでいきます。
チェックポイントを外部に置く
まず、進捗をプロセスの外に逃がします。大げさな仕組みは要らず、個人開発の規模なら SQLite 一枚で十分でした。
import sqlite3, json, time
class Checkpoint:
def __init__(self, path="batch_state.db"):
self.db = sqlite3.connect(path)
self.db.execute("""
CREATE TABLE IF NOT EXISTS items (
key TEXT PRIMARY KEY,
status TEXT NOT NULL, -- pending / claimed / done / failed
op_name TEXT, -- サービス側の実行 ID
result TEXT,
updated_at REAL
)
""")
self.db.commit()
def seed(self, items):
for it in items:
self.db.execute(
"INSERT OR IGNORE INTO items(key, status, updated_at) VALUES (?, 'pending', ?)",
(it["key"], time.time()),
)
self.db.commit()
def pending_keys(self):
cur = self.db.execute(
"SELECT key FROM items WHERE status IN ('pending', 'claimed')"
)
return [row[0] for row in cur.fetchall()]
def set(self, key, status, op_name=None, result=None):
self.db.execute(
"UPDATE items SET status=?, op_name=COALESCE(?, op_name), "
"result=COALESCE(?, result), updated_at=? WHERE key=?",
(status, op_name, json.dumps(result) if result else None, time.time(), key),
)
self.db.commit()ポイントは status を pending / claimed / done / failed の 4 状態にしたことです。done は再実行で必ず飛ばすので、二度払いがここで止まります。再開時は pending_keys() が返す未完了分だけを処理すれば良く、137 件目で落ちても次回は 137 件目から走り出します。
key には、入力から決まる安定した値(記事スラッグなど)を使います。実行ごとに変わる UUID を振ると、再開時に「同じ仕事」と認識できず、結局やり直しになります。安定キーは再開設計の土台です。