🤲

Google Apps ScriptでプロンプトのABテストツールを作ってみた

に公開

結論を3行で

  • ある時プロンプトを編集したところ、Grok APIからのレスポンスがあまりにも遅くなって困った。その過程で、プロンプトをテストする仕組みを作ろうと思った
  • 同じプロンプトでモデルを変えたり、モデルを揃えたままでプロンプトだけ微妙に変えるABテストツールとして作ってみた
  • 推論モデル(grok-3-mini-beta)は具体的で、定量性があって、背景情報もちゃんと拾っていて、納得感がある提案が返ってくるなぁと実感。

事の経緯

筆者はLLM関連やAIを使った業務効率化みたいな学習を始める時に

「日誌をつけよう」

と思い立ち、スプレッドシートとGoogle Apps Script(GAS)で簡単な記録ツールを作りました。

日誌では「今日の取り組み内容(事実)」と「思考の火種(取り組みの中での感情の揺らぎ)」を記載しており、それをAI(現在はGrok 3 mini)に送信すると、イーロン・マスク風のGrokがいろいろ煽ってくる有益なコメントをくれる、というスタイルでやってます。

ある時、プロンプトを調整したところ、Grokの応答がめちゃくちゃ遅くなり、GASの時間制限(360秒)を超えてしまうという問題が生じました。

「もしかして、プロンプトをこねこねいじったせい?もう前のプロンプト忘れちゃったし、そのあたり感覚的だとダメだなぁ(※翌朝やり直したら20秒で返ってきたため、たぶんサーバー側の都合)」

と思い、さくっと作った(作ってもらった)のがプロンプトのABテストツールです。

「とりあえず試しに」ってことでスプレッドシートとGoogle Apps Scriptで作りました

スプレッドシートの構成とソースコード

スプレッドシートの構成

シートの構成は以下の通り。A列からH列まであり、A~D列がユーザーが事前に入力、E~H列がAIが処理に伴って入力するエリアです。

ユーザーがテスト前に入力(A~D列)

  • A列:番号
  • B列:テスト実行日
  • C列:テストの名前・目的
  • D列:テスト対象のモデル

AIが実行中に入力(E~H列)

  • E列:ファイル名
  • F列:AIの応答
  • G列:トークン数
  • H列:処理時間(秒)

テスト対象のモデルはOpenAIのフォーマットに合わせており、現状ではChatGPT APIとGrok APIの一部のモデルに使えます(Geminiはメッセージのフォーマットが微妙に異なるため使えません)。セルのモデル名を取得し、そのまま送信メッセージに含める感じです。プロンプトを固定して、モデル名だけ交互に変更すれば、モデル別にどんな返信が得られるか比較できる仕組みです。

ファイル名はグーグルドライブの指定したフォルダ(IDで指定)から、prompt_A.mdとprompt_B.mdを交互に読み込みます。モデル名を固定しておけば、プロンプトの違いで出力がどう変わるかを比較できる仕組みです。また、ファイル自体をバージョン管理しておけば、GASやスプシに直接書くよりも管理しやすいかなぁと思い、すべてmarkdownから読み出す形式にしました。

A列が記載済みで、E列が未記載の行に対して処理を行います。GASは6分の時間制限があるため、多くてもせいぜい10回分ぐらいで使うのが現実的です。

ソースコード

ソースコード

// プロンプトファイルの保存先
const FOLDER_ID = 'GOOGLE_DRIVE_FOLDER_ID';

// reasoningモデル用の追加パラメータ
const MODEL_CONFIG = {
  "grok-3-mini-beta": { reasoning_effort: "high" },
  "grok-3-mini-fast-beta": { reasoning_effort: "high" },
  "gpt-4.1-nano": {},
  "gpt-4o-mini": {}, 
  "gpt-4": {},
  "gpt-3.5-turbo": {}
};

// onOpenでカスタムメニューを追加
function onOpen() {
  SpreadsheetApp.getUi()
    .createMenu('Prompt Test')
    .addItem('Run Tests', 'runPromptTest')
    .addToUi();
}

// 指定したモデルに合わせてURLを取得する
function getApiURL(model) {
  if (model.includes("grok")) {
    return "https://api.x.ai/v1/chat/completions";
  }
  else {
    return "https://api.openai.com/v1/chat/completions";
  }
}

// 指定したモデルに合わせてAPIキーを取得する
function getApiKey(model) {
  if (model.includes("grok")) {
    return PropertiesService.getScriptProperties().getProperty('XAI_GROK_KEY');
  }
  else {
    return PropertiesService.getScriptProperties().getProperty('OPENAI_API_KEY');
  }
}

// 指定列の最終行を取得するユーティリティ関数
function getLastRowInColumn(sheet, colIndex) {
  const values = sheet.getRange(1, colIndex, sheet.getMaxRows()).getValues();
  for (let i = values.length - 1; i >= 0; i--) {
    if (values[i][0] !== '') return i + 1;
  }
  return 0;
}

// メイン処理: A~D列に事前記入されたテスト条件をもとに、E列以降を追記
function runPromptTest() {

  // スプレッドシートとグーグルドライブを取得
  const sheet = SpreadsheetApp.getActiveSpreadsheet().getActiveSheet();
  const folder = DriveApp.getFolderById(FOLDER_ID);

  // A列とE列の最終行を取得
  const lastRowA = getLastRowInColumn(sheet, 1);
  const lastRowE = getLastRowInColumn(sheet, 5);

  // E列がA列より前なら、未実行行があるのでテスト実行
  for (let row = lastRowE + 1; row <= lastRowA; row++) {
    // ABテスト用ファイル名を交互に設定
    const fileName = (row % 2 === 1) ? 'prompt_A.md' : 'prompt_B.md';
    // フォルダから対象ファイルを取得
    const files = folder.getFilesByName(fileName);
    // ファイルからプロンプトを取得
    let promptText = '';
    if (files.hasNext()) {
      promptText = files.next().getBlob().getDataAsString();
    } 
    else {
      promptText = `Error: ${fileName} not found.`;
    }

    // 時間測定開始
    const startTime = new Date();
    // D列のモデル名を取得して渡す
    const model = sheet.getRange(row, 4).getValue();
    // APIキーとAPIURLを取得する
    const apiUrl = getApiURL(model);
    const apiKey = getApiKey(model);
    // ChatGPTに送信
    const result = callChatGPT(promptText, apiKey, model, apiUrl);
    // 時間測定終了
    const endTime = new Date();

    // E列: ファイル名
    sheet.getRange(row, 5).setValue(fileName);
    // F列: AI応答
    sheet.getRange(row, 6).setValue(result.content);
    // G列: 総トークン数
    sheet.getRange(row, 7).setValue(result.totalTokens);
    // H列: 処理時間(秒)
    sheet.getRange(row, 8).setValue((endTime - startTime) / 1000);

    // 制限回避用ウェイト
    Utilities.sleep(2000);
  }
}

// ChatGPTとの送受信関数(モデル指定対応)
function callChatGPT(prompt, apiKey, model, apiurl) {
  const url = apiurl;
  const payload = {
    model: model,
    messages: [
      { role: 'system', content: 'You are a helpful assistant.' },
      { role: 'user', content: prompt }
    ],
    ...MODEL_CONFIG[model] // 推論モデルの場合パラメータを追加
  };
  const options = {
    method: 'post',
    contentType: 'application/json',
    headers: { Authorization: 'Bearer ' + apiKey },
    payload: JSON.stringify(payload),
    muteHttpExceptions: true,
  };
  const res = UrlFetchApp.fetch(url, options);
  if (res.getResponseCode() === 200) {
    const data = JSON.parse(res.getContentText());
    const content = data.choices[0].message.content.trim();
    const tokens = data.usage ? data.usage.total_tokens : 0;
    return { content: content, totalTokens: tokens };
  } else {
    return { content: `Error (${res.getResponseCode()}): ${res.getContentText()}`, totalTokens: 0 };
  }
}

細かいエラー処理などは行っていません。

GOOGLE_DRIVE_FOLDER_IDはprompt_A.mdとprompt_B.mdを置いているフォルダのIDを指定します。また、APIキーはPropertiesService.getScriptProperties().getProperty('XAI_GROK_KEY')でスクリプトプロパティから呼び出しています。

・・・安価なモデルしか使っておらず、MODEL_CONFIGに記載したモデルも安価なものばかりです。もし、本ソースコードを使う際に、ChatGPTとGrokの別モデルを追加したいときは、MODEL_CONFIGにもモデル名を追加してください。

実際試してみた

今回は安価にAPIを使えるモデルとして、以下の3モデルで試してみました。

  • gpt-4.1-nano
  • gpt-4o-mini
  • grok-3-mini-beta

評価軸の設定に悩むものの、今回はGoogle AI StudioでGemini 2.5 Flash Preview 04-17に正確性、字数制限、定量性、実現難易度、背景情報との関連性といった項目で評価してもらいました。

プロンプトは以下の流れで作ったものをグーグルドライブに保存しています。

  • タスク
  • 背景情報
  • 今日の取り組み内容
  • 改めてタスク

具体的には「GASでプロンプトをいじったらAIからのレスポンスがタイムアウトして困った」って話をあれこれこねこねして3000トークンぐらいにして送っています。

プロンプト詳細

※以下は、普段スプレッドシートから読みだす形で使ってるプロンプトです。この変数部分にいろいろ情報が書きこまれてると思ってください。
※記載の都合上、見出し、小見出しとしています。

プロジェクトの効率化手段を3つ考え、1つあたり最大300文字で提案せよ

見出し:Context
以下は、分析・提案の対象となるプロジェクトの背景情報

小見出し:プロジェクトの概要
{{projectOverview}}

小見出し:プロジェクトの目標
{{projectTarget}}

小見出し:プロジェクトで使っているツール(クラウド/SaaS/アプリ等)
{{projectTool}}

小見出し:プロジェクトの制約条件(技術的・コスト・人的など)
{{projectLimit}}

小見出し:追加情報
以下は、業務上の追加情報や活用ツール・ユーザーのスキルなどをまとめたもの
{{retrievalContext}}

見出し:Today Task
以下は、今日の業務内容

小見出し:今日の進捗
{{progressSummary}}

小見出し:手作業の内容
{{progressManualTask}}

見出し:回答のフォーマット
各300文字以内で日本語で回答せよ。

以下はGeminiの評価例を抜粋したものです。

gpt-4.1-nanoへの評価

  • 総評:API連携、プロンプト管理、AIエージェント間の連携といった、やや技術的で特定のシステムやワークフローに特化した効率化に焦点を当てています。
  • 良い点:特定の技術的な課題(プロンプトのチューニングやAPI応答の安定化など)に対する具体的なアプローチを示しています。
  • 悪い点:工数削減効果が「半減」「数分に短縮」「大幅に短縮」と、具体的な時間や削減率の提示が少ないため、効果の大きさを比較しにくいです。「エージェント間連携」など、やや抽象的な概念が含まれており、具体的な実装イメージが湧きにくい場合があります。

gpt-4o-miniへの評価

  • 総評:日報作成やタスク進捗管理といった、より普遍的で多くのプロジェクトやチームに共通する業務効率化に焦点を当てています。
  • 良い点:解決しようとしている課題(日報の手入力、データ転記、タスク状況確認)が分かりやすいです。Googleフォーム、Googleスプレッドシート、GAS、Slack/Discordといった、比較的多くの組織で利用されているツールを活用する案であり、導入のイメージが湧きやすいです。
  • 悪い点:案1, 2, 3がそれぞれ日報、データ入力、タスク管理と、少しずつ異なる側面の効率化を提案しているため、全体として一つの包括的な効率化提案というよりは、複数の個別改善提案集といった側面があります。

grok-3-mini-beta(※推論モデル)への評価

  • 総評:プロジェクトの核となるAI(特にGrok API)とのインタラクションの効率化と安定化に強く焦点を当てています
  • 良い点:プロンプトの背景情報・今日の課題との関連性が高い: プロンプトで提示された「今日の進捗」(API遅延、手作業でのプロンプト見直し)や「ツール」(Obsidian, GAS, Grok API等)に具体的に言及しており、プロジェクトの現状と課題を深く理解した上で提案されている点が優れています。
  • 悪い点:スコープの限定性: 提案内容はAIインタラクションの効率化に特化しており、プロジェクト全体の効率化(例: 日報入力自体の効率化、情報発信への再利用プロセスなど)という観点では網羅的ではありません。

総評

プロンプトで提供された「今日の進捗」という具体的な課題(API遅延と手作業でのプロンプト調整)に最も直接的に言及し、それに対する解決策を、プロンプトが要求する形式(定量的な効果、分類、フォーマット)で提示できているという点で、grok-3-mini-betaが最も優れています。

実際、gpt-4.1-nanoの内容はどこか抽象的で、なんかすごそうなこと言ってるようで何も言ってない、みたいなレスポンスでした。助言用途には向いてないですね。

こうやって比較してみると、やっぱり推論モデルのほうが具体的で、定量性があって、背景情報もちゃんと拾っていて、納得感がある提案という感じでした。

ちなみに、gpt-4.1-nanoは平均7秒ぐらいで返信が来るのに対して、grok-3-mini-betaは平均23秒ぐらいかかります

用途に合わせてモデルを使い分ける大切さを改めて実感するのでした。

また見てね!

また、記事を書くのでフォローしてね!

Xはお金の話中心です
https://x.com/instockexnet

Discussion