1
1

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

Gemini API で JSON が途切れる・空になる原因と直し方(思考トークンと max_output_tokens)

1
Posted at

はじめに

Gemini API に JSON を返させたら、閉じ括弧がなかった。エラーは出ていません。

日次バッチで数百件のテキストを処理していたところ、十数件だけ JSON の parse に失敗しました。数件は本文が空。同じプロンプト、同じモデルなのに、一部だけ壊れる。

原因は思考トークンでした。max_output_tokens は答えの上限だと思っていたのですが、思考の分も含めた上限です。長く考えた入力ほど、答えを書く分が残らない。

急いでいる人向けに、やることを先に書きます。

  • 確認する: レスポンスの finish_reasonMAX_TOKENS で、thoughts_token_count が上限の大半を占めていれば、この記事の原因
  • 変更する: 思考の設定(3.6 Flash なら thinking_level、2.5 系なら thinking_budget)を下げ、max_output_tokens を思考込みで見積もり直す
  • 注意する: 思考設定の既定値と指定できる値はモデルごとに違う。公式のモデル別設定表 で確認する

ここで解決したなら、以下は読まなくて大丈夫です。仕組みと再現コード、直し方の比較は続きに書きます。

対象は Python(google-genai SDK)で Gemini の JSON 出力を運用している人です。再現は Gemini 3.6 Flash で行いました。私が当たったのは 2.5 Flash ですが、max_output_tokens が思考を含む点は両方同じです。

私の環境はこうです。Gemini API を直接呼ぶ処理なら、GCP でなくても同じことが起きます。

Cloud Scheduler(毎朝 6 時に起動)
   │
   ▼
Cloud Run のジョブ
   │  1. BigQuery から前日分のテキストを数百件取得
   │  2. 1 件ずつ Gemini API に投げ、
   │     「分類ラベル・要約・スコア」の 3 項目を JSON で受け取る  ← ここで問題が起きた
   │  3. JSON を parse して BigQuery に書き戻す
   ▼
翌朝の集計で利用

※ 構成と題材は記事用に置き換えた架空のものです。現象と原因はそのまま書いています。

起きたこと

期待していたのはこれ。

{"label": "要望", "summary": "次回から日本語の説明書を同封することを希望している", "score": 0.85}

返ってきたのはこれ。

配送が

括弧すらない。json.loads は当然落ちます。

もう 1 パターンあって、response.text が空のケース。HTTP は 200、例外なし。parse で初めて落ちます。

どちらも finish_reasonMAX_TOKENS でした。上限に達したから止めた、という意味です。

ここで「上限を上げればいい」と思いました。半分正解で、半分外れでした。

原因: max_output_tokens は思考と答えの合計に対する上限

Gemini 2.5 以降のモデルは、答えを書く前に「思考」をします。この思考にもトークンが使われ、thoughts_token_count に出ます。

公式ドキュメントにはこう書いてあります。

max_output_tokens は、思考トークンを含めた、レスポンスが生成できるトークンの最大数を設定する。モデルが推論中にこの上限に達すると finish_reason: MAX_TOKENS で停止し、途切れた出力か空の出力を返す(生成された思考トークンには課金される)。

出典: https://ai.google.dev/gemini-api/docs/generate-content/thinking

答えの上限ではなく、思考と答えを足した数の上限。思考が大半を使えば、答えの分は残りません。

後述のコードで測った値で示します。

途切れたケース(max_output_tokens = 120)
┌─────────────────────────────────────────────┬─┐
│ 思考 114                                    │2│ ← ここで MAX_TOKENS。答えは「配送が」の 2 トークン
└─────────────────────────────────────────────┴─┘

空で返るケース
┌───────────────────────────────────────────────┐
│ 思考 120                                      │ ← 答えを書く前に MAX_TOKENS
└───────────────────────────────────────────────┘

修正後(max_output_tokens = 400、思考を最小に)
┌──┬────────────┬────────────────────────────────┐
│思0│ 答え 40    │ 未使用                         │ ← STOP、JSON が閉じる
└──┴────────────┴────────────────────────────────┘

十数件だけ壊れた理由もこれです。判断に迷う入力ほどモデルは長く考える。その分、答えに使えるトークンが減る。

なぜ思考が伸びたのか、なぜ上限を上げるだけでは足りないのか

私のケースでは、思考量を制限するパラメータを設定していませんでした。それが直接の原因です。

2.5 Flash は thinking_budget を指定しないと動的思考になり、最大 24,576 トークンまで考えます。一方で max_output_tokens は答えの長さだけ見て決めていた。思考だけで上限を超えられる設定でした。

本記事で使った 3.6 Flash は thinking_level(minimal / low / medium / high)で指定し、未指定なら medium で動きます。既定のまま上限を答えの長さで決めると、同じことが起きます。既定値と指定できる値はモデルごとに違うので、他のモデルは 公式表 を見てください。

思考の量はモデルが入力ごとに決めます。thinking_budget は目安で、公式にも「はみ出すことがある」とあります。thinking_level は段階指定なので、トークン数そのものは指定できません。なので、120 を 600 に上げても、600 近く考える入力が来れば同じです。上限を上げることと思考を抑えることは別の対策で、両方やります。

入力の長さは間接要因です。入力トークンは prompt_token_count として別に数えられ、max_output_tokens を減らしません。ただ、長く複雑な入力ほど長く考える。

どこを見れば分かるか

レスポンスの 4 つの値で判定できます。

見る場所 意味
usage_metadata.prompt_token_count 入力に使ったトークン
usage_metadata.thoughts_token_count 思考に使ったトークン
usage_metadata.candidates_token_count 答えに使ったトークン
candidates[0].finish_reason 止まった理由
usage = response.usage_metadata
print("finish_reason         :", response.candidates[0].finish_reason.name)
print("prompt_token_count    :", usage.prompt_token_count)
print("thoughts_token_count  :", usage.thoughts_token_count)
print("candidates_token_count:", usage.candidates_token_count)

finish_reasonMAX_TOKENS で、thoughts_token_count が上限の大半なら、この記事の原因です。私の場合、壊れた十数件は全部これでした。

再現コード

1 件のテキストから「分類ラベル・要約・スコア」の JSON を作らせる、最小のスクリプトです。

わざと起こすために、修正前は thinking_level="high" で長く考えさせて上限 120。修正後は minimal で上限 400。

pip install google-genai
export GEMINI_API_KEY="自分のキー"
gemini_thinking_truncation.py
import json
from google import genai
from google.genai import types

MODEL = "gemini-3.6-flash"

TEXT = (
    "先日購入した商品は届くのが早くて助かりましたが、箱が少し潰れていました。"
    "中身は無事でした。ただ、同封の説明書が英語だけで、設定に時間がかかりました。"
    "サポートに問い合わせたところ丁寧に対応してもらえました。"
    "返金を希望しているわけではないのですが、次回は日本語の説明書を入れてほしいです。"
)

PROMPT = f"""次の顧客の声を分析し、JSON で返してください。

判断は慎重に行ってください。分類ラベルは「問い合わせ」「クレーム」「要望」「感謝」のうち
最も適切な 1 つを選びますが、複数の要素が混在している場合は、どの要素が中心かを
根拠とともに丁寧に検討した上で決めてください。スコアは 0.0〜1.0 で、
その分類にどれだけ確信があるかを表します。

出力形式:
{{"label": "<分類ラベル>", "summary": "<50 文字以内の要約>", "score": <0.0〜1.0>}}

顧客の声:
{TEXT}
"""

client = genai.Client()  # GEMINI_API_KEY を読む


def run(title: str, max_output_tokens: int, thinking_level: str) -> None:
    response = client.models.generate_content(
        model=MODEL,
        contents=PROMPT,
        config=types.GenerateContentConfig(
            response_mime_type="application/json",
            max_output_tokens=max_output_tokens,
            thinking_config=types.ThinkingConfig(thinking_level=thinking_level),
        ),
    )
    usage = response.usage_metadata
    text = response.text or ""
    try:
        json.loads(text)
        parse = "OK"
    except json.JSONDecodeError as e:
        parse = f"NG ({e.msg})"

    print(f"=== {title} ===")
    print(f"max_output_tokens     : {max_output_tokens}")
    print(f"thinking_level        : {thinking_level}")
    print(f"finish_reason         : {response.candidates[0].finish_reason.name}")
    print(f"prompt_token_count    : {usage.prompt_token_count}")
    print(f"thoughts_token_count  : {usage.thoughts_token_count or 0}")
    print(f"candidates_token_count: {usage.candidates_token_count or 0}")
    print(f"json.loads            : {parse}")
    print(f"response.text         : {text or '(空)'}")
    print()


run("修正前", max_output_tokens=120, thinking_level="high")
run("修正後", max_output_tokens=400, thinking_level="minimal")

結果です(2026-09-22、gemini-3.6-flash、google-genai 2.24.0)。

=== 修正前 ===
max_output_tokens     : 120
thinking_level        : high
finish_reason         : MAX_TOKENS
prompt_token_count    : 209
thoughts_token_count  : 114
candidates_token_count: 2
json.loads            : NG (Expecting value)
response.text         : 配送が

=== 修正後 ===
max_output_tokens     : 400
thinking_level        : minimal
finish_reason         : STOP
prompt_token_count    : 209
thoughts_token_count  : 0
candidates_token_count: 40
json.loads            : OK
response.text         : {"label": "要望", "summary": "配送やサポートへの感謝をしつつも、次回から日本語の説明書を同封することを希望している。", "score": 0.85}

上限 120 のうち思考が 114。答えは 2 トークンで切られ、返ってきたのは「配送が」だけ。修正後は思考 0、答え 40 で閉じています。

直し方 3 つ

どの案も、思考の設定を変えた前後で正常系のサンプルの結果を比べてから決めます。思考の質が結果に効く処理では、精度が落ちることがあります。

1. 思考の量を下げる

3.6 Flash なら thinking_level を medium から low か minimal に。2.5 系なら thinking_budget を小さい値にします。

同じ上限でも答えの分が残ります。

2. 上限を「思考の実測 + 答え」で決める

max_output_tokens を答えの長さではなく、思考と答えの合計で見積もります。

正常に返った数件の thoughts_token_countcandidates_token_count を見て、最大値を足して余裕を乗せる。3 項目 JSON なら答えは 40〜100 トークン程度。上限のほとんどは思考のために取ることになります。

3. 2.5 系なら予算を明示して、上限の初期値を「予算 + 答え + 余裕」にする

2.5 系は thinking_budget でトークン数を指定できるので、上限の初期値を計算で出せます。予算 1024、答え 100 なら、1024 + 100 + 余裕。思考が要らない処理なら thinking_budget=0 で切れます(2.5 Pro は 0 にできません)。

ただし予算は目安で、はみ出すことがあります。初期値を決めたあとは、実測の thoughts_token_count を見て余裕を調整します。再発しない保証にはなりません。

3.6 Flash ではこの手は使えません。thinking_budget は互換のために受け付けるだけで非推奨、思考を完全に切ることもできないからです。

選ぶ基準: 3.6 Flash なら 1 と 2 の組み合わせ。2.5 系なら 3 で初期値を決めて、実測で調整。

私は 2.5 Flash だったので 3 でした。予算を明示して、上限を予算 + 答え + 余裕で決め直しています。

直した後に見るもの

finish_reasonSTOP に変わり、JSON が閉じていること。先ほどの 4 つの値で確認します。

本番では 4 つの値を 1 件ごとにログに出しておきます。Cloud Logging で MAX_TOKENS の件数を数えれば再発にすぐ気づけます。parse 例外で止まってから気づくより、ずっと早い。

ハマりどころ

  • ログに candidates_token_count だけ出していると気づけません。答えが 40 なら「上限 400 の 1 割しか使っていない」と読めてしまう。残りを思考が使っていたことが見えない。thoughts_token_count を必ず隣に出します
  • response.text が空でも例外は出ません。parse の前に空チェックを入れておくと切り分けが楽です
  • 思考トークンも課金されます。途切れた出力にも料金はかかる
  • 3.6 Flash は思考を完全に切れません。minimal は「ほとんどの場合ゼロ」で、保証ではない。今回は 0 でしたが、余裕は持たせます
  • response_schema を使っていても途切れます。形は保証されるが、長さは保証されない
  • 2.5 Flash は 2026 年 9 月時点で新規ユーザーには提供されていません。API が 404 で「3.6 Flash に移行」と返します。既存の利用者は使えます

まとめ

やることは 3 つ。思考の量を下げる、上限を思考込みで見積もる、finish_reasonthoughts_token_count をログに出す。

200 が返っても中身は見る。これが一番の教訓でした。

参考

1
1
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
1
1

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?