惯性聚合 高效追踪和阅读你感兴趣的博客、新闻、科技资讯
阅读原文 在惯性聚合中打开

推荐订阅源

cs.CL updates on arXiv.org
cs.CL updates on arXiv.org
B
Blog RSS Feed
宝玉的分享
宝玉的分享
腾讯CDC
博客园_首页
T
Tailwind CSS Blog
月光博客
月光博客
博客园 - 司徒正美
奇客Solidot–传递最新科技情报
奇客Solidot–传递最新科技情报
M
MIT News - Artificial intelligence
A
About on SuperTechFans
云风的 BLOG
云风的 BLOG
钛媒体:引领未来商业与生活新知
钛媒体:引领未来商业与生活新知
有赞技术团队
有赞技术团队
freeCodeCamp Programming Tutorials: Python, JavaScript, Git & More
大猫的无限游戏
大猫的无限游戏
MongoDB | Blog
MongoDB | Blog
博客园 - 聂微东
V
Visual Studio Blog
H
Hackread – Cybersecurity News, Data Breaches, AI and More
SecWiki News
SecWiki News
美团技术团队
P
Privacy International News Feed
H
Help Net Security
让小产品的独立变现更简单 - ezindie.com
让小产品的独立变现更简单 - ezindie.com
Microsoft Security Blog
Microsoft Security Blog
Know Your Adversary
Know Your Adversary
Y
Y Combinator Blog
D
DataBreaches.Net
Project Zero
Project Zero
T
The Blog of Author Tim Ferriss
Cyberwarzone
Cyberwarzone
C
Cybersecurity and Infrastructure Security Agency CISA
C
Cisco Blogs
S
Schneier on Security
G
GRAHAM CLULEY
博客园 - 三生石上(FineUI控件)
Cisco Talos Blog
Cisco Talos Blog
小众软件
小众软件
Forbes - Security
Forbes - Security
D
Docker
T
Tenable Blog
S
Secure Thoughts
雷峰网
雷峰网
S
Security @ Cisco Blogs
T
The Exploit Database - CXSecurity.com
The Cloudflare Blog
博客园 - 【当耐特】
Spread Privacy
Spread Privacy
阮一峰的网络日志
阮一峰的网络日志

DEV Community

Authentication Security Deep Dive: From Brute Force to Salted Hashing (With Java Examples) Why AI Systems Don’t Fail — They Drift Spilling beans for how i learn for exam😁"Reinforcement Learning Cheat Sheet" I Replaced Chrome with Safari for AI Browser Automation. Here's What Broke (and What Finally Worked) How Python Borrows Other People's Work The $40 Architecture: Processing 1 Billion API Requests with 99.99% Uptime Vibe Coding: A Workflow Guide (From Zero to SaaS) Most webhook security guides protect the wrong side. The scary part is delivery. Headless CMS for TanStack Start: Build a Blog with Cosmic EU Age Verification App "Hacked in 2 Minutes" — What Actually Happened Comfy Cloud’s delete function does not actually remove files Running AI Models on GPU Cloud Servers: A Beginner Guide Event-driven media intelligence with AWS Step Functions and Bedrock I scored 500 AI prompts across 8 quality dimensions — here's what broke How to Call Google Gemini API from Next.js (Free Tier, No Backend Needed) The Portal Protocol: Reclaiming Human Connection in the Age of AI How to Fix Your Team's Scattered Knowledge Problem With a Self-Hosted Forum Intro to tc Cloud Functors: A Graph-First Mental Model for the Modern Cloud Designing Multi-Tenant Backends With Both Ownership and Team Access I Built a Neumorphic CSS Library with 77+ Components — Here's What I Learned PostgreSQL Performance Optimization: Why Connection Pooling Is Critical at Scale Cómo construí un SaaS multi-rubro para gestionar expensas en Argentina con FastAPI + Vue 3 🚀 I Built an Ethical Hacking Scanner Tool – Open Source Project I Replaced /usage and /context in Claude Code With a Single Statusline A Pythonic Way to Handle Emails (IMAP/SMTP) with Auto-Discovery and AI-Ready Design I Collected 8.9 Million Polymarket Price Points — Here's What I Found About How Markets Really Move EcoTrack AI — Carbon Footprint Tracker & Dashboard Everyone's Using AI. No One Agrees How. 5 self-hosted ebook managers worth trying in 2026 Building Your First AI Agent with LangChain: From Chatbot to Autonomous Assistant Common SOC 2 Failures (Real World) Stop Vibe-Checking Your AI App: A Practical Guide to Evals How to Use SonarQube and SonarScanner Locally to Level Up Your Code Quality Your Next To-Do App Is Dead — I Replaced Mine with an OpenClaw AI Sign a Nostr event in 60 lines of Python using coincurve — no nostr-sdk, no nbxplorer, no rust toolchain ITGC Audit Explained Like You’re in Big 4 Patch Tuesday abril 2026: Microsoft parcha 163 vulnerabilidades y un zero-day en SharePoint Stop scraping everything: a better way to track competitor price changes Listing on MCPize + the Official MCP Registry while routing payments OUTSIDE the marketplace — how I kept 100% of my x402 revenue Building an AI-Powered Risk Intelligence System Using Serverless Architecture Why We Ripped Function Overloading Out of Our AI Toolchain Testing AI-Generated Code: How to Actually Know If It Works SaaS Churn Is Killing Your Business. Here Is What to Do About It (Without a Support Team) The Speed of AI Is No Longer Linear - And Self-Improving Models Are Why How to Implement RBAC for MCP Tools: A Practical Guide for Engineering Teams From Standard Quote to Persuasive Proposal: AI Automation for Arborists I built a CLI that scaffolds complete multi-tenant SaaS apps Axios CVE-2025–62718: The Silent SSRF Bug That Could Be Hiding in Your Node.js App Right Now The dashboard that ended our friendship Data Pipelines Explained Simply (and How to Build Them with Python) The Hidden Cost of AI Systems Nobody Talks About. undefined vs undeclared, and how typeof behaves Switching from file-based jobs to NATS/Kafka in Rust without changing code io_uring Adventures: Rust Servers That Love Syscalls Why Agentic AI is Killing the Traditional Database The POUR principles of web accessibility for developers and designers Quantum Neural Network 3D — A Deep Dive into Interactive WebGL Visualization How To Install Caveman In Codex On macOS And Windows Automation Pipeline Reliability: Why Your Workflow Breaks When Nobody Is Watching I Built an 'Open World' AI Coding Agent — It Works From ANY Folder From Freelancing to Product: A Tech Service Company's SaaS Transformation China's AI Giants: Adding Tencent Hunyuan & ByteDance Doubao to AI University (74 Providers) On the Vibe Coders and Their Lies clerk: Auto-Summarize Your Claude Code Sessions AI Weekly — 2026/04/10–04/17 | The Model Lockdown Is Here, but the Toolchain Is the Real Battleground AI 週報 — 2026/04/10–2026/04/17 模型封鎖潮來了,但工具鏈才是真戰場 Maybe this is how Open-Source apps are born... 🚀 Fine-Tune LLMs with LoRA and QLoRA: 2026 Guide tRPC v11 + Next.js App Router: End-to-End Type Safety Without the Boilerplate ShadCN UI in 2026: Why I Stopped Installing Component Libraries and Started Owning My Components SaaS Billing in React Server Components: Stripe + Supabase Without a Single `useEffect` Join our DEV Weekend Challenge — $1,000 in Prizes Across TEN winners! Submissions Due April 20 at 6:59 AM UTC. Implementing FSRS Spaced Repetition in Flutter + Supabase — Adding Memory Science to an AI Learning App "I Texted My Localhost From the Train — Claude Code Fixed the Bug Before I Got Home" I Built a Sales Prep AI and It Went Deeper Than Expected Design to Code #2: One JSON, Eleven Outputs Solving the 100M-Row Problem: A Summary Table Pattern for High-Volume Push Notification Logs Flutter Web With Wasm: What Actually Changes For Developers I Built 50 Royalty-Free Soundtracks for My Side Project in a Weekend Using AI Music Generation The Vibe Coding Security Checklist: 7 Things to Check Before You Ship Stop Letting Googlebot Guess Fix Your React App's SEO Right Desconstruindo o Streaming do LinkedIn: Como Criar um Engine de Extração de Vídeo de Alta Performance com HLS e FFmpeg (EDA Part-1) EDA (Exploratory Data Analysis) Explained With Real Life — Why Looking at Your Data Is the Most Important Step in Machine Learning Brand Relationship Management at Scale: Our 4-Touch Outreach System for 200+ Brands Why String.fromEnvironment() Might Return an Empty String in Dart JGuardrails 1.0.0 — Hardening Java LLM Apps Against Jailbreaks, Toxicity, and Prompt Injection Plan and Schedule a Full Week of Threads Content From One Claude Conversation Coding Cat Oran Ep3, Five Tables Changed Everything Updated: BFF Pattern I'm done watching freelancers get buried by 200 proposals. So I'm building the alternative. This is my first post BFS Algorithm in Java Step by Step Tutorial with Examples Tracking LLM Pricing Monthly: An Open Dataset for 22 AI Models How We Measure Content ROI on a Comparison Site: Revenue Attribution Without Perfect Data Introducing Nova AI Ops: The AI-Native Operating System for SRE Teams I built a free desktop video downloader for Windows — Grabbit How Talkie OCR Helps Vision-Impaired & Dyslexic Users Read the World Around Them VRCFaceTracking安装和iPhone面捕配置教程,有bug Even CrowdStrike Can't See Your Agents The Automation Gold Rush: What n8n Workflows and Claude Are Opening Up for Developers Right Now
LLM Structured Output Validation in Python That Holds Up
Rost · 2026-05-15 · via DEV Community

Most LLM "structured output" tutorials are unserious.
They teach you to ask for JSON politely and then hope the model behaves.
That is not validation.
That is optimism with braces.

OpenAI's own docs make the distinction explicit. JSON mode gives you valid JSON, while Structured Outputs enforces schema adherence, and OpenAI recommends using Structured Outputs instead of JSON mode when possible.

That still does not make the payload trustworthy. JSON Schema defines structure and allowed values, Pydantic gives you typed validation in Python, and OpenAI explicitly notes that a schema-valid response can still contain incorrect values. On top of that, refusals and incomplete outputs can bypass the shape you expected. In production, structured output validation is a pipeline, not a toggle. The same boundary also has to live inside the wider story of throughput, retries, and scheduler limits on the LLM performance engineering hub.

Structured output validation is a contract

Structured output validation for LLMs means you define the shape of the answer up front, constrain the model to produce that shape when possible, and then validate the result again before your application trusts it. In practical terms, that means checking required fields, types, enums, closed object shapes, and domain rules before the payload touches your database, UI, queue, or downstream service. JSON Schema exists for exactly this kind of structural validation, Pydantic is built to validate untrusted data against Python type hints, and Python's jsonschema library gives you a direct way to validate an instance against a schema.

There is also a clean split between two common use cases. If the model is supposed to answer the user in a structured format, use a structured response format. If the model is supposed to call your application's tools or functions, use function calling. OpenAI's docs spell out that distinction, and for function calling they recommend enabling strict: true so the arguments reliably adhere to the function schema.

My strong opinion is simple. Treat every structured LLM response as an API boundary. Once you start thinking in terms of contracts instead of prompts, the architecture gets cleaner, the bugs get cheaper, and the whole "why did the model invent a new field in production" problem mostly disappears. That is the real answer to "what is structured output validation for LLMs" and it is a much better answer than "ask the model nicely for JSON."

JSON mode is not validation

If you remember only one thing from this article, make it this. JSON mode is not schema validation. OpenAI's Help Center says JSON mode will not guarantee the output matches any specific schema, only that it is valid JSON and parses without errors. The Structured Outputs guide says the same thing in a cleaner way. Both JSON mode and Structured Outputs can produce valid JSON, but only Structured Outputs enforces schema adherence.

That difference matters more than people admit. In its Structured Outputs launch post, OpenAI reported that gpt-4o-2024-08-06 with Structured Outputs scored 100 percent on its complex JSON schema evals, while gpt-4-0613 scored under 40 percent. You do not need to treat those numbers as universal truth to see the broader point. Schema enforcement changes the failure surface from "anything could happen" to "the contract is much tighter."

There are still edge cases, and pretending otherwise is how toy demos become pager duty. OpenAI documents that the model can refuse an unsafe request, and those refusals are surfaced outside your normal schema path. It also documents incomplete responses, including cases such as hitting max_output_tokens or a content filter interruption. So the FAQ "is JSON mode enough for reliable LLM output" has a short answer and a longer one. The short answer is no. The longer answer is that even strict structured output still needs explicit failure handling.

Where structured output still breaks

Schema enforcement shrinks the problem. It does not delete it. In real traffic you still see broken or surprising payloads for reasons that have little to do with your prompt wording.

Failure shapes worth designing for

Models and clients disagree about details. You can get extra prose before or after the JSON, Markdown fenced blocks around the payload, or a tool call whose name is valid but whose arguments are JSON that does not match your Pydantic model. Streaming makes it worse because you might validate a half-finished buffer. Defensive code should assume "string in, maybe JSON inside" rather than "bytes on the wire already match my model."

Provider and API differences

Not every host exposes the same structured-output surface. One stack might give you a first-class schema-bound completion, another might only guarantee JSON syntax, and local runtimes might lag behind hosted APIs. That is one reason the FAQ "how do you validate LLM JSON in Python" starts with provider enforcement when it exists and still ends with Python-side validation. For a wider view of how vendors compare, see the structured output comparison across popular LLM providers. If you run models locally, the same validation pipeline applies after you normalize the wire format, for example after extraction with Ollama as in structured LLM output with Ollama in Python and Go. When a runtime still wraps JSON with odd prefixes or reasoning traces, expect the same class of parser failures described in Ollama GPT-OSS structured output issues.

The Python stack that actually works

My recommendation is boring on purpose. First, let the model provider enforce the structural contract when it can. Second, validate the returned payload in Python with Pydantic. Third, use explicit business-rule validation for facts that a schema alone cannot prove. Fourth, test the contract with fixtures and adversarial examples instead of waving at a playground screenshot and calling it done. OpenAI's Structured Outputs docs, Pydantic's validator model, Python's jsonschema tooling, and OpenAI's own structured-output eval examples all point in that direction.

Pydantic is the right center of gravity for Python. It lets you model the output as normal Python types, generate JSON Schema with model_json_schema(), and validate raw JSON with model_validate_json(). Pydantic's docs also note that model_validate_json() is generally the better path than doing json.loads(...) first and then validating, because that two-step route adds extra parsing work in Python.

If you keep standalone schema files in your repo, or you want CI to validate fixture payloads independently of model code, Python's jsonschema package gives you the simplest possible contract check with jsonschema.validate(...). If you want that in pre-commit, check-jsonschema exists specifically as a CLI and pre-commit hook built on jsonschema. That is a very good fit for teams that want schema changes reviewed like code changes.

Frameworks can reduce plumbing, but they do not remove the need for actual validation. LangChain now auto-selects provider-native structured output when the provider supports it and falls back to a tool strategy otherwise. Instructor layers Pydantic response models, validation, retries, and multi-provider support on top of model calls. Guardrails focuses on validators and input-output guard layers. Useful tools, all of them. But the schema and the business rules still belong to you. If you are choosing between higher-level libraries, the BAML vs Instructor comparison for Python is a useful companion to this article.

A minimal OpenAI and Pydantic example

The smallest production-worthy example has a few non-negotiables. Use a closed set of enum-like values where possible. Forbid extra keys. Add field descriptions so the schema is understandable to humans and more legible to the model. Keep the root object explicit and boring. OpenAI recommends clear names plus titles and descriptions for important keys, JSON Schema uses enum to restrict values, and Pydantic can close the object shape with extra="forbid".

from typing import Literal

from openai import OpenAI
from pydantic import BaseModel, ConfigDict, Field

class TicketClassification(BaseModel):
    model_config = ConfigDict(extra="forbid")

    category: Literal["billing", "bug", "how_to", "abuse"] = Field(
        description="Support ticket category."
    )
    priority: Literal["low", "medium", "high"] = Field(
        description="Operational urgency."
    )
    needs_human: bool = Field(
        description="Whether a human should review the case."
    )
    summary: str = Field(
        description="A one sentence summary of the issue."
    )

client = OpenAI()

response = client.responses.parse(
    model="gpt-4o-2024-08-06",
    input=[
        {
            "role": "system",
            "content": "Classify support tickets. Return only the structured result.",
        },
        {
            "role": "user",
            "content": "Customer reports duplicate charges after refreshing checkout.",
        },
    ],
    text_format=TicketClassification,
)

result = response.output_parsed
print(result.model_dump())

Enter fullscreen mode Exit fullscreen mode

Two details in that example are easy to miss and absolutely worth caring about. extra="forbid" on the Pydantic side mirrors the JSON Schema idea of additionalProperties: false, which is also a requirement for strict tool schemas in OpenAI's function-calling docs. And enums are not cosmetic. They are one of the simplest ways to stop the model from inventing a value your code does not understand.

The OpenAI Python SDK supports client.responses.parse(...) with a Pydantic model supplied as text_format, and the parsed object is returned on response.output_parsed. The same SDK also supports client.chat.completions.parse(...), where the parsed object lives on message.parsed. If you want direct structured data extraction with minimal glue, those helpers are the cleanest starting point.

Parse, normalize, then validate

Structured Outputs and model_validate_json remove a lot of parsing pain when the stack is aligned end to end. The moment you support a provider that returns plain chat text, a model that wraps JSON in fences, or a logging path that stores the raw completion string, you want one choke point that turns text into a dict before Pydantic runs.

import json

def parse_json_from_llm_text(text: str) -> dict:
    cleaned = text.strip()
    if cleaned.startswith("```

"):
        cleaned = cleaned.split("\n", 1)[1]
        cleaned = cleaned.rsplit("

```", 1)[0].strip()

    # Common "Sure, here is the JSON:" prefix before the object.
    if not cleaned.startswith("{") and "{" in cleaned and "}" in cleaned:
        start = cleaned.find("{")
        end = cleaned.rfind("}")
        if end > start:
            cleaned = cleaned[start : end + 1]

    return json.loads(cleaned)

ticket_dict = parse_json_from_llm_text(raw_completion_text)
ticket = TicketClassification.model_validate(ticket_dict)

Enter fullscreen mode Exit fullscreen mode

That helper is intentionally boring. It handles fenced "

json ...

" blocks and a leading natural-language preamble when the payload is still a single top-level object. It is not a full JSON extractor. If the model nests braces inside string values, naive slicing can break, and the right fix is usually stricter prompting, schema-bound completions, or a dedicated parser library.

Streaming completions

If you stream chat tokens, do not run json.loads or model_validate_json on every delta. Buffer until the API reports a finished message (check your client for the stream termination or finish_reason), concatenate the text, then parse once. The same rule applies when tool-call arguments arrive in chunks. You only validate after the arguments string is complete.

chunks: list[str] = []
for chunk in completion_stream:
    delta = chunk.choices[0].delta.content or ""
    chunks.append(delta)
raw_completion_text = "".join(chunks)
ticket = TicketClassification.model_validate_json(raw_completion_text)

Enter fullscreen mode Exit fullscreen mode

You can still pass raw_completion_text through parse_json_from_llm_text first when you expect fences or chatter around the JSON.

Once you own plain-string parsing, the next constraint is often not Python but the provider's JSON Schema dialect and what the remote API actually accepts.

Provider schema limits (before you get clever in Python)

Do not blindly dump any schema generator output into an API and assume every JSON Schema feature is supported. OpenAI supports a subset of JSON Schema, requires all fields to be required for Structured Outputs, requires the root to be an object rather than a top-level anyOf, and documents limits on nesting depth and total property count. Keep the provider-facing schema simple. That is not a compromise. That is good engineering.

If you need a provider-agnostic validation path, or you want to validate stored fixtures and mocks, Pydantic plus jsonschema is still a great combination.

from jsonschema import validate as validate_json

schema = TicketClassification.model_json_schema()

payload = {
    "category": "bug",
    "priority": "high",
    "needs_human": True,
    "summary": "Checkout duplicates charges after refresh.",
}

validate_json(instance=payload, schema=schema)
ticket = TicketClassification.model_validate(payload)
print(ticket)

Enter fullscreen mode Exit fullscreen mode

That pattern is especially handy in tests, contract fixtures, and integrations where the model provider does not offer native structured output enforcement. Just remember that a locally generated schema may be broader than a given provider's supported subset, so "valid locally" does not automatically mean "accepted by every LLM API." Also note that some providers preprocess and cache schema artifacts, so the first request for a new schema can be slower than warm requests.

Tool calls are a second contract

Function or tool calling is the other major structured-output shape. The model chooses a name and passes arguments that should match a JSON Schema you control. OpenAI recommends strict: true on tool definitions so arguments stay aligned with that schema. In agent-heavy stacks, bad sampling turns into invalid tool JSON fast; keep sampler settings aligned with multi-step work using the agentic inference parameters reference for Qwen and Gemma.

The snippets below assume you already mapped the provider's tool-call object into a name string and an arguments dict, for example by parsing tool_calls[].function on chat completions (JSON string arguments become json.loads first). dispatch_tool is the step after that normalization.

Two practical rules help in Python. First, validate the tool name against an explicit allowlist before you route execution. Second, validate the arguments dict with the same Pydantic model you use in tests, not with ad hoc key access. The failure mode you are avoiding is "valid JSON arguments, wrong shape for the tool that fired," which slips past string checks.

from typing import Any, Callable

from pydantic import BaseModel

ToolHandler = Callable[[dict[str, Any]], str]

def dispatch_tool(
    *,
    name: str,
    arguments: dict[str, Any],
    handlers: dict[str, tuple[type[BaseModel], ToolHandler]],
) -> str:
    if name not in handlers:
        raise ValueError(f"unsupported tool {name}")
    model_cls, handler = handlers[name]
    validated = model_cls.model_validate(arguments)
    return handler(validated.model_dump())

handlers: dict[str, tuple[type[BaseModel], ToolHandler]] = {
    "classify_ticket": (
        TicketClassification,
        lambda data: f"queued as {data['category']}",
    ),
}

Enter fullscreen mode Exit fullscreen mode

That pattern keeps routing and validation in one place. Your real handlers will be richer, but the split should stay the same: allowed names, typed arguments, then side effects.

Schema validation still needs business rules

A valid object is not the same thing as a correct object. OpenAI says this directly. Structured Outputs does not prevent mistakes inside the values of the JSON object. That is why the FAQ "why do schema validation and business-rule validation both matter" has a blunt answer. Because a response can match the schema perfectly and still be wrong in a way that hurts the business.

Here is a realistic example. The structure can be valid, but the pricing logic can still be nonsense.

from decimal import Decimal
from typing import Literal
from typing_extensions import Self

from pydantic import BaseModel, ConfigDict, Field, model_validator

class Offer(BaseModel):
    model_config = ConfigDict(extra="forbid")

    currency: Literal["USD", "EUR", "GBP"]
    amount: Decimal = Field(gt=0)
    original_amount: Decimal | None
    discounted: bool

    @model_validator(mode="after")
    def check_discount_logic(self) -> Self:
        if self.discounted:
            if self.original_amount is None:
                raise ValueError(
                    "original_amount is required when discounted is true"
                )
            if self.original_amount <= self.amount:
                raise ValueError(
                    "original_amount must be greater than amount"
                )
        return self

Enter fullscreen mode Exit fullscreen mode

That validator does something schemas alone often do poorly in real systems. It checks cross-field semantics after the whole model has been parsed. Pydantic's model_validator exists exactly for this kind of whole-object validation. Notice the Decimal | None field without a default. That keeps the field present while still allowing null, which matches OpenAI's documented pattern for optional-like values under strict Structured Outputs.

If you want validation failures to feed back into the model automatically, Instructor is a practical layer on top of Pydantic. Its docs describe a retry loop where validation errors are captured, formatted as feedback, and used to ask the model to try again.

import instructor

retrying_client = instructor.from_provider("openai/gpt-4o", max_retries=2)

offer = retrying_client.create(
    response_model=Offer,
    messages=[
        {
            "role": "user",
            "content": (
                "Extract the offer from this text. "
                "Was 49.00 USD, now 19.00 USD."
            ),
        }
    ],
)

Enter fullscreen mode Exit fullscreen mode

This is one of the few conveniences I will happily recommend. Automatic retries tied to real validation errors are useful. Silent coercion is not. Instructor's model layer, retry docs, and validation docs all lean into that same idea, and they are right to do so.

You can implement the same idea without a framework. The loop is small. Ask the model, validate with Pydantic, and if validation fails, send the error details back in a follow-up user message and ask for corrected JSON only. Cap attempts, log the final failure, and surface a controlled error to callers. When you already rely on responses.parse or other schema-bound helpers, you may rarely exercise this path. It still matters for JSON mode, older chat endpoints, or any gateway that hands you a raw string.

from openai import OpenAI
from pydantic import ValidationError

client = OpenAI()

messages = [
    {"role": "system", "content": "Return only JSON that matches the ticket schema."},
    {"role": "user", "content": "Customer reports duplicate charges after refreshing checkout."},
]

ticket: TicketClassification | None = None
for attempt in range(2):
    completion = client.chat.completions.create(
        model="gpt-4o-2024-08-06",
        messages=messages,
        response_format={"type": "json_object"},
    )
    raw_text = completion.choices[0].message.content or ""
    try:
        ticket = TicketClassification.model_validate_json(raw_text)
        break
    except ValidationError as exc:
        messages.append(
            {
                "role": "user",
                "content": f"Validation failed with {exc.errors()}. Return corrected JSON only.",
            }
        )
else:
    raise RuntimeError("exhausted structured output retries")

assert ticket is not None

Enter fullscreen mode Exit fullscreen mode

In real services you would attach tracing IDs, redact customer text in logs, and distinguish recoverable validation errors from refusals or incomplete responses. The important part is that the retry is driven by real validator output, not by a generic "try again" message.

Test, retry, and fail closed

What should happen when LLM validation fails? Not a shrug. Reject the payload, log the failure, retry with bounded attempts if the task is worth retrying, and fail closed instead of normalizing garbage into something that only looks acceptable. This is also where many teams forget to handle refusals and incomplete outputs explicitly, even though the provider docs tell them those paths exist.

For OpenAI's Responses API, failure handling should be first-class code, not an afterthought. The variable is response from client.responses.create or parse, not completion from chat streaming elsewhere in this article.

if response.status == "incomplete":
    raise RuntimeError(response.incomplete_details.reason)

content = response.output[0].content[0]

if content.type == "refusal":
    raise RuntimeError(content.refusal)

Enter fullscreen mode Exit fullscreen mode

That is not defensive over-engineering. It is directly aligned with the documented failure modes. If the model refuses, you are not holding a schema-valid payload. If the response is incomplete, you are not holding a schema-valid payload. Treat both as explicit branches in your control flow.

You should also test the contract outside the model call itself.

import pytest
from jsonschema import validate as validate_json
from pydantic import ValidationError

def test_ticket_fixture_matches_schema():
    payload = {
        "category": "bug",
        "priority": "high",
        "needs_human": True,
        "summary": "Checkout duplicates charges after refresh.",
    }
    validate_json(instance=payload, schema=TicketClassification.model_json_schema())

def test_discount_logic_rejects_broken_offer():
    with pytest.raises(ValidationError):
        Offer.model_validate(
            {
                "currency": "USD",
                "amount": "19.00",
                "original_amount": "10.00",
                "discounted": True,
            }
        )

def test_ticket_rejects_unknown_category_string():
    with pytest.raises(ValidationError):
        TicketClassification.model_validate(
            {
                "category": "refund",
                "priority": "high",
                "needs_human": True,
                "summary": "Customer wants a refund.",
            }
        )

def test_ticket_rejects_extra_keys():
    with pytest.raises(ValidationError):
        TicketClassification.model_validate(
            {
                "category": "bug",
                "priority": "high",
                "needs_human": True,
                "summary": "Broken flow.",
                "severity": "critical",
            }
        )

Enter fullscreen mode Exit fullscreen mode

This is the right shape of test strategy for LLM output validation in Python. Validate golden fixtures with jsonschema so every field in the contract is exercised. Validate semantics with Pydantic, then add adversarial cases such as illegal enum strings, forbidden extra keys, and cross-field contradictions you care about. If you snapshot real model outputs, scrub PII and treat them as regression fixtures.

If your team lives in the OpenAI stack, the Evals API also includes structured-output evaluation recipes specifically for testing and iterating on tasks that depend on machine-readable formats. And if you keep raw schema files in the repo, wire check-jsonschema into CI or pre-commit. Ship contracts, not vibes.

Production checks that save you later

When validation fails, the FAQ answer is blunt. Reject the payload, log why, retry with targeted feedback when the task is worth another attempt, and fail closed instead of coercing bad data into a queue.

A short operations checklist helps teams avoid repeat incidents.

  • Log schema version or a hash of the JSON Schema you sent to the provider so you can replay failures accurately.
  • Redact model inputs and outputs in logs. Structured logs are useless if they leak customer text.
  • Emit counters or metrics for refusal rate, incomplete response rate, validation failure rate, and repair success rate. Spikes there beat guessing when a model or prompt change shipped.

Broader observability for LLM systems guidance helps wire those signals into dashboards, traces, and SLO reviews once the counters exist.

The best practice is not complicated. Use provider-side Structured Outputs or strict tool schemas when you can. Normalize raw text when you must. Mirror the contract in Python with Pydantic. Add business-rule validation for what the schema cannot prove. Handle refusals and incomplete responses as normal branches. Test the contract until it stops being a demo and starts being software. Anything less is just prompt engineering cosplay.