GASでClaude APIの
バッチ処理でコストを50%削減する方法
数百行にAIを回すとき、1行ずつAPIを呼んで6分の実行時間制限と戦っていませんか。Message Batches APIなら、まとめて投げて後で結果を取りに行くだけ。料金は半額になり、実行時間の心配もなくなります。動くGASコードで解説します。
Table of Contents
1行ずつ処理する方式の限界
GASからClaude APIを使う定番の書き方は、スプレッドシートの行をループで回し、1行ごとにAPIを呼ぶ方式です。件数が少ないうちは問題ありません。しかし数百行を超えると、2つの壁にぶつかります。
壁1:6分の実行時間制限
1回の呼び出しに数秒かかるため、300行なら合計で10分以上。GASの実行時間制限(6分)を超えて途中で止まります。
壁2:料金がそのまま件数分
同期的な呼び出しは通常料金です。すぐに答えが要らない処理でも、リアルタイム分の値段を払い続けることになります。
ここで効くのが発想の転換です。夜間に回す集計や、溜まった問い合わせの分類は「今すぐ」答えが要りません。このすぐに答えが要らないという性質を渡す代わりに、料金を半額にしてもらう仕組みが Message Batches API です。
Message Batches APIの仕組み
Message Batches API(バッチ処理API)は、複数のリクエストをまとめて1回で送り、あとから結果を取りに行く仕組みです。答えはその場で返らず、Anthropic側で非同期に(裏側で順次)処理されます。流れは3ステップだけです。
処理の流れ
- 複数のリクエストをまとめて投げる(レスポンスはすぐ返り、バッチIDが得られる)
- ときどき状態を確認する(処理中は in_progress、完了すると ended になる)
- 完了したら結果をまとめてダウンロードする
GASとの相性が良いのは、待ち時間がGASの外にあるからです。投げる処理はAPIを1回叩くだけで終わり、実際の推論はAnthropic側で進みます。GASは数分おきのトリガーで様子を見て、終わっていたら結果を書き込むだけ。6分の実行時間制限とにらめっこする必要がなくなります。
料金と上限の要点
バッチ経由の利用は入力・出力ともに通常の50%で課金されます(例:Claude Sonnet 4.5 は入力 $1.50 / 出力 $7.50 per MTok)。1つのバッチには最大100,000リクエスト、またはサイズ256MBまで入ります。多くのバッチは1時間以内に完了しますが、時間の保証はなく、上限は24時間です。
バッチを作成する(custom_idが要)
まずAPIキーの取り出しと共通ヘッダーです。バッチ処理は正式機能なので、ベータ用の追加ヘッダーは要りません。通常のMessages APIと同じヘッダーで動きます。
// APIキーはスクリプトプロパティに保存する(コードに直書きしない)
// 「プロジェクトの設定」→「スクリプト プロパティ」で CLAUDE_API_KEY を登録
function getApiKey() {
const key = PropertiesService
.getScriptProperties()
.getProperty("CLAUDE_API_KEY");
if (!key) {
throw new Error("CLAUDE_API_KEY が未設定です。スクリプトプロパティに登録してください。");
}
return key;
}
// 全リクエスト共通のヘッダー(バッチ専用のベータヘッダーは不要)
function apiHeaders() {
return {
"x-api-key": getApiKey(),
"anthropic-version": "2023-06-01",
};
}次にバッチ本体です。requests配列に、custom_id(自分で決める識別子)とparams(通常のMessages APIと同じ中身)の組を並べるだけです。
// シートの未処理行をまとめて1つのバッチとして投げる
// ポイント:custom_id に行番号を埋め込み、あとで結果と突き合わせられるようにする
function submitBatch() {
const sheet = SpreadsheetApp.getActive().getSheetByName("問い合わせ");
const data = sheet.getDataRange().getValues();
const requests = [];
for (let i = 1; i < data.length; i++) {
const text = String(data[i][0]).trim();
const done = data[i][1]; // B列に分類済みなら入っている
if (!text || done) continue; // 空行・処理済みはスキップ
requests.push({
// custom_id は英数字・ハイフン・アンダースコアのみ、1〜64文字
custom_id: "row-" + (i + 1), // 実際のシート行番号(1始まり)
params: {
model: "claude-sonnet-4-5",
max_tokens: 300,
system: "問い合わせを「見積依頼 / サポート / クレーム / 営業 / その他」から1つ選び、"
+ 'JSONのみで返してください。形式: {"category":"サポート"}',
messages: [{ role: "user", content: text }],
},
});
}
if (requests.length === 0) {
Logger.log("処理対象の行がありません。");
return;
}
const res = UrlFetchApp.fetch("https://api.anthropic.com/v1/messages/batches", {
method: "post",
contentType: "application/json",
headers: apiHeaders(),
payload: JSON.stringify({ requests: requests }),
muteHttpExceptions: true,
});
if (res.getResponseCode() !== 200) {
throw new Error("バッチ作成に失敗: " + res.getResponseCode() + " / " + res.getContentText());
}
const batch = JSON.parse(res.getContentText());
Logger.log("バッチID: " + batch.id); // msgbatch_...
Logger.log("状態: " + batch.processing_status); // 最初は in_progress
// バッチIDを保存しておく(あとで結果を取りに行くときに使う)
PropertiesService.getScriptProperties().setProperty("BATCH_ID", batch.id);
return batch.id;
}custom_id が最重要な理由
結果は入力した順番で返るとは限りません。custom_idだけが結果と元データを結ぶ手がかりです。上のコードのように行番号を埋め込んでおけば、順不同で返ってきても正しい行に書き戻せます。使える文字は英数字・ハイフン・アンダースコアのみで、1〜64文字です。
進み具合を確認する
バッチIDを使って状態を問い合わせます。見るべきはprocessing_statusで、in_progressから始まり、全リクエストの処理が終わるとendedに変わります。
// バッチの進み具合を確認する
// processing_status は in_progress → ended と変化し、ended で結果が揃う
function checkBatch(batchId) {
const res = UrlFetchApp.fetch(
"https://api.anthropic.com/v1/messages/batches/" + batchId,
{ method: "get", headers: apiHeaders(), muteHttpExceptions: true }
);
if (res.getResponseCode() !== 200) {
throw new Error("状態取得に失敗: " + res.getContentText());
}
const batch = JSON.parse(res.getContentText());
const counts = batch.request_counts;
Logger.log("状態: " + batch.processing_status);
Logger.log(
"処理中:" + counts.processing +
" 成功:" + counts.succeeded +
" エラー:" + counts.errored +
" 期限切れ:" + counts.expired
);
return batch;
}
// batch.results_url は処理中は null で、ended になると結果のURLが入るrequest_countsには、処理中・成功・エラー・キャンセル・期限切れの件数が入ります。進捗をログに出しておくと、想定より遅いときにすぐ気づけます。
結果を取り出してシートに書き戻す
状態がendedになると、results_urlに結果のURLが入ります(処理中は null です)。このURLから取得できるのはJSONL形式(1行に1件のJSONが並んだ形式)です。
// 結果を取り出してシートに書き戻す
// 結果は JSONL(1行1件のJSON)形式で返る
function fetchResults(batchId) {
const batch = checkBatch(batchId);
if (batch.processing_status !== "ended") {
Logger.log("まだ処理中です。あとでもう一度確認してください。");
return false;
}
// results_url にも同じ認証ヘッダーを付けて取得する
const res = UrlFetchApp.fetch(batch.results_url, {
method: "get",
headers: apiHeaders(),
muteHttpExceptions: true,
});
const sheet = SpreadsheetApp.getActive().getSheetByName("問い合わせ");
// 1行ずつ JSON としてパースする(空行は無視)
const lines = res.getContentText().split("\n").filter(function (l) {
return l.trim() !== "";
});
lines.forEach(function (line) {
const item = JSON.parse(line);
// custom_id から行番号を復元する(結果は順不同なので必須)
const rowNum = Number(item.custom_id.replace("row-", ""));
if (item.result.type === "succeeded") {
const text = item.result.message.content[0].text;
try {
sheet.getRange(rowNum, 2).setValue(JSON.parse(text).category);
} catch (e) {
sheet.getRange(rowNum, 2).setValue("解析失敗: " + text);
}
} else if (item.result.type === "errored") {
// 課金されない。invalid_request ならリクエスト側の不備
sheet.getRange(rowNum, 2).setValue("エラー: " + item.result.error.type);
} else if (item.result.type === "expired") {
// 24時間以内に処理されなかった。課金されないので投げ直す
sheet.getRange(rowNum, 2).setValue("期限切れ(要再実行)");
}
});
Logger.log(lines.length + "件を書き戻しました。");
return true;
}各行のresult.typeは4種類です。成功以外もきちんと分岐しておくと、あとで原因を追いやすくなります。
succeeded(成功)
result.message に通常のMessages APIと同じ形の返答が入ります。
errored(エラー)
リクエストの不備やサーバー側の問題。課金されません。invalid_request なら送った内容の見直しが必要です。
expired(期限切れ)
24時間以内に処理されなかった分。課金されないので、投げ直せば大丈夫です。
canceled(キャンセル)
処理前に自分でバッチをキャンセルした分。これも課金されません。
トリガーで「投げっぱなし」運用にする
最後に、この2つを時間主導型トリガー(指定した間隔で自動実行するGASの仕組み)でつなぎます。バッチを投げたら監視トリガーを登録し、完了を検知したら書き戻してトリガーを消す、という流れです。
// 「投げっぱなし」運用:トリガーで完了を待ち受ける
// submitBatch() を実行したあと、この関数を5分おきのトリガーに登録する
function watchBatch() {
const props = PropertiesService.getScriptProperties();
const batchId = props.getProperty("BATCH_ID");
if (!batchId) return; // 監視対象なし
const finished = fetchResults(batchId);
if (finished) {
props.deleteProperty("BATCH_ID"); // 監視終了
removeWatchTriggers(); // トリガーも片付ける
Logger.log("完了しました。");
}
}
// 5分おきのトリガーを作る(submitBatch のあとに1回だけ呼ぶ)
function startWatching() {
removeWatchTriggers(); // 二重登録を防ぐ
ScriptApp.newTrigger("watchBatch").timeBased().everyMinutes(5).create();
}
// watchBatch のトリガーだけを削除する
function removeWatchTriggers() {
ScriptApp.getProjectTriggers().forEach(function (t) {
if (t.getHandlerFunction() === "watchBatch") {
ScriptApp.deleteTrigger(t);
}
});
}
// 実行の流れ:
// 1. submitBatch() → バッチを投げてIDを保存(数秒で終わる)
// 2. startWatching() → 5分おきの監視トリガーを登録
// 3. watchBatch() → 完了していれば書き戻してトリガーを削除
// 推論の待ち時間はAnthropic側で進むため、GASの6分制限に当たらないこれで、人が張り付く必要も、6分の制限を気にする必要もなくなります。1回の実行はどれも数秒で終わり、待ち時間はすべてGASの外にあるからです。完了後にトリガーを削除している点も大切で、消し忘れると空振りの実行が延々と続きます。
実務での注意点
1. リクエストの中身の検証は「あとから」行われる
params の中身のチェックは非同期に行われ、不備はバッチ処理が終わってから errored として返ってきます。つまり、投げた直後は成功に見えても、1時間後に全件エラーだった、という事故が起こり得ます。まず通常のMessages APIで1件だけ試し、形が正しいと確認してからバッチに載せるのが安全です。
2. 結果の順番を信用しない
繰り返しになりますが、結果は任意の順序で返ります。行の並びを前提にすると、分類結果が別の行に入るという最悪のバグになります。必ずcustom_idで突き合わせてください。
3. 結果の保存期間は29日
バッチの結果は作成から29日間ダウンロードできます。それ以降もバッチ自体は参照できますが、結果は取得できません。取りっぱなしにせず、シートやドライブに保存しておきましょう。
4. プロンプトキャッシュと併用するなら1時間TTL
共通の長い指示を使い回すなら、各リクエストの params に cache_control を付けてキャッシュも併用できます。ただしバッチ処理は5分以上かかることがあり、既定の5分キャッシュでは切れやすくなります。公式ドキュメントでも、バッチと組み合わせるなら1時間キャッシュ(ttl: "1h")の方がヒット率を上げやすいと案内されています。
5. 向かない処理もある
完了時間が保証されない以上、チャットボットの応答やフォーム送信直後の返信など、即時性が要る処理には使えません。バッチが向くのは「夜間に溜まったデータをまとめて処理する」「大量の評価を回す」といった、待てる処理です。
まとめ
すぐに答えが要らない処理なら、Message Batches API に載せ替えるだけで料金は半額になり、GASの6分制限からも解放されます。実装の要点は3つです。requestsにまとめて投げること、結果は順不同なのでcustom_idで突き合わせること、そして完了の待ち受けをトリガーに任せること。プロンプトキャッシュ(1時間TTL)と組み合わせれば、大量処理のコストはさらに下げられます。
よくある質問
多くのバッチは1時間以内に完了します。ただし完了時間は保証されていません。上限は24時間で、24時間以内に処理が終わらなかったリクエストは「expired(期限切れ)」となり、その分の料金はかかりません。「いつ終わるか分からないが、遅くとも24時間以内」という前提で組むのが安全です。すぐに答えが必要な処理(チャットの応答など)には向きません。
はい。バッチ処理経由の利用は、入力・出力ともに通常のAPI料金の50%で課金されます。割引を受けるために特別な申請やベータ用のヘッダーは不要で、エンドポイントをバッチ用に変えるだけです。なお、エラーになったリクエスト・キャンセルされたリクエスト・期限切れになったリクエストは課金されません。
1つのバッチにつき最大100,000リクエスト、またはサイズ256MBのどちらか早く到達した方が上限です。実務のスプレッドシート処理(数百〜数千行)なら十分収まります。ただしGAS側にはUrlFetchAppのペイロードサイズや実行時間の制約があるため、1回のバッチは数百件程度に区切り、行数が多い場合は複数のバッチに分けるのが現実的です。
いいえ。バッチの結果は入力した順番と一致しない可能性があり、任意の順序で返ります。行の並び順を前提に「1件目の結果は1行目」と決め打ちすると、データが入れ違いになります。必ず各リクエストに付けたcustom_id(自分で決める識別子)で結果を突き合わせてください。custom_idに行番号を埋め込んでおくのが実務では手軽です。
バッチ処理は「投げる」「結果を取りに行く」の2つが別の実行に分かれるため、むしろ相性が良い仕組みです。投げる処理はAPIを1回呼ぶだけなのですぐ終わり、実際の推論はAnthropic側で進みます。あとは時間主導型トリガーで数分おきに状態を確認し、完了していたら結果を書き込むだけです。1行ずつ同期的に呼び出す方式のように、6分の制限とにらめっこする必要がなくなります。
できます。バッチ内の各リクエストは通常のMessages APIと同じパラメータを取れるため、cache_controlも指定できます。ただしバッチの処理には5分以上かかることがあり、既定の5分キャッシュでは有効期間が切れやすくなります。共通の長いプロンプトを使い回すなら、ttlに1hを指定した1時間キャッシュの方がヒット率を上げやすい、と公式ドキュメントでも案内されています。
AI×GAS自動化を
相談する。
大量データのAI処理を、コストと実行時間の両面から設計してご相談いただけます。バッチ処理・キャッシュ設計・トリガー運用まで対応します。