ユーザーが提出した請求書から構造化データを抽出するために、GPT-4を呼び出す機能をリリースしました。 開発環境では完璧に動作します — モデルは毎回きれいなJSONオブジェクトを返してきます。ところが本番で、 午前2時にSentryアラート:JSON.parse: unexpected token。モデルは実際のペイロードの前に 「もちろん!こちらが依頼されたJSONです:」と前置きすることにしたのです。1週間後、同じ機能で、 別のバグ:モデルはtotal_amountの代わりにtotalAmountを返し、 下流のデータベース書き込みはそのフィールドを黙って落とします。LLM出力の信頼性を プロンプトエンジニアリングで回避してきたなら、 OpenAI Structured Outputs があなたが待っていた修正です。

2024年8月にOpenAIがリリースしたStructured Outputsは、response_formatパラメーター経由で JSON Schema を供給し、そのスキーマに完全に一致する妥当性が保証されたレスポンスを受け取ることができます。 これは、出力が有効なJSONであることだけを保証していた古いJSONモード({"type": "json_object"}) とは異なります — 特定の形状に一致することは保証していませんでした。Function callingとも区別されます。 Function callingはモデルの出力をツール呼び出しに流しますが、独自のセレモニー層を追加します。 Structured Outputsは最もクリーンなパスです:欲しい形を記述し、毎回正確にその形を返してもらう。 内部ではOpenAIは制約付きデコーディングを使用しています — モデルのトークンサンプリングはスキーマによって導かれるため、 モデルは文字通り無効なレスポンスを生成することができません

最初の構造化出力

python
from openai import OpenAI
import json

client = OpenAI()

response = client.chat.completions.create(
    model="gpt-4o-2024-08-06",
    messages=[
        {
            "role": "user",
            "content": "Extract the vendor name, invoice number, and total amount from this text: "
                       "Invoice #INV-2024-0892 from Acme Supplies Ltd. Total due: $1,450.00"
        }
    ],
    response_format={
        "type": "json_schema",
        "json_schema": {
            "name": "invoice_extract",
            "strict": True,
            "schema": {
                "type": "object",
                "properties": {
                    "vendor_name":     {"type": "string"},
                    "invoice_number":  {"type": "string"},
                    "total_amount":    {"type": "number"}
                },
                "required": ["vendor_name", "invoice_number", "total_amount"],
                "additionalProperties": False
            }
        }
    }
)

data = json.loads(response.choices[0].message.content)
print(data)
# {"vendor_name": "Acme Supplies Ltd", "invoice_number": "INV-2024-0892", "total_amount": 1450.0}

3つの注目点。第一に、モデルはgpt-4o-2024-08-06です — Structured Outputsは 明示的にそれをサポートするモデルが必要です(GPT-4o用の-2024-08-06スナップショット以降、または gpt-4o-mini)。第二に、response_format.type"json_schema"であり、 "json_object"ではありません。第三に、"strict": Trueこそが保証を与えるものです — これがないとベストエフォートの領域に戻ります。nameフィールドはモデルが見るラベルで、 パースには影響しませんが、API ログを読みやすくします。

JSON Schemaを設計する

こちらは製品カタログ抽出タスクのためのより現実的なスキーマです — 非構造化の製品説明、 ECリスティング、PDFデータシートから構造化データを引き出すのに使う類のものです。 API呼び出しに組み込む前にスキーマを視覚的に構築・検証するには、 JSON Schema Generatorを使ってください。

json
{
  "type": "object",
  "properties": {
    "product_name": {
      "type": "string",
      "description": "The full commercial name of the product"
    },
    "sku": {
      "type": "string",
      "description": "Stock keeping unit identifier"
    },
    "price_usd": {
      "type": "number",
      "description": "Price in US dollars, numeric only"
    },
    "in_stock": {
      "type": "boolean"
    },
    "categories": {
      "type": "array",
      "items": { "type": "string" }
    },
    "dimensions": {
      "type": "object",
      "properties": {
        "width_cm":  { "type": "number" },
        "height_cm": { "type": "number" },
        "depth_cm":  { "type": "number" }
      },
      "required": ["width_cm", "height_cm", "depth_cm"],
      "additionalProperties": false
    }
  },
  "required": [
    "product_name", "sku", "price_usd",
    "in_stock", "categories", "dimensions"
  ],
  "additionalProperties": false
}
  • strictモードでは、すべてのプロパティはrequiredに含める必要があります。 オプショナルなフィールドは持てません。ソースデータに存在しない可能性があるフィールドは、union型を使用してください:{"type": ["string", "null"]} を使い、常にrequiredに含めます。
  • additionalPropertiesはすべてのオブジェクトレベルでfalseである必要があります。 これは再帰的に適用されます — ルートだけでなく、ネストされたオブジェクトにも必要です。
  • strictモードでサポートされる型: stringnumberintegerbooleannullarrayobject。型のunion(["string", "null"])は許可されます。
  • strictモードでは$refや再帰スキーマは使えません。 すべてインライン化する必要があります。再利用可能な定義が必要なら、コピーしてください。
  • descriptionフィールドを惜しまず追加しましょう。 モデルはそれらを読みます。「Price in US dollars, numeric only — do not include currency symbols」のように言えば、モデルが正しく推測してくれることを期待するよりもクリーンな出力が得られます。
  • enumが動作します。 {"type": "string", "enum": ["pending", "shipped", "delivered"]}は完全にサポートされており、モデルはこれら3つの値のいずれかしか出力しません。

strictモードと非strictモード

python
# Strict mode — guaranteed conformance, tighter schema rules
response_format_strict = {
    "type": "json_schema",
    "json_schema": {
        "name": "product_extract",
        "strict": True,   # <-- the key flag
        "schema": product_schema
    }
}

# Non-strict — more schema flexibility, best-effort conformance
response_format_lenient = {
    "type": "json_schema",
    "json_schema": {
        "name": "product_extract",
        "strict": False,
        "schema": product_schema
    }
}

strict: Trueを指定すると、OpenAIは最初に使用されたときにスキーマを前処理し、 制約付きデコーダーをキャッシュします。新しいスキーマでの最初の呼び出しはやや遅くなりますが、 同じスキーマでのそれ以降の呼び出しは高速です。見返りに得られるのは:モデル出力が構造的に 保証されること — json.loads()を呼んでから、防御的なチェックなしに直接フィールドにアクセスできます。 諦めるもの:$ref、構造的バリアント間のanyOf、 再帰スキーマはサポートされません。非strictモードはより広い範囲の JSON Schema 機能を受け入れますが、ベストエフォートにフォールバックします — モデルはスキーマに従おうとしますが、 トークンレベルでは制約されません。本番の抽出パイプラインでは、常にstrictモードを使用してください。 スキーマ制約は、理解すれば管理可能です。

ネストされたオブジェクトと配列

ネストされた構造はうまく機能しますが、ネストされた各オブジェクトには独自の"additionalProperties": falseと、 すべてのプロパティをリストする独自の"required"配列が必要です。よくある間違いは、ルートオブジェクトに厳格なルールを適用し、 子を忘れることです — OpenAIはバリデーションエラーでスキーマを拒否します。

python
from openai import OpenAI
import json

client = OpenAI()

order_schema = {
    "type": "object",
    "properties": {
        "order_id": {"type": "string"},
        "customer": {
            "type": "object",
            "properties": {
                "name":  {"type": "string"},
                "email": {"type": "string"}
            },
            "required": ["name", "email"],
            "additionalProperties": False   # required on nested objects too
        },
        "line_items": {
            "type": "array",
            "items": {
                "type": "object",
                "properties": {
                    "description": {"type": "string"},
                    "quantity":    {"type": "integer"},
                    "unit_price":  {"type": "number"}
                },
                "required": ["description", "quantity", "unit_price"],
                "additionalProperties": False  # required on array item schemas too
            }
        },
        "total_usd": {"type": "number"}
    },
    "required": ["order_id", "customer", "line_items", "total_usd"],
    "additionalProperties": False
}

response = client.chat.completions.create(
    model="gpt-4o-2024-08-06",
    messages=[{
        "role": "user",
        "content": (
            "Parse this order: Order #ORD-5531 for Jane Smith ([email protected]). "
            "2x Wireless Keyboard at $49.99 each, 1x USB Hub at $29.99. Total: $129.97"
        )
    }],
    response_format={
        "type": "json_schema",
        "json_schema": {
            "name": "order_extract",
            "strict": True,
            "schema": order_schema
        }
    }
)

order = json.loads(response.choices[0].message.content)
for item in order["line_items"]:
    print(f"${item['unit_price']:.2f} x{item['quantity']}  {item['description']}")

拒否の処理

Structured Outputsを使っていても、モデルは応答を拒否することがあります — 通常はプロンプトが コンテンツポリシー(有害なものからデータを抽出するよう求める)をトリガーする場合です。これが起きたとき、 finish_reason"stop"ですがmessage.contentnullで、 message.refusalが拒否テキストを含みます。これをチェックしないと、 json.loads(None)を呼ぼうとしたときにAttributeErrorが発生します。 finish_reason == "length"にも注意してください — レスポンスがmax_tokensで切れた場合、 Structured Outputsに関わらずJSONは不完全でパースできません。

python
import json
from openai import OpenAI

client = OpenAI()

def extract_invoice(raw_text: str) -> dict | None:
    response = client.chat.completions.create(
        model="gpt-4o-2024-08-06",
        messages=[{"role": "user", "content": f"Extract invoice fields: {raw_text}"}],
        response_format={
            "type": "json_schema",
            "json_schema": {
                "name": "invoice_extract",
                "strict": True,
                "schema": invoice_schema
            }
        },
        max_tokens=1024
    )

    choice = response.choices[0]

    if choice.finish_reason == "length":
        raise ValueError("Response truncated — increase max_tokens or simplify your schema")

    if choice.message.refusal:
        # Model refused to answer — log and return None rather than crashing
        print(f"Model refused: {choice.message.refusal}")
        return None

    return json.loads(choice.message.content)


result = extract_invoice("Invoice #2024-441 from BuildRight Inc., due $3,200 by Dec 15")
if result:
    print(result["vendor_name"], result["total_amount"])

同じスキーマを検証に使う

活用されていないパターンのひとつ:OpenAIに渡すのと同じ JSON Schemaを使って、 他のソースからのデータ — webhook、ファイルアップロード、サードパーティAPI — も同時に検証します。 これによりデータ形状の単一の真実の源が得られます。Pythonではjsonschemaライブラリを使いましょう。 Node.jsではAjvを使います。 コードを書かずに素早く手動でサニティチェックしたい場合は、スキーマを JSONバリデーターに貼り付けることもできます。

python
import json
import jsonschema
from jsonschema import validate, ValidationError

# The same schema used in your OpenAI call
invoice_schema = {
    "type": "object",
    "properties": {
        "vendor_name":    {"type": "string"},
        "invoice_number": {"type": "string"},
        "total_amount":   {"type": "number"}
    },
    "required": ["vendor_name", "invoice_number", "total_amount"],
    "additionalProperties": False
}

def validate_invoice(data: dict) -> bool:
    try:
        validate(instance=data, schema=invoice_schema)
        return True
    except ValidationError as e:
        print(f"Validation failed: {e.message}")
        print(f"  Path: {' -> '.join(str(p) for p in e.path)}")
        return False


# Validate a payload from a webhook — same schema, zero extra work
webhook_payload = json.loads(request_body)
if not validate_invoice(webhook_payload):
    return HTTPResponse(status=400, body="Invalid invoice payload")

# Validate the OpenAI output too, for belt-and-suspenders safety
llm_output = json.loads(openai_response.choices[0].message.content)
assert validate_invoice(llm_output), "LLM output failed schema validation — check schema definition"
print(f"Processing invoice {llm_output['invoice_number']} for ${llm_output['total_amount']}")
スキーマをより速く構築: JSON Schema Generator を使って視覚的にスキーマを作成・洗練しましょう — サンプルJSONオブジェクトを貼り付けると、自動で 出発点となるスキーマを生成します。結果をOpenAIのresponse_formatに直接コピーしてください。

JavaScript / Node.js版

OpenAI Node.js SDKは Python APIをほぼ正確に反映しています。主な違いは、strictが同じように json_schemaオブジェクトの中に置かれ、JSON.parse()でレスポンスをパースすることです。 Ajvでのバリデーションは、Pythonの jsonschemaライブラリのNode.js相当です — 高速で、TypeScriptサポートが優れています。

js
import OpenAI from "openai";
import Ajv from "ajv";

const client = new OpenAI();
const ajv = new Ajv();

const invoiceSchema = {
  type: "object",
  properties: {
    vendor_name:    { type: "string" },
    invoice_number: { type: "string" },
    total_amount:   { type: "number" },
    line_items: {
      type: "array",
      items: {
        type: "object",
        properties: {
          description: { type: "string" },
          amount:      { type: "number" }
        },
        required: ["description", "amount"],
        additionalProperties: false
      }
    }
  },
  required: ["vendor_name", "invoice_number", "total_amount", "line_items"],
  additionalProperties: false
};

const validateInvoice = ajv.compile(invoiceSchema);

async function extractInvoice(rawText) {
  const response = await client.chat.completions.create({
    model: "gpt-4o-2024-08-06",
    messages: [{ role: "user", content: `Extract invoice fields: ${rawText}` }],
    response_format: {
      type: "json_schema",
      json_schema: {
        name: "invoice_extract",
        strict: true,
        schema: invoiceSchema
      }
    }
  });

  const choice = response.choices[0];

  if (choice.message.refusal) {
    throw new Error(`Model refused: ${choice.message.refusal}`);
  }

  const data = JSON.parse(choice.message.content);

  // Validate even though strict mode guarantees structure —
  // useful for catching schema drift between environments
  if (!validateInvoice(data)) {
    console.error("Schema validation errors:", validateInvoice.errors);
    throw new Error("Output failed schema validation");
  }

  return data;
}

const invoice = await extractInvoice(
  "Invoice #INV-881 from Nordic Parts AS. " +
  "3x Brake Pads at $28.50 each. Total: $85.50"
);

console.log(`${invoice.vendor_name} — Invoice ${invoice.invoice_number}`);
invoice.line_items.forEach(item =>
  console.log(`  ${item.description}: $${item.amount}`)
);

まとめ

Structured Outputsは、モデルが午前2時にパーサーを壊すほぼ正しいJSONを返すという 本番バグのカテゴリ全体を排除します。ワークフローはシンプルです:スキーマを慎重に設計し (ネストされた各オブジェクトにはadditionalProperties: falseと完全な required配列が必要)、strict: trueを設定し、拒否と切り詰めを明示的に処理します。 スキーマが整えば、スタック全体で再利用できます — OpenAI呼び出しで、webhook検証で、 テストフィクスチャで — jsonschema(Python)や Ajv(Node.js)などのライブラリで。 スキーマをゼロから始めるなら、JSON Schema Generatorが サンプルペイロードから動作するベーススキーマを得る最速の方法です。信頼性のあるJSON出力に 向けてプロンプトエンジニアリングをする日々は終わりました — それのために作られたツールを使いましょう。