powered by TechFeed
表示モード
Deep Dive

JSONが正しくても値が正しいとは限らない — LLMのStructured Outputsが「形式」しか保証しない理由と対策

9月1日、Towards Data Scienceが「Your LLM Can Return Perfect JSON and Still Be Wrong」と題した記事を公開した。LLMのStructured Outputs機能は構造的な正しさを保証するが、意味的な正しさは保証しない——この見落とされがちな落とし穴と、実装レベルの対策を詳しく紹介している。

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 のユニオンで示しているが、これは概念を伝えるための簡略表現と見るのが自然だ。実際のコードベースでは、型安全性を保つためにフィールドごとに型付きラッパーを派生させる実装も検討に値する。

ポイントは**evidencevalue より先に並べること**。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や、vLLMOutlinesのセルフホスト構成でも、APIコール部分を差し替えるだけでPydanticモデルはそのまま使える。


本質的な問題の整理

Structured Outputsは確かに機能する。ただし、機能する範囲が当初想定より狭い。

「モデルが目の前にあるものに関係なく全フィールドを埋めようとするなら、それは信頼性があるのではなく、単に自信満々なだけだ——これは別物であり、より危険だ」

信頼できる抽出パイプラインの問いは「JSONが壊れていないか」ではなく、「このオブジェクトの各値が実際に存在する理由があるか」だ。スキーマはその問いを隠していただけで、問いそのものはずっとそこにあった。

詳細はYour LLM Can Return Perfect JSON and Still Be Wrongを参照していただきたい。