GASでClaude APIの
ストリーミングは使える?

チャットAIのように文字が少しずつ出てくる仕組みを、GASでも作りたい。 結論から言うと、GAS単体では回答を途中から受け取れません。ただし「待たされている感じ」は設計で大きく減らせます。できないことの理由と、実務で効く代わりの手を4つ紹介します。

|対象: GAS / Claude API / UrlFetchApp

Table of Contents

ストリーミングが欲しくなる場面

ストリーミング(生成された文字を、完成を待たずに少しずつ受け取る仕組み)が欲しくなるのは、 たいてい次のような場面です。

社内チャットツールを作った

送信してから20秒間なにも起きず、ユーザーが壊れたと思って何度も押す

長文の要約や記事生成

出力が長いほど待ちも長い。完成まで画面が固まって見える

シートの一括処理

100行を順番に処理すると、進捗が分からないまま数分が過ぎる

サイドバーからAIを呼ぶ

作業を止めて待つことになり、結局使われなくなる

どれも本質は同じで、「時間がかかること」ではなく「進んでいるか分からないこと」が問題です。ここを分けて考えると、GASでも打てる手が見えてきます。

UrlFetchAppは途中経過を受け取れない

GASから外部APIを呼ぶ手段はUrlFetchApp.fetch()です。この関数はレスポンスがすべて届いてから戻ってくる作りになっていて、受信中のデータを少しずつ読むための入り口が用意されていません。 返り値のHTTPResponseから取り出せるのは、完成後の本文だけです。

ブラウザのJavaScriptならfetch()のレスポンスを少しずつ読み進められますが、GASのサーバー側コードにその仕組みはありません。 つまり、1回のAPI呼び出しの中で逐次表示を作ることは、GASでは不可能です。ここは工夫でどうにかなる部分ではないので、先に諦めて別の設計に切り替えたほうが早く終わります。

補足: HtmlServiceの画面から直接APIを呼ぶのは避ける

画面側(ブラウザ)のJavaScriptから直接Claude APIを叩けば逐次表示はできますが、 APIキーをブラウザに渡すことになり、開発者ツールから誰でも読めてしまいます。 社内利用でも避けてください。キーはサーバー側(GAS)に置いたままにするのが原則です。

stream: true を送ると何が返るか

Claude APIにはstream: trueというパラメータがあります。GASから指定してもエラーにはなりません。 ただし返ってくるのは、SSE(Server-Sent Events。サーバーが小さなイベントを連続で送る形式)のテキストが丸ごと1つの文字列になったものです。生成が終わってから届くので、待ち時間は変わりません。

const ENDPOINT = "https://api.anthropic.com/v1/messages";

function callClaudeStream(prompt) {
  const key = PropertiesService.getScriptProperties().getProperty("ANTHROPIC_API_KEY");

  const res = UrlFetchApp.fetch(ENDPOINT, {
    method: "post",
    contentType: "application/json",
    headers: { "x-api-key": key, "anthropic-version": "2023-06-01" },
    payload: JSON.stringify({
      model: "claude-sonnet-5",
      max_tokens: 1024,
      stream: true, // 指定はできる。ただし途中経過は受け取れない
      messages: [{ role: "user", content: prompt }],
    }),
    muteHttpExceptions: true,
  });

  // ここに到達した時点で、生成はすべて終わっている。
  // res.getContentText() には SSE 形式のテキストが丸ごと入っている
  return extractTextFromSse(res.getContentText());
}

// SSEのテキストから、本文の断片だけをつなぎ直す
function extractTextFromSse(body) {
  let text = "";
  body.split("\n").forEach(function (line) {
    if (line.indexOf("data: ") !== 0) return; // "event: ..." 行や空行は捨てる
    let ev;
    try {
      ev = JSON.parse(line.slice(6));
    } catch (e) {
      return; // 途中で切れた行は無視する
    }
    if (ev.type === "content_block_delta" && ev.delta && ev.delta.type === "text_delta") {
      text += ev.delta.text;
    }
  });
  return text;
}

本文はcontent_block_deltaというイベントに小分けで入っているので、上のようにつなぎ直せば取り出せます。 このほかにmessage_startcontent_block_startmessage_stopといったイベントが並びます。

つまりGASでstreamを使う意味は、ほぼありません。パースの手間が増えるだけです。 素直に指定なしで呼び、content配列からテキストを取り出す形にしてください。

対策1: 待ち時間そのものを削る

逐次表示ができないなら、まず待ち時間を短くします。 AIの生成時間は出力の長さにほぼ比例するので、効くのは出力を絞ることです。

// GASでは、こちらのほうがシンプルで速い
function callClaude(prompt, options) {
  const opt = options || {};
  const key = PropertiesService.getScriptProperties().getProperty("ANTHROPIC_API_KEY");

  const res = UrlFetchApp.fetch(ENDPOINT, {
    method: "post",
    contentType: "application/json",
    headers: { "x-api-key": key, "anthropic-version": "2023-06-01" },
    payload: JSON.stringify({
      // 短い出力なら軽量モデルで十分。体感が最も変わるのはここ
      model: opt.model || "claude-haiku-4-5",
      // 生成時間は出力の長さにほぼ比例する。上限は必要な分だけにする
      max_tokens: opt.maxTokens || 300,
      system: "出力は本文のみ。前置き・言い訳・繰り返しは書かない。",
      messages: [{ role: "user", content: prompt }],
    }),
    muteHttpExceptions: true,
  });

  if (res.getResponseCode() !== 200) {
    throw new Error("Claude API エラー(" + res.getResponseCode() + "): " + res.getContentText().slice(0, 300));
  }
  const json = JSON.parse(res.getContentText());
  return json.content.map(function (b) { return b.text || ""; }).join("");
}

max_tokensを必要な分に絞る

出力の上限トークン数。4096のまま放置せず、用途に合った値へ。分類なら数十で足りる

プロンプトでも長さを指定する

max_tokensは途中で打ち切る設定。「200字以内で」と指示しないと文が切れる

軽いモデルに替える

分類・抽出・整形なら claude-haiku-4-5 で十分なことが多い

前置きを禁止する

systemで「本文のみ」と決めるだけで、無駄な出力が消えて速くなる

判断の難しい処理まで軽量モデルに寄せると精度が落ちます。まず用途を「速さ優先」と「精度優先」に分けるのが先で、モデル選択はその結果です。速さ優先の処理ではclaude-haiku-4-5、精度が要る処理ではclaude-sonnet-5claude-opus-5、という置き方が扱いやすいです。

なお複数件をまとめて処理する場合は、UrlFetchApp.fetchAll()で並列に投げると全体の待ちが大きく縮みます。10件を順番に呼べば10回分待ちますが、まとめて投げれば一番遅い1件分で済みます。

対策2: 出力を分割して1件ずつ書き込む

1回のAPI呼び出しの中では逐次表示できませんが、呼び出しの回数を増やせば、届いた順に画面へ反映できます。5つの段落を1回でまとめて作らせる代わりに、1段落ずつ5回呼ぶ形です。

// 5項目を1回で作らせると長く待つ。1項目ずつ呼んで、届いた順に書き込む
function generateSectionsToSheet() {
  const sheet = SpreadsheetApp.getActive().getSheetByName("原稿");
  const theme = sheet.getRange("B1").getValue();

  const sections = ["導入", "課題", "解決策", "導入手順", "まとめ"];

  sections.forEach(function (name, i) {
    const row = i + 3;
    sheet.getRange(row, 1).setValue(name);
    sheet.getRange(row, 3).setValue("生成中...");
    SpreadsheetApp.flush(); // ここで画面に即反映される

    const text = callClaude(
      "テーマ「" + theme + "」について、「" + name + "」の段落を200字以内で書いてください。",
      { model: "claude-sonnet-5", maxTokens: 400 }
    );

    sheet.getRange(row, 2).setValue(text);
    sheet.getRange(row, 3).setValue("完了");
    SpreadsheetApp.flush();
  });
}

ポイントはSpreadsheetApp.flush()です。GASはシートへの書き込みをまとめて後で反映する仕組みのため、これを呼ばないと最後に全部が一気に出ます。 1件書くたびにflushすると、埋まっていく様子がその場で見えます。

合計の処理時間は、分割したほうがわずかに増えます。それでも「20秒無言」より「4秒ごとに1つ埋まる」ほうが体感は圧倒的に短いです。逆に、通常の一括読み書きではflush()を毎回呼ぶと遅くなるので、進捗を見せたい場面に限って使ってください。

対策3: トリガーで非同期にする

件数が多いときは、そもそもユーザーを待たせないのが正解です。 ボタンを押したら「受け付けました」とだけ返し、本体の処理はトリガー(指定した条件で自動実行する仕組み)に任せます。

const STATUS_COL = 3; // C列: 未処理 / 処理中 / 完了 / 失敗

// 画面から呼ぶのはこれだけ。すぐ返るのでユーザーを待たせない
function enqueueJob() {
  const sheet = SpreadsheetApp.getActive().getSheetByName("依頼");
  const last = sheet.getLastRow();
  if (last >= 2) {
    sheet.getRange(2, STATUS_COL, last - 1, 1).setValue("未処理");
  }
  createRunnerTrigger();
  return { queued: Math.max(last - 1, 0) };
}

function createRunnerTrigger() {
  // 同じトリガーが重複しないよう、既存を消してから作る
  ScriptApp.getProjectTriggers().forEach(function (t) {
    if (t.getHandlerFunction() === "runQueue") ScriptApp.deleteTrigger(t);
  });
  ScriptApp.newTrigger("runQueue").timeBased().after(1000).create();
}

// トリガーから呼ばれる本体。実行時間の上限に当たる前に自分で切り上げる
function runQueue() {
  const started = Date.now();
  const sheet = SpreadsheetApp.getActive().getSheetByName("依頼");
  const values = sheet.getDataRange().getValues();

  for (let i = 1; i < values.length; i++) {
    if (values[i][STATUS_COL - 1] !== "未処理") continue;

    // 4分を超えたら残りは次のトリガーに任せる(上限は6分)
    if (Date.now() - started > 4 * 60 * 1000) {
      createRunnerTrigger();
      return;
    }

    const row = i + 1;
    sheet.getRange(row, STATUS_COL).setValue("処理中");
    SpreadsheetApp.flush();

    try {
      const answer = callClaude(String(values[i][0]), { maxTokens: 500 });
      sheet.getRange(row, 2).setValue(answer);
      sheet.getRange(row, STATUS_COL).setValue("完了");
    } catch (e) {
      sheet.getRange(row, STATUS_COL).setValue("失敗: " + e.message);
    }
  }
}

状態列を持たせるのが肝です。未処理・処理中・完了・失敗が分かれば、途中で止まっても続きから再開できますし、失敗した行だけ後で流し直せます。 あわせて、経過時間を見て4分で自分から切り上げ、次のトリガーを作り直しています。 GASのスクリプト実行時間には上限(無料アカウントで6分)があるため、これがないと長い処理は必ず途中で落ちます。

なおafter(1000)で作る時間ベースのトリガーは、指定した時刻ちょうどではなく多少ずれて実行されます。 秒単位の正確さが要る処理には向きません。

対策4: 画面には進捗だけを出す

非同期にすると、今度は「終わったのか分からない」という別の問題が出ます。 そこで画面側から数秒おきに進捗だけを問い合わせます。処理そのものは裏で進んでいるので、画面は軽いままです。

// --- サーバー側(.gs)---
function getProgress() {
  const values = SpreadsheetApp.getActive()
    .getSheetByName("依頼")
    .getDataRange()
    .getValues();

  let done = 0;
  let total = 0;
  for (let i = 1; i < values.length; i++) {
    if (!values[i][0]) continue;
    total++;
    if (String(values[i][STATUS_COL - 1]).indexOf("完了") === 0) done++;
  }
  return { done: done, total: total };
}

// --- 画面側(HTMLファイル内の script タグ)---
// function start() {
//   google.script.run.withSuccessHandler(poll).enqueueJob();
// }
//
// function poll() {
//   google.script.run
//     .withSuccessHandler(function (p) {
//       document.getElementById("bar").textContent = p.done + " / " + p.total;
//       if (p.done < p.total) setTimeout(poll, 3000); // 3秒おきに見に行く
//     })
//     .getProgress();
// }

google.script.runは、HtmlServiceの画面からサーバー側の関数を呼ぶ仕組みです。 進捗を返す関数はシートを読むだけの軽い処理に留めるのがコツで、ここでAPIを呼ぶと本末転倒になります。

問い合わせ間隔は3〜5秒で十分です。1秒おきにすると、進捗確認そのものがシートへのアクセス上限を圧迫します。 件数が多い場合は「20/100件 完了」のように数字で見せると、残り時間の見当も付いて安心感が出ます。

まとめ

GASのUrlFetchAppはレスポンスを分割して受け取れないため、1回のAPI呼び出しで逐次表示を作ることはできません。stream: trueを指定しても、SSE形式のテキストが最後にまとめて届くだけです。

代わりに効くのは4つです。max_tokensとモデルで待ち時間そのものを削る、出力を分割して1件ずつflushしながら書き込む、重い処理はトリガーで非同期にする、画面には進捗だけを出す。 このうち最初の2つは数行の修正で入るので、まずそこから試してください。 「速くする」より「待たせない」に発想を切り替えると、GASでも実用的な使い心地になります。

よくある質問

サーバー側(GAS)で1文字ずつ受け取ることはできません。UrlFetchAppはレスポンスが完成してから結果を返す仕組みで、途中経過を読む手段がないためです。ただし「AIの回答を短く区切って複数回に分けて呼び、届いた順に画面へ追記する」という作り方なら、近い体験は作れます。1リクエストの中で逐次表示することはできない、と理解してください。

エラーにはなりません。リクエスト自体は成功し、SSE(Server-Sent Events)形式のテキストがまとめて1つの文字列として返ってきます。つまり通信量と処理は増えるのに、体感速度は変わりません。逐次表示を自前で作る必要がないなら、GASではstreamを指定しないほうがシンプルです。

2種類の上限が関係します。1つはUrlFetchApp1回あたりの応答待ち時間、もう1つはスクリプト全体の実行時間(無料アカウントで6分)です。長文生成を何十件も回すと後者に先に当たります。max_tokensを必要な分だけに絞る、1回の実行で処理する件数を区切る、続きは次のトリガーに回す、という設計にしてください。

max_tokens(出力の上限トークン数)とモデル選択の2つです。生成時間は出力の長さにほぼ比例するため、「200字以内で」と指示したうえでmax_tokensも合わせて絞ると効果が大きく出ます。分類や抽出のような短い出力の用途なら、軽量なclaude-haiku-4-5に替えるだけで体感が変わります。

UrlFetchApp.fetchAll()で複数リクエストを並列送信できます。10件を順番に呼ぶと10回分待ちますが、まとめて投げれば一番遅い1件分の待ちで済みます。ただし同時に投げすぎるとレート制限(429エラー)を踏むので、5〜10件ずつのかたまりに分けるのが現実的です。

重い処理はトリガーで裏に回し、画面側は進捗だけを見に行く形にします。スプレッドシートに「未処理/処理中/完了」の状態列を持たせ、HtmlServiceの画面から数秒おきに件数を問い合わせれば、待たせずに進捗を表示できます。この記事の後半に実装例を載せています。

待たせないAIツールを
相談する。

AIを組み込んだ社内ツールの設計から、非同期処理や進捗表示を含む使い心地の作り込みまでご相談いただけます。

AI×GAS自動化サービスを見る