📑

Gemini 2.5 Previewの"ny"問題を調べてみた

に公開

"ny"問題とは?

私がそう呼んでいるだけです。
2025年4月8日執筆現在、gemini-2.5-pro-preview-03-25でStructured Outputをすると、割と高確率でレスポンスの先頭にnyが付き、Pydanticモデルにパースできなくなってしまう問題をそう呼んでいます。
Pydanticにできないことにより後続の処理が全部落ちますから、大問題ですね。

https://x.com/SimplrSh/status/1909446878615208401

なぜこんなStructured Outputが不安定なのか、気になったのでPythonのgoogle.genaiライブラリを少し調べてみました。
※ 素人調査なので、間違っている可能性があります。

まずは正常のレスポンス

今回は下記のコードを実行しました。

from dotenv import load_dotenv
from google import genai
from pydantic import BaseModel

load_dotenv("../.env")

client = genai.Client()


class CountryInfo(BaseModel):
    name: str
    population: int
    capital: str
    continent: str
    major_cities: list[str]
    gdp: int
    official_language: str
    total_area_sq_mi: int


def main():
    response = client.models.generate_content(
        model="gemini-2.5-pro-preview-03-25",
        contents="日本の情報を教えて",
        config={
            "response_mime_type": "application/json",
            "response_schema": CountryInfo,
            "temperature": 1.0,
        },
    )

    print(f"{response=}")


if __name__ == "__main__":
    main()

うまくいった場合、下記のようにresponseparsedに、Pydanticモデルのレスポンスが返ってきます。

response=GenerateContentResponse(
        candidates=[
            Candidate(
                content=Content(
                    parts=[
                        Part(
                            video_metadata=None,
                            thought=None,
                            code_execution_result=None,
                            executable_code=None,
                            file_data=None,
                            function_call=None,
                            function_response=None,
                            inline_data=None,
                            text='{\n  "name": "Japan",\n  "population": 125120000,\n  "capital": "Tokyo",\n  "continent": "Asia",\n  "major_cities": ["Tokyo", "Yokohama", "Osaka", "Nagoya", "Sapporo", "Fukuoka", "Kyoto"],\n  "gdp": 4231141000000,\n  "official_language": "Japanese",\n  "total_area_sq_mi": 145937\n}'
                    )
                ],
                    role='model'),
            citation_metadata=None,
            finish_message=None,
            token_count=None,
            avg_logprobs=None,
            finish_reason=<FinishReason.STOP: 'STOP'>,
            grounding_metadata=None,
            index=0,
            logprobs_result=None,
            safety_ratings=None)
        ],
        model_version='gemini-2.5-pro-preview-03-25',
        prompt_feedback=None,
        usage_metadata=GenerateContentResponseUsageMetadata(cached_content_token_count=None,
        candidates_token_count=483,
        prompt_token_count=4,
        total_token_count=487),
        automatic_function_calling_history=[],
        parsed=CountryInfo(
            name='Japan',
            population=125120000,
            capital='Tokyo',
            continent='Asia',
            major_cities=['Tokyo', 'Yokohama', 'Osaka', 'Nagoya', 'Sapporo', 'Fukuoka', 'Kyoto'],
            gdp=4231141000000,
            official_language='Japanese',
            total_area_sq_mi=145937
        )
    )

ny問題では、parsed=Noneで返ってきてしまいます。

nyが入っているレスポンス

確率で下記が返ってきます。

response = GenerateContentResponse(
    candidates=[
        Candidate(
            content=Content(
                parts=[
                    Part(
                        video_metadata=None,
                        thought=None,
                        code_execution_result=None,
                        executable_code=None,
                        file_data=None,
                        function_call=None,
                        function_response=None,
                        inline_data=None,
                        text='ny\n```json\n{\n  "name": "Japan",\n  "population": 125120000,\n  "capital": "Tokyo",\n  "continent": "Asia",\n  "major_cities": ["Tokyo", "Yokohama", "Osaka", "Nagoya", "Sapporo", "Fukuoka", "Kobe", "Kyoto"],\n  "gdp": 4231141221114,\n  "official_language": "Japanese",\n  "total_area_sq_mi": 145937\n}\n```'
                    )
                ],
                role='model'
            ),
            citation_metadata=None,
            finish_message=None,
            token_count=None,
            avg_logprobs=None,
            finish_reason=<FinishReason.STOP: 'STOP'>,
            grounding_metadata=None,
            index=0,
            logprobs_result=None,
            safety_ratings=None
        )
    ],
    model_version='gemini-2.5-pro-preview-03-25',
    prompt_feedback=None,
    usage_metadata=GenerateContentResponseUsageMetadata(
        cached_content_token_count=None,
        candidates_token_count=487,
        prompt_token_count=4,
        total_token_count=491
    ),
    automatic_function_calling_history=[],
    parsed=None
)

textは、下記となっていました。先頭にnyが追加され、その後コードブロックになってします。
そして、parsed=Noneが返ってきました。

text='ny\n```json\n{\n  "name": "Japan",\n  "population": 125120000,\n  "capital": "Tokyo",\n  "continent": "Asia",\n  "major_cities": ["Tokyo", "Yokohama", "Osaka", "Nagoya", "Sapporo", "Fukuoka", "Kobe", "Kyoto"],\n  "gdp": 4231141221114,\n  "official_language": "Japanese",\n  "total_area_sq_mi": 145937\n}\n```'

ちなみに、正常なレスポンスのtextは下記です。

text='{\n  "name": "Japan",\n  "population": 125120000,\n  "capital": "Tokyo",\n  "continent": "Asia",\n  "major_cities": ["Tokyo", "Yokohama", "Osaka", "Nagoya", "Sapporo", "Fukuoka", "Kyoto"],\n  "gdp": 4231141000000,\n  "official_language": "Japanese",\n  "total_area_sq_mi": 145937\n}'

なぜparsed = Noneとなるのか

結論、それはそうだろという話ですが、Google側が意図してなかったレスポンス形式で返ってきているためです。
少し、genaiのライブラリをいじり、レスポンスの形を強制的に変更して実験してみました。

下記レスポンスがapiから返ってきた直後に、とても雑に変更しています。
操作内容としては、'ny'文字列と、コードブロックを取り除くだけの作業です。
https://github.com/googleapis/python-genai/blob/018846ae3e1f73d91522b8606e611152b7f63002/google/genai/models.py#L3935-L3938

    response_dict = self._api_client.request(
        'post', path, request_dict, http_options
    )

    print(f"{request_dict=}")
    print(f"{response_dict=}")

    response_dict["candidates"][0]["content"]["parts"][0]["text"] = response_dict["candidates"][0]["content"]["parts"][0]["text"][11:-4]
    print(f"{response_dict=}")

すると、問題なくparsed含むレスポンスが帰ってきました。

request_dict = {
    "contents": [{"parts": [{"text": "日本の情報を教えて"}], "role": "user"}],
    "generationConfig": {
        "responseMimeType": "application/json",
        "responseSchema": {
            "propertyOrdering": [
                "name",
                "population",
                "capital",
                "continent",
                "major_cities",
                "gdp",
                "official_language",
                "total_area_sq_mi",
            ],
            "type": <Type.OBJECT: 'OBJECT'>,
            "properties": {
                "name": {"type": <Type.STRING: 'STRING'>},
                "population": {"type": <Type.INTEGER: 'INTEGER'>},
                "capital": {"type": <Type.STRING: 'STRING'>},
                "continent": {"type": <Type.STRING: 'STRING'>},
                "major_cities": {
                    "type": <Type.ARRAY: 'ARRAY'>,
                    "items": {"type": <Type.STRING: 'STRING'>},
                },
                "gdp": {"type": <Type.INTEGER: 'INTEGER'>},
                "official_language": {"type": <Type.STRING: 'STRING'>},
                "total_area_sq_mi": {"type": <Type.INTEGER: 'INTEGER'>},
            },
            "required": [
                "name",
                "population",
                "capital",
                "continent",
                "major_cities",
                "gdp",
                "official_language",
                "total_area_sq_mi",
            ],
        },
    },
}

response_dict = {
    "candidates": [
        {
            "content": {
                "parts": [
                    {
                        "text": 'ny\n```json\n{\n  "name": "Japan",\n  "population": 125120000,\n  "capital": "Tokyo",\n  "continent": "Asia",\n  "major_cities": ["Tokyo", "Yokohama", "Osaka", "Nagoya", "Sapporo", "Fukuoka", "Kobe", "Kyoto"],\n  "gdp": 4231141221114,\n  "official_language": "Japanese",\n  "total_area_sq_mi": 145937\n}\n```'
                    }
                ],
                "role": "model",
            },
            "finishReason": "STOP",
            "index": 0,
        }
    ],
    "usageMetadata": {
        "promptTokenCount": 4,
        "candidatesTokenCount": 487,
        "totalTokenCount": 491,
        "promptTokensDetails": [{"modality": "TEXT", "tokenCount": 4}],
        "thoughtsTokenCount": 351,
    },
    "modelVersion": "gemini-2.5-pro-preview-03-25",
}

response_dict = {
    "candidates": [
        {
            "content": {
                "parts": [
                    {
                        "text": '{\n  "name": "Japan",\n  "population": 125120000,\n  "capital": "Tokyo",\n  "continent": "Asia",\n  "major_cities": ["Tokyo", "Yokohama", "Osaka", "Nagoya", "Sapporo", "Fukuoka", "Kobe", "Kyoto"],\n  "gdp": 4231141221114,\n  "official_language": "Japanese",\n  "total_area_sq_mi": 145937\n}'
                    }
                ],
                "role": "model",
            },
            "finishReason": "STOP",
            "index": 0,
        }
    ],
    "usageMetadata": {
        "promptTokenCount": 4,
        "candidatesTokenCount": 487,
        "totalTokenCount": 491,
        "promptTokensDetails": [{"modality": "TEXT", "tokenCount": 4}],
        "thoughtsTokenCount": 351,
    },
    "modelVersion": "gemini-2.5-pro-preview-03-25",
}


response = GenerateContentResponse(
    candidates=[
        Candidate(
            content=Content(
                parts=[
                    Part(
                        video_metadata=None,
                        thought=None,
                        code_execution_result=None,
                        executable_code=None,
                        file_data=None,
                        function_call=None,
                        function_response=None,
                        inline_data=None,
                        text='{\n  "name": "Japan",\n  "population": 125120000,\n  "capital": "Tokyo",\n  "continent": "Asia",\n  "major_cities": ["Tokyo", "Yokohama", "Osaka", "Nagoya", "Sapporo", "Fukuoka", "Kobe", "Kyoto"],\n  "gdp": 4231141221114,\n  "official_language": "Japanese",\n  "total_area_sq_mi": 145937\n}'
                    )
                ],
                role='model'
            ),
            citation_metadata=None,
            finish_message=None,
            token_count=None,
            avg_logprobs=None,
            finish_reason=<FinishReason.STOP: 'STOP'>,
            grounding_metadata=None,
            index=0,
            logprobs_result=None,
            safety_ratings=None
        )
    ],
    model_version='gemini-2.5-pro-preview-03-25',
    prompt_feedback=None,
    usage_metadata=GenerateContentResponseUsageMetadata(
        cached_content_token_count=None,
        candidates_token_count=487,
        prompt_token_count=4,
        total_token_count=491
    ),
    automatic_function_calling_history=[],
    parsed=CountryInfo(
        name='Japan',
        population=125120000,
        capital='Tokyo',
        continent='Asia',
        major_cities=['Tokyo', 'Yokohama', 'Osaka', 'Nagoya', 'Sapporo', 'Fukuoka', 'Kobe', 'Kyoto'],
        gdp=4231141221114,
        official_language='Japanese',
        total_area_sq_mi=145937
    )
)

やはり、Json文字列のみが返ってくることを期待しているように見えますね。

え、、Json以外の文字列がはいってると即アウトなの?

パッと見た感じ、その様になってます。
Pydanticにパースしてる部分は、下記になります。

https://github.com/googleapis/python-genai/blob/7099e1e99ce9e80a3b1080dcd1141a51e1990fea/google/genai/types.py#L3180

テキストをそのままPydanticに型変更してるように見えます。
モデル内部でStructured Outputように学習されているのか、裏で問題ない様になる(はずの)仕組みがあるのかわからないですが、今回は失敗してしまいましたね。
出力は確率に左右されるので、仕方ないかなぁとは思いつつ、少し困っちゃいますね!

対策

これはgeminiのモデルの問題なので、モデルが改善されるまで待ちましょう。
まだpreviewですからね。

nyってなに?

わかりませんが、トークンカウントしてみたところ1トークンでした。
なので、トークナイザーを学習させたときに、nyが登録されたのでしょう。

また、temperatureを0.0にするとnyの出現頻度が上がります。
種類にもよるとは思いますが、Structured Outputするときには、次トークン予測でnyが選ばれる確率が高いのでしょう。

Discussion