速水拓真 営業×AI 実践シリーズ
書き下ろし

GAS×AI — スプレッドシートに自作AI関数を実装する(=AI_TRANSLATE と30秒制限の壁)

書き下ろし(当サイト初出)

はじめに

スプレッドシートでAIを使うとき、多くの人は「メニューから実行するバッチ処理」を思い浮かべます。しかしGASにはもう一つ、セルに数式を入力するだけでAIが動く仕組みがあります。カスタム関数です。

=AI_TRANSLATE(A2, "ja", "en")

この記事では =AI_TRANSLATE() =AI_SUMMARIZE() =AI_CLASSIFY() の3つを実際に実装し、そのあとでカスタム関数固有の制約(30秒タイムアウト・再計算による課金・レート制限)と、それを踏まえた「バッチ処理との使い分け基準」まで書きます。

  • 前提: GASのスクリプトエディタが開ける / OpenAI APIキーを持っている(Gemini APIでも構造は同じです)
  • ゴール: セルに関数を打ち込むとAI処理が返る状態、かつ運用で破綻しない設計

カスタム関数の最小形

カスタム関数は特別な登録作業が要りません。GASのプロジェクトに関数を定義するだけで、その名前がセルから呼べます。

/**
 * 最小のカスタム関数
 * セルに =DOUBLE(5) と入力すると 10 が返る
 */
function DOUBLE(value) {
  return value * 2;
}

@customfunction のJSDocタグを付けておくと、セルで関数名を打ち始めたときに入力補助(引数の説明)が出ます。動作には影響しませんが、他人と共有するシートでは付けておく価値があります。

この return の中身をAPI呼び出しに置き換えれば、それがそのままAI関数になります。

共通部分: APIキーとAPI呼び出し

3つの関数で同じ処理を書かないよう、先に共通ヘルパーを用意します。キーは PropertiesService に置き、コードに直書きしません

const PROPS = PropertiesService.getScriptProperties();

/**
 * ChatGPT APIを1回呼んで本文テキストだけ返す
 * @param {string} systemPrompt 役割・制約を書いたシステムプロンプト
 * @param {string} userText 処理対象テキスト
 * @param {number} temperature 0.0〜1.0(翻訳0.2 / 分類0.0 のように用途で変える)
 */
function callChatGPT(systemPrompt, userText, temperature) {
  const apiKey = PROPS.getProperty('OPENAI_API_KEY');
  if (!apiKey) return 'エラー: APIキーが未設定';

  const res = UrlFetchApp.fetch(
    'https://api.openai.com/v1/chat/completions',
    {
      method: 'post',
      contentType: 'application/json',
      headers: { Authorization: 'Bearer ' + apiKey },
      payload: JSON.stringify({
        model: 'gpt-4o-mini',
        messages: [
          { role: 'system', content: systemPrompt },
          { role: 'user', content: userText }
        ],
        temperature: temperature
      }),
      muteHttpExceptions: true
    }
  );

  const data = JSON.parse(res.getContentText());
  if (data.error) return 'エラー: ' + data.error.message;
  return data.choices[0].message.content.trim();
}

muteHttpExceptions: true を付けないと、429や500が例外として飛んできてセルには #ERROR! しか残りません。原因をセルに文字で出すために必ず付けます。

=AI_TRANSLATE — 翻訳関数

/**
 * AI翻訳
 * @param {string} text 翻訳対象
 * @param {string} sourceLang 元の言語コード
 * @param {string} targetLang 翻訳先の言語コード
 * @return {string} 翻訳結果
 * @customfunction
 */
function AI_TRANSLATE(text, sourceLang, targetLang) {
  if (!text || String(text).trim() === '') return '';

  const system = [
    'あなたはプロの翻訳者です。',
    '入力テキストを' + sourceLang + 'から' + targetLang + 'に翻訳してください。',
    '翻訳結果のみを出力し、説明や注釈は付けないでください。',
    '専門用語は一般的な表現に置き換えず、原文の意味を正確に保ってください。'
  ].join('\n');

  return callChatGPT(system, String(text), 0.2);
}

temperature: 0.2 は翻訳のブレを抑えるためです。翻訳は「毎回同じ結果」が正義なので低めに固定します。セルに =AI_TRANSLATE(A2, "ja", "en") と入力すれば、A2の日本語が英語になって返ります。

=AI_SUMMARIZE — 要約関数

/**
 * AI要約
 * @param {string} text 要約対象
 * @param {number} targetLength 目標文字数(省略時は100)
 * @return {string} 要約結果
 * @customfunction
 */
function AI_SUMMARIZE(text, targetLength) {
  if (!text || String(text).trim() === '') return '';
  const len = targetLength || 100;

  const system = [
    'あなたは要約の専門家です。',
    '入力テキストを' + len + '文字以内に要約してください。',
    '元の文の主張・数値・固有名詞は落とさないでください。',
    '要約のみを出力してください。'
  ].join('\n');

  return callChatGPT(system, String(text), 0.3);
}

議事録のセルに =AI_SUMMARIZE(A2, 200) と入れておけば、長文が200文字に圧縮されます。「まず概要だけ読んで、必要なら原文へ」という読み方に切り替えられます。

=AI_CLASSIFY — 分類関数

分類で最も効くのは候補を閉じた集合として渡すことです。自由記述で答えさせると表記ゆれ(「要望」と「ご要望」と「要望事項」)が発生し、ピボット集計が壊れます。

/**
 * AI分類(候補の中から1つ選ばせる)
 * @param {string} text 分類対象
 * @param {string} categories カンマ区切りのカテゴリ一覧
 * @return {string} カテゴリ名
 * @customfunction
 */
function AI_CLASSIFY(text, categories) {
  if (!text || String(text).trim() === '') return '';
  if (!categories || String(categories).trim() === '') return 'エラー: カテゴリ未指定';

  const list = String(categories).split(',')
    .map(function (c) { return c.trim(); })
    .filter(function (c) { return c !== ''; });

  const system = [
    'あなたはテキスト分類の専門家です。',
    '入力テキストを次のいずれかに分類してください: ' + list.join(', '),
    'ルール:',
    '1. カテゴリ名のみを出力する',
    '2. どれにも該当しない場合は「その他」と出力する',
    '3. 複数該当する場合は最も適切な1つを選ぶ'
  ].join('\n');

  return callChatGPT(system, String(text), 0.0);
}

temperature: 0.0 にすると、同じ入力に対して毎回同じカテゴリが返りやすくなります。分類タスクでは再現性が最優先です。=AI_CLASSIFY(B2, "質問,要望,バグ報告,称賛,クレーム") のように使います。

カスタム関数固有の5つの制約

ここからが本題です。カスタム関数は便利ですが、通常のGAS関数とは別物だと思ったほうが安全です。

1. 実行時間は30秒で打ち切られる

通常のGASは6分まで動きますが、カスタム関数は30秒です。1セルあたりのAPI呼び出しが2〜5秒なので通常は収まりますが、1つの関数の中で複数回APIを呼ぶ設計にすると簡単に超えます。1関数1リクエストを守ります。

2. セルを編集するたびにAPIが呼ばれる

=AI_TRANSLATE(A2, "ja", "en") は、A2を編集するたびに翻訳APIを呼び直します。シート全体の再計算でも走ることがあり、気づかないうちに課金が積み上がります

3. 一斉計算でレート制限に当たる

数十セルに一斉にAI関数を貼ると、ほぼ同時にリクエストが飛びます。レート制限に当たり、セルに エラー: Rate limit... が並びます。「動かない」のではなく「同時に投げすぎている」だけなので、後述の「値で固定」で解決します。

4. 使えないサービスがある

カスタム関数のコンテキストでは、SpreadsheetApp.getActiveSpreadsheet() など一部のサービスが制限されます。本記事の実装は PropertiesServiceUrlFetchApp だけで完結させているので安全です。

5. 結果がキャッシュされる

プロンプトを直しても、参照セルを編集しない限り古い結果が表示され続けることがあります。該当セルをダブルクリックしてEnter、もしくはシートを再読み込みすると更新されます。「プロンプトを変えたのに変わらない」ときはまずこれを疑ってください。

対策: 結果を「値」で固定する

制約1〜3の対策はシンプルで、確認が済んだセルは数式を値に置き換えることです。手作業の「値のみ貼り付け」でもいいのですが、列が増えると漏れます。一括で固定するヘルパーを置きます。

/**
 * シート上の =AI_ で始まる数式を、現在の値で上書きして固定する
 */
function freezeAIFunctions() {
  const sheet = SpreadsheetApp.getActiveSpreadsheet().getActiveSheet();
  const range = sheet.getDataRange();
  const formulas = range.getFormulas();
  const values = range.getValues();

  for (let r = 0; r < formulas.length; r++) {
    for (let c = 0; c < formulas[r].length; c++) {
      if (formulas[r][c].indexOf('=AI_') === 0) {
        formulas[r][c] = values[r][c];
      }
    }
  }
  range.setValues(formulas);
}

これを実行すると =AI_* の数式が結果の値に置き換わり、以降の再計算でAPIは呼ばれません。「AIに書かせて、確認したら固定する」という運用にすると、コストが読める形になります。

カスタム関数 vs バッチ処理 — 50件が分岐点

同じ「AIで一括処理」でも向き不向きがあります。

カスタム関数 バッチ処理(メニュー / トリガー)
実行のきっかけ セル入力・再計算 メニュー実行・時間主導トリガー
実行時間の上限 30秒 6分(分割実行で延長可)
件数の目安 〜50件 50件〜数千件
向く用途 対話的な確認、引数を変えながら試行 定期的な一括処理、ログ管理
失敗したとき セルにエラー文字列 ログ・再実行カーソルで復帰

実務での目安は50件です。50件を超えると、再計算のたびに50回のAPI呼び出しが走る構造になり、コストと時間の両面で不利になります。逆に、翻訳先の言語を変えながら数件を眺めるような作業はカスタム関数の独壇場です。

すべての関数をまとめて登録する

3つの関数はそれぞれ独立していますが、onOpen でメニューにまとめておくと、シートを開いた人全員が「何ができるか」を把握できます。

function onOpen() {
  SpreadsheetApp.getUi()
    .createMenu('AI関数')
    .addItem('AI関数の結果を値で固定', 'freezeAIFunctions')
    .addToUi();
}

カスタム関数そのものはセルから呼ぶものなので、メニューに置くのは「補助操作」だけにします。関数の一覧をメニューに並べても、セルから打つ手間は変わりません。

まとめ

  • カスタム関数は「定義するだけ」でセルから呼べる。APIを呼べば =AI_XXX() が作れる
  • 共通ヘルパーに寄せ、muteHttpExceptions: trueエラーをセルに文字で出す
  • 翻訳は0.2、要約は0.3、分類は0.0 — 用途で temperature を変える
  • 分類は候補を閉じた集合で渡す。自由記述にしない
  • 30秒制限・再計算課金・レート制限は設計で回避する。1関数1リクエスト、確認後は値で固定
  • 50件以下ならカスタム関数、それ以上はバッチ処理

「AIを業務に組み込む」と聞くと大げさに感じますが、実態はこの程度の関数が3つあれば始まります。大事なのは機能を増やすことではなく、どこで人間が確定させるかを最初に決めておくことです。

読者特典(無料)

本記事のような実装パターンを横断的にまとめたチートシートと、Obsidian・Notionのテンプレート集を無料配布しています。8冊の内容から「何をどのツールでやるか」の判断チャートも含みます。

🎁 読者特典を受け取る(無料・メール登録)

📗 Google Apps Script × AI 実践入門 — スプレッドシートで動かすAIワークフロー(Kindle・読み放題対象)

この記事で作った3つのカスタム関数は、書籍では「経理・問い合わせ・議事録」の3業務フローに組み込むところまで踏み込んで解説しています。APIキーの管理とコスト上限の設計も収録しています。Kindle Unlimited会員は読み放題で読めます。


著者: 葉山悠希 — 書籍シリーズは Zenn / Amazon で公開中

営業×AI実践シリーズ 全3作

Kindle Unlimited 読み放題対象 — 追加料金なしで読めます

← 記事一覧へ

← 想定質問を100個もらっても、商談で言葉に詰まる…商談で読み上げた数字が、ひとつだけ古かった話… →