以前の記事で、CloudWatch監視のSDK移行中に踏んだ5つの罠について書きました。あの記事の最後に「画像も一緒に分析させたほうが良いのでは?」と書いたのですが、実際にGCP API監視へ横展開するタイミングで、まさにその Vision API 統合を本格的にやることになりました。

で、そこでまたハマりました。今度は「トークン」です。

入力が大きすぎてダメ、削ったら今度は出力が足りない。正直、LLMを監視パイプラインに組み込むと、こういう「見えにくい制約」との戦いが地味に続きます。この記事では、GCP API監視のプロンプト最適化で踏んだ2つの問題と、その切り分けプロセスを記録します。

前提:何をやっていたか

GCP API(Search Console、Analytics Admin など)のトラフィックメトリクスを定期取得し、LLM(Azure OpenAI)で分析レポートを自動生成する監視パイプラインです。

CloudWatch編と同じ監視SDKの上に構築していて、基本的な流れはこうなっています。

GCP API メトリクス取得
  → 統計分析(異常検知・データ品質チェック)
  → グラフ画像生成(PNG)
  → LLM分析(Vision API で画像 + 統計サマリーを送信)
  → Markdownレポート出力

CloudWatch編での学びを活かして、最初から Vision API を統合する設計にしました。統計値だけでは見えないトレンドやスパイクのパターンを、グラフ画像から読み取らせる狙いです。

問題1:プロンプトが大きすぎる──hourly_stats の重複送信

パイプラインを動かしてみると、一部のサービスでLLMが空のレスポンスを返してきます。

OpenAI APIから空のレスポンスが返されました。
finish_reason='length'

finish_reason='length' は、トークン制限に引っかかったサイン。まずはプロンプトのサイズを調べました。

デバッグ用にプロンプトを保存する

ここでまずやったのは、LLMに送信するプロンプトをファイルに書き出す仕組みの追加です。

# デバッグ用:送信プロンプトをファイルに保存
prompt_path = os.path.join(report_dir, f"{service_name}_prompt_sent.md")
with open(prompt_path, "w", encoding="utf-8") as f:
    f.write(prompt_text)

地味ですが、これがないとLLMに何を送っているのか正確に把握できません。「たぶんこのくらいのサイズだろう」という推測でデバッグすると、遠回りします。

保存したプロンプトを見て気づいたこと

書き出されたプロンプトファイルを開くと、400行を超えていました。中身を見ていくと、hourly_stats(24時間分の1時間ごとの統計データ)が2箇所に重複して含まれていることがわかりました。

1. raw_data(JSON)の中に hourly_stats が丸ごと入っている
2. analysis_text(人間向けテキスト)の中にも同じデータが整形されて入っている

つまり、同じ情報を形式を変えて2回送っていた。さらに、グラフ画像(Vision API)にも同じ時系列データが視覚化されて送られているので、実質3重に送信していたことになります。

graph TD
    A[hourly_stats] --> B[raw_data JSON]
    A --> C[analysis_text テキスト]
    A --> D[グラフ画像 PNG]
    B --> E[LLMプロンプト]
    C --> E
    D --> E
    E --> F[3重に同じ情報を送信]

修正:summary のみを送信する

raw_dataanalysis_text の両方から hourly_stats を除外しました。

# raw_data: 統計サマリーのみ(hourly_stats は除外)
summary_data = {
    "service": service_name,
    "metric": metric,
    "data_quality": asdict(analysis.data_quality),
    "overall_stats": analysis.overall_stats,
    "anomalies_count": len(analysis.anomalies)
}

# analysis_text: hourly_stats を除外して生成
def build_analysis_results_text(analysis):
    payload = {
        "data_quality": asdict(analysis.data_quality),
        "anomalies": [asdict(a) for a in analysis.anomalies],
        "overall_stats": analysis.overall_stats,
    }
    return json.dumps(payload, ensure_ascii=False, indent=2)

これで約240行(hourly_stats の24時間分)が削減されました。時系列の詳細パターンはグラフ画像から Vision API が読み取ればいい。テキストで送るのは全体統計と異常検知の結果だけで十分です。

判断ポイント: 「何を削るか」より「LLMに何を判断させたいか」から逆算する。全データを送れば精度が上がるわけではなく、むしろノイズになる。統計サマリー + グラフ画像という組み合わせは、数値の正確さと視覚パターンの両方をカバーできるバランスの良い設計だと判断しました。

問題2:入力を削っても直らない──出力トークンの罠

プロンプトを大幅に削って再実行。Search Console のメトリクス分析は成功しました。

searchconsole → レスポンス取得成功: length=1843

が、Analytics Admin はまた同じエラー。

analyticsadmin → finish_reason='length'、content=''

ここで一瞬「まだプロンプトが長いのか?」と思いました。でも、冷静に考えると Search Console では成功している。同じプロンプトテンプレートを使っているのに、なぜ片方だけ失敗するのか。

切り分け:入力か、出力か

finish_reason='length' は「出力トークンが上限に達した」という意味です。入力が大きすぎるケースとは別の問題。

ここは罠です。「トークンの問題」と聞くと、つい「入力を減らそう」と考えてしまう。でも、LLMのトークン制約には2つの軸があります。

入力トークン: プロンプト(システムプロンプト + ユーザーメッセージ + 画像)
出力トークン: LLMが生成するレスポンスの長さ(max_completion_tokens で制限)

今回の設定を確認すると、max_completion_tokens: 2500 でした。Search Console のレスポンスが1843トークンで収まった一方、Analytics Admin は2500を超えて途中で打ち切られていた。

なぜ Analytics Admin だけレスポンスが長くなるのか

ここでデータの特徴を見比べてみました。

Analytics Admin のメトリクスには missing_hours が10個以上あり、データ欠落が多い状態でした。LLMはデータ品質の問題を検出すると、より詳細な分析コメント(欠落の原因推測、影響範囲、推奨アクション)を生成しようとします。その結果、レスポンスが長くなって2500トークンに収まらなかったわけです。

つまり、データの品質が悪いサービスほど、LLMは多くのトークンを使って説明しようとする。これは考えてみれば自然な挙動ですが、設定で上限を固定していると失敗します。

修正:max_completion_tokens の引き上げ

openai:
  deployment_name: "gpt-5-mini"
  api_version: "2024-02-15-preview"
  max_completion_tokens: 4000  # 2500 → 4000
  max_retries: 3

2500から4000に引き上げて解決。全サービスで分析レポートが正常に生成されるようになりました。

判断ポイント: 4000という数値は「現状の最大レスポンス長 + 余裕」で決めました。必要以上に大きくすると、コストとレイテンシが増加します。一方で、ギリギリに設定すると将来サービスが増えたときにまた同じ問題が起きる。今回は倍弱にしておいて、実際の使用量をモニタリングしながら調整する方針にしました。

全体の流れ

graph TD
    A[GCP API監視の実装] --> B["finish_reason='length' エラー"]
    B --> C[仮説: 入力プロンプトが大きすぎる]
    C --> D[調査: プロンプトをファイルに書き出し]
    D --> E[発見: hourly_stats が3重に送信されていた]
    E --> F[修正: summary のみ送信]
    F --> G[Search Console → 成功]
    F --> H["Analytics Admin → まだ失敗"]
    H --> I[再考: 入力ではなく出力の問題では?]
    I --> J[原因: max_completion_tokens = 2500 が不足]
    J --> K["修正: 4000 に引き上げ"]
    K --> L[全サービス正常動作]

今回の判断ポイントまとめ

問題 最初の仮説 実際の原因 学び
レスポンス空(1回目) プロンプトが長すぎる hourly_stats の3重送信 送信データは「LLMに何を判断させたいか」から逆算する
レスポンス空(2回目) まだプロンプトが長い? ❌ 出力トークン制限 finish_reason='length' は出力側の問題

2つ目の問題で「入力を削ったのに直らない」という状況に陥ったとき、同じ方向(入力削減)に固執しなかったのが結果的に良かったです。Search Console では成功しているという事実から、「入力サイズは同じなのに結果が違う → 入力以外に原因がある」と切り替えられました。

LLMを監視パイプラインに組み込むときのトークン設計

今回の経験を踏まえて、LLMをパイプラインに組み込む際のトークン設計で意識すべきポイントを整理しておきます。

入力側の設計:

出力側の設計:

デバッグの基盤:

おわりに

CloudWatch編の「モグラ叩きデバッグ」に続いて、今度は「トークン沼」でした。1つ解消すると次が見える、というパターンは相変わらずです。

ただ、今回はデバッグの途中で「自分が今、入力と出力のどちらの問題を見ているのか」を意識できたのは進歩かなと思います。LLMを使ったシステムは、従来のソフトウェアとは違う軸でのデバッグスキルが求められるなと感じています。

入力を削っても直らないときは、出力を疑う。当たり前のことのようですが、渦中にいると意外と見落とします。この記事が、同じ沼にハマった誰かの参考になれば嬉しいです。

参考書籍

LLMアプリケーション開発や監視設計の参考になる書籍をご紹介します。

[📦 商品リンク: moshimo-card-tpujl]

監視パイプラインの設計については、以下のオライリー本が参考になります。

[📦 商品リンク: moshimo-book-monitoring-intro]