
こんにちは、MNTSQ(モンテスキュー)でアルゴリズムエンジニアをしている清水です。
MNTSQは契約書を解析・管理・検索するプロダクトを提供しています。これらのプロダクトには大規模言語モデル(以下LLM)が搭載された機能が実装されています。また、LLMを活用した新プロダクトも鋭意開発中です。
LLMをアプリケーションに組み込む際の大きな課題の一つとして、「LLMの出力形式(型)を如何に矯正するか?」が挙げられます。単純なチャットアプリケーションであればそこまで問題にはなりませんが、LLMによる生成結果を後続のプログラムで処理する必要がある場合、事前に定義された型に従って出力を生成する必要があります。
現在、複数のLLMサービスで出力形式を制御する機能が搭載されていますが、本記事ではGoogleが提供しているGeminiのStructured outputを取り上げます。本記事では、開発の過程で得られた、GeminiのStructured outputにおける7つのTipsを紹介したいと思います。
サンプルコード
例として以下のようにPythonのGoogle Gen AI SDK(google-genai)を使用することを想定しています。google-genaiではtypes.GenerateContentConfigのresponse_schemaにPydanticのモデルを渡すことで、Structured outputを使用することができます。
本記事ではStructured outputの機能にフォーカスするのでプロンプトは最低限の内容にしています。また、スキーマとして指定するPydanticモデルも、タイトルの抽出と契約書かどうかを判定するだけのシンプルなものにしています。また、Gemini APIではなくVertex AIのAPIを介してGeminiを使用します。(ほとんどのケースでGemini APIに対しても同じTipsが適用できると思いますが、一部仕様が異なる可能性があります。)
from google.genai import Client, types from pydantic import BaseModel PROMPT_TEMPLATE = """\ JSONスキーマに従って、ドキュメントの内容を分析してください。 <json_schema> {json_schema} </json_schema> <document> {document_text} </document> """ class ContractAnalysisResult(BaseModel): document_name: str is_contract: bool def analyze_contract(document_text: str) -> ContractAnalysisResult: client = Client(vertexai=True, project="development", location="global") prompt = PROMPT_TEMPLATE.format( json_schema=ContractAnalysisResult.model_json_schema(), document_text=document_text, ) contract_analysis_result = client.models.generate_content( model="gemini-2.5-flash", contents=prompt, # response_schemaにPydanticモデルを渡す config=types.GenerateContentConfig( response_schema=ContractAnalysisResult, ), ) return contract_analysis_result
Tips 1: プロンプトにJSONスキーマを含めない
一番簡単に試すことができるテクニックは、「プロンプトにJSONスキーマを含めない」です。実は、response_schemaを設定した場合は、JSONスキーマをプロンプトに含めないことが公式のドキュメントで推奨されています。
警告:
responseSchemaを構成する場合は、テキスト プロンプトでスキーマを指定しないでください。これにより、予期しない結果や品質の低い結果が生じる可能性があります。
以下のサンプルコードでは、上記のサンプルコードからJSONスキーマを埋め込んでいた箇所を削除しています。
# response_schemaを指定する際には、JSONスキーマをプロンプトに含めない PROMPT_TEMPLATE = """\ JSONスキーマに従って、ドキュメントの内容を分析してください。 <document> {document_text} </document> """
Structured outputについてすべての仕組みが詳細に明かされているわけではないので、なぜJSONスキーマをプロンプトに含めないことが推奨されるのか技術的な理由は分かりません。公式のドキュメントで”don't duplicate the schema in the text prompt.”と書いてあることから、重複した情報をLLMに与えることが悪影響を及ぼすのかもしれません。
また、OpenAIやAnthropicのドキュメントには同様の記述は見当たらず(見逃していたらすみません)、Gemini特有の性質である可能性もあります。
Tips 2: titleとdescriptionを設定する
JSONスキーマの各フィールドにおいて、自然言語による説明を付けたい時は以下のようにtitleやdescriptionフィールドを使いましょう。Structured outputではJSONスキーマによる構造化されたデータしか渡せないと勘違いされがちですが、LLMらしく自然言語による情報も与えることができます。
from pydantic import BaseModel, Field class ContractAnalysisResult(BaseModel): document_name: str = Field( title="タイトル (Title)", description="ドキュメント冒頭に記載されているタイトル", ) is_contract: bool = Field( title="契約書かどうか (Is Contract)", description="ドキュメントが契約書かどうかを示す。就業規則や賃金規定などは契約書ではない。", )
ここで定義されたtitleとdescriptionはVertex AIのAPIリファレンスで記述されているresponseSchemaフィールドのtitleフィールドとdescriptionフィールドに渡されます。(詳しくは次の項目で言及します)
Tips 3: APIに渡されるパラメータを確認する
Pydanticモデルをresponse_schemaに渡すだけでStructured outputを使用できますが、最終的にどのような形式でAPIに渡されるのかを確認することは有効です。以下のようにして、response_schemaに渡したPydanticモデルが、どのようにAPIに渡すためのパラメータに変換されるかを確認することができます。
from google.genai import Client from google.genai import _transformers as t from pydantic import BaseModel, Field class ContractAnalysisResult(BaseModel): document_name: str = Field( title="タイトル (Title)", description="ドキュメント冒頭に記載されているタイトル", ) is_contract: bool = Field( title="契約書かどうか (Is Contract)", description="ドキュメントが契約書かどうか。就業規則や賃金規定などは契約書ではない。", ) if __name__ == "__main__": client = Client(vertexai=True, project="development", location="global") # _transformers.t_schemaにClientオブジェクトとPydanticモデルを渡す request_params = t.t_schema(client, ContractAnalysisResult) print(request_params.model_dump_json(indent=2, exclude_none=True))
出力結果
{ "properties": { "document_name": { "description": "ドキュメント冒頭に記載されているタイトル", "title": "タイトル (Title)", "type": "STRING" }, "is_contract": { "description": "ドキュメントが契約書かどうか。就業規則や賃金規定などは契約書ではない。", "title": "契約書かどうか (Is Contract)", "type": "BOOLEAN" } }, "property_ordering": [ "document_name", "is_contract" ], "required": [ "document_name", "is_contract" ], "title": "ContractAnalysisResult", "type": "OBJECT" }
例えば、私は開発の過程で以下のようなgoogle-genaiの仕様(というよりはバグ?)を見つけました1。
下記のように、Pydanticモデルが入れ子構造になっているスキーマにおいて、以下のように親モデルのFieldにおいてtitleとdescriptionを設定します。
from google.genai import Client from google.genai import _transformers as t from pydantic import BaseModel, Field class ContractTerm(BaseModel): effective_date: str expiration_date: str class ContractAnalysisResult(BaseModel): contract_term: ContractTerm = Field( title="契約期間 (Contract Term)", description="契約有効日と失効日から構成される契約の期間。", ) if __name__ == "__main__": client = Client(vertexai=True, project="development", location="global") request_params = t.t_schema(client, ContractAnalysisResult) print(request_params.model_dump_json(indent=2, exclude_none=True))
このスキーマのt_schemaの出力を確認してみると、以下のようにtitleとdescriptionが消えてしまっていることが確認できます。(title はデフォルト値のクラス名(ContractTerm)が代わりに格納されています。)
{ "properties": { "contract_term": { "properties": { "effective_date": { "title": "Effective Date", "type": "STRING" }, "expiration_date": { "title": "Expiration Date", "type": "STRING" } }, "property_ordering": [ "effective_date", "expiration_date" ], "required": [ "effective_date", "expiration_date" ], "title": "ContractTerm", "type": "OBJECT" } }, "required": [ "contract_term" ], "title": "ContractAnalysisResult", "type": "OBJECT" }
以下のように親モデルのFieldではなく子モデルのConfigDictでtitleを、docstringでdescriptionを設定すると、問題なく変換されます。
from google.genai import Client from google.genai import _transformers as t from pydantic import BaseModel, ConfigDict class ContractTerm(BaseModel): """契約有効日と失効日から構成される契約の期間。""" # docstringを設定するとdescriptionとして認識される model_config = ConfigDict(title="契約期間 (Contract Term)") effective_date: str expiration_date: str class ContractAnalysisResult(BaseModel): contract_term: ContractTerm if __name__ == "__main__": client = Client(vertexai=True, project="development", location="global") request_params = t.t_schema(client, ContractAnalysisResult) print(request_params.model_dump_json(indent=2, exclude_none=True))
出力結果
{ "properties": { "contract_term": { "description": "契約有効日と失効日から構成される契約の期間。", "properties": { "effective_date": { "title": "Effective Date", "type": "STRING" }, "expiration_date": { "title": "Expiration Date", "type": "STRING" } }, "property_ordering": [ "effective_date", "expiration_date" ], "required": [ "effective_date", "expiration_date" ], "title": "契約期間 (Contract Term)", "type": "OBJECT" } }, "required": [ "contract_term" ], "title": "ContractAnalysisResult", "type": "OBJECT" }
思ったように型の矯正が効かないときはこのようなエッジケースを踏んでいるのかもしれません。そのような時は、この方法を使ってAPIに渡されるパラメータを確認すると良いでしょう。
Tips 4: date型やdatetime型を使用する
スキーマを定義するPydanticモデルの各フィールドおいて、Pythonのdate型やdatetime型を使用することができます。以下のPydanticモデルをt_schemaに渡すと以下のようなパラメータに変換されていることが確認できます。
from datetime import date from google.genai import Client from google.genai import _transformers as t from pydantic import BaseModel class ContractTerm(BaseModel): effective_date: date # str型ではなくdate型を指定 expiration_date: date if __name__ == "__main__": client = Client(vertexai=True, project="development", location="global") request_params = t.t_schema(client, ContractTerm) print(request_params.model_dump_json(indent=2, exclude_none=True))
出力結果("format": "date", となっている箇所に注目してください)
{ "properties": { "effective_date": { "format": "date", "title": "Effective Date", "type": "STRING" }, "expiration_date": { "format": "date", "title": "Expiration Date", "type": "STRING" } }, "property_ordering": [ "effective_date", "expiration_date" ], "required": [ "effective_date", "expiration_date" ], "title": "ContractTerm", "type": "OBJECT" }
このformatフィールドはtitleやdescriptionと同様にAPIのresponseSchemaでサポートされているフィールドです。ただし、どのようなformatでも良いわけではなく現状はdate、date-time、time、durationのみがサポートされているようです。それぞれPythonのdatetimeライブラリのdateクラス、datetimeクラス、timeクラス、timedeltaクラスが対応しています2。
from datetime import date, datetime, time, timedelta from google.genai import Client from google.genai import _transformers as t from pydantic import BaseModel class DateTimeClasses(BaseModel): date_field: date datetime_field: datetime time_field: time duration_field: timedelta if __name__ == "__main__": client = Client(vertexai=True, project="development", location="global") request_params = t.t_schema(client, DateTimeClasses) print(request_params.model_dump_json(indent=2, exclude_none=True))
出力結果
{ "properties": { "date_field": { "format": "date", "title": "Date Field", "type": "STRING" }, "datetime_field": { "format": "date-time", "title": "Datetime Field", "type": "STRING" }, "time_field": { "format": "time", "title": "Time Field", "type": "STRING" }, "duration_field": { "format": "duration", "title": "Duration Field", "type": "STRING" } }, ... }
Tips 5: その他サポートされているAPIのフィールドを使用する
responseSchemaがサポートしているフィールドは、上記で紹介したtitle、description、formatフィールド以外にもあります。詳しくは公式ドキュメントをご参照ください。これらのフィールドはPydanticで以下のように表現できます。
from datetime import date, datetime, time, timedelta from enum import Enum from google.genai import Client from google.genai import _transformers as t from pydantic import BaseModel, Field class EnumClass(Enum): A = "a" B = "b" C = "c" class Schema(BaseModel): number_field: int = Field(ge=1, le=10) string_field: str = Field(min_length=1, max_length=10) list_field: list[int] = Field(min_items=1, max_items=10) with_pattern_field: str = Field(pattern=r"^[a-z]+$") # examplesを渡すとエラーになるので注意 with_example_field: str = Field(json_schema_extra={"example": "example string"}) nullable_field: str | None = Field(default=None) any_of_field: str | int enum_field: EnumClass if __name__ == "__main__": client = Client(vertexai=True, project="development", location="global") request_params = t.t_schema(client, Schema) print(request_params.model_dump_json(indent=2, exclude_none=True))
出力結果
{ "properties": { "number_field": { "maximum": 10.0, "minimum": 1.0, "title": "Number Field", "type": "INTEGER" }, "string_field": { "max_length": 10, "min_length": 1, "title": "String Field", "type": "STRING" }, "list_field": { "items": { "type": "INTEGER" }, "max_items": 10, "min_items": 1, "title": "List Field", "type": "ARRAY" }, "with_pattern_field": { "pattern": "^[a-z]+$", "title": "With Pattern Field", "type": "STRING" }, "with_example_field": { "example": "example string", "title": "With Example Field", "type": "STRING" }, "nullable_field": { "nullable": true, "title": "Nullable Field", "type": "STRING" }, "any_of_field": { "any_of": [ { "type": "STRING" }, { "type": "INTEGER" } ], "title": "Any Of Field" }, "enum_field": { "enum": [ "a", "b", "c" ], "title": "EnumClass", "type": "STRING" } }, "property_ordering": [ "number_field", "string_field", "list_field", "with_pattern_field", "with_example_field", "nullable_field", "any_of_field", "enum_field" ], "required": [ "number_field", "string_field", "list_field", "with_pattern_field", "with_example_field", "any_of_field", "enum_field" ], "title": "Schema", "type": "OBJECT" }
Tips 6: エラー回避のためのvalidatorを実装する
上記で紹介したdate型のフィールドやmax_engthなどはPydanticモデルの制約として働きます。例えば、date型のフィールドに無効な日付の文字列が代入されるとエラーになります。また、max_length=10と指定されているフィールドに11文字以上の文字列が渡されると同じくエラーになります。
この時、Geminiがこれらの制約に違反したJSONを生成する可能性があることに注意が必要です。一定の矯正力はありますが、100%制約を守ってくれるわけではありません3。制約に違反したテキストが生成されたときにエラーにならないように、生成されたテキストを加工するvalidatorを実装しておくと安全でしょう。
例えば私は、date型のフィールドに対して0000-01-01のような無効な日付をGeminiが生成するケースを観測したことがあります。この場合、以下のようなvalidatorを実装してエラーを回避すると良いでしょう。
import logging from datetime import date from typing import Any from pydantic import BaseModel, Field, ModelWrapValidatorHandler, ValidationError, field_validator class EffectiveDate(BaseModel): effective_date: date | None = Field(default=None) @field_validator("effective_date", mode="wrap") def date_parsing_validator(value: Any, handler: ModelWrapValidatorHandler[Any]) -> Any: """0000-01-01のような無効な日付をNoneに変換する""" try: return handler(value) except ValidationError as e: if "date_parsing" in (error["type"] for error in e.errors()): logging.warning(f"Invalid date: {value}") return None else: raise e if __name__ == "__main__": # Geminiが0000-01-01のような無効な日付を生成したと想定 effective_date = EffectiveDate.model_validate_json('{"effective_date": "0000-01-01"}') print(effective_date) # WARNING:root:Invalid date: 0000-01-01 # effective_date=None
Tips 7: Chain of Thoughtを意識する
Chain of Thought (CoT)とは、結論だけでなく推論の過程も生成させることでLLMの性能を向上させる手法のことです。通常はプロンプトを工夫したり、専用にチューニングされたモデルを使用することでCoTを実現するのですが、response_schemaを工夫することで擬似的なCoTを実現することができます。
例として、以下のようなPydanticモデルを定義します。
from datetime import date from pydantic import BaseModel, Field class ContractTerm(BaseModel): effective_date: date | None = Field(description="契約期間の有効日") period: int | None = Field(description="契約が持続する期間") expiration_date: date | None = Field(description="契約期間の失効日")
欲しいのはeffective_date とexpiration_date だけですが、同時にperiod も抽出するようにしています。このようにすることで、例えば「本契約は2025年1月1日から3年間有効とする」のように契約失効日が直接的に書かれていない場合でも、事前に抽出したeffective_date とperiod からexpiration_date を計算してくれる効果が期待できます。
このように、フィールドを定義する順番を工夫したり、関連する情報を抽出するように促すことで、擬似的なCoTが期待できるでしょう。
注意事項
本記事で紹介した内容は、2025年6月時点のGemini/Vertex AIの仕様と、google-genaiのバージョン1.19.0の仕様に基づいています。今後のアップデートによってGeminiやSDKの仕様が変更される可能性があります。実際に利用される際は、必ず公式のドキュメントをご確認いただくようお願いします。
まとめ
本記事では、GeminiのStructured outputでレスポンスの型を矯正するためのTipsをいくつか紹介しました。開発で得られた知見を全て盛り込んだら想定よりも多い文字数になってしまいました。是非開発のヒントにしていただけたら幸いです。
冒頭でも触れましたが、MNTSQではLLMを活用したプロダクトを鋭意開発中です。もしMNTSQの仕事にご興味を持っていただけたら、ぜひお気軽にカジュアル面談でお話ししましょう!