GASでClaude APIの
プロンプトキャッシュでコスト削減する方法
大量の行にAIを回すとき、毎回同じ指示(システムプロンプト)を送って入力トークンを二重払いしていませんか。cache_controlで固定部分を使い回し、2回目以降の入力料金をぐっと抑える方法を、GASの動くコードで解説します。
Table of Contents
なぜ固定の指示を毎回送るとコストが膨らむのか
GASからClaude APIを使って、スプレッドシートの何百行もの問い合わせを分類したり、文章を校正したりする処理はよくあります。 このとき、役割や出力形式、分類基準、お手本例といった「毎回まったく同じ指示」を、行の数だけ繰り返し送ることになります。
APIの料金は入力トークン(送った文章の量)に比例します。固定の指示が長いほど、1行処理するたびに同じ入力料金を支払い続ける形になり、 処理件数が増えるほどムダが積み重なります。プロンプトキャッシュ(同じ内容の使い回し機能)は、この「固定部分の二重払い」をなくす仕組みです。
具体例
固定の指示が2,000トークン、1行の本文が100トークンだとすると、300行処理すれば固定部分だけで約60万トークン。 この大半をキャッシュに置き換えられれば、固定部分の入力料金は概算で10分の1程度まで下げられます。
プロンプトキャッシュの仕組み
プロンプトキャッシュは「プレフィックス(先頭からの一致)」で働きます。プロンプトの先頭から、指定した区切り(cache_controlを付けた位置)までがまったく同じなら、その部分は前回の計算結果を使い回します。
Claude APIでの評価順はsystem→messagesの順です。そこで「変わらない部分(system)」を前に、「1行ごとに変わる部分(messages)」を後ろに置き、 その境目に区切りを打つのが基本設計です。1バイトでも先頭側が変わると、そこから後ろのキャッシュは無効になります。
キャッシュ向き(前に置く)
役割・出力形式の指示、分類基準、用語集、お手本例(few-shot)、マスタデータなど、毎回同じで量が多いもの
キャッシュに入れない(後ろに置く)
1行ごとの本文、日時、ID、ユーザー入力など、呼び出しごとに変わるもの
systemを配列にしてcache_controlを付ける
普段はsystemに文字列を渡しますが、キャッシュを使うときは「内容ブロックの配列」に変え、キャッシュしたいブロックにcache_controlを付けます。まず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;
}// キャッシュしたい「毎回まったく同じ」固定プロンプト
// ここに役割・出力形式・分類基準・お手本例などをまとめる(量が多いほど効果大)
const FIXED_SYSTEM = [
"あなたは問い合わせを分類するアシスタントです。",
"以下のカテゴリから必ず1つだけ選び、JSONで返してください。",
"",
"# カテゴリ一覧",
"- 見積依頼 / サポート / クレーム / 営業 / その他",
"",
"# 判断基準(例)",
"- 金額・納期・数量の相談 → 見積依頼",
"- 不具合・使い方の質問 → サポート",
"- 不満・謝罪要求 → クレーム",
"- 売り込み・提携提案 → 営業",
"",
"# 出力形式(JSONのみ。前後に説明文を付けない)",
'{"category":"サポート","reason":"根拠を一言で"}',
].join("\n");
// system を「文字列」ではなく「内容ブロックの配列」にして、
// 最後のブロックに cache_control を付けるとそこまでがキャッシュされる
function buildSystemBlocks() {
return [
{
type: "text",
text: FIXED_SYSTEM,
cache_control: { type: "ephemeral" }, // ← これだけでキャッシュが有効になる
},
];
}区切りは1リクエストにつき最大4か所まで置けます。固定の指示とマスタデータを別々のブロックに分けて2か所付ける、といった使い方も可能です。
キャッシュヒットを確認する(usageの読み方)
呼び出しの共通関数では、本文だけでなくusage(トークンの内訳)も一緒に受け取ります。ここを見れば、キャッシュが効いているかがひと目で分かります。
// Claude API(Messages API)を呼び出す共通関数
// 固定部分は systemBlocks(キャッシュ対象)、可変部分は userText に渡す
function callClaude(systemBlocks, userText) {
const url = "https://api.anthropic.com/v1/messages";
const payload = {
model: "claude-sonnet-4-5",
max_tokens: 300,
system: systemBlocks, // 配列を渡す
messages: [{ role: "user", content: userText }],
};
const options = {
method: "post",
contentType: "application/json",
headers: {
"x-api-key": getApiKey(),
"anthropic-version": "2023-06-01",
// プロンプトキャッシュ専用のベータヘッダーは不要(正式機能)
},
payload: JSON.stringify(payload),
muteHttpExceptions: true,
};
const res = UrlFetchApp.fetch(url, options);
const code = res.getResponseCode();
if (code !== 200) {
throw new Error("Claude APIエラー: " + code + " / " + res.getContentText());
}
const body = JSON.parse(res.getContentText());
// 本文と一緒に usage(トークン内訳)も返す
return {
text: body.content[0].text,
usage: body.usage,
};
}cache_creation_input_tokensは書き込み(初回)、cache_read_input_tokensは読み込み(2回目以降)です。実際に2回続けて呼び、ログで確認してみましょう。
// キャッシュが効いているかを usage で確認する
function checkCache() {
const systemBlocks = buildSystemBlocks();
// 1回目:キャッシュへの「書き込み」が発生する
const first = callClaude(systemBlocks, "納期と単価を教えてください。");
Logger.log("1回目 書き込み: " + first.usage.cache_creation_input_tokens);
Logger.log("1回目 読み込み: " + first.usage.cache_read_input_tokens);
// 2回目:同じ固定プロンプトなのでキャッシュから「読み込み」される
const second = callClaude(systemBlocks, "ログインできません。対処法は?");
Logger.log("2回目 書き込み: " + second.usage.cache_creation_input_tokens);
Logger.log("2回目 読み込み: " + second.usage.cache_read_input_tokens);
}
// 期待するログ:
// 1回目 書き込み: 500前後 / 読み込み: 0
// 2回目 書き込み: 0 / 読み込み: 500前後 ← ヒットしている2回目でも読み込みが0のままなら、固定プロンプトに毎回変わる値が混ざっているか、固定部分が最小トークン数(Claude Sonnet系で約1,024トークン)に届いていない可能性があります。
大量データをキャッシュを効かせて処理する
実務ではスプレッドシートの行を順番に処理します。ここでのコツは、固定プロンプトのブロックをループの外で1回だけ組み立て、毎回まったく同じものを渡すことです。行ごとに作り直すと文字列がわずかに変わってキャッシュが外れることがあります。
// 問い合わせ列を1件ずつ分類し、結果を書き戻す
// systemBlocks は最初に1回だけ作り、毎回同じものを渡すのがポイント
function classifyInquiries() {
const ss = SpreadsheetApp.getActive();
const sheet = ss.getSheetByName("問い合わせ");
const data = sheet.getDataRange().getValues();
const systemBlocks = buildSystemBlocks(); // ← ループの外で1回だけ
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; // 空行・処理済みはスキップ
const result = callClaude(systemBlocks, "次の問い合わせを分類:\n" + text);
const parsed = JSON.parse(result.text);
sheet.getRange(i + 1, 2).setValue(parsed.category);
sheet.getRange(i + 1, 3).setValue(parsed.reason);
Utilities.sleep(300); // レート制限に配慮
}
}
// 2件目以降はキャッシュヒットで固定部分の入力料金がほぼかからない。
// 5分以内に次の呼び出しが続けばキャッシュは延長され続ける。2件目以降は固定部分がキャッシュから読まれるため、入力料金がほぼかかりません。キャッシュはヒットのたびに有効期間が延長されるので、連続して回している間は保持され続けます。
キャッシュが無効化される「うっかり」を避ける
キャッシュはプレフィックスが完全一致したときだけ効きます。固定プロンプト側に少しでも変動する要素が入ると、毎回別物と判定されて一度もヒットしません。よくある落とし穴を、良い例・悪い例で見比べます。
// ❌ 悪い例:固定プロンプトに毎回変わる値を混ぜる
// → プレフィックスが毎回変わり、キャッシュが一度もヒットしない
const badSystem = "現在時刻は " + new Date() + " です。カテゴリを選んでください…";
// ❌ 悪い例:オブジェクトを毎回バラバラの順序で文字列化する
// → 同じ内容でも文字の並びが変わり、別物と判定される
const badMaster = JSON.stringify(masterObject); // キー順が不定
// ✅ 良い例:固定部分は完全に同じ文字列を使い回す。
// 日時やIDなどの可変値は userText(messages側)へ回す
const goodUser = "現在時刻: " + new Date() + "\n本文: " + text;
// ✅ 良い例:JSONを埋め込むならキー順を固定する
function stableJson(obj) {
const keys = Object.keys(obj).sort();
const sorted = {};
keys.forEach((k) => (sorted[k] = obj[k]));
return JSON.stringify(sorted);
}固定側にモデルや設定の変更を混ぜない
モデル名を途中で変えると、キャッシュはモデルごとに分かれているため作り直しになります。バッチの途中でmodelを切り替えないようにしましょう。
TTLと料金の考え方・実務での注意点
キャッシュの有効期間(TTL)は既定で5分、ヒットするたびに延長されます。処理の間隔が長く空くなら、1時間保持するttl: "1h"を指定できます。
// 長い間隔で処理する場合は 1 時間キャッシュ(ttl: "1h")を指定できる
function buildSystemBlocks1h() {
return [
{
type: "text",
text: FIXED_SYSTEM,
cache_control: { type: "ephemeral", ttl: "1h" },
},
];
}
// 5分TTL:書き込みは通常入力の約1.25倍、読み込みは約0.1倍
// 1時間TTL:書き込みは約2倍だが、間隔が空いても消えにくい
// → 数分おきに回すなら既定の5分、1時間おきなら "1h" が目安1. 書き込みは少し割高。回数が少ないと逆効果
キャッシュへの書き込みは通常入力より少し高くつきます(5分TTLで約1.25倍)。読み込みは約0.1倍と安いため、同じ固定プロンプトを何度も使う処理でこそ得になります。1〜2回しか呼ばない処理には向きません。
2. 最小トークン数を下回るとキャッシュされない
固定部分が短すぎると、区切りを付けてもキャッシュされません(Claude Sonnet系で約1,024トークンが目安)。指示・基準・お手本例をまとめて、ある程度のまとまりにするのがコツです。
3. まず usage で効果を数値で確認する
導入したら必ずcache_read_input_tokensをログに残し、狙い通りヒットしているか確認しましょう。数字で見れば、うっかり無効化にもすぐ気づけます。
まとめ
GASからClaude APIを大量に呼ぶときは、systemを内容ブロックの配列にしてcache_controlを付けるだけで、固定部分の入力料金を大幅に減らせます。固定を前・可変を後ろに置き、固定側に変動要素を混ぜないこと、そしてusageでヒットを確認することが要点です。料金記録やリトライ処理と組み合わせれば、大量処理でも安定してコストを抑えられます。
よくある質問
条件を満たしたときだけ下がります。ポイントは2つです。1つは、同じ固定プロンプトを短い間隔(既定では5分以内)で繰り返し送ること。もう1つは、キャッシュ対象の固定部分が最小トークン数(Claude Sonnet系で約1024トークン、日本語で1500〜2000文字程度が目安)を超えていること。1回しか呼ばない処理や、固定部分が短い処理では効果が出ないか、むしろ書き込みコストの分だけ割高になります。
既定の有効期間(TTL)は5分です。しかもキャッシュにヒットするたびに5分延長されるため、5分以内に次の呼び出しが続く限り保持され続けます。行を1件ずつ処理するようなバッチ処理と相性が良い仕組みです。まとめて処理する時間が長く空く場合は、cache_controlに ttl: "1h"(1時間)を指定する選択肢もあります。
APIの返答に含まれる usage を見ます。cache_creation_input_tokens はキャッシュに書き込んだトークン数(初回に発生)、cache_read_input_tokens はキャッシュから読み込んだトークン数(2回目以降に発生)です。2回目以降も cache_read_input_tokens が0のままなら、システムプロンプトに毎回変わる値が混ざっているなど、キャッシュが無効化される原因があります。
「毎回まったく同じで、量が多い部分」です。具体的には、役割や出力形式の指示、分類基準や用語集、few-shot(お手本例)、マスタデータなどが対象になります。逆に、1行ごとに変わる本文や日時・IDなどの可変部分はキャッシュ対象に入れず、messages側(キャッシュの区切りより後ろ)に置きます。
現在は不要です。system の内容ブロックに cache_control: { type: "ephemeral" } を付けるだけで有効になります。GAS側では anthropic-version ヘッダー(2023-06-01)はこれまで通り付けたまま、payloadのsystemを文字列から内容ブロックの配列に変えるだけで対応できます。
AI×GAS自動化を
相談する。
Claude APIを使った分類・要約・校正などの自動化を、コスト最適化まで含めてご相談いただけます。プロンプト設計・トークン削減・安定運用まで対応します。