一覧に戻る
    LLM監視パイプラインの「トークン沼」──入力を削っても直らないとき、疑うべき場所
    開発ラボ
    PRこの記事には広告が含まれています

    LLM監視パイプラインの「トークン沼」──入力を削っても直らないとき、疑うべき場所

    15 分で読める

    以前の記事で、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重に送信していたことになります。

    修正: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は多くのトークンを使って説明しようとする。これは考えてみれば自然な挙動ですが、設定で上限を固定していると失敗します。

    修正:maxcompletiontokens の引き上げ

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

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

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

    全体の流れ

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

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

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

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

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

    入力側の設計:

    • 同じ情報を複数の形式で送らない(JSON + テキスト + 画像 で3重になりがち)
    • 「LLMに判断させたいこと」から逆算して、必要最小限のデータを選ぶ
    • Vision API を使うなら、時系列データはグラフ画像に任せてテキストでは統計サマリーだけ送る

    出力側の設計:

    • max_completion_tokens はサービスごとのデータ特性を考慮して設定する
    • データ品質が悪い入力ほど、LLMは長い説明を生成しようとする
    • 余裕を持った設定にしつつ、実際の使用量をモニタリングする

    デバッグの基盤:

    • LLMに送信するプロンプトは必ずファイルに書き出す仕組みを入れておく
    • finish_reason を必ずログに出力する('stop' = 正常完了、'length' = トークン制限)

    おわりに

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

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

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

    参考書籍

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

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

    この記事は役に立ちましたか?

    Coffee cup

    この記事が、何かの整理につながったら

    コーヒー1杯分の応援をもらえると嬉しいです。

    ※ これは応援とは別の話ですが、

    同じようなテーマを自分の文脈で整理したい場合は、 (文章だけだと詰まりやすい人向けに) 思考整理の壁打ちという形で対話の時間も取っています。

    対話の時間について