はじめに — デモでは動いたのに、本番で静かに壊れるやつ
LLMをアプリに組み込み始めたとき、たぶん多くの人が一度はこういうコードを書きます。
# よくある「お願いベース」のコード(本番で静かに壊れる)
import json
import re
prompt = "次の問い合わせメールを分類して、JSONで返して。{category, priority, summary}"
response_text = call_llm(prompt) # ← LLMの生テキスト
# テキストからJSONっぽい部分を正規表現で抜き出す
match = re.search(r"\{.*\}", response_text, re.DOTALL)
data = json.loads(match.group()) # ← ここでよく落ちる
ローカルで5回くらい試して、ちゃんと動いて、「よし、いけた」と思ってデプロイする。そして本番で、ある日突然 json.JSONDecodeError で落ちる。ログを見たら、LLMがこんなのを返していた…という経験、ありませんか。
承知しました。以下が分類結果です。
```json
{
"category": "請求",
"priority": "high",
"summary": "二重請求についての問い合わせ"
}
```
ご確認ください。
前置きの「承知しました」、Markdownのコードフェンス、後ろの「ご確認ください」。re.search(r"\{.*\}") がこの全部をうまく拾えるかは、その日のLLMの気分次第。LLMの出力を「ただの文字列」として扱った瞬間に、本番は静かに壊れ始めます。
この記事は、その「お願いして、regexで抜いて、祈る」というやり方を卒業して、出力の形をこっちが先に決めて、その形でしか返ってこないようにする「構造化出力(Structured Outputs)」へ移行するための、実践的なガイドです。
正直に言いましょう。これは派手な話ではないです。でも、LLMを「すごいデモ」から「本番で何ヶ月も動き続ける部品」に変えるための、いちばん地味で、いちばん効く一歩なんですよね。AI開発にまだ不慣れな方でも、用語の意味・なぜ大事か・どこで使うか・最初の一歩まで、省略せずいきます。
この記事で持って帰れるもの:
- 「JSON mode」「構造化出力(strict)」「function calling(tool use)」の 違いと使い分け
- OpenAI / Claude / Gemini の 2026年時点の挙動の違い(ここを知らないと事故ります)
- Python(Pydantic)と TypeScript(Zod)の、そのまま動く実装(検証+自動リトライつき)
- そして、ほとんどの記事が触れない 「構文は正しいのに中身が間違っている」問題 への対処
1. そもそも「構造化出力」って何なんでしょう
専門用語をいきなり並べる前に、一個だけ比喩を置かせてください。
構造化出力とは、AIに「自由作文」をさせるのではなく、こっちが用意した"記入用紙"の枠の中だけで答えさせる仕組みのことです。
役所の窓口を想像してください。「あなたのことを自由に書いてください」と白紙を渡すと、人によって便箋にびっしり書く人もいれば、一行で終わる人もいる。これを集計するのは地獄です。だから役所は 記入欄が決まった用紙 を配る。「氏名」「住所」「生年月日(西暦)」と枠が決まっていれば、誰が書いても同じ形で集まる。
構造化出力は、これとまったく同じ発想です。LLMに白紙の作文をさせるのをやめて、「ここに category、ここに priority、ここに summary を書いてね」という**枠(スキーマ)**を先に渡す。すると、出力が毎回同じ形で返ってくる。
登場人物を整理しておきましょう
この分野、言葉が多くて最初みんな混乱します。先に登場人物を紹介しておきます。
- JSON Schema(ジェイソン・スキーマ): 「JSONはこういう形であるべき」を機械が読める形で書いた仕様書。これが"記入用紙の設計図"です。「category は文字列」「priority は high/medium/low のどれか」みたいに書く。
- JSON mode: LLMに「とにかく壊れていないJSONを返してね」とだけお願いするモード。形(スキーマ)までは強制しない。構文は守るけど、中身の項目までは保証しないゆるめのやつ。
- 構造化出力(Structured Outputs / strict mode): JSON Schema を渡して、その形でしか返せないように生成を制約する仕組み。いちばん強い。
- function calling(tool use / ツール呼び出し): 本来は「AIに道具を使わせる」機能だけど、その引数が JSON Schema で定義されるので、構造化出力の手段としても使える。
- Pydantic(パイダンティック)/ Zod(ゾッド): それぞれ Python と TypeScript で、「データがこの形になっているか」を検証するためのライブラリ。スキーマをコードで書いて、受け取ったデータを叩いて確かめる"検品係"だと思ってください。
この5人が分かれば、もう8割は読めます。
2. なぜ「お願いベース」だと、本番で静かに壊れるのか
「プロンプトに『JSONで返して』って書けば、だいたい返ってくるじゃないですか」。そうなんです。だいたいは返ってくる。問題は、その「だいたい」が99%とかで、残りの1%が本番で牙をむくところなんですよね。
LLMが「お願いベース」のときにやらかす、典型的な壊れ方を並べておきます。
- 前置き・後置きの混入 — 「承知しました」「以上です」が本文にくっつく
-
コードフェンス —
json ...で囲んでくる -
フィールドの欠落 —
summaryを書き忘れる、あるいは勝手に増やす -
型のゆらぎ —
priorityに"high"ではなく"とても高い"を入れてくる - 末尾カンマ・シングルクォート — JSONとして不正な構文を混ぜる
ここで大事なのは、これはLLMが「バカだから」起きるんじゃないということです。LLMは「それっぽいテキストを続ける」装置なので、放っておけば自然な文章として前置きをつけたくなる。枠をはめていないこっちの設計ミスなんですよね。
構造化出力(strict)が効くのは、ここに技術的なカラクリがあるからです。難しく聞こえるかもしれませんが、一言で言うと 「文法制約デコード(constrained decoding)」。
LLMは1文字ずつ「次に来そうな文字」を選んで出力していきます。構造化出力では、スキーマ上「次は " か、決められた値しか来てはいけない」という場面で、それ以外の文字の確率を強制的にゼロにする。つまり、ルール違反の文字は物理的に出力できないようにする。だから「だいたい」が「ほぼ必ず」に変わるわけです。
OpenAI はこの仕組みを使った構造化出力(strict mode)について、公式に失敗率0.1%未満と説明しています。「お願いベースで祈る」のとは、信頼性の桁が変わるんですよね。
3. 3つの方式の使い分け(と、2026年の各社事情)
ここがこの記事のキモのひとつです。「どれを使えばいいの?」に決着をつけましょう。
| 方式 | 何をするか | 強制力 | 向いている場面 |
|---|---|---|---|
| JSON mode | 壊れていないJSONであることだけ保証 | 弱(構文のみ) | スキーマがゆるくていい・とにかく落ちないJSONが欲しい |
| 構造化出力(strict) | JSON Schemaの形で生成を制約 | 強(構文+形) | 出力をそのままDB・APIに流す・形が崩れたら困る |
| function calling(tool use) | ツールの引数として構造化データを得る | 中〜強(モデル依存) | AIに複数の道具から選ばせる・エージェント的な処理 |
そして、ここから先が 2026年時点で本当に大事な注意点 です。各社で挙動が違います。
-
OpenAI: 構造化出力(strict mode)が成熟していて、前述の通り文法制約デコードでスキーマ準拠をほぼ保証します(失敗率0.1%未満)。
response_formatに JSON Schema を渡すか、SDKの.parse()を使う。 -
Anthropic(Claude): 構造化出力は tool use(function calling)の仕組み を通して行います。ただし 2026年4月時点の公式ドキュメントで、「
strictパラメータは現状ツール定義では無視される。Claudeはベストエフォートで有効な引数を返すが、スキーマ準拠は保証しない」 と明記されています。つまりClaudeでは「strictだから安心」は通用せず、受け取った後の検証とリトライが必須になります。 -
Gemini: 2.0以降は
responseJsonSchema/response_schemaというネイティブ機能があり、additionalProperties: false(=決めた項目以外は禁止)にも対応しています。コスト対信頼性のバランスが良いと言われています。
ここから導ける、迷ったときの判断はシンプルです。
どのモデル・どの方式を使っても、受け取った後に必ず自分で検証する。
OpenAIの保証が0.1%未満でも、ゼロではない。Claudeに至っては公式に「保証しない」と言っている。だから「プロバイダが保証してるから検証はいらない」は、いちばんやってはいけない油断なんですよね。 ここは太字で胸に刻んでおきましょう。保証ありでも、検証は省かない。
4. 実装①:Python で「スキーマファースト+検証+自動リトライ」
では、実際に「壊れない部品」を作っていきます。設計の順番がすごく大事で、プロンプトから書き始めない。まずスキーマ(記入用紙)から書く。これを「スキーマファースト」と言います。
ここでは Pydantic を使います。Pydantic は「データがこの形か」をPythonのクラスで定義して検証できるライブラリです。
from enum import Enum
from pydantic import BaseModel, Field
# --- Step 1: まず「記入用紙」を定義する(スキーマファースト)---
class Priority(str, Enum):
high = "high"
medium = "medium"
low = "low"
class Category(str, Enum):
billing = "billing" # 請求
technical = "technical" # 技術的問題
account = "account" # アカウント
other = "other" # その他
class TicketDraft(BaseModel):
# description は飾りではなく、LLMへの"記入の指示書"になる。必ず書く。
category: Category = Field(description="問い合わせの主題。最も近いものを1つ選ぶ")
priority: Priority = Field(description="緊急度。即時対応が要るものだけ high")
summary: str = Field(description="問い合わせ内容を日本語50字以内で要約")
needs_human: bool = Field(description="人間のオペレーターによる確認が必要なら true")
ポイントは2つ。Enum で値を閉じること(priority に「とても高い」が入る事故を構造で防ぐ)。そして description を全フィールドに書くこと。この description は、人間向けのコメントではなく、LLMが「ここに何を書けばいいか」を読むための指示書になります。ここを丁寧に書くだけで精度がぐっと上がるんですよね。
次に、これを使って実際にLLMを呼び、検証して、ダメなら自動でやり直すループを書きます。これは「Instructorパターン」と呼ばれる、2026年の定番です。
from pydantic import ValidationError
def extract_ticket(email_text: str, max_retries: int = 2) -> TicketDraft:
"""問い合わせメールからチケット草案を構造化抽出する。
検証に失敗したら、エラー内容をLLMに伝えて自動リトライする。
"""
messages = [
{"role": "system", "content": "あなたはサポート問い合わせを分類する係です。"},
{"role": "user", "content": f"次のメールを分類してください:\n\n{email_text}"},
]
last_error = None
# max_retries は必ず上限を設ける。無限ループはコスト爆発の元。
for attempt in range(max_retries + 1):
# OpenAIの構造化出力(strict)を使う例。response_formatにスキーマを渡す。
completion = client.beta.chat.completions.parse(
model="gpt-5.4",
messages=messages,
response_format=TicketDraft, # ← ここでスキーマを強制
)
raw = completion.choices[0].message
try:
# .parsed は構造化出力をPydanticモデルに変換済み。だが念のため再検証する。
ticket = TicketDraft.model_validate(raw.parsed.model_dump())
return ticket # 検証OK。これでようやく「信頼できる部品」になった
except ValidationError as e:
last_error = e
# 失敗の理由をそのままLLMに伝えて、次の試行で直してもらう
messages.append({"role": "assistant", "content": str(raw.parsed)})
messages.append({
"role": "user",
"content": f"前回の出力は検証に失敗しました。次のエラーを直して再出力してください:\n{e}",
})
raise RuntimeError(f"{max_retries}回リトライしても検証に通りませんでした: {last_error}")
このコードの肝は3つあります。
-
response_format=TicketDraftでスキーマを強制している — 構文の崩れはここでほぼ防げる -
受け取った後に
model_validateで再検証している — 保証ありでも、ここを省かない - 失敗したら、エラー文をそのままLLMに食わせてリトライ — しかも 回数に上限がある
3つ目、地味だけど超重要です。リトライは無限ループにすると、LLMがずっと同じ間違いを繰り返してAPI課金だけが膨らむという、笑えない事故が起きます。リトライには必ず上限を。 これも明日の自分を守る一行なんですよね。
5. 実装②:TypeScript で Zod を使う
フロントやNode.jsのバックエンドなら、Pydanticの相棒にあたる Zod を使います。考え方はまったく同じ。まずスキーマから書く。
import { z } from "zod";
import OpenAI from "openai";
import { zodResponseFormat } from "openai/helpers/zod";
const client = new OpenAI();
// --- Step 1: 記入用紙をZodで定義 ---
const TicketDraft = z.object({
category: z
.enum(["billing", "technical", "account", "other"])
.describe("問い合わせの主題。最も近いものを1つ選ぶ"),
priority: z
.enum(["high", "medium", "low"])
.describe("緊急度。即時対応が要るものだけ high"),
summary: z.string().max(50).describe("問い合わせ内容を日本語50字以内で要約"),
needsHuman: z.boolean().describe("人間の確認が必要なら true"),
});
type TicketDraft = z.infer<typeof TicketDraft>;
// --- Step 2: 呼び出し+検証 ---
async function extractTicket(emailText: string): Promise<TicketDraft> {
const completion = await client.chat.completions.parse({
model: "gpt-5.4",
messages: [
{ role: "system", content: "あなたはサポート問い合わせを分類する係です。" },
{ role: "user", content: `次のメールを分類してください:\n\n${emailText}` },
],
// Zodスキーマをそのまま構造化出力のフォーマットに変換して渡す
response_format: zodResponseFormat(TicketDraft, "ticket"),
});
const parsed = completion.choices[0].message.parsed;
// 念押しでZodでも検証する(保証ありでも省かない)
return TicketDraft.parse(parsed);
}
z.infer で スキーマからTypeScriptの型が自動で生まれるのが気持ちいいところです。スキーマ・検証・型が一本につながるので、「LLMの戻り値、any でいいか…」みたいな妥協がなくなる。これも未来の自分への小さなプレゼントですね。
6. 最大の落とし穴 — 「構文が正しい」と「中身が正しい」は、別の話
ここからが、この記事でいちばん伝えたいところです。多くの記事は「構造化出力を使えば信頼できる」で終わります。でも、それは半分しか本当じゃないんですよね。
落ち着いて考えてみましょう。構造化出力が保証してくれるのは、「形(構文・スキーマ)が正しいこと」だけです。中身(意味)が正しいことは、一切保証してくれません。
たとえば、さっきの TicketDraft。構造化出力を通れば、priority には必ず high/medium/low のどれかが入ります。でも、それが「正しい緊急度か」は別問題です。LLMが「パスワードを忘れた」という軽い問い合わせを high に分類しても、スキーマ的にはピカピカに正しい。summary に事実と違う要約が入っていても、文字列であることは満たしている。
もっと怖い例を挙げます。LLMに「確信度(confidence)を0〜1で返して」とスキーマで強制したとします。返ってくる confidence: 0.95 は、それっぽい数字を生成しただけで、統計的な根拠はどこにもない。構造化出力は、捏造された数字に「正しい型の服」を着せてしまうんです。これは知らないと、本当に怖い。
構文準拠(schema compliance)は、意味的信頼(semantic reliability)ではない。
構文的に正しいJSONでも、誤った注文IDや、捏造された確信度を含みうる。
だから、構造化出力の後ろに、もう一段 「セマンティック検証(意味の検証)」 を置きます。これは、そのドメインのビジネスルールで中身を叩く処理です。
def semantic_check(ticket: TicketDraft, email_text: str) -> list[str]:
"""構文ではなく「意味・業務ルール」で中身を検証する。
返り値は違反メッセージのリスト(空なら合格)。
"""
issues: list[str] = []
# ルール1: high と言うからには、本文に緊急性の根拠があるはず
urgent_words = ["至急", "今すぐ", "止まっ", "ログインできない", "二重請求"]
if ticket.priority == Priority.high and not any(w in email_text for w in urgent_words):
issues.append("priority=high だが、本文に緊急性を示す語が見当たらない")
# ルール2: 要約が本文よりやたら長い、または空はおかしい
if not ticket.summary.strip():
issues.append("summary が空")
if len(ticket.summary) > len(email_text):
issues.append("summary が原文より長い(要約になっていない)")
# ルール3: 請求カテゴリなのに人間確認不要は、運用ポリシー上あやしい
if ticket.category == Category.billing and not ticket.needs_human:
issues.append("請求案件は needs_human=true が原則(要確認)")
return issues
ポイントは、セマンティック検証はLLMに任せず、人間が決めたルールをコードで書くということです。ここはAIに丸投げしてはいけない領域。「請求案件は必ず人間が確認する」みたいな業務上の約束ごとは、こっちが責任を持って明文化する。AIは形を埋める係、人間は意味の番人。この役割分担がはっきりすると、システムが一気に頑丈になるんですよね。
7. スキーマ設計の原則 — 失敗率は「設計」で下げられる
精度が出ないとき、つい「プロンプトをもっと工夫しなきゃ」と思いがちです。でも実は、スキーマの設計を直すほうが効くことが多いんですよね。2026年時点で共有されている原則を、表にまとめておきます。
| 原則 | なぜ効くか | 具体的にどうする |
|---|---|---|
| フラットに保つ | ネストが深いほど失敗率が上がる | 3階層以上の入れ子は避け、可能なら平らに展開する |
| description を全部書く | LLMへの記入指示になる | 各フィールドに「何を・どんな粒度で」を1文 |
| enum で値を閉じる | 表記ゆれ・想定外の値を構造で禁止 | 自由文字列より列挙型を優先する |
| additionalProperties: false | 余計な項目の追加を禁止する | 「決めた枠以外は書けない」状態にする |
| Optional は慎重に | 「なくてもいい」が増えると曖昧になる | 必須は必須と明示。null許容かどうかを決め切る |
| ハイブリッドルーティング | 全部を高級モデルに投げると高い | 単純なスキーマは安いモデル、複雑なものだけ堅いモデルへ |
最後の ハイブリッドルーティング は、コスト面で効きます。簡単な分類は安いモデル、複雑で重要なスキーマだけ高信頼モデルに回すと、構造化出力のコストを40〜60%削れるという報告もあります。全部を一番高いモデルに投げるのは、気持ちはわかるけど、もったいないんですよね。
additionalProperties: false だけ補足しておくと、これは「この記入用紙に書いていい欄は、ここで挙げたものだけ。勝手に欄を増やすの禁止」という指定です。これを入れておくと、LLMが気を利かせて余計なフィールドを足してくる事故を防げます。
8. 人間とAIの役割分担 — どこを設計し、どこを任せるか
この記事を貫いている問いは、結局これです。人間は何を設計し、何を判断し、何をAIに任せるのか。 構造化出力という文脈で、はっきり線を引いておきましょう。
| 工程 | 主担当 | 中身 |
|---|---|---|
| スキーマ(記入用紙)の設計 | 人間 | どの項目を・どんな型で・どこを必須にするか |
| description の言語化 | 人間 | 各欄の記入指示。ここの質が精度を決める |
| 枠の中を埋める | AI | 制約された形の中で、最尤の値を生成する |
| 構文・型の検証 | コード | Pydantic / Zod で機械的に叩く |
| 意味・業務ルールの検証 | 人間が書いたコード | セマンティック検証。業務の約束ごと |
| 検証失敗時のリトライ判断 | コード(上限つき) | 何回までやり直すか、超えたらどうするか |
| 最終的な責任 | 人間 | この出力を本番に流していいかの線引き |
見てのとおり、AIが担うのは真ん中の「枠を埋める」一箇所だけなんですよね。その前後(枠の設計と、中身の検証)は人間とコードが握る。「AIにお任せ」ではなく、「AIに、安全に任せられる範囲を、人間が設計する」。これが構造化出力の本質だと思っています。
9. そのまま使えるプロンプト例3本
スキーマ設計や検証ルールを考えるとき、AI自身に壁打ち相手になってもらうと早いです。汎用化したプロンプトを3本置いておきます(固有名や社内情報は入れず、必ず一般化して使ってください)。
プロンプト1:スキーマ設計レビュー
あなたはLLMの構造化出力に詳しいシニアエンジニアです。
次のユースケースに対するJSON Schema案をレビューしてください。
# ユースケース
{何をするか・出力をどこに流すかを1〜3行で}
# 現在のスキーマ案
{JSON Schema または Pydantic/Zod 定義を貼る}
# 観点
1. フラットさ(ネストが深すぎないか、平らにできないか)
2. 各フィールドの description は記入指示として十分か
3. 自由文字列を enum に閉じられる箇所はないか
4. 必須/Optional の線引きは妥当か(曖昧なOptionalはないか)
5. additionalProperties: false にすべきか
出力は「指摘」「理由」「修正後スキーマ案」の3部構成で。
プロンプト2:壊れ方・エッジケースの洗い出し
次のスキーマに対して、LLMがやらかしがちな「壊れ方」と
業務的にありえない「意味の間違い」を、それぞれ列挙してください。
# スキーマ
{スキーマを貼る}
# 出力
- 構文・形の壊れ方(型ゆらぎ、欠落、想定外の値 など)を5件
- 意味の間違い(構文は正しいが業務的に誤り)を5件
- それぞれを再現するためのテスト用入力例(ダミーデータ)も添えてください
このリストは、後でセマンティック検証とテストケースに使います。
プロンプト3:セマンティック検証ルールの抽出
あなたはこのドメインの業務に詳しいレビュアーです。
次のスキーマと業務説明から、「構文は正しいが意味が間違っている」
パターンを検出するための検証ルールを抽出してください。
# スキーマ / 業務説明
{スキーマと、業務上の約束ごとを箇条書きで}
# 出力
- 検証ルールを「ルール名 / 何を防ぐか / 判定ロジック(擬似コード)」で列挙
- 各ルールが「コードで機械的に判定できる」ものか
「人間の最終確認が要る」ものかを明記
擬似コードは後でPython/TypeScriptに落とします。
この3本を回すだけでも、「とりあえず動くスキーマ」から「壊れにくいスキーマ」への距離が、ぐっと縮まるはずです。
10. おわりに — 壊れない部品は、明日の自分へのプレゼント
長くなったので、要点だけ畳んでおきます。
- LLMの出力を 「文字列」として扱うのをやめて、「枠(スキーマ)」を先に決める
- 方式は JSON mode / 構造化出力(strict) / function calling を使い分ける
- OpenAIは保証強め、Claudeは保証なし(2026-04時点)、Geminiはバランス型 — だが、どれでも受け取った後に必ず検証する
- Python(Pydantic)/ TypeScript(Zod)で、検証+上限つき自動リトライを組む
- そして 「構文OK ≠ 意味OK」。業務ルールでのセマンティック検証を、人間が責任を持って書く
僕がこの一連の作業を、地味だけど大事だと思っている理由は、結局これなんですよね。
構造化出力って、未来の自分とチームへのプレゼントなんです。
「お願いベースで祈る」コードは、書いてる今日の自分はちょっと楽です。スキーマも検証も書かなくていいから。でもそのツケは、必ず未来の誰かが払う。ある日の深夜、本番が JSONDecodeError で落ちて、叩き起こされるのは未来の自分か、チームの誰か。
逆に、今日スキーマを1枚ちゃんと書いて、検証を1個足しておくと、半年後の自分が「これ書いといてくれて、あざっす」って言ってくれる。僕はいつも、この「明日の自分があざっすって言うほうはどっち?」を選択の軸にしています。構造化出力は、まさにその軸にぴったりハマるんですよね。
最後に、完璧主義に倒れないでほしいので、これだけ。全部を一気にやらなくていいです。 まずは、いちばん壊れて困っている1機能に、検証を1枚足すところから。それだけで、明日の自分の安心がひとつ増えます。
今日からの最初の一歩チェックリスト
- regexでJSONを抜いている箇所を1つ見つける
- その出力の「記入用紙」を Pydantic か Zod で書いてみる
-
受け取った後に
validate/parseを1行足す - リトライがあるなら、上限がついているか確認する
- 「構文は正しいけど業務的にありえない」パターンを1つ、コードで弾く
小さく、でも確実に。AIを「すごいデモ」から「明日も動いてる部品」へ。一緒に積んでいきましょう。