氷河期世代のクラウドエンジニア、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 / 30 | 0 / 30 | 8,220 |
format: "json"(JSON モード) | 30 / 30 | 0 / 30 | 8,070 |
| JSON Schema を渡す | 30 / 30 | 30 / 30 | 8,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行だと分かるまでの遠回りを、この記事に押し込んだ形です。