[AI-103 Hands-On] 第3回: JSON構造化出力の方法


こんにちは、Lena Nadir Blog 管理人です。

第1回ではWeb Search Toolを使ったAgentを、第2回ではFile SearchによるRAGを扱いました。第3回では、この2つを組み合わせます。Web Searchで実際の求人票を検索しつつ、その内容をAI-103のGenerative AI/Agentドメインでも問われる構造化出力(Structured Outputs)でJSON化する、という実務でよくあるパターンです。

「Web検索で見つけた非構造テキストを、LLMでJSON形式に変換する」というのは多くの実装で当たり前に行われていますが、そのやり方には大きく2通りあります。

  1. Pattern A: Instructionsのみ: 自然言語で「このJSON形式で出力して」と指示する
  2. Pattern B: Instructions + JSON Schema: strict: trueのJSON Schemaで構造そのものを強制する

「使った方がいいのは知っているけど、実際どれくらい違うのか」を、感覚ではなく実測で確認します。


今回検証すること

同じ検索+抽出タスクを、上記2通りのやり方でそれぞれ複数回実行し、以下を比較します。

  • パース成功率: 出力がそのままjson.loadsできるか
  • フィールド完全一致率: 期待したフィールドが過不足なく揃っているか
  • 型・値の検証成功率(Type-valid rate): フィールド名だけでなく、値の型・語彙まで正しいか
  • 出力構成の揺れ: 同じ指示でも、出力されるキー構成が何パターンに分かれるか
  • リトライを含めた実質トークンコスト: パース失敗時に再試行が発生した場合、その分のトークンも含めた「1件の成功あたりのコスト」

検証タスク

Web Searchで実際の求人票(東京勤務のシニアバックエンドエンジニア)を検索し、そのテキストから決まったフィールドを抽出します。検索と抽出は、後述する通り同じ1回の呼び出しの中で行います。

抽出したいフィールド:

EXPECTED_FIELDS = {
    "role",
    "location",
    "primary_languages",
    "dev_environment",
    "required_skills",
    "salary_min",
    "work_style",
}

比較するために、Agentを2つ作成する

Pattern AとPattern Bは、それぞれ専用のFoundry Agentをproject.agents.create_version(...)で作成し、agent_reference経由で呼び出します。

📝 注記: agent_reference()には、AGENT_NAME_Aのような名前の文字列定数ではなく、作成したagent_aagent_bオブジェクトそのものを渡します。名前を文字列でハードコードすると、Agentを再作成してバージョンが上がった際にコード側の指定が古いバージョンのまま取り残される可能性がありますが、agent.nameagent.versionをオブジェクトから直接読み取ることで、常に「その実行で今しがた作成したバージョン」を確実に呼び出せます。

両Agentのmodel・Instructions・Tools(Web Search)は完全に同一で、違いは「JSON Schemaを設定するかどうか」だけです。あえてフィールド名だけを伝え、型(integerarray of strings)やwork_styleのenum値はInstructionsに書いていません。型ヒントまで自然言語で書いていません。

from azure.ai.projects.models import PromptAgentDefinition, WebSearchTool

AGENT_NAME_A = "job-posting-agent-instructions-only"
AGENT_NAME_B = "job-posting-agent-json-schema"

# NOTE: 型・enumのヒントはあえて書いていない(理由は本文参照)
AGENT_INSTRUCTIONS = """You are an assistant that finds job postings and extracts structured data from them.

Given a role and location in the user message, find exactly one currently
open job posting matching those criteria, from a job board or a company's
own careers page.

From that posting, extract the following fields and return them as JSON:
- role
- location
- primary_languages
- dev_environment
- required_skills
- salary_min
- work_style

Return ONLY the JSON object, no other text.
"""

JSON_SCHEMA = {
    "type": "object",
    "properties": {
        "role": {"type": "string"},
        "location": {"type": "string"},
        "primary_languages": {"type": "array", "items": {"type": "string"}},
        "dev_environment": {"type": "array", "items": {"type": "string"}},
        "required_skills": {"type": "array", "items": {"type": "string"}},
        "salary_min": {"type": "integer"},
        "work_style": {"type": "string", "enum": ["remote", "hybrid", "onsite"]},
    },
    "required": [
        "role",
        "location",
        "primary_languages",
        "dev_environment",
        "required_skills",
        "salary_min",
        "work_style",
    ],
    "additionalProperties": False,
}

# Pattern A用: スキーマなし
agent_a = project.agents.create_version(
    agent_name=AGENT_NAME_A,
    definition=PromptAgentDefinition(
        model=model,
        instructions=AGENT_INSTRUCTIONS,
        tools=[WebSearchTool()],
    ),
    description="Finds a job posting on the web and extracts structured fields from it as JSON (no schema).",
)

# Pattern B用: 同じInstructions・Toolsに、textでJSON Schema(strict)を追加
agent_b = project.agents.create_version(
    agent_name=AGENT_NAME_B,
    definition=PromptAgentDefinition(
        model=model,
        instructions=AGENT_INSTRUCTIONS,
        tools=[WebSearchTool()],
        text={
            "format": {
                "type": "json_schema",
                "name": "job_posting_extraction",
                "strict": True,
                "schema": JSON_SCHEMA,
            }
        },
    ),
    description="Finds a job posting on the web and extracts structured fields from it as JSON (schema-enforced).",
)

共通処理: agent_referenceと検索クエリ

agent_reference()と検索クエリ(WEB_SEARCH_QUERY)は、Pattern A・Pattern Bのどちらからも共通で使う部分なので、先にまとめて定義しておきます。

def agent_reference(agent):
    return {
        "agent_reference": {
            "name": agent.name,
            "version": agent.version,
            "type": "agent_reference",
        }
    }


WEB_SEARCH_QUERY_JA = (
    "日本語の求人サイトや企業の採用ページで、東京勤務のシニアバックエンド"
    "エンジニアの求人を探してください。"
)

WEB_SEARCH_QUERY_EN = (
    "Find a senior backend engineer job posting based in an English-speaking "
    "country (e.g. the US, UK, Canada, or Australia), from an English-language "
    "job board or a company's own careers page."
)

# "en" に切り替えると、英語の求人票を対象にします
SEARCH_LANGUAGE = "ja"  # "ja" | "en"
WEB_SEARCH_QUERY = WEB_SEARCH_QUERY_EN if SEARCH_LANGUAGE == "en" else WEB_SEARCH_QUERY_JA

WEB_SEARCH_QUERYは「今回検索してほしい条件」であり、Pattern A・Bのどちらの呼び出しでもinputにそのまま渡します。検索の指示やJSON化のルールはAgent側のInstructionsに既に含まれているため、呼び出し側で毎回書く必要はありません。


Pattern A: WebSearchTool + Instructionsのみ

agent_a(スキーマなし)を1回呼び出すだけで、検索から回答生成までが完結します。

response = openai_client.responses.create(
    input=WEB_SEARCH_QUERY,
    extra_body=agent_reference(agent_a),
)

extra_body=agent_reference(agent_a)は、responses.create()にAzure独自の拡張パラメータとしてagent_referenceを渡すための書き方です。これにより、このリクエストは生のモデルに対してではなく、agent_aという名前・バージョンで指定したAgentに対して実行されます。


Pattern B: WebSearchTool + Instructions + JSON Schema

呼び出し方はPattern Aと全く同じで、参照するAgentをagent_bに変えるだけです。呼び出し時にtextを渡す必要はありません(Agent定義に既に設定済みのため)。

response = openai_client.responses.create(
    input=WEB_SEARCH_QUERY,
    extra_body=agent_reference(agent_b),
)

agent_bにはadditionalProperties: falserequiredを明示したスキーマが設定されているため、モデルが出力できる形が厳密に制約されます。パース失敗によるリトライも不要です——ただしそれは、モデルが正常に応答を完了した場合に限られます。

⚠️ 留意点: JSON Schema(strict)が出力形状を保証するのは、あくまでモデルが正常に応答を生成できた場合だけです。モデルの安全性・コンテンツポリシー判断により拒否(refusal)が発生した場合——たとえば"I'm sorry, but I cannot assist with that request."のような応答が返る場合——その拒否メッセージは自由記述の文字列であり、JSON Schemaの制約を経由しないため、try_parse_json()は失敗します。実際にSEARCH_LANGUAGE = "en"で検証したところ、Web検索で取得した一部の求人ページがこの拒否を誘発し、Pattern Bのパース成功率も100%を下回りました。つまり「Pattern Bはリトライ不要」が成り立つのは拒否が発生しない場合に限られ、本番運用ではPattern Aだけでなく、Pattern Bにもリトライ/フォールバック処理を用意しておくべきです。なお、この検証は日本国内のAzure環境から、US/UK/Canada/Australiaの求人を検索する形で実行しています。Web Search Toolの検索結果はリソースのリージョンによって変わり得るため、日本から英語圏の求人を検索するというミスマッチ自体が、不安定さの一因になっている可能性があります。この検証だけではその変数を切り分けられていないため、確定した原因ではなく仮説として扱ってください。


実測結果

stability_benchmark.py(モデル: gpt-4.1-mini、各パターン20回ずつ、それぞれ独立してライブ検索)を実行した結果です。

指標Pattern A: WebSearch + InstructionsのみPattern B: WebSearch + Instructions + JSON Schema
パース成功率(出力がjson.loadsできたか)100%100%
フィールド完全一致率(キー名だけの一致)90%100%
work_styleの表記ゆれ(20回中の異なる表現数)17通り2通り
出力構成のばらつき(異なるキー構成の種類数)2種類1種類
リトライ発生回数(20回合計)1回0回
平均入力トークン数(成功時)21031.320572.5
平均出力トークン数(成功時)306.1225.4
合計トークン数(入力+出力、参考)21337.420797.9

コード内部の判定ロジックでは「型・値の検証成功率(Type-valid rate)」も算出しており(結果はPattern A 0% / Pattern B 100%、生ログは後述)、これ単体を見るとPattern Aが大きく劣っているように見えます。ただしagent_aにはそもそも型やwork_styleのenum値を一切伝えていないため、schemaで定義されたenumと一致しないのは事前情報がない以上当然の結果でもあり、これだけをPattern Aの欠陥として評価するのはフェアではありません。そこで上の表では、同じ意味内容を、Pattern Aがどれだけ表記ゆれなく一貫して出力できているかという、より公平な指標(異なる表現の出現数)で比較しています。次のwork_styleの出力値一覧は、まさにその表記ゆれの実態を示しています。

salary_minの値の型は、両パターンとも20回中20回すべてint型で出力されました。差が出たのはwork_styleです。

work_styleの出力値の内訳(Pattern A: WebSearch + Instructionsのみ、20回、上位3件を抜粋)

出力値出現回数
一部リモート可4回
フルリモート可, フレックスタイム制度, 副業制度あり1回
フルリモート可、フレックスタイム、服装自由、副業制度あり1回
…(他14通り、いずれも1回ずつ)-

いずれも求人票の原文表現をそのまま書き出したもので、20回で17通りの異なる表記が出現しています(全件は末尾の生ログを参照)。enum(remote / hybrid / onsite)のどれにも一致しない、というより先に、そもそも同じ意味の情報が毎回バラバラな表記で出力されている、という表記ゆれの大きさ自体が問題です。

work_styleの出力値の内訳(Pattern B: WebSearch + Instructions + JSON Schema、20回)

出力値出現回数
hybrid19回
remote1回

両方ともenumの範囲内の値であり、types_validは20回ともTrueです(実際の求人内容によってhybridremoteかが変わるのは、検索結果の違いによる自然な差であり、型崩れではありません)。

実行結果の生ログ(参考)
--- Pattern A: WebSearch + Instructions only (with retry) ---
  Parse success rate               : 100%
  Field-set match rate (keys only) : 90%
  Type-valid rate (keys + types)   : 0%
  Distinct field-set count (lower = more consistent): 2
  work_style value distribution: {'一部リモート可': 4, '月給制、フレックスタイム制、コアタイムなし、育児・介護による時短勤務制度あり、転勤なし': 1, 'フルリモート可, フレックスタイム制度, 副業制度あり': 1, 'フルリモート可、フレックスタイム、服装自由、副業制度あり': 1, 'フルリモート可、フレックスタイム制度、副業制度あり': 1, 'フレックスタイム制、リモートワーク可、コアタイムなし': 1, 'フルリモート可、フレックスタイム制、正社員': 1, 'リモートワーク可、フレックスタイム制度、副業制度あり、私服勤務OK': 1, '一部リモート可、フルフレックス制度あり': 1, '一部リモート可, フルフレックス': 1, '正社員、フレックスタイム制、リモートワーク可、コアタイムなし、転勤なし': 1, '正社員、一部リモート可、フルフレックス勤務可能': 1, 'リモートワーク可(フルリモート可能)、フレックスタイム制度あり、正社員': 1, 'リモートワーク可、フレックスタイム制度、完全週休二日制(土日)、土日祝日休み、有給休暇、年末年始休暇、慶弔休暇': 1, '一部リモート可, 正社員, フルフレックス': 1, 'フレックスタイム制度あり、リモートワーク可(一部リモート可)': 1, 'フレックスタイム制、リモートワーク制度あり、コアタイムなし、週2日程度の出社あり': 1}
  salary_min value type distribution: {'int': 20}
  Total retries needed across all runs: 1
  Avg input tokens per successful record : 21031.3
  Avg output tokens per successful record: 306.1
  Avg total tokens per successful record : 21337.4

--- Pattern B: WebSearch + Instructions + JSON Schema ---
  Parse success rate               : 100%
  Field-set match rate (keys only) : 100%
  Type-valid rate (keys + types)   : 100%
  Distinct field-set count (lower = more consistent): 1
  work_style value distribution: {'hybrid': 19, 'remote': 1}
  salary_min value type distribution: {'int': 20}
  Total retries needed across all runs: 0
  Avg input tokens per successful record : 20572.5
  Avg output tokens per successful record: 225.4
  Avg total tokens per successful record : 20797.9

わかったこと

  • 安定性: Pattern Bの一番のメリットは、JSON Schema(strict)によって出力形式そのものを強制できるため、出力後にバリデーション/後処理をする必要がないことです。実際Pattern Aはwork_styleが20回の出力で17通りもの異なる表現に分かれており(表記ゆれ)、下流で使う前に正規化処理が必須になります。一方Pattern Bはenumの範囲内の値(hybrid/remote)しか返ってこないため、そのまま安心して使えます
  • コスト: Pattern Aは20回中1回リトライが発生しましたが、Pattern Bはリトライなしで毎回1回の呼び出しだけで完了しました。合計トークン数もPattern A 21337.4 / Pattern B 20797.9と大差はなく(約2.5%減)、単価も同じなので、コスト面で大きな差にはなりませんでした

なぜこうなるのか

  • JSON Schema(strict)は生成時にトークンレベルで無効な出力をマスクするため、enum外の値や指定と異なる型をそもそも生成できません
  • Instructionsのみの場合、モデルは「どの語彙を使うべきか」を自然言語の説明からしか推測できず、型・enumのヒントがなければwork_styleのような分類系フィールドは求人票の生の表現をそのまま書き出してしまいます
  • JSON Schemaはagent_referenceの呼び出し時ではなく、Agent定義(PromptAgentDefinitiontextパラメータ)に設定する必要があります(response_formatというパラメータは存在しません)

まとめ

  • Instructionsのみでの構造化出力は、work_styleのように表記ゆれが起きやすく(20回で17通り)、下流で使う前に正規化などの後処理が必要になる
  • JSON Schema(strict: true)は値の型・enumを構造として保証できるため後処理が不要。ただしagent_referenceの呼び出し時ではなく、Agent定義(PromptAgentDefinitiontextパラメータ)に設定する必要がある
  • コスト面はPattern A/Bで大差なく(トークン数はほぼ同等、Pattern Bはリトライも不要)。本番運用でJSONの構造(特にenumのような分類フィールド)が重要なタスクには、JSON Schemaを組み合わせるのが安全

次回(第4回)は、Responsible AIのガードレール設定を扱います。


確認テスト(AI-103形式)

AI-103本番の出題形式(シナリオ設定+最適な選択肢を選ぶ形式)に寄せた3問です。今回の内容の理解度をチェックしてみましょう。


Q1. InstructionsのみでJSON出力を指示した場合に発生しやすい問題として、最も適切なものはどれですか。

  • A トークン単価が上昇する
  • B パースには成功しても、enumのような分類フィールドの値が期待した語彙から外れることがある
  • C モデルがツールを呼び出せなくなる
  • D 認証エラーが発生しやすくなる
解答を見る

正解: B

Instructionsのみの場合、モデルの出力形式は自然言語の指示に依存するため、JSONとしてのパース自体は成功しても、分類系フィールドの値が期待した語彙(enum)から外れやすくなります。トークン単価(A)は変わらず、認証(D)やツール呼び出し(C)とは無関係です。


Q2. text.format.json_schemastrict: trueを指定する主な効果はどれですか。

  • A レスポンス速度が必ず高速化する
  • B モデルが生成時に、スキーマに違反するトークンを出力できないよう制約される
  • C 使用できるモデルの種類が増える
  • D 入力トークンの課金が免除される
解答を見る

正解: B

strict: trueは、生成時にスキーマに準拠しないトークンをマスクする仕組み(Context-Free Grammarベースの制約)によって、出力の構造を強制します。速度(A)や課金免除(D)とは別の話です。


Q3. 「JSON Schemaを使うとコストが下がる」という主張について、最も正確な説明はどれですか。

  • A JSON Schemaは1トークンあたりの単価そのものを下げる
  • B JSON Schemaは無料で使えるため、そもそもコストが発生しない
  • C 単価は変わらないが、パース失敗によるリトライの減少や出力の簡潔化により実質的な運用コストが下がりやすい
  • D JSON Schemaを使うと、常にInstructionsのみより高コストになる
解答を見る

正解: C

Structured OutputsとJSON Schemaなしのリクエストは同じトークン単価で課金されます。コスト面での実質的な差は、パース失敗によるリトライの発生有無や、出力トークン数(自由記述かenumの短い1単語か)の違いから生まれます。


コード一式

この記事で使用したコードは、GitHubリポジトリの azure/ai-103/episode-03-structured-output/ に公開しています。

👉 tech-hands-on-labs (GitHub)


💡 本番前に、実力を確認したい方へ

一通り手を動かした後、自分の理解度が試験レベルに達しているかを確認したい方向けに、Udemyで『【2026年版】AI-103 模擬試験集|良問厳選・Azure AI アプリ&エージェント開発 試験対策』を公開しています。

🎟 期間限定クーポン: AI103BLOG1000YENOFF(1,000円オフ、2026/09/08まで) → クーポン適用リンクはこちら

© 2026 Lena Nadir. All rights reserved.