#ERROR! が並んだ列を最初に見たとき、私はプロンプトを疑いました。セルのノートには Exceeded maximum execution time (line 0). とだけ書かれています。420行のうち37行。プロンプトは同じ、モデルも同じ、入力の長さもたいして変わりません。壊れているのは特定の行ではなく、行が並んでいるという状況そのものでした。
個人開発でアプリのレビュー要約をスプレッドシートに集めていて、C列に本文、D列に =GEMINI_SUMMARY(C2) を貼る、という素朴な構成を試したときのことです。10行で試したときは気持ちよく動きました。数式をコピーして420行に伸ばしたところで、この構成が持っている性質が一度に表に出てきました。
厄介だったのは #ERROR! そのものではありません。その週の終わりに使用量を見て、私が数式を貼ったのは420セルなのに、呼び出しは1,180回に達していたことのほうです。誰も新しい行を足していない朝にも、数字は静かに増えていました。
セルの数だけ、独立に呼ばれています
公式ドキュメントは最適化の節で、この構成の核心をはっきり書いています。「スプレッドシートでカスタム関数が使われるたびに、Sheets は Apps Script サーバーへの個別の呼び出しを行う」。数十・数百・数千のカスタム関数呼び出しがシートにあると、この過程は遅くなる、と。
つまり =GEMINI_SUMMARY(C2) を420セルに貼るというのは、420個の独立したリクエストを1枚のシートに埋め込むことと同じです。1つのジョブが420行を順に処理するのではありません。420個の別々の実行が、それぞれ自分の30秒を持って走ります。
この違いが効いてくるのは、失敗したときと、増幅したときです。420行のバッチなら「どこまで進んだか」を1箇所で持てます。420個の独立実行では、進捗という概念そのものがどこにも存在しません。あるのは、たまたま値が入っているセルと、たまたま #ERROR! になっているセルだけです。
Google 自身がカスタム関数のサンプルとして「ADK エージェントと Gemini モデルで記述をファクトチェックする」例を公開しています。よくできた入口だと思います。ただ、入口の形のまま奥へ伸ばそうとすると、以下の性質にぶつかります。
誰も触っていない朝に、呼び出しだけが増えていた
再計算は、私が意図した瞬間には起きません。ドキュメントの記述はこうです。再計算を起こすには、参照するセルまたはレンジを引数として直接渡す必要がある。そうでなければ、数式を編集するか、参照先セルの値を変えるまで再計算されない、と。
裏を返すと、参照セルを引数として直接渡している限り、その参照先が変わるたびに再計算が走ります 。私のシートでは、C列(レビュー本文)を触るたびにD列が丸ごと呼び直されていました。誤字を1つ直す、言語判定の列を差し込む、並べ替えのために列を移動する。どれも「編集」です。
さらに、引数は決定的(deterministic)でなければならない、という制約があります。NOW() や RAND() のような揮発性の組み込み関数はカスタム関数の引数に使えません。使うと Loading... が永遠に消えなくなります。ここで多くの人が「なら現在時刻はセルに固定値で置こう」と考えますが、その固定値を更新した瞬間、それも編集として再計算の引き金になります。
私の1週間の内訳は、数えてみるとこうなりました。数字は私の手元のもので、シートの触り方でいくらでも動きます。ご自身の環境で数え直すための型として読んでいただければ幸いです。
きっかけ 回数 再計算された行 呼び出し
最初に数式を420行へ展開 1 420 420
C列の誤字修正(1セルずつ) 23 23 23
言語コード列の挿入・削除 2 420 840 ではなく 654※
スクリプト側のプロンプト調整で保存 4 — 83
合計 — — 1,180
※ 全行が毎回そろって走り直すわけではなく、キャッシュされた値がそのまま残る行もあります。だからこそ厄介で、「何回呼ばれたか」はシートを見ても分かりません。
貼った数式は420。実際の呼び出しは1,180。倍率にして2.8倍です。ここで大事なのは倍率の大きさではなく、この倍率が自分の操作と結びついていない ことだと思っています。列を1本挿すという、コストと無関係に見える操作が、静かに数百回の課金を生んでいました。
自分のシートで数えるなら、まず呼び出し側に1行足すのがいちばん早いです。
/**
* レビュー本文を要約します(計測付き・この形は本番向けではありません)。
*
* @param {string} text 要約するレビュー本文。
* @return {string} 要約テキスト。
* @customfunction
*/
function GEMINI_SUMMARY ( text ) {
// 呼ばれた事実だけを先に記録する。実行ログ(表示 > 実行数)で件数を数えられます。
console. log ( 'GEMINI_SUMMARY invoked len=%s' , String (text). length );
const key = PropertiesService. getScriptProperties (). getProperty ( 'GEMINI_API_KEY' );
const url = 'https://generativelanguage.googleapis.com/v1beta/models/'
+ 'gemini-flash-latest:generateContent?key=' + key;
const res = UrlFetchApp. fetch (url, {
method: 'post' ,
contentType: 'application/json' ,
payload: JSON . stringify ({
contents: [{ parts: [{ text: '次のレビューを1文で要約してください。 \n\n ' + text }] }],
}),
muteHttpExceptions: true ,
});
if (res. getResponseCode () !== 200 ) {
// カスタム関数から返せるのは値だけです。原因はここで潰れます。
return '#GEMINI_ERROR' ;
}
const json = JSON . parse (res. getContentText ());
return json.candidates[ 0 ].content.parts[ 0 ].text. trim ();
}
これを貼って1週間ふつうに使い、Apps Script ダッシュボードの実行数を眺めてみてください。私の場合、その数字が構成を変える決め手になりました。議論より先に、自分のシートの実測を1つ持つほうが早いです。
30秒は「6分」ではありません
ここは混同しやすいところなので、はっきり分けておきます。ドキュメントの記述は明快です。カスタム関数の呼び出しは30秒以内に戻らなければならない 。戻らなければセルは #ERROR! を表示し、セルのノートは Exceeded maximum execution time (line 0). になります。
一方、メニューやトリガーから起動する通常のスクリプト実行の上限は6分です。同じ Apps Script でも、カスタム関数だけが12分の1の予算で走っています。私が #ERROR! を37行分見たのは、モデルが遅かったからでも入力が長すぎたからでもなく、混雑した時間帯に30秒を超えた行が偶然37個あった 、というだけのことでした。
この30秒は、後で出てくる「1呼び出しで何行処理できるか」の天井を決めます。私の手元では、gemini-flash-latest への1行あたりの往復が、短い要約でおおよそ1.7〜2.1秒でした。安全側に2.0秒とすると、30秒に収まるのは15行前後。実運用のばらつきを見込んで7割に落とすと10行程度が現実的な上限になります。
実行の種類 時間の上限 超えたときの見え方
カスタム関数(=GEMINI_SUMMARY()) 30秒 セルに #ERROR!・ノートに超過メッセージ
メニュー / インストーラブルトリガー 6分 実行ログに例外・途中まで書き戻し済み
ここには小さな落とし穴もあります。30秒超過の #ERROR! は、そのセルを再編集すると消えて値が入ることがあります。注意点として先に知っておかないと、「たまに失敗する行がある」という認識のまま止まってしまいます。しかも、失敗した行だけを手で押し直して回避しようとすると、その編集がそのまま次の再計算を呼びます。
30秒に収めるためにプロンプトを削り、出力を短くし、モデルを軽いほうへ寄せる。私も一通りやりました。どれも効きますが、行数を増やせばまた同じ壁に戻ります。上限が動かない以上、上限の内側で工夫し続けるより、上限の外へ設計を出すほうが素直だと考えるようになりました。
レンジで受けて2次元配列で返す — 最初の一手
設計を丸ごと変える前に、コストを一桁下げる手が公式に用意されています。カスタム関数はレンジを2次元配列として受け取り、2次元配列を返して隣接セルへあふれさせられます。420セルに420個の数式を置くのではなく、1セルに1つの数式 を置いて420行分を返す形です。
/**
* レビュー本文のレンジを受け取り、要約を1列で返します。
* 呼び出し回数はセル数ではなく1回になります。
*
* @param {Array<Array<string>>} range 要約するレビュー本文のレンジ。
* @return {Array<Array<string>>} 要約テキストの1列。
* @customfunction
*/
function GEMINI_SUMMARY_RANGE ( range ) {
const rows = (Array. isArray (range) ? range : [[range]])
. map (( row ) => String (row[ 0 ] || '' ). trim ());
// 30秒の予算を先に確保する。超えそうなら残りは印を返して諦める。
const deadline = Date. now () + 25 * 1000 ;
const out = [];
for ( const text of rows) {
if ( ! text) { out. push ([ '' ]); continue ; }
if (Date. now () > deadline) { out. push ([ '#BUDGET' ]); continue ; }
out. push ([ summarizeOne_ (text)]);
}
return out;
}
function summarizeOne_ ( text ) {
const key = PropertiesService. getScriptProperties (). getProperty ( 'GEMINI_API_KEY' );
const url = 'https://generativelanguage.googleapis.com/v1beta/models/'
+ 'gemini-flash-latest:generateContent?key=' + key;
const res = UrlFetchApp. fetch (url, {
method: 'post' ,
contentType: 'application/json' ,
payload: JSON . stringify ({
contents: [{ parts: [{ text: '次のレビューを1文で要約してください。 \n\n ' + text }] }],
generationConfig: { maxOutputTokens: 120 },
}),
muteHttpExceptions: true ,
});
if (res. getResponseCode () !== 200 ) return '#GEMINI_ERROR' ;
return JSON . parse (res. getContentText ()).candidates[ 0 ].content.parts[ 0 ].text. trim ();
}
=GEMINI_SUMMARY_RANGE(C2:C11) とすれば、Apps Script サーバーへの呼び出しは1回です。ここまでで、シート上の見た目はほとんど変わらないまま、呼び出し回数が二桁減ります。
ただし deadline の行に、この手の限界がそのまま出ています。30秒しかないので、レンジに渡せる行数には天井があります。私の1行2.0秒という実測なら10〜12行。それ以上を渡すと、後ろのほうが #BUDGET で埋まります。420行を10行ずつ42セルに分けて貼る、という運用は書けますが、それは42個の30秒予算を並べているだけで、再計算の増幅からは逃げられていません。
この形が向いているのは、探索の場です。 数十行を眺めながらプロンプトを詰める、出力の粒度を決める、といった用途では、シート上ですぐ結果が返る手触りに価値があります。私自身、プロンプトを決めるまではこの形を使います。決まった瞬間に、外へ出します。
カスタム関数から見えない3つのもの
行数の話とは別に、カスタム関数という実行モデルには、本番運用で効いてくる制約が3つあります。ドキュメントの表に淡々と書かれているのですが、動いているうちは目に入りません。
1つ目は、書き戻せないことです。 カスタム関数から使える Spreadsheet サービスは読み取り専用で、get*() は使えても set*() は使えません。SpreadsheetApp.openById() や openByUrl() で他のシートを開くこともできません。加えて、カスタム関数は自分が値を返すセル以外に影響を与えられません。つまり「処理済みフラグを別列に立てる」「失敗した行を別シートの台帳へ落とす」という、バッチなら当たり前の記録が、原理的にできません。
2つ目は、失敗が値に潰れることです。 返せるのは表示用の値だけなので、私のコードでは #GEMINI_ERROR という文字列を返しています。429 なのか、キーの権限なのか、スキーマなのか。区別はセルに残りません。console.log で実行ログには出せますが、シートを見ている人には「なんか失敗している」以上のことが分かりません。
3つ目は、鍵の置き場所です。 カスタム関数は誰にも認可を求めません。だから個人データに触れるサービスを呼べない、というのが制約の出発点です。Properties サービスは使えますが、getUserProperties() はスプレッドシート所有者のプロパティしか返さず、編集者はカスタム関数の中でユーザープロパティを設定できません。そして、シートに紐づくスクリプトはシートと同じアクセス権を共有します。シートを編集者として共有するということは、スクリプトを編集できるようにするということです。 スクリプトプロパティに置いた API キーは、その人が3行書けば読み出せます。共有前提のシートで自作の =GEMINI() を配る構成は、鍵の配布とほぼ同義になります。
やりたいこと カスタム関数 メニュー / トリガー実行
結果を任意のセル・別シートへ書く できません(読み取り専用・返すセルのみ) できます
処理済みフラグで冪等にする できません できます
失敗を種類別に記録する 実行ログのみ(シートには残りません) 台帳シートに残せます
実行のタイミングを自分で決める 再計算に従います 決められます
認可が必要なサービスを使う できません できます
「式」をやめて「依頼票」にする
移行の考え方は一行で書けます。セルに置くのを「呼び出し」から「状態」に変える。 D列には数式ではなく値が入り、E列にはその値がいつ・どのモデルで作られたかが入る。呼び出すのはメニューから起動したバッチだけ。これで再計算の増幅はゼロになります。誰が列を挿しても、値は値のままです。
const SHEET_NAME = 'reviews' ;
const COL = { text: 3 , summary: 4 , status: 5 , model: 6 , at: 7 };
function onOpen () {
SpreadsheetApp. getUi ()
. createMenu ( 'Gemini' )
. addItem ( '未処理行を要約する' , 'runSummaryBatch' )
. addToUi ();
}
/**
* 未処理行だけを要約し、値として書き戻します。
* 途中で6分に達しても、書き戻した分は残ります。再実行すれば続きから進みます。
*/
function runSummaryBatch () {
const sheet = SpreadsheetApp. getActive (). getSheetByName ( SHEET_NAME );
const last = sheet. getLastRow ();
if (last < 2 ) return ;
const values = sheet. getRange ( 2 , 1 , last - 1 , COL .at). getValues ();
const deadline = Date. now () + 5 * 60 * 1000 ; // 6分上限の内側で自分から降りる
let done = 0 ;
for ( let i = 0 ; i < values. length ; i ++ ) {
if (Date. now () > deadline) break ;
const row = i + 2 ;
const text = String (values[i][ COL .text - 1 ] || '' ). trim ();
const status = String (values[i][ COL .status - 1 ] || '' ). trim ();
if ( ! text) continue ;
if (status === 'ok' ) continue ; // 冪等性はここだけで担保されます
const result = summarizeWithRetry_ (text);
// 1行ずつ書き戻す。途中で落ちても、ここまでは確定しています。
sheet. getRange (row, COL .summary, 1 , 4 ). setValues ([[
result.ok ? result.text : '' ,
result.ok ? 'ok' : 'error:' + result.reason,
'gemini-flash-latest' ,
new Date (),
]]);
done ++ ;
}
SpreadsheetApp. getActive (). toast (done + ' 行を処理しました' , 'Gemini' , 5 );
}
function summarizeWithRetry_ ( text ) {
const key = PropertiesService. getScriptProperties (). getProperty ( 'GEMINI_API_KEY' );
const url = 'https://generativelanguage.googleapis.com/v1beta/models/'
+ 'gemini-flash-latest:generateContent?key=' + key;
const payload = JSON . stringify ({
contents: [{ parts: [{ text: '次のレビューを1文で要約してください。 \n\n ' + text }] }],
generationConfig: { maxOutputTokens: 120 },
});
for ( let attempt = 0 ; attempt < 4 ; attempt ++ ) {
const res = UrlFetchApp. fetch (url, {
method: 'post' ,
contentType: 'application/json' ,
payload: payload,
muteHttpExceptions: true ,
});
const code = res. getResponseCode ();
if (code === 200 ) {
const parts = JSON . parse (res. getContentText ()).candidates?.[ 0 ]?.content?.parts;
if ( ! parts || ! parts[ 0 ]?.text) return { ok: false , reason: 'empty' };
return { ok: true , text: parts[ 0 ].text. trim () };
}
// 429 / 5xx だけ待って再挑戦する。400 系を再送しても結果は変わりません。
if (code !== 429 && code < 500 ) return { ok: false , reason: 'http' + code };
Utilities. sleep (Math. pow ( 2 , attempt) * 1000 );
}
return { ok: false , reason: 'retry_exhausted' };
}
ステータス列が1本あるだけで、性質が変わります。6分で途中終了しても、書き戻した行は ok として確定しています。もう一度メニューを押せば続きから進みます。失敗した行は error:http429 のように理由が残るので、後から絞り込めます。420行を無人で流し切る段取り、つまりトリガーで自分にバトンを渡して6分の壁を越える方法は、Apps Script から Gemini でスプレッドシート数千行を処理する設計 に実装を分けて書きました。この記事はその手前、「そもそも式の形をやめる」ところを扱っています。
移した後、私の週あたりの呼び出しは1,180回から420回になりました。減った分の大半は、私が何もしていない時間に起きていたものです。
どこまではカスタム関数でよいか
すべてをバッチにすべき、とは思っていません。カスタム関数のいちばんの価値は、結果がその場に返ってくる手触りです。カスタム関数を選ぶ場合は、行数と共有範囲の2つだけ先に確かめるようにしています。判断の目安を、私が使っている粒度で置いておきます。
状況 私の選択 理由
プロンプトを詰めている最中・数十行 カスタム関数(レンジ受け取り型) 編集して即座に見えることに価値があります
一度書いたら二度と再計算したくない メニュー起動バッチ+書き戻し 値は再計算されません
100行を超える・毎週回す メニュー起動バッチ+書き戻し 30秒では収まらず、増幅が効きます
他人と編集権限で共有する バッチ、または Fill with Gemini スクリプトの鍵が共有されます
翻訳・言い換えなど汎用の変換 まず組み込み機能を試します 自作しないのがいちばん安いです
最後の行は本音です。ストア説明文の多言語化のように、組み込みの機能で足りるなら、そちらのほうが速くて安全です。Dolice で公開しているアプリのメタデータ周りはSheets の Fill with Gemini で多言語化する運用 に寄せていて、自作の関数はそこに乗らないものだけに使っています。自分で書ける、ということと、自分で書いたほうがいい、ということは別です。
まず1シートだけ、開き直して数える
移行の判断を、設計論から始める必要はないと思います。いま =GEMINI() 系の数式が入っているシートを1枚選んで、上のように console.log を1行足し、1週間ふつうに使ってください。Apps Script ダッシュボードの実行数と、シートに入っている数式の数を並べてみる。その2つの数字が離れているほど、値で書き戻す設計に移す理由がはっきりします。
私自身、この構成のまま半年ほど回していて、増えていたのはコストだけでなく、何が起きているか分からない時間のほうでした。数えてみて、ようやく手が動きました。お読みいただきありがとうございました。