9月1日、Towards Data Scienceが「Your LLM Can Return Perfect JSON and Still Be Wrong」と題した記事を公開した。LLMのStructured Outputs機能は構造的な正しさを保証するが、意味的な正しさは保証しない——この見落とされがちな落とし穴と、実装レベルの対策を詳しく紹介している。
「JSONが正しい」は「値が正しい」ではない
支払い確認メッセージから取引記録を抽出するパイプラインにStructured Outputs(OpenAIが提供するスキーマ強制機能)を導入して3週間後、照合ジョブが小さな不一致を継続的に検出し始めた。
クラッシュでも不正なレコードでもない。金額と送信者は完璧に一致しているが、日付だけがズレている。週次ボリュームの2〜3%程度。
犯人はタイムゾーンのバグではなかった。元のソースメッセージを照合すると、パターンが浮かんだ。日付が記載されていないメッセージから来た取引すべてで、日付が捏造されていた。
Payment received from Chinedu, ₦45,000, ref TXN-82K91.
テキストに日付は一切ない。しかしスキーマの transaction_date: date はrequiredだ。モデルは何かを返さなければならない。だから返した——ほぼ毎回、抽出ジョブを実行した日の日付を。型チェックは通る。値は完全に作り話だ。
これがStructured Outputsの核心的な罠である。構造(shape)を保証するが、真実(truth)は保証しない。
対策1:Nullableフィールドで「発明」を防ぐ
まず全フィールドをNullableにする。
class Transaction(BaseModel):
sender: str | None
amount: float | None
transaction_id: str | None
transaction_date: date | None
これだけで、日付がなければモデルは None を返せるようになる。値を捏造する圧力がなくなる。
ここで重要な概念の区別がある。「抽出(extraction)」と「推論(inference)」は別物だ。 「Tuesday払い」というテキストからISOフォーマットの日付を返させるのは、意図しなくても推論を要求している。Nullableフィールドはその判断を呼び出し元のコードに返す:
if transaction.transaction_date is None:
request_missing_info(transaction_id=transaction.transaction_id)
対策2:「証拠フィールド」でハルシネーションを可視化する
Nullableにしても別の問題が残る。モデルが値を返したとき、それがテキストから読み取った値なのか、パターンマッチで補完した値なのか、レスポンスを見ても区別がつかない。
通常のチャットレスポンスなら推論過程を観察できるが、Structured Outputsは最終形態に直接ジャンプする。
そこで、各値に「根拠テキスト」フィールドを添付するパターンを導入する:
from pydantic import Field
class Extracted(BaseModel):
"""汎用ラッパー。フィールド型ごとにほぼ同じクラスを書かずに済む"""
value: float | date | str | None
evidence: str | None = Field(description="exact quote backing this value, empty if not found")
class Transaction(BaseModel):
sender: str | None
amount: Extracted
transaction_id: str | None
transaction_date: Extracted
※編集部の考察:元記事では Extracted クラスの value 型を float | date | str | None のユニオンで示しているが、これは概念を伝えるための簡略表現と見るのが自然だ。実際のコードベースでは、型安全性を保つためにフィールドごとに型付きラッパーを派生させる実装も検討に値する。
ポイントは**evidence を value より先に並べること**。JSONのキーは生成順に出力されるため、モデルは先に「何を見たか」を書き下してから値を確定しなければならない——強制的に「答えを示す前に根拠を書かせる」構造になる。
value が入っているのに evidence が空、あるいはソーステキストに存在しない文字列が入っていれば、それがハルシネーションの証拠として記録に残る。
コストは無視できない。数百件の取引メッセージに対して証拠フィールドを追加した結果、出力トークンが約1/3増加し、レイテンシもパイプライン規模では無視できないほど上昇した。5桁の郵便番号程度なら割に合わないが、誰かが実際に使う金融数値なら十分な投資だ。
対策3:Pydanticバリデーターで「意味的な妥当性」を担保する
スキーマはfloatを保証する。そのfloatが負でないことや、日付が未来でないことは保証しない。
これをプロンプトで解決しようとするのは間違いだ。「金額は必ず正の値にしてください」と言語モデルに頼むより、バリデーターが毎回確実に処理する:
from pydantic import model_validator, ValidationError
class Transaction(BaseModel):
sender: str | None
amount: float | None
transaction_id: str | None
transaction_date: date | None
@model_validator(mode="after")
def check_sane_values(self) -> "Transaction":
# 負の金額が過去2回発生——どちらも返金メッセージが原因だった
if self.amount is not None and self.amount <= 0:
raise ValueError(f"amount must be positive, got {self.amount}")
if self.transaction_date is not None and self.transaction_date > date.today():
raise ValueError(f"transaction_date {self.transaction_date} is in the future")
return self
バリデーション失敗時はリトライをハードキャップ付きで行う。失敗時はエラー内容をそのまま会話履歴に追記し、モデルに問題のあるフィールドのみ修正させる構造だ。2回失敗したらほぼ確実にソース文書自体が問題であり、3回目の自動リトライはAPIコストを無駄に消費するだけだ。そのケースは人間が10秒で解決できる。
MAX_RETRIES = 2
def extract_with_retry(document: str) -> Transaction:
history = [
{"role": "system", "content": "Extract the transaction details."},
{"role": "user", "content": document},
]
for attempt in range(MAX_RETRIES + 1):
completion = client.beta.chat.completions.parse(
model="gpt-4o", messages=history, response_format=Transaction
)
raw = completion.choices[0].message.content
try:
return Transaction.model_validate_json(raw)
except ValidationError as e:
if attempt == MAX_RETRIES:
raise # 諦めて呼び出し元が人間にルーティングする
history += [
{"role": "assistant", "content": raw},
{"role": "user", "content": f"That failed validation: {e}. Fix only the bad field."},
]
なお、このPydanticモデルはOpenAI固有ではない。AnthropicのTool Useや、vLLM+Outlinesのセルフホスト構成でも、APIコール部分を差し替えるだけでPydanticモデルはそのまま使える。
本質的な問題の整理
Structured Outputsは確かに機能する。ただし、機能する範囲が当初想定より狭い。
「モデルが目の前にあるものに関係なく全フィールドを埋めようとするなら、それは信頼性があるのではなく、単に自信満々なだけだ——これは別物であり、より危険だ」
信頼できる抽出パイプラインの問いは「JSONが壊れていないか」ではなく、「このオブジェクトの各値が実際に存在する理由があるか」だ。スキーマはその問いを隠していただけで、問いそのものはずっとそこにあった。
詳細はYour LLM Can Return Perfect JSON and Still Be Wrongを参照していただきたい。