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 mode が通るのに schema validation で落ちたので切り分けた

0
Posted at

はじめに

同じ分類 prompt を複数モデルに流したとき、response_format: {"type":"json_object"} を付けているのに後段の schema validation で落ちました。

JSON mode だから大丈夫、と思っていた私が雑でした。今回のログを見ると、問題はかなり単純で、JSON として読めることと、アプリ側の schema に合っていることは別物でした。

この記事では、Flatkey AI の OpenAI 互換 endpoint で複数モデルに同じ request を投げたときの実ログを使って、次の順に切り分けます。

  • JSON parse が通るか
  • schema validation が通るか
  • json_objectjson_schema で差が出るか
  • retry / fallback で何をログに残すか

再現環境

検証日は 2026-07-06 です。endpoint は Flatkey AI の OpenAI 互換 router で、/v1/chat/completions を使いました。API key は当然ログに出していません。

python3 agents/qiita-growth-agent/results/qiita_issue_155_json_output_probe.py \
  --base-url https://router.flatkey.ai/v1 \
  --api-key-env FLATKEY_API_KEY \
  --endpoint chat \
  --mode json_object \
  --temperature 0 \
  --models gpt-4o-mini,gemini-3-flash-preview,claude-haiku-4-5 \
  --out qiita_issue_155_json_output_probe_rows_2026-07-06.jsonl

schema はかなり小さくして、enum のズレが見えるようにしました。

{
  "type": "object",
  "additionalProperties": false,
  "required": ["intent", "priority", "owner_team", "action_items"],
  "properties": {
    "intent": { "type": "string" },
    "priority": { "type": "string", "enum": ["low", "medium", "high"] },
    "owner_team": {
      "type": "string",
      "enum": ["support", "backend", "billing", "unknown"]
    },
    "action_items": { "type": "array", "items": { "type": "string" } }
  }
}

prompt は「請求書が重複し、JSON parser は通ったが validator で落ちた」という support note を分類するものです。現実の顧客データではなく、検証用に作った文面です。

エラー全文

まず gemini-3-flash-preview は HTTP 200 で、JSON parse も通りました。ただし enum が schema に合いませんでした。

{
  "model": "gemini-3-flash-preview",
  "mode": "json_object",
  "status": 200,
  "raw_text": "{\n  \"intent\": \"Bug Report\",\n  \"priority\": \"High\",\n  \"owner_team\": \"Billing Engineering\",\n  \"action_items\": [\n    \"Investigate model route switch from previous night\",\n    \"Resolve schema validation failure in downstream job\",\n    \"Deduplicate affected invoices\",\n    \"Implement mitigation before the next billing export\"\n  ]\n}",
  "parse_ok": true,
  "validation_ok": false,
  "validation_errors": [
    "priority invalid enum: 'High'",
    "owner_team invalid enum: 'Billing Engineering'"
  ]
}

次に claude-haiku-4-5 は、JSON を返しているように見えますが、markdown fence 付きだったため json.loads() で落ちました。

{
  "model": "claude-haiku-4-5",
  "mode": "json_object",
  "status": 200,
  "raw_text": "```json\n{\n  \"intent\": \"bug_report\",\n  \"priority\": \"high\",\n  \"owner_team\": \"backend_infrastructure\",\n  \"action_items\": [\n    \"Investigate duplicate invoice generation after model route switch\",\n    \"Review JSON schema validator rejection in downstream job\",\n    \"Implement mitigation before next billing export\",\n    \"Debug API response handling between parser and validator\",\n    \"Verify model route configuration changes from last night\"\n  ]\n}\n```",
  "parse_ok": false,
  "validation_ok": false,
  "extraction_error": "JSONDecodeError: Expecting value: line 1 column 1 (char 0)"
}

ついでに gpt-4o-mini はこの環境では route 側の 503 でした。これは schema の話とは分けて扱いました。

{
  "model": "gpt-4o-mini",
  "mode": "json_object",
  "status": 503,
  "response_error": {
    "error": {
      "code": "model_not_found",
      "message": "分组 company-employees 下模型 gpt-4o-mini 无可用渠道(distributor) (request id: REDACTED)",
      "type": "new_api_error"
    }
  }
}

再現手順

私が見たかったのは、モデルごとの賢さ比較ではなく、後段処理に渡してよい形かどうかです。そのため probe では次の列だけを残しました。

field 見る理由
model route や fallback の判断に必要
mode json_objectjson_schema かを分ける
status API route の失敗と validation の失敗を混ぜない
raw_text validator 前の実出力を残す
parse_ok JSON として読めるか
validation_ok アプリの schema に合うか
validation_errors retry prompt や fallback 判定に使う

この粒度で残しておくと、HTTP 200 なのに downstream job が落ちる という事故の説明がだいぶ楽になります。逆に、最初から例外メッセージだけを見ると、API の問題なのか schema の問題なのかが混ざると思います。

原因の調査

OpenAI の Structured Outputs docs では、JSON mode は valid JSON を出すための機能で、schema adherence まで保証するものではない、という整理になっています。Structured Outputs の json_schema が使える場合はそちらが推奨されています。

今回のログも、その説明どおりでした。

model mode parse validation 見えたこと
gemini-3-flash-preview json_object OK NG valid JSON だが enum が合わない
claude-haiku-4-5 json_object NG NG markdown fence 付きで parser が落ちる
gemini-3-flash-preview json_schema OK OK schema に合う形で返った
claude-haiku-4-5 json_schema NG NG この互換 route では fence 付きのままだった

ここで大事なのは、json_schema にしたら全モデルが必ず直る、と読まないことだと思います。少なくとも私の検証では、route やモデルの組み合わせによって結果が違いました。なので client 側 validation は残したほうが安全です。

解決方法

まず、使えるモデルと endpoint では json_schema を使いました。今回 gemini-3-flash-preview はこの形で validation まで通りました。

{
  "model": "gemini-3-flash-preview",
  "mode": "json_schema",
  "status": 200,
  "raw_text": "{\"intent\":\"Report of duplicate invoices and schema validation failure following a model route change.\",\"priority\":\"high\",\"owner_team\":\"billing\",\"action_items\":[\"Investigate the cause of duplicate invoices after model route switch\",\"Resolve schema validation failure in downstream processing\",\"Implement mitigation before the next billing export\"]}",
  "parse_ok": true,
  "validation_ok": true,
  "validation_errors": []
}

Chat Completions 互換 endpoint では、probe script 側ではこう投げました。

body["response_format"] = {
    "type": "json_schema",
    "json_schema": {
        "name": "ticket_triage",
        "strict": True,
        "schema": SCHEMA,
    },
}

ただし、これだけで本番処理を信じ切るのは少し怖いです。私なら次のように、parse、validate、retry、fallback を分けてログに残します。

def parse_and_validate(raw_text: str) -> tuple[dict, list[str]]:
    parsed = json.loads(raw_text)
    errors = validate_payload(parsed)
    return parsed, errors


def classify_with_retry(client, request_body: dict, fallback_model: str) -> dict:
    first = client.chat.completions.create(**request_body)
    raw_text = first.choices[0].message.content

    try:
        parsed, errors = parse_and_validate(raw_text)
    except json.JSONDecodeError as exc:
        errors = [f"parse_error: {exc}"]
        parsed = {}

    if not errors:
        return {
            "ok": True,
            "result": parsed,
            "retry_count": 0,
            "fallback_model": None,
        }

    repair_body = {
        **request_body,
        "model": fallback_model,
        "messages": request_body["messages"] + [
            {
                "role": "user",
                "content": (
                    "The previous output failed validation. "
                    f"Return JSON that matches this schema exactly. errors={errors}"
                ),
            }
        ],
    }
    second = client.chat.completions.create(**repair_body)
    repaired, repair_errors = parse_and_validate(second.choices[0].message.content)

    return {
        "ok": not repair_errors,
        "result": repaired if not repair_errors else {},
        "retry_count": 1,
        "fallback_model": fallback_model,
        "validation_errors": repair_errors,
    }

TypeScript 側で受けるなら、同じ schema を Zod などに寄せておくと確認しやすいです。Python と TypeScript で別々に schema を手書きすると、後でどちらかが古くなるので、ここはできれば生成に寄せたいところです。

import { z } from "zod";

const TicketTriage = z.object({
  intent: z.string(),
  priority: z.enum(["low", "medium", "high"]),
  owner_team: z.enum(["support", "backend", "billing", "unknown"]),
  action_items: z.array(z.string()),
}).strict();

const parsed = JSON.parse(rawText);
const result = TicketTriage.safeParse(parsed);

if (!result.success) {
  console.warn({
    event: "llm_schema_validation_failed",
    model,
    mode,
    issues: result.error.issues,
  });
}

Flatkey AI で見たこと

Flatkey AI は、ひとつの API key と OpenAI 互換 base URL で複数モデルを試せるので、今回のようなモデル差の観察には使いやすかったです。今回使った endpoint は https://router.flatkey.ai/v1/chat/completions です。

ただし、この記事で言いたいのは「Flatkey が schema adherence を保証する」という話ではありません。むしろ逆で、multi-model routing を本番処理に入れるなら、route、model、mode、raw_text、validation_errors を client 側で残すべきだと思いました。

特に今回の gpt-4o-mini の 503 は、JSON の品質問題ではなく route 側の availability 問題です。この種のエラーと、HTTP 200 の validation error を同じ fallback 条件に混ぜると、後からかなり読みにくくなります。

ログに残す粒度

本番に入れるなら、私は raw output を全部ずっと保存するより、まず構造化した失敗理由を安定して残すほうを優先します。たとえば llm_parse_failedllm_schema_validation_failedllm_route_unavailable のように event 名を分けます。その上で、model、endpoint、mode、temperature、retry_count、fallback_model、validation_errors を同じ log record に入れます。

この粒度なら、後から「特定モデルだけ enum が大文字になる」「特定 route だけ markdown fence が混ざる」「API availability と schema 失敗を混ぜて retry している」みたいな見方ができます。prompt 本文やユーザー入力をそのまま残すかどうかは別の判断なので、私は redaction と保存期間を先に決めてから raw_text を扱うのがよさそうだと思いました。

気づき

今回の反省は、json_object を schema 保証のように扱っていたことです。parse が通っただけで後段 job に渡すと、enum や key 名の揺れで普通に落ちます。

私の中では、次の運用に落ち着きました。

  1. schema が必要な処理では、対応している限り json_schema を使う。
  2. それでも client 側 validation は消さない。
  3. validation failure は例外ログだけでなく、model、mode、retry_count、fallback_model と一緒に構造化ログにする。
  4. HTTP status の失敗と schema validation の失敗を別イベントにする。
  5. raw output は秘匿情報を除いて、短期間だけ追えるようにする。

地味ですが、ここを分けておくと「モデルを変えたら downstream が落ちた」系の調査がかなり早くなると思います。

参考

間違いあったらコメントください。よろしくお願いします。

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?