⏱ 読了目安: 約6分
- 事実と背景:OpenAIのStructured OutputsはJSON Schemaへの完全準拠を保証するが、出力内容の業務的な正しさまでは保証しない。
- 技術的変革:構造検証(型や必須キー)はAPI境界で自動解決されるが、日付の前後関係などの「意味的検証」はアプリ側の別層で行う必要がある。
- 現場への影響:スキーマ定義の肥大化を止め、ドメインバリデータを独立させることで、LLM特有のハルシネーションに強い堅牢なシステムを構築できる。
「スキーマ100%準拠」という甘い罠
深夜2時、本番環境のアラートで叩き起こされる。ログを追いかけると、LLMが返したJSONのキーが1つ足りないだけで、後続のパース処理がデッドロックを引き起こしていた――。我々エンジニアにとって、LLMの出力制御は長らく頭痛の種であり、まさにスパゲッティコードの温床だった。そんな中、OpenAIが提供する「Structured Outputs」の登場は、救世主のように思えた。指定したJSON Schemaに100%準拠した出力を得られるという約束は、我々をあの不毛な「JSONパースエラー対策の防御コード」から解放してくれるはずだったからだ。
しかし、私はここで強い警鐘を鳴らさざるを得ない。多くの開発現場で、「Structured Outputsを導入したから、アプリ側のバリデーションコードはすべて削除できる」という致命的な誤解が蔓延している。結論から言おう。スキーマをどれだけ厳格に定義しようとも、業務上の正しさを検証するコードを消し去ることは絶対にできない。なぜなら、構造の正しさと、意味の正しさは、全く異なる次元のレイヤーに存在するからだ。
2026年9月30日時点のOpenAI公式ドキュメント(最新のgpt-6-astraモデルなどを想定した仕様)においても、Structured Outputsは指定したJSON Schemaへの準拠を保証する仕組みとして説明される一方、生成内容そのものには誤り(ハルシネーションや論理的矛盾)が残り得ると明記されている。我々が直面しているのは、フォーマットの崩れという「低レイヤーのバグ」が消え去った結果、より検知が困難な「高レイヤーの意味的バグ」が素通りしてシステム深部に侵入してくるという、新たなフェーズの課題なのだ。
境界線を引け:構造検証とドメイン検証の分離
具体的な事例で考えてみよう。ユーザーの入力から「作業依頼」を抽出し、以下のJSON構造に変換するシステムを構築するとする。
{
"title": "在庫表を確認する",
"priority": "high",
"start_date": "2026-10-08",
"due_date": "2026-10-05"
}
このJSONは、Structured Outputsのstrict modeを通過する。なぜなら、すべての必須キー(required)が存在し、priorityは指定されたenum(low | medium | high)の範囲内であり、日付フォーマットも正しいからだ。しかし、このデータは業務システムとしては完全に「ゴミ」である。開始日(start_date)が期限(due_date)よりも未来になっているからだ。このような「複数フィールドの相関関係から決まる不変条件(Invariant)」は、JSON Schemaの表現力の限界を超えている。
さらに厄介なのが「根拠なき補完(ハルシネーション)」だ。入力文に「在庫表を来週確認しておいて」としか書かれていない場合、LLMはスキーマを満たすために、勝手に優先度をhighに設定し、日付をでっち上げる。OpenAIの公式ドキュメントにも、ユーザー入力がSchemaに適合する回答を作れない場合、モデルがSchemaへ合わせようとして値を捏造する可能性があるため、その場合の扱いを指示するよう注意書きがある。スキーマを複雑にしても、この「入力と出力の因果関係」は検証できない。
解決策は、APIの境界線で「構造検証」を行い、その直後のアプリケーション層で「ドメイン検証(domain validator)」を行うという、明確なアーキテクチャの分離である。以下に、Python 3.11以降で動作する、この設計思想を体現した最小限のコードを示す。
from datetime import date
from typing import Literal, TypedDict
class WorkRequest(TypedDict):
title: str
priority: Literal["low", "medium", "high"]
start_date: str
due_date: str
def validate_domain(req: WorkRequest) -> None:
start = date.fromisoformat(req["start_date"])
due = date.fromisoformat(req["due_date"])
if due < start:
raise ValueError("due_date must be on or after start_date")
# 構造は正しいが、ドメインルールに違反しているデータ
invalid_data: WorkRequest = {
"title": "在庫表を確認する",
"priority": "high",
"start_date": "2026-10-08",
"due_date": "2026-10-05"
}
try:
validate_domain(invalid_data)
except ValueError as e:
print(f"ドメイン検証エラー: {e}")
このように、Structured Outputsは「LLMとの境界で、アプリが安全にパースできる型を固定する」ために使い、業務ルールのチェックはホスト言語(Pythonなど)の決定論的なコードに委ねる。この役割分担こそが、破綻しないLLMアプリケーションの鉄則である。
我々はAIに「正しさ」の審判を委ねるのか
「スキーマを厳しくすれば、バリデーションコードは不要になる」という甘美な幻想は、今すぐ捨てるべきだ。我々エンジニアが直面している本質的な問いは、LLMという「確率論的に動くブラックボックス」を、いかにして「決定論的な業務システム」に安全に組み込むかという、システムデザインの敗戦処理に近い。Structured Outputsは、APIの境界における「型キャスト」を自動化してくれたに過ぎず、データの「意味的な正しさ」を保証する魔法ではないのだ。
では、我々が明日からの実務で取るべき具体的な処方箋とは何か。私は以下の3ステップを提案する。
- 1. スキーマは「境界の固定」に徹する: Structured Outputsのスキーマ定義(Pydanticモデルなど)は、必須キーの存在やプリミティブな型、enumの制限など、機械的に100%保証できる「構造」の検証のみに集中させ、肥大化を防ぐ。
- 2. ドメインバリデータを独立した層として実装する: 日付の前後関係、ビジネスロジックに基づく相関チェック、マスターデータとの整合性検証などは、LLMの外側のアプリケーション層(PythonやTypeScriptなど)で、テストコードが容易に書ける純粋関数として実装する。
- 3. 根拠(Source)の追跡可能性を設計する: 抽出された値が「入力のどこに基づいているか」を検証するため、スキーマに
source_textやis_explicitといったメタデータフィールドを持たせ、アプリ側で原文との部分一致を検証する仕組みを導入する。
最後に、我々技術コミュニティに属するすべての開発者に問いかけたい。システムが高度化し、LLMが自律的にエージェントとして動き始める未来において、我々はどこまで「正しさの検証」を自動化し、どこからを人間の判断、あるいは決定論的なコードの監視下に置くべきなのか。この「境界線の設計」こそが、これからのAI時代におけるシニアエンジニアの真の価値であり、我々がコードで表現すべき最後の砦なのではないだろうか。


コメント