0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

構造化出力(JSON スキーマ)で LLM の出力を安全に扱う:スキーマ検証を通った後に残る 4 つの穴

0
Posted at

LLM の出力を後段の処理がそのまま信用してしまう問題は、OWASP Top 10 for LLM Applications で Improper Output Handling(2025 年版の LLM05)として扱われています。対策としてよく挙がるのが、モデルに自由な文章ではなく JSON スキーマに沿った出力(構造化出力)を返させ、受け取る側でもスキーマで検証する方法です。

ルールベースのガードレールの記事では、構造化出力で「形の崩れた出力」は止まり、「形は正しいが自由文の中身が危ない出力」は止まらないことを確かめました。SQL・シェル・HTML に渡すときの記事では、自由文の項目を渡し先ごとに無害化する方法を扱いました。

この記事はその間を埋めます。返金や送金のような操作の引数をモデルに構造化出力で返させるとき、「スキーマの検証を通ったから安全」と考えると見落とす穴が 4 つあります。

  1. 検証ライブラリが値を気を利かせて変換してしまう
  2. JSON のパーサーが NaN や重複キーを受け付けてしまう
  3. スキーマは誰の注文かを知らない
  4. 途中で切れた出力を部分的に解釈してしまう

それぞれを検証コードで確かめ、塞ぎ方を示します。

検証環境と前提

  • Python 3.13.18、pydantic 2.13.5(クラウド上の Linux コンテナ)
  • LLM は呼び出していません。モデルの出力はすべて固定値の JSON 文字列で与え、受け取る側の検証だけを確かめています。どの API・モデルを使っても、受け取った文字列をアプリが検証する部分は同じです
  • ライブラリの挙動は上記のバージョンで実際に動かして確認したものです。2026 年 11 月 2 日時点の情報です

構造化出力が保証するのは「形」だけ

構造化出力の機能を持つ API の多くは、生成の段階でスキーマに合うトークンだけを選ばせる(制約付きデコーディング)ことで、出力の形を揃えます。ただし、対応する JSON Schema のキーワードの範囲や、断られたとき・出力が途中で切れたときの返り方は API ごとに違います。使う API のドキュメントで確認したうえで、受け取る側でも同じスキーマで検証するのが前提です。

そして、受け取る側の検証が厳密でも、保証されるのは次の表の上の段までです。

確かめたいこと スキーマで決まるか この記事で扱う対策
項目の名前・型・必須 決まる strict=True、extra="forbid"
値の範囲・選択肢 決まる(書けば) Field(le=...)、Literal、NaN の拒否
JSON として素直か パーサー次第 重複キー・NaN の拒否、サイズ上限
その利用者が操作してよい対象か 決まらない サーバーのデータとの照合、利用者ごとのスキーマ
出力が最後まで届いたか 決まらない 部分的な検証の結果で操作しない

穴 1:検証ライブラリが値を変換してしまう

pydantic は既定で「lax モード」で動き、"1200" のような文字列を整数に、"yes" を True に変換して受け付けます。また、スキーマにない項目は既定ではエラーにせず、黙って捨てます。人が書いた設定ファイルを読むには便利ですが、モデルの出力を操作の引数として受け取るには緩すぎます。

types_demo.py
from typing import Literal
from pydantic import BaseModel, ConfigDict, ValidationError

class LaxAction(BaseModel):
    """よくある書き方(pydantic の既定の設定のまま)"""
    action: Literal["refund", "reply_only"]
    amount: int
    notify_customer: bool

class StrictAction(BaseModel):
    """型の変換をせず、知らない項目は拒否する"""
    model_config = ConfigDict(strict=True, extra="forbid")
    action: Literal["refund", "reply_only"]
    amount: int
    notify_customer: bool

# モデルの出力(固定値)
outputs = {
    "正常":         '{"action": "refund", "amount": 1200, "notify_customer": true}',
    "文字列の数値": '{"action": "refund", "amount": "1200", "notify_customer": "yes"}',
    "余計な項目":   '{"action": "refund", "amount": 1200, "notify_customer": true, "skip_approval": true}',
}

for name, raw in outputs.items():
    for model in (LaxAction, StrictAction):
        try:
            m = model.model_validate_json(raw)
            print(f"{name:<8} {model.__name__:<12} 通過 {m.model_dump()}")
        except ValidationError as e:
            err = e.errors()[0]
            print(f"{name:<8} {model.__name__:<12} 拒否 {err['type']} @ {err['loc']}")
正常       LaxAction    通過 {'action': 'refund', 'amount': 1200, 'notify_customer': True}
正常       StrictAction 通過 {'action': 'refund', 'amount': 1200, 'notify_customer': True}
文字列の数値   LaxAction    通過 {'action': 'refund', 'amount': 1200, 'notify_customer': True}
文字列の数値   StrictAction 拒否 int_type @ ('amount',)
余計な項目    LaxAction    通過 {'action': 'refund', 'amount': 1200, 'notify_customer': True}
余計な項目    StrictAction 拒否 extra_forbidden @ ('skip_approval',)

結果から読み取れること

  • lax モードでは "yes" が True になる: 「顧客に通知するか」のような項目で、モデルが曖昧な文字列を返したときに、アプリが勝手に「する」と解釈します。strict=True にすると、JSON の型がスキーマと一致しないものは拒否されます
  • 知らない項目は既定で捨てられる: skip_approval は検証済みのオブジェクトからは消えますが、エラーにもなりません。検証前の辞書を別の処理が参照していたり、後でスキーマに同名の項目が足されたりすると、モデルが勝手に足した項目が効き始めます。extra="forbid" にすると、想定外の項目が来た時点で止まり、ログにも残せます

extra="forbid" を付けたモデルから model_json_schema() で作ったスキーマには "additionalProperties": false が入るので、API に渡すスキーマと受け取る側の検証を同じモデルから作れます。

穴 2:JSON のパーサーが NaN や重複キーを受け付ける

JSON の仕様(RFC 8259)には NaN や Infinity はありません。また、同じオブジェクト内のキーは一意であるべき(SHOULD)とされ、重複したときの振る舞いは実装によって違うと書かれています。一方で Python の json モジュールは既定で NaN を受け付け、重複キーは後の値を採用します。

上限金額をアプリ側の if で確かめている返金処理に、これらを流してみます。

parser_demo.py
import json
from typing import Literal
from pydantic import BaseModel, ConfigDict, ValidationError

class Refund(BaseModel):
    model_config = ConfigDict(strict=True, extra="forbid")
    action: Literal["refund"]
    amount: float          # 上限チェックはアプリ側のコードで行っている想定

LIMIT = 5000

def app_check(amount: float) -> str:
    # よくある「上限を超えたら止める」書き方
    if amount > LIMIT:
        return "上限超過で停止"
    return "自動で実行"

# モデルの出力(固定値)
outputs = {
    "重複キー": '{"action": "refund", "amount": 100, "amount": 999999}',
    "NaN":      '{"action": "refund", "amount": NaN}',
    "巨大な数": '{"action": "refund", "amount": 1e400}',
}

for name, raw in outputs.items():
    py = json.loads(raw)                      # 標準ライブラリ
    try:
        m = Refund.model_validate_json(raw)   # pydantic(Rust 製のパーサー)
        pd = f"通過 amount={m.amount} → {app_check(m.amount)}"
    except ValidationError as e:
        pd = f"拒否 {e.errors()[0]['type']}"
    print(f"{name:<6} json.loads: amount={py['amount']!r:<10} pydantic: {pd}")

# 深い入れ子
deep = '{"a":' * 100_000 + '1' + '}' * 100_000
for label, fn in [("json.loads", json.loads), ("pydantic", lambda s: Refund.model_validate_json(s))]:
    try:
        fn(deep)
        print(label, "通過")
    except RecursionError:
        print(f"{label:<10} 深い入れ子: RecursionError")
    except ValidationError as e:
        print(f"{label:<10} 深い入れ子: 拒否 {e.errors()[0]['type']}")
重複キー   json.loads: amount=999999     pydantic: 通過 amount=999999.0 → 上限超過で停止
NaN    json.loads: amount=nan        pydantic: 通過 amount=nan → 自動で実行
巨大な数   json.loads: amount=inf        pydantic: 通過 amount=inf → 上限超過で停止
json.loads 深い入れ子: RecursionError
pydantic   深い入れ子: 拒否 json_invalid

結果から読み取れること

  • NaN は amount > LIMIT をすり抜ける: NaN との大小比較は常に偽になるので、「上限を超えたら止める」という書き方では止まりません。pydantic も strict=True だけでは JSON 中の NaN を float として受け付けました
  • 巨大な数は Infinity になる: 1e400 は float の範囲を超え、標準ライブラリでも pydantic でも inf になりました。この例では上限チェックで止まりましたが、下限だけを確かめるコードなら通ります
  • 重複キーは後の値が勝つ: 今回の 2 つのパーサーはどちらも後の値を採用しましたが、ガードレールとアプリで別のパーサーを使っていて、片方が先の値を採用すると、検査した値と実行される値が食い違います
  • 深い入れ子で標準ライブラリは例外を出す: json.loads は RecursionError になりました。例外処理が ValueError しか捕まえていないと、処理全体が落ちます

塞ぎ方は、パースの前に大きさを制限し、標準ライブラリで「JSON として素直か」を確かめてから、スキーマで範囲まで検証することです。

hardened.py
import json
from typing import Literal
from pydantic import BaseModel, ConfigDict, Field, ValidationError

MAX_BYTES = 4_096   # 想定している出力の大きさに合わせて決める
LIMIT = 5000

class Refund(BaseModel):
    model_config = ConfigDict(strict=True, extra="forbid", allow_inf_nan=False)
    action: Literal["refund"]
    amount: int = Field(gt=0, le=LIMIT)   # 範囲はスキーマで縛り、コードの if に頼らない

def reject_duplicates(pairs):
    keys = [k for k, _ in pairs]
    dup = {k for k in keys if keys.count(k) > 1}
    if dup:
        raise ValueError(f"重複したキー: {sorted(dup)}")
    return dict(pairs)

def no_constants(name):
    raise ValueError(f"JSON にない値: {name}")

def parse_refund(raw: str) -> Refund:
    if len(raw.encode()) > MAX_BYTES:
        raise ValueError("出力が大きすぎる")
    # 1. 標準ライブラリで「JSON として素直か」を確かめる(重複キー・NaN・Infinity を拒否)
    json.loads(raw, object_pairs_hook=reject_duplicates, parse_constant=no_constants)
    # 2. 同じ文字列をスキーマで検証する
    return Refund.model_validate_json(raw)

outputs = {
    "正常":     '{"action": "refund", "amount": 1200}',
    "重複キー": '{"action": "refund", "amount": 100, "amount": 999999}',
    "NaN":      '{"action": "refund", "amount": NaN}',
    "巨大な数": '{"action": "refund", "amount": 1e400}',
    "上限超過": '{"action": "refund", "amount": 999999}',
    "深い入れ子": '{"a":' * 100_000 + '1' + '}' * 100_000,
}
for name, raw in outputs.items():
    try:
        r = parse_refund(raw)
        print(f"{name:<6} 通過 amount={r.amount}")
    except ValidationError as e:
        print(f"{name:<6} 拒否 (スキーマ) {e.errors()[0]['type']}")
    except (ValueError, RecursionError) as e:
        print(f"{name:<6} 拒否 (パース) {e}")
正常     通過 amount=1200
重複キー   拒否 (パース) 重複したキー: ['amount']
NaN    拒否 (パース) JSON にない値: NaN
巨大な数   拒否 (スキーマ) int_type
上限超過   拒否 (スキーマ) less_than_equal
深い入れ子  拒否 (パース) 出力が大きすぎる

金額は float ではなく int(最小単位の整数)で受け取り、範囲はスキーマに書きます。allow_inf_nan=False を設定に入れておくと、float の項目を足したときにも NaN と Infinity が拒否されます。parse_constant が呼ばれるのは NaN・Infinity・-Infinity の 3 つだけで、1e400 はここでは止まらずスキーマ側の int で止まっている点に注意してください。

穴 3:スキーマは「誰の注文か」を知らない

ここまでの検証をすべて通っても、order_id がその利用者の注文かはスキーマからは分かりません。顧客対応のエージェントが、問い合わせメールに仕込まれた指示に誘導されて他人の注文番号を返せば、形も型も範囲も正しいまま通ります。

対策は 2 つあります。サーバーのデータと照合する検証を足す方法と、リクエストごとに「選べる値」をその利用者の注文だけに絞ったスキーマを作る方法です。

semantic.py
from typing import Literal
from pydantic import BaseModel, ConfigDict, Field, ValidationError, ValidationInfo, create_model, field_validator

# サーバー側のデータ(DB の代わり)
ORDERS = {
    "A-1001": {"owner": "alice", "total": 3000},
    "A-1002": {"owner": "alice", "total": 800},
    "B-2001": {"owner": "bob",   "total": 50000},
}

# --- 固定のスキーマ: 形は正しくても「誰の注文か」は分からない ---
class RefundShape(BaseModel):
    model_config = ConfigDict(strict=True, extra="forbid")
    order_id: str = Field(pattern=r"^[A-Z]-\d{4}$")
    amount: int = Field(gt=0)

# --- 同じ形に、サーバーのデータとの照合を足したもの ---
class RefundRequest(RefundShape):
    @field_validator("amount")
    @classmethod
    def within_order_total(cls, v: int, info: ValidationInfo) -> int:
        # 他の項目とサーバーのデータを突き合わせる検査は、context で渡す
        ctx = info.context or {}
        order = ctx.get("orders", {}).get(info.data.get("order_id"))
        if order is None or order["owner"] != ctx.get("user"):
            raise ValueError("この利用者の注文ではない")
        if v > order["total"]:
            raise ValueError("注文金額を超える返金")
        return v

# --- リクエストごとのスキーマ: 選べる値をその利用者の注文だけにする ---
def refund_schema_for(user: str) -> type[BaseModel]:
    own = tuple(oid for oid, o in ORDERS.items() if o["owner"] == user)
    return create_model(
        "RefundFor_" + user,
        __config__=ConfigDict(strict=True, extra="forbid"),
        order_id=(Literal[own], ...),
        amount=(int, Field(gt=0)),
    )

# alice との会話で、モデルが返した出力(固定値)。2・3 件目は誘導された想定
outputs = {
    "自分の注文":     '{"order_id": "A-1001", "amount": 3000}',
    "他人の注文":     '{"order_id": "B-2001", "amount": 50000}',
    "金額の水増し":   '{"order_id": "A-1002", "amount": 3000}',
}

per_user = refund_schema_for("alice")
print("リクエストごとのスキーマの選択肢:",
      per_user.model_json_schema()["properties"]["order_id"]["enum"])

for name, raw in outputs.items():
    shape = "通過"
    try:
        RefundShape.model_validate_json(raw)              # 形だけ
    except ValidationError as e:
        shape = "拒否"
    try:
        RefundRequest.model_validate_json(raw, context={"orders": ORDERS, "user": "alice"})
        sem = "通過"
    except ValidationError as e:
        sem = "拒否: " + e.errors()[0]["msg"]
    try:
        per_user.model_validate_json(raw)
        enum = "通過"
    except ValidationError as e:
        enum = "拒否: " + e.errors()[0]["type"]
    print(f"{name:<7} 形だけ={shape}  サーバーと照合={sem}  利用者ごとの選択肢={enum}")
リクエストごとのスキーマの選択肢: ['A-1001', 'A-1002']
自分の注文   形だけ=通過  サーバーと照合=通過  利用者ごとの選択肢=通過
他人の注文   形だけ=通過  サーバーと照合=拒否: Value error, この利用者の注文ではない  利用者ごとの選択肢=拒否: literal_error
金額の水増し  形だけ=通過  サーバーと照合=拒否: Value error, 注文金額を超える返金  利用者ごとの選択肢=通過

結果から読み取れること

  • 形だけの検証は 3 件とも通す: 他人の注文も、注文金額を超える返金も、スキーマの上では正しい値です
  • 利用者ごとのスキーマは「対象」を絞れるが「金額」は絞れない: order_id の選択肢を alice の注文だけにすると、他人の注文はスキーマの段階で止まります。制約付きデコーディングに対応した API に渡せば、モデルはそもそも他の注文番号を出力できません。ただし金額のように他の項目やサーバーのデータとの関係で決まる条件は、スキーマだけでは書けません
  • サーバーとの照合は両方を止める: pydantic の context で利用者とデータを渡し、バリデーターで突き合わせます

利用者ごとのスキーマは、選択肢が多すぎるとスキーマが大きくなるので、数十件程度までの「この利用者が今操作できる対象」に向いています。どちらの方法でも、最終的な認可は実際に返金を実行する API の側で、利用者の権限でもう一度確かめます。検証から実行までの間にデータが変わることもあり、モデルの出力の検証は「早めに止めて理由を記録する」ための層と考えます。

穴 4:途中で切れた出力を部分的に解釈する

出力トークンの上限に達したり、ストリーミング中に接続が切れたりすると、JSON は途中で切れます。通常の検証なら json_invalid で止まりますが、ストリーミングの表示用に「途中までの JSON を解釈する」機能を使っていると、切れた値をそのまま受け取ってしまいます。pydantic にも、実験的な機能として部分的な検証(experimental_allow_partial)があります。

partial.py
from pydantic import BaseModel, ConfigDict, Field, TypeAdapter, ValidationError

class Transfer(BaseModel):
    model_config = ConfigDict(strict=True, extra="forbid")
    to_account: str
    amount: int = Field(gt=0)
    memo: str = ""

full = '{"to_account": "123-4567", "amount": 300000, "memo": "10月分"}'
# 出力トークンの上限などで途中で切れた出力(固定値)
cuts = {
    "数値の途中": full[:full.index("300000") + 3],      # "amount": 300
    "memo の途中": full[:full.index("10月") + 1],
}
for name, raw in cuts.items():
    print(f"[{name}] {raw}")
    try:
        Transfer.model_validate_json(raw)
        print("  通常の検証:   通過")
    except ValidationError as e:
        print("  通常の検証:   拒否", e.errors()[0]["type"])
    try:
        t = TypeAdapter(Transfer).validate_json(raw, experimental_allow_partial=True)
        print("  部分的な検証: 通過", t.model_dump())
    except ValidationError as e:
        print("  部分的な検証: 拒否", e.errors()[0]["type"], e.errors()[0]["loc"])
[数値の途中] {"to_account": "123-4567", "amount": 300
  通常の検証:   拒否 json_invalid
  部分的な検証: 通過 {'to_account': '123-4567', 'amount': 300, 'memo': ''}
[memo の途中] {"to_account": "123-4567", "amount": 300000, "memo": "1
  通常の検証:   拒否 json_invalid
  部分的な検証: 通過 {'to_account': '123-4567', 'amount': 300000, 'memo': ''}

結果から読み取れること

  • 300000 の途中で切れると 300 として通る: 数値は途中で切れても数値として読めてしまいます。この例では金額が小さくなる方向ですが、桁の多い ID や口座番号なら別の対象を指す値になります
  • 既定値のある項目は、切れた値の代わりに既定値になる: memo は途中まで届いていたのに、空文字列で通りました

部分的な解釈は、生成中の文章を画面に少しずつ表示するためのものです。操作の引数は、出力が最後まで届き、通常の検証を通ったものだけを使います。 API が出力の終了理由を返す場合は、それが正常終了であることも確かめます。

検証に失敗したときの扱い

検証に失敗した出力は、操作を実行せず(fail closed)、拒否の理由と元の出力をログに残します。モデルに再生成させる場合は回数に上限を付けます。検証エラーをそのままモデルに返して直させる実装は便利ですが、拒否されるたびに少しずつ値を変えて試す動きを許すことにもなるので、同じ会話で同じ種類の拒否が続いたら、人の確認に回すなどして打ち切ります。

レッドチームの観点では、この記事の検証コードのようにモデルの出力を固定値で与えて受け取る側だけを試すテストが、そのまま回帰テストになります。NaN、重複キー、文字列の数値、余計な項目、他人の ID、途中で切れた JSON を固定のテストケースにしておけば、LLM を呼ばずに CI で毎回確かめられます。

参考

0
0
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?