サイトロゴ まいの雑記帳
【GCP破産RTA】SSGでのLLM要約機能実装の顛末

【GCP破産RTA】SSGでのLLM要約機能実装の顛末

投稿した日
2025/08/24
更新した日
2025/08/24
読了まで
6.08分で読み終われます (3,647文字)

【GCP破産RTA】SSGでのLLM要約機能実装の顛末

どうも、わたしです。

先日、このブログにLLMによる記事の要約機能を実装しました。
記事一覧のDescriptionが、単に本文の冒頭を切り取ったものではなく、Gemini 2.5 Flashが生成した要約文になる、というものです。

技術的な課題としては、microCMSの無料プランではwriteAPIが利用できず、生成した要約をCMS側に保存できないという制約をどう乗り越えるか、という点にありました。

アーキテクチャ選定と過信

最も安直なSSRでは、リクエスト毎にAPIを叩くことになり、コストとUXの観点から論外です。
そこで、ビルド時に全記事の要約を済ませておくSSGのアプローチを選択しました。
具体的には、Nuxtのビルドフックを利用して、ビルドプロセスの中で全記事のデータをmicroCMSから取得、記事IDごとに要約生成用のAPIエンドポイント (/api/summarize-article/:id) をprerenderの対象に加える、という設計です。

これなら、LLMのAPIコールはビルド時に限定され、ユーザーへのレスポンスは静的ファイルなので高速。
コストとUXの問題を両立できる、かなりいい感じでイケイケな設計だと、この時点では本気で考えていました。
夜のテンションも相まって、自分の実装に若干の陶酔すら覚えていたかもしれません。

https://github.com/chan-mai/mq1-web/commit/c21b057b369abada5554390d6e825160fcb4e38f🔗

破滅

しかし、実装から24時間も経たないうちに、GCPから予算アラートの通知が届きました。

一瞬、何かの間違いかと思いました。
APIキーの漏洩も考えましたが、クライアントサイドからの呼び出しは実装しておらず、キーの管理も適切に行っているはず。
GCPの請求ダッシュボードを確認すると、グラフが異常な角度で跳ね上がっており、明らかに何かがおかしい。

メトリクスを確認すると、APIの呼び出し時刻はビルド実行時のみ。
ここでようやく、問題が自分の実装そのものにある可能性に行き着きました。

原因は、わたしの設計にあった致命的な考慮漏れでした。
以下が問題のコードです。

/nuxt.config.ts
  nitro: {
    prerender: {
      autoSubfolderIndex: true,
      crawlLinks: false,
      routes: [],
      failOnError: false,
    }
  },
  hooks: {
    async "nitro:config"(nitroConfig) {
      if (nitroConfig.dev)  return;
      if (nitroConfig.prerender?.routes === undefined) return;
      
      const client = createClient({
        serviceDomain: process.env.MICROCMS_SERVICE_DOMAIN!,
        apiKey: process.env.MICROCMS_API_KEY!,
      });

      const [articles, tags] = await Promise.all([
        client.get({
          endpoint: 'articles',
          queries: {
            limit: 100, // <-- 問題の根源その1
            orders: "-publishedAt",
          },
        }),
        client.get({
          endpoint: 'tags',
          queries: {
            limit: 100,
            orders: "-publishedAt",
          },
        }),
      ]);

      // 記事
      const articleRoutes = articles.contents.flatMap((mount: any) => [
        `/entry/${mount.id}`,
        `/api/summarize-article/${mount.id}`, // <-- 問題の根源その2
      ]);
      // タグ
      const tagRoutes = tags.contents.map((mount: any) => `/tag/${mount.id}`);

      nitroConfig.prerender.routes = [
        ...nitroConfig.prerender.routes,
        ...articleRoutes,
        ...tagRoutes,
      ];
    },
  },

勘のいいガキは嫌いだよ…なんてセリフを言うまでもなく、聡明な読者の方ならこの実装の問題に気づいたかもしれません。

わたしは、Nuxtのビルドプロセスにフックを仕掛けて、microCMSから記事を100件まとめて取得しています。
そして、その取得した100件の記事すべてに対して、prerender.routesに要約生成APIのエンドポイント (/api/summarize-article/${mount.id}) を追加しているのです。

これが何を意味するか、わかりますか?

ビルドが走るたびに、記事の内容が1文字も変更されていなくても常に100件の記事すべてに対する要約生成リクエストがGeminiに飛ばされる。
そう、毎回、です。
ちょっとCSSを1行直しただけのコミットでも、package.jsonのライブラリを更新しただけのコミットでも、CI/CDが健気にビルドを走らせるたびに、100回、GeminiのAPIが叩かれる。

しかも、このブログの記事、一つ一つが無駄に長い。
短いものでも3,000文字、長いものだと3万文字を超えたりします。
LLMの料金は概ね入力と出力のトークン数で決まります。
大量の文字は、大量のトークンを消費する。

つまり、私は「ビルドのたびに、長文記事100個分の要約リクエストを強制的に実行する」という、GCP破産直行便みたいな処理を良かれと思って実装してしまっていたのです。
本当に、ぐえーーって感じ。
昨日の私を本気で殴りに行きたい。
なんで気づかなかったんだろう。
キャッシュ戦略がどうとか、差分だけ更新するとか、そういう当たり前の地味で大事なことをすっかり忘れていました。
成長が、ない。

恐ろしい。本当に恐ろしいです。

対処と結果

原因が特定できた以上、選択肢は一つしかありません。
このまま放置すれば、わたしのけして分厚くはないお財布は、Google神への強制的なお布施によって、数日のうちに塵と化すでしょう。
リアルに生活が終わってしまう。

LLM関連のコミットをすべてRevertし、機能を丸ごとロールバック。

副次的な効果として、ビルド時間が劇的に改善されました。
以前は律儀に100件近い記事の要約APIの応答を待っていたため、8分ほどかかっていたビルドが、機能削除後はわずか50秒で完了するようになってしまいました。

あまりの速さにもはや笑うしかありませんでした。
物理的に重たい処理がなくなったのですから当然ですが、これだけの時間をAPIとの通信に費やしていたという事実を改めて突きつけられました。

さいごに

今回のトラブルから得られた教訓は、極めてシンプルです。

  • ビルドプロセスにおける外部有料APIのコールは慎重に検討する

    ビルドは、コンテンツの更新以外にも、依存関係の更新や設定変更など、様々なトリガーで実行されます。
    そのたびに意図しないコストが発生する可能性があることを常に念頭に置くべきでした。

  • 冪等性を意識し、キャッシュ戦略を導入する

    今回のようなケースでは、一度生成した要約はCloudflare R2やVercel KVのような外部ストレージにキャッシュとして保存し、記事が更新された場合のみ再生成する、といった差分更新の仕組みを導入すべきでした。
    全件処理は、やはり悪手です。

割と高い勉強代にはなりましたが、インフラコストに対する意識が甘かったことを痛感させられる良い経験だったと、今は思うことにしています。
皆さんも、ビルド時のAPIコールにはくれぐれもご注意ください。

それでは。

ブログの更新をお知らせ

RSSで購読すると新しい記事の投稿を知ることができます。