LLM SDK

JSON で返ってきたから正しい、ではありません ── 構造化出力と Pydantic を読む

AIが書く抽出コードには response_format={"type":"json_object"} と Model(**result) が並んでいます。30回ずつ通したところ、JSON としては30回とも読めて、スキーマとしては30回とも不合格でした。何が保証されて何が保証されないのかを、実測で切り分けます。

  • LLM SDK
  • response_format
  • json_object
  • model_json_schema()
  • model_validate()
  • ValidationError

ここを見れば気づける

  • JSON モードは「JSON であること」しか保証しない。こちらのスキーマは守られない
  • Pydantic のスキーマをそのまま渡すと、pattern 制約でリクエストごと 400 になる
  • 型が合っているだけの値(桁が狂った数値・和暦のままの日付)は検証を素通りする
  • スキーマを渡さずリトライで粘る(実測: 再送は0/10、エラーを返しても1/10。スキーマを渡せば30/30)

証拠の出し方:同じ入力を「お願いするだけ / JSON モード / スキーマ強制」の3通りで30回ずつ通し、JSON パース成功数と Pydantic 検証通過数を分けて数える。リトライは再送・エラー添付の2通りで、合格率と1件あたりトークンを比べる

氷河期世代のクラウドエンジニア、terralien です。「LLM の出力を JSON で受け取って、そのまま後続の処理に流す」というコードは、たぶん今いちばん量産されている形だと思います。

そして、いちばん静かに壊れる形でもあります。JSON として読めてしまうので、例外が出ないんですよね。

数字はすべて手元の実測です。MacBook Pro(Apple M5 Pro / 64GB / macOS 26.6.1)+ Ollama 0.32.14 + gemma4:latest(8.0B・Q4_K_M)、クライアントは Python 3.11.2 + pydantic 2.13.5。2026-08-29 に計測しました。各条件30回ずつ通しています。

用途 ── 「JSON で返して」と「この形で返して」は別の依頼です

構造化出力まわりには、似た顔をした3つの道具があります。保証している範囲がまったく違います。

やり方保証されること保証されないこと
プロンプトでお願いするだけ何も全部。コードブロックの ``` が付いてくることもある
response_format={"type": "json_object"}(JSON モード)構文的に妥当な JSON であることキーの名前・数・型・値。あなたのスキーマは一切見ていない
JSON Schema を渡す(Structured Outputs / Ollama の format)キー・型・必須・列挙値まで値が正しいこと。型が合った嘘は通る

真ん中の JSON モードが、いちばん誤解されています。名前から「JSON にしてくれる機能」と読めるので、スキーマも守ってくれる気がしてしまうんです。

代表要素 ── 3つ

要素何をするか罠
response_format={"type": "json_object"}出力を JSON 構文に限定するキー名も型も見ていない。{} だけ返ってきても合格
Model.model_json_schema()Pydantic モデルから JSON Schema を作るそのまま API に渡せるとは限らない(後述・実測で 30/30 が HTTP 400)
Model.model_validate(obj)辞書をモデルに通し、違反があれば ValidationError例外を投げるだけ。捕まえて何をするかを書かないと、ただの落ち方が変わっただけ

AIが書く例 ── 実際に書かせたもの

手元の Qwen3.8:27b に「請求書テキストから構造化データを抽出する関数を Pydantic で書いて」と頼んだら、これが返ってきました(末尾の抜粋・一字も直していません)。

    response = client.chat.completions.create(
        model="gpt-4o",
        messages=[
            {"role": "system", "content": "You are an expert at extracting structured data from invoices. Return only valid JSON."},
            {"role": "user", "content": prompt}
        ],
        response_format={"type": "json_object"}
    )

    result = json.loads(response.choices[0].message.content)
    return Invoice(**result)

Pydantic モデルもきちんと定義されていて、Field(..., description=...) まで書いてあります。丁寧です。

ただし、この Pydantic モデルは1バイトもモデルに渡っていません。response_format は json_object(JSON であればよい)で、スキーマは「プロンプトの日本語」としてしか伝わっていません。そして最後の Invoice(**result) には try がありません。

監査① ── JSON モードは、あなたのスキーマを守りません

同じ請求書テキストから同じ5項目を抜く処理を、3通りで30回ずつ通しました。判定は2段階です。まず json.loads() が通るか。次に Pydantic の検証が通るか。

やり方JSON として読めたスキーマ検証を通った消費トークン
お願いするだけ30 / 300 / 308,220
format: "json"(JSON モード)30 / 300 / 308,070
JSON Schema を渡す30 / 3030 / 308,070

上2つはJSON としては60回とも読めて、スキーマとしては60回とも不合格でした。JSON モードを付けても付けなくても、合格率は 0 のまま動いていません。

何が違ったのか。返ってきたものを見ると分かります。

{
  "issued_on": "2026-08-28",
  "partner": "株式会社サンプル商事",
  "currency": "円",
  "total": 14168,
  "items": [
    {"name": "事務用チェア", "qty": 7, "unit_price": 1280},
    {"name": "卓上ライト",   "qty": 4, "unit_price": 980}
  ]
}

日付は ISO 形式、金額も明細も完璧です。違うのは "currency": "円" の1箇所だけ。仕様は "JPY" か "USD" でした。30回とも、ここだけを外しています。

役所の書類で、住所も氏名も正しいのに、日付欄だけ和暦で書いたようなものです。読む人には意味が通じます。受け取るのが機械だと、そこで止まります。 そして「JSON モードで返してもらったので大丈夫です」というのは、「ちゃんとペンで書きました」と言っているのと同じで、様式の話をしていません。

json.loads() が通ったことは、何の検証にもなっていません。ここが監査の第一ポイントです。

監査② ── Pydantic のスキーマは、そのままでは渡らないことがあります

「では JSON Schema を渡せばいい」——そのとおりです。そして、AIはたいていこう書きます。

schema = Invoice.model_json_schema()      # Pydantic から自動生成
resp = call(..., format=schema)           # そのまま渡す

これを30回投げた結果が、30回とも HTTP 400 でした。

{"error":{"code":400,"message":"Failed to initialize samplers: failed to parse grammar",
 "type":"invalid_request_error"}}

原因を1つずつ潰して切り分けました。

スキーマに入っていたもの結果
素の type / properties / required だけ200
title(Pydantic が自動で付ける)200
ネストしたオブジェクトの配列200
$defs と $ref(Pydantic がネストで自動生成)200
pattern(正規表現)400

犯人は pattern でした。Python 側では、日付の形式を縛るためにこう書いただけです。

class Invoice(BaseModel):
    issued_on: str = Field(pattern=r"^\d{4}-\d{2}-\d{2}$")   # ← これ

Pydantic としては何も間違っていません。model_json_schema() が pattern を出力するのも仕様どおりです。推論側の文法コンパイラがそれを扱えないだけで、しかも落ち方が「リクエスト全体が 400」なので、どのフィールドが原因かはエラーに出ません。

pattern を1行外したら、同じ30回が 30/30 で検証通過になりました。

これは Ollama 固有の話ではなく、構造化出力を実装しているすべてのサービスに同じ形の制約がある、という話です。提供側は JSON Schema の全機能をサポートしているわけではなく、対応範囲がそれぞれ違います。だから監査ポイントは「pattern を消せ」ではありません。
model_json_schema() の出力を、一度も目で見ずに API へ流し込んでいないか。 ここです。

監査③ ── 型が合った嘘は、検証を素通りします

スキーマを渡せば安心、でもありません。切り分けの途中で、こんな出力が出ました。

{"items": [{"name": "事務用チェア", "qty": 7000000000000000},
           {"name": "事務用チェア", "qty": 7}]}

qty は整数です。スキーマは満たしています。7000兆脚の椅子も整数です。同じ品目が2回出ているのも、配列の要素数を縛っていないので合格です。

別の回では、pattern を外した副作用で日付がこう返りました。

{"issued_on": "2026年8月28日", "currency": "JPY", "total": 14168}

型は string。スキーマ的には完全に合格です。下流で date.fromisoformat() に渡した瞬間に落ちます。

層見ているもの見ていないもの
JSON 構文カッコと引用符キーも型も
JSON Schemaキー・型・必須・列挙値値が現実に合っているか
Pydantic の型Python の型に変換できるか同上
field_validator / 業務ルール桁・範囲・整合(total と明細の合計が一致するか)ここを書かない限り、誰も見ていない

一番下の行が書かれていないコードは、「検証しました」と言えるところまで来ていません。

監査④ ── リトライは、スキーマ不整合の解ではありませんでした

最後がリトライです。「ValidationError を捕まえて投げ直す」は正しい作法に見えます。実際に測ったら、思っていたのと違う結果が出ました。

条件を揃えるため、1回の問い合わせあたりの平均トークンで並べます(リトライありは最大4回まで)。

方針スキーマ合格率1件あたりトークン
お願いするだけ0%(0/30)274
JSON モード0%(0/30)269
JSON モード+同じ内容で再送0%(0/10)1,076
JSON モード+検証エラーを添えて再送10%(1/10)2,642
JSON Schema を渡す(リトライなし)100%(30/30)269

素朴な再送が 0/10 なのは当然です。同じ入力を同じ設定で投げているので、同じ "currency": "円" が返ってきます。変えていないものが変わることを期待しているのがこのループの正体で、消えたのは課金だけでした。

驚いたのは次の行です。検証エラーの内容を添えて投げ直しても、10回中1回しか通りませんでした。しかもトークンは 9.8 倍かかっています。会話履歴を積んで投げ直すので、往復のたびに入力が伸びるからです。

同じ問題を、スキーマを渡すだけで解いた場合の合格率は 100%、コストは 269 トークンでした。リトライ実装のおよそ10分の1です。

書式の違う書類が返ってくるたびに突き返しているのと、最初から記入欄が印刷された用紙を渡すのとの差です。突き返しは、相手が様式を覚えていることに賭けています。用紙を渡すほうは、賭けていません。

だからこの節の監査ポイントは「リトライを書け」ではありません。逆です。

コードの形判定
スキーマを渡さず、リトライで粘っている❌ 順序が逆。まずスキーマを渡す
スキーマを渡した上で、リトライが無い○ 実測ではこれで 30/30。多くの場合ここで足りる
スキーマを渡した上で、上限付きのリトライがある◎ 一時的な失敗に備える形。上限と諦め方が書いてあること
except: continue だけ❌ リトライではなく再送
リトライ上限が無い❌ 失敗が続くほど課金が伸びる

リトライを書くとしても、ValidationError の中身は必ず本文に載せます。e.json() にどのフィールドがなぜ落ちたかが入っているので、そのまま渡せます。

for attempt in range(MAX_RETRY + 1):
    text = call(messages, schema=SCHEMA)      # ★スキーマは毎回渡す
    try:
        return Invoice.model_validate_json(text)
    except ValidationError as e:
        messages += [
            {"role": "assistant", "content": text},
            {"role": "user",
             "content": f"次の検証エラーを直して JSON だけ出し直してください:\n{e.json()}"},
        ]
raise RuntimeError("スキーマに合う出力が得られませんでした")

握り潰して continue するコードは、リトライではなく再送です。 そして再送は、実測で1件も直しませんでした。

隣で読むもの

記事ここと繋がるところ
while True の中でAIが喋り続けますツールの引数も JSON Schema です。スキーマを渡す・検証する構図は同じ
temperature を下げても答えは合いませんmax_tokens で切られた JSON は、この記事の検証層に届く前に壊れています
Amazon Bedrock(AIP-C01 ノート)マネージドサービス側で同じことをどう扱っているか

チートシート

見たもの疑うこと直し方
json.loads() が通ったので OK としているJSON 構文しか見ていないPydantic モデルに通す
response_format={"type":"json_object"}スキーマは守られない(実測 0/30)JSON Schema を渡す形にする
Model(**result) に try が無い例外がそのまま利用者に出るmodel_validate + ValidationError を捕まえる
model_json_schema() をそのまま API へpattern 等でリクエストごと 400一度出力を見る。落ちる制約は Python 側の検証に回す
スキーマは合格するが値が変型の検証しかしていないfield_validator で桁・範囲・整合を書く
リトライで粘っている順序が逆(実測 1/10・コスト9.8倍)先に JSON Schema を渡す(30/30)
リトライ上限が無い課金だけ増える上限と、諦めたときの返し方を決める
読めるようになったか、ひとつだけ確認

Q. 「JSON モードを使っているので、パースエラーは起きません」というレビューコメントは正しいですか?

前半は正しく、結論が足りません。JSON 構文としては実測で 30/30 読めました。同じ30回のうち、こちらが定義したスキーマを満たしたものは 0 件です。JSON モードが守るのはカッコと引用符であって、キーの名前でも列挙値でもありません。

偉そうに書きましたが、この記事の計測コードを書くにあたって、こちらも Field(pattern=...) を素直に渡して 400 を30回もらっています。エラーメッセージが failed to parse grammar だけだったので、しばらく自分のリクエストの組み立て方を疑っていました。犯人が正規表現1行だと分かるまでの遠回りを、この記事に押し込んだ形です。

出典