GASでClaude APIの
バッチ処理でコストを50%削減する方法

数百行にAIを回すとき、1行ずつAPIを呼んで6分の実行時間制限と戦っていませんか。Message Batches APIなら、まとめて投げて後で結果を取りに行くだけ。料金は半額になり、実行時間の心配もなくなります。動くGASコードで解説します。

|対象: GAS / Claude API / バッチ処理

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ステップだけです。

処理の流れ

  1. 複数のリクエストをまとめて投げる(レスポンスはすぐ返り、バッチIDが得られる)
  2. ときどき状態を確認する(処理中は in_progress、完了すると ended になる)
  3. 完了したら結果をまとめてダウンロードする

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処理を、コストと実行時間の両面から設計してご相談いただけます。バッチ処理・キャッシュ設計・トリガー運用まで対応します。

AI×GAS自動化を見る