Phase 11 - Lesson 03

Outputs Estruturados: JSON, Validação de Schema, Decodificação Restrita

This lesson includes a graded coding exercise that runs in your browser, unlocked with lifetime access.

O seu LLM retorna uma string. A sua aplicação precisa de JSON. Essa lacuna já derrubou mais sistemas em produção do que qualquer alucinação de modelo. Output estruturado é a ponte entre linguagem natural e dados tipados. Acerte nisso e seu LLM se tornará uma API confiável. Erre e você estará analisando texto livre com regex às 3 da manhã.

Tipo: Build Linguagens: Python Pré-requisitos: Fase 10, Lições 01-05 (LLMs do Zero) Tempo: ~90 minutos Relacionado: Fase 5 · 20 (Outputs Estruturados & Decodificação Restrita) cobre a teoria no nível do decodificador (processadores de logits FSM/CFG, Outlines, XGrammar). Esta lição foca na superfície do SDK de produção (OpenAI response_format, uso de ferramentas da Anthropic, Instructor) — leia a Fase 5 · 20 primeiro se quiser entender o que está acontecendo por baixo da API.

Objetivos de Aprendizagem

  • Implementar modo JSON e outputs restritos por schema usando parâmetros de API da OpenAI e Anthropic
  • Construir uma camada de validação Pydantic que rejeita outputs malformados de LLM e tenta novamente com feedback de erro
  • Explicar como a decodificação restrita força um JSON válido no nível do token sem pós-processamento
  • Projetar prompts de extração robustos que convertem de forma confiável texto não estruturado em estruturas de dados tipadas

O Problema

Você pergunta a um LLM: "Extraia o nome do produto, preço e disponibilidade deste texto." Ele responde:

The product is the Sony WH-1000XM5 headphones, which cost $348.00 and are currently in stock.

Essa é uma resposta perfeitamente correta. Ela também é completamente inútil para a sua aplicação. Seu sistema de inventário precisa de {"product": "Sony WH-1000XM5", "price": 348.00, "in_stock": true}. Você precisa de um objeto JSON com chaves específicas, tipos específicos e restrições de valor específicas. Você não precisa de uma frase.

A solução ingênua: adicionar "Responda em JSON" ao seu prompt. Isso funciona 90% das vezes. Nos outros 10%, o modelo envolve o JSON em blocos de código markdown, ou adiciona um preâmbulo como "Aqui está o JSON:", ou produz um JSON sintaticamente inválido porque fechou um colchete antes da hora. Seu parser de JSON quebra. Sua pipeline falha. Você adiciona try/except e um loop de repetição. A repetição às vezes produz dados diferentes. Agora você tem um problema de consistência além de um problema de parsing.

Isso não é um problema de engenharia de prompt. É um problema de decodificação. O modelo gera tokens da esquerda para a direita. Em cada posição, ele escolhe o próximo token mais provável de um vocabulário de mais de 100 mil opções. A maioria dessas opções produziria um JSON inválido em qualquer posição específica. Se o modelo emitir apenas {"price":, o próximo token deve ser um dígito, aspas (para string), null, true, false ou um sinal negativo. Qualquer outra coisa produz um JSON inválido. Sem restrições, o modelo pode escolher uma palavra em inglês perfeitamente razoável que seja catastroficamente errada do ponto de vista sintático.

O Conceito

O Espectro de Outputs Estruturados

Existem quatro níveis de controle de output estruturado, cada um mais confiável que o anterior.

graph LR
    subgraph Spectrum["Structured Output Spectrum"]
        direction LR
        A["Prompt-based\n'Return JSON'\n~90% valid"] --> B["JSON Mode\nGuaranteed valid JSON\nNo schema guarantee"]
        B --> C["Schema Mode\nJSON + matches schema\nGuaranteed compliance"]
        C --> D["Constrained Decoding\nToken-level enforcement\n100% compliance"]
    end

    style A fill:#1a1a2e,stroke:#ff6b6b,color:#fff
    style B fill:#1a1a2e,stroke:#ffa500,color:#fff
    style C fill:#1a1a2e,stroke:#51cf66,color:#fff
    style D fill:#1a1a2e,stroke:#0f3460,color:#fff

Baseado em prompt ("Responda em JSON válido"): sem aplicação de regras. O modelo geralmente obedece, mas às vezes não. Confiabilidade: ~90%. Modo de falha: blocos de markdown, preâmbulos, outputs truncados, estrutura incorreta.

Modo JSON: a API garante que o output seja um JSON válido. O parâmetro response_format: { type: "json_object" } da OpenAI ativa isso. O output será analisado sem erros. Mas ele pode não corresponder ao schema esperado -- chaves extras, tipos incorretos, campos ausentes.

Modo Schema: a API recebe um JSON Schema e garante que o output corresponda a ele. Em 2026, todos os principais provedores suportam isso nativamente: response_format: { type: "json_schema", json_schema: {...} } da OpenAI (também como tool_choice="required"), o uso de ferramentas da Anthropic com input_schema e response_schema + response_mime_type: "application/json" do Gemini. O output terá as chaves, tipos e restrições exatos que você especificou.

Decodificação restrita: em cada posição de token durante a geração, o decodificador mascara todos os tokens que produziriam um output inválido. Se o schema exigir um número e o modelo estiver prestes a emitir uma letra, a probabilidade daquele token é definida como zero. O modelo só pode produzir tokens que levem a um output válido. É isso que o modo de output estruturado da OpenAI e bibliotecas como Outlines e Guidance implementam nos bastidores.

JSON Schema: A Linguagem de Contrato

JSON Schema é como você diz ao modelo (ou à camada de validação) qual formato o output deve ter. Todos os principais sistemas de output estruturado utilizam isso.

{
  "type": "object",
  "properties": {
    "product": { "type": "string" },
    "price": { "type": "number", "minimum": 0 },
    "in_stock": { "type": "boolean" },
    "categories": {
      "type": "array",
      "items": { "type": "string" }
    }
  },
  "required": ["product", "price", "in_stock"]
}

Este schema diz: o output deve ser um objeto com uma string product, um número não negativo price, um booleano in_stock e um array opcional de strings categories. Qualquer output que não corresponda é rejeitado.

Os schemas lidam com casos difíceis: objetos aninhados, arrays com itens tipados, enums (restringir uma string a valores específicos), correspondência de padrões (regex em strings) e combinadores (oneOf, anyOf, allOf para outputs polimórficos).

O Padrão Pydantic

Em Python, você não escreve JSON Schema manualmente. Você define um modelo Pydantic e ele gera o schema para você.

from pydantic import BaseModel

class Product(BaseModel):
    product: str
    price: float
    in_stock: bool
    categories: list[str] = []

Isso produz o mesmo JSON Schema acima. A biblioteca Instructor (e o SDK da OpenAI) aceitam modelos Pydantic diretamente: passe a classe do modelo, receba uma instância validada. Se o output do LLM não corresponder, o Instructor repete automaticamente.

Chamada de Função / Uso de Ferramentas

Uma interface alternativa para o mesmo problema. Em vez de pedir ao modelo para produzir JSON diretamente, você define "ferramentas" (funções) com parâmetros tipados. O modelo produz uma chamada de função com argumentos estruturados. A OpenAI chama isso de "chamada de função" (function calling). A Anthropic chama de "uso de ferramentas" (tool use). O resultado é o mesmo: dados estruturados.

graph TD
    subgraph ToolUse["Tool Use Flow"]
        U["User: Extract product info\nfrom this review text"] --> M["Model processes input"]
        M --> TC["Tool Call:\nextract_product(\n  product='Sony WH-1000XM5',\n  price=348.00,\n  in_stock=true\n)"]
        TC --> V["Validate against\nfunction schema"]
        V --> R["Structured Result:\n{product, price, in_stock}"]
    end

    style U fill:#1a1a2e,stroke:#0f3460,color:#fff
    style TC fill:#1a1a2e,stroke:#e94560,color:#fff
    style V fill:#1a1a2e,stroke:#ffa500,color:#fff
    style R fill:#1a1a2e,stroke:#51cf66,color:#fff

O uso de ferramentas é preferível quando o modelo precisa escolher qual função chamar, não apenas preencher parâmetros. Se você tiver 10 schemas de extração diferentes e o modelo precisar escolher o correto com base no input, o uso de ferramentas oferece tanto a seleção do schema quanto o output estruturado.

Modos de Falha Comuns

Mesmo com a aplicação de regras por schema, os outputs estruturados podem falhar de maneiras sutis.

Valores alucinados: o output corresponde ao schema, mas contém dados inventados. O modelo produz {"price": 299.99} quando o texto diz $348. A validação de schema não consegue capturar isso -- o tipo está correto, o valor está errado.

Confusão de Enum: você restringe um campo a ["in_stock", "out_of_stock", "preorder"]. O modelo produz "available" -- semanticamente correto, mas não está no conjunto permitido. Uma boa decodificação restrita evita isso. Abordagens baseadas em prompt não.

Profundidade de objetos aninhados: schemas profundamente aninhados (4+ níveis) produzem mais erros. Cada nível de aninhamento é mais um local onde o modelo pode perder o controle da estrutura.

Comprimento de array: o modelo pode produzir itens a mais ou a menos em um array. Os schemas suportam minItems e maxItems, mas nem todos os provedores os aplicam no nível de decodificação.

Omissão de campos opcionais: o modelo omite campos que são tecnicamente opcionais, mas semanticamente importantes para o seu caso de uso. Defina-os como obrigatórios no schema, mesmo que os dados às vezes estejam ausentes -- force o modelo a produzir null explicitamente.

Construa

Passo 1: Validador de JSON Schema

Construa um validador do zero que verifica se um objeto Python corresponde a um JSON Schema. É isso que roda no lado do output para verificar a conformidade.

import json

def validate_schema(data, schema):
    errors = []
    _validate(data, schema, "", errors)
    return errors

def _validate(data, schema, path, errors):
    schema_type = schema.get("type")

    if schema_type == "object":
        if not isinstance(data, dict):
            errors.append(f"{path}: expected object, got {type(data).__name__}")
            return
        for key in schema.get("required", []):
            if key not in data:
                errors.append(f"{path}.{key}: required field missing")
        properties = schema.get("properties", {})
        for key, value in data.items():
            if key in properties:
                _validate(value, properties[key], f"{path}.{key}", errors)

    elif schema_type == "array":
        if not isinstance(data, list):
            errors.append(f"{path}: expected array, got {type(data).__name__}")
            return
        min_items = schema.get("minItems", 0)
        max_items = schema.get("maxItems", float("inf"))
        if len(data) < min_items:
            errors.append(f"{path}: array has {len(data)} items, minimum is {min_items}")
        if len(data) > max_items:
            errors.append(f"{path}: array has {len(data)} items, maximum is {max_items}")
        items_schema = schema.get("items", {})
        for i, item in enumerate(data):
            _validate(item, items_schema, f"{path}[{i}]", errors)

    elif schema_type == "string":
        if not isinstance(data, str):
            errors.append(f"{path}: expected string, got {type(data).__name__}")
            return
        enum_values = schema.get("enum")
        if enum_values and data not in enum_values:
            errors.append(f"{path}: '{data}' not in allowed values {enum_values}")

    elif schema_type == "number":
        if not isinstance(data, (int, float)):
            errors.append(f"{path}: expected number, got {type(data).__name__}")
            return
        minimum = schema.get("minimum")
        maximum = schema.get("maximum")
        if minimum is not None and data < minimum:
            errors.append(f"{path}: {data} is less than minimum {minimum}")
        if maximum is not None and data > maximum:
            errors.append(f"{path}: {data} is greater than maximum {maximum}")

    elif schema_type == "boolean":
        if not isinstance(data, bool):
            errors.append(f"{path}: expected boolean, got {type(data).__name__}")

    elif schema_type == "integer":
        if not isinstance(data, int) or isinstance(data, bool):
            errors.append(f"{path}: expected integer, got {type(data).__name__}")

Passo 2: Modelo Estilo Pydantic para Schema

Construa um conversor de classe para schema minimalista. Defina uma classe Python e gere seu JSON Schema automaticamente.

class SchemaField:
    def __init__(self, field_type, required=True, default=None, enum=None, minimum=None, maximum=None):
        self.field_type = field_type
        self.required = required
        self.default = default
        self.enum = enum
        self.minimum = minimum
        self.maximum = maximum

def python_type_to_schema(field):
    type_map = {
        str: "string",
        int: "integer",
        float: "number",
        bool: "boolean",
    }

    schema = {}

    if field.field_type in type_map:
        schema["type"] = type_map[field.field_type]
    elif field.field_type == list:
        schema["type"] = "array"
        schema["items"] = {"type": "string"}
    elif isinstance(field.field_type, dict):
        schema = field.field_type

    if field.enum:
        schema["enum"] = field.enum
    if field.minimum is not None:
        schema["minimum"] = field.minimum
    if field.maximum is not None:
        schema["maximum"] = field.maximum

    return schema

def model_to_schema(name, fields):
    properties = {}
    required = []

    for field_name, field in fields.items():
        properties[field_name] = python_type_to_schema(field)
        if field.required:
            required.append(field_name)

    return {
        "type": "object",
        "properties": properties,
        "required": required,
    }

Passo 3: Filtro de Token Restrito

Simule uma decodificação restrita. Dado uma string JSON parcial e um schema, determine quais categorias de token são válidas na posição atual.

def next_valid_tokens(partial_json, schema):
    stripped = partial_json.strip()

    if not stripped:
        return ["{"]

    try:
        json.loads(stripped)
        return ["<EOS>"]
    except json.JSONDecodeError:
        pass

    last_char = stripped[-1] if stripped else ""

    if last_char == "{":
        return ['"', "}"]
    elif last_char == '"':
        if stripped.endswith('":'):
            return ['"', "0-9", "true", "false", "null", "[", "{"]
        return ["a-z", '"']
    elif last_char == ":":
        return [" ", '"', "0-9", "true", "false", "null", "[", "{"]
    elif last_char == ",":
        return [" ", '"', "{", "["]
    elif last_char in "0123456789":
        return ["0-9", ".", ",", "}", "]"]
    elif last_char == "}":
        return [",", "}", "]", "<EOS>"]
    elif last_char == "]":
        return [",", "}", "<EOS>"]
    elif last_char == "[":
        return ['"', "0-9", "true", "false", "null", "{", "[", "]"]
    else:
        return ["any"]

def demonstrate_constrained_decoding():
    partial_states = [
        '',
        '{',
        '{"product"',
        '{"product":',
        '{"product": "Sony"',
        '{"product": "Sony",',
        '{"product": "Sony", "price":',
        '{"product": "Sony", "price": 348',
        '{"product": "Sony", "price": 348}',
    ]

    print(f"{'Partial JSON':<45} {'Valid Next Tokens'}")
    print("-" * 80)
    for state in partial_states:
        valid = next_valid_tokens(state, {})
        display = state if state else "(empty)"
        print(f"{display:<45} {valid}")

Passo 4: Pipeline de Extração

Combine tudo em uma pipeline de extração: defina um schema, simule um LLM produzindo output estruturado, valide o output e trate as repetições (retries).

def simulate_llm_extraction(text, schema, attempt=0):
    if "headphones" in text.lower() or "sony" in text.lower():
        if attempt == 0:
            return '{"product": "Sony WH-1000XM5", "price": 348.00, "in_stock": true, "categories": ["audio", "headphones"]}'
        return '{"product": "Sony WH-1000XM5", "price": 348.00, "in_stock": true}'

    if "laptop" in text.lower():
        return '{"product": "MacBook Pro 16", "price": 2499.00, "in_stock": false, "categories": ["computers"]}'

    return '{"product": "Unknown", "price": 0, "in_stock": false}'

def extract_with_retry(text, schema, max_retries=3):
    for attempt in range(max_retries):
        raw = simulate_llm_extraction(text, schema, attempt)

        try:
            data = json.loads(raw)
        except json.JSONDecodeError as e:
            print(f"  Attempt {attempt + 1}: JSON parse error -- {e}")
            continue

        errors = validate_schema(data, schema)
        if not errors:
            return data

        print(f"  Attempt {attempt + 1}: Schema validation errors -- {errors}")

    return None

product_schema = {
    "type": "object",
    "properties": {
        "product": {"type": "string"},
        "price": {"type": "number", "minimum": 0},
        "in_stock": {"type": "boolean"},
        "categories": {"type": "array", "items": {"type": "string"}},
    },
    "required": ["product", "price", "in_stock"],
}

Passo 5: Executar a Pipeline Completa

def run_demo():
    print("=" * 60)
    print("  Structured Output Pipeline Demo")
    print("=" * 60)

    print("\n--- Schema Definition ---")
    product_fields = {
        "product": SchemaField(str),
        "price": SchemaField(float, minimum=0),
        "in_stock": SchemaField(bool),
        "categories": SchemaField(list, required=False),
    }
    generated_schema = model_to_schema("Product", product_fields)
    print(json.dumps(generated_schema, indent=2))

    print("\n--- Schema Validation ---")
    test_cases = [
        ({"product": "Test", "price": 10.0, "in_stock": True}, "Valid object"),
        ({"product": "Test", "price": -5.0, "in_stock": True}, "Negative price"),
        ({"product": "Test", "in_stock": True}, "Missing price"),
        ({"product": "Test", "price": "ten", "in_stock": True}, "String as price"),
        ("not an object", "String instead of object"),
    ]

    for data, label in test_cases:
        errors = validate_schema(data, product_schema)
        status = "PASS" if not errors else f"FAIL: {errors}"
        print(f"  {label}: {status}")

    print("\n--- Constrained Decoding Simulation ---")
    demonstrate_constrained_decoding()

    print("\n--- Extraction Pipeline ---")
    texts = [
        "The Sony WH-1000XM5 headphones are priced at $348 and currently available.",
        "The new MacBook Pro 16-inch laptop costs 
499 but is sold out.", "This is a random sentence with no product info.", ] for text in texts: print(f"\n Input: {text[:60]}...") result = extract_with_retry(text, product_schema) if result: print(f" Output: {json.dumps(result)}") else: print(f" Output: FAILED after retries")

Use

Outputs Estruturados na OpenAI

# from openai import OpenAI
# from pydantic import BaseModel
#
# client = OpenAI()
#
# class Product(BaseModel):
#     product: str
#     price: float
#     in_stock: bool
#
# response = client.beta.chat.completions.parse(
#     model="gpt-5-mini",
#     messages=[
#         {"role": "system", "content": "Extract product information."},
#         {"role": "user", "content": "Sony WH-1000XM5, $348, in stock"},
#     ],
#     response_format=Product,
# )
#
# product = response.choices[0].message.parsed
# print(product.product, product.price, product.in_stock)

O modo de output estruturado da OpenAI usa decodificação restrita internamente. Cada token gerado pelo modelo é garantido como produtor de um output que corresponde ao schema Pydantic. Não há necessidade de repetições (retries) ou de validação. A restrição é integrada ao processo de decodificação.

Uso de Ferramentas na Anthropic

# import anthropic
#
# client = anthropic.Anthropic()
#
# response = client.messages.create(
#     model="claude-opus-4-7",
#     max_tokens=1024,
#     tools=[{
#         "name": "extract_product",
#         "description": "Extract product information from text",
#         "input_schema": {
#             "type": "object",
#             "properties": {
#                 "product": {"type": "string"},
#                 "price": {"type": "number"},
#                 "in_stock": {"type": "boolean"},
#             },
#             "required": ["product", "price", "in_stock"],
#         },
#     }],
#     messages=[{"role": "user", "content": "Extract: Sony WH-1000XM5, $348, in stock"}],
# )

A Anthropic alcança outputs estruturados por meio do uso de ferramentas. O modelo emite uma chamada de ferramenta com argumentos estruturados que correspondem ao input_schema. O mesmo resultado, com uma interface de API diferente.

Biblioteca Instructor

# pip install instructor
# import instructor
# from openai import OpenAI
# from pydantic import BaseModel
#
# client = instructor.from_openai(OpenAI())
#
# class Product(BaseModel):
#     product: str
#     price: float
#     in_stock: bool
#
# product = client.chat.completions.create(
#     model="gpt-5-mini",
#     response_model=Product,
#     messages=[{"role": "user", "content": "Sony WH-1000XM5, $348, in stock"}],
# )

O Instructor envolve qualquer cliente LLM e adiciona repetições automáticas com validação. Se a primeira tentativa falhar na validação, ele envia os erros de volta ao modelo como contexto e pede para corrigir o output. Isso funciona com qualquer provedor, não apenas com a OpenAI.

Publique

Esta lição produz outputs/prompt-structured-extractor.md -- um template de prompt reutilizável que extrai dados estruturados de qualquer texto dada uma definição de schema. Forneça a ele um JSON Schema e um texto não estruturado, e ele retornará um JSON validado.

Ela também produz outputs/skill-structured-outputs.md -- uma estrutura de decisão para escolher a estratégia certa de output estruturado com base no seu provedor, requisitos de confiabilidade e complexidade do schema.

Exercícios

  1. Estenda o validador de schema para suportar oneOf (os dados devem corresponder exatamente a um dos vários schemas). Isso lida com outputs polimórficos -- por exemplo, um campo que pode ser um objeto Product ou Service com formatos diferentes.

  2. Construa uma ferramenta de "schema diff" que compara dois schemas e identifica alterações incompatíveis (remoção de campos obrigatórios, alteração de tipos) versus alterações compatíveis (adição de campos opcionais, relaxamento de restrições). Isso é essencial para versionar seus schemas de extração em produção.

  3. Implemente um simulador de decodificação restrita mais realista. Dado um JSON Schema e um vocabulário de 100 tokens (letras, dígitos, pontuação, palavras-chave), percorra a geração passo a passo, mascarando tokens inválidos em cada posição. Meça qual porcentagem do vocabulário é válida em cada passo.

  4. Construa uma suíte de avaliação de extração. Crie 50 descrições de produtos com outputs JSON rotulados manualmente. Execute sua pipeline de extração em todas as 50 e meça correspondência exata, precisão ao nível do campo e conformidade de tipos. Identifique quais campos são os mais difíceis de extrair corretamente.

  5. Adicione "pontuações de confiança" (confidence scores) à sua pipeline de extração. Para cada campo extraído, estime quão confiante o modelo está (com base nas probabilidades dos tokens ou executando a extração 3 vezes e medindo a consistência). Marque campos com baixa confiança para revisão humana.

Termos-Chave

Termo O que dizem por aí O que realmente significa
Modo JSON "Retorna JSON" Parâmetro da API que garante um output JSON sintaticamente válido, mas não força nenhum schema específico
Output estruturado "JSON tipado" Output que corresponde a um JSON Schema específico com chaves, tipos e restrições corretos
Decodificação restrita "Geração guiada" Em cada posição de token, mascara os tokens que produziriam um output inválido -- garante 100% de conformidade com o schema
JSON Schema "Um modelo de JSON" Uma linguagem declarativa para descrever a estrutura, tipos e restrições de dados JSON (usada por OpenAPI, JSON Forms, etc.)
Pydantic "Dataclasses do Python+" Biblioteca Python que define modelos de dados com validação de tipos, usada pelo FastAPI e pelo Instructor para gerar JSON Schemas
Chamada de função "Uso de ferramentas" O LLM produz uma invocação de função estruturada (nome + argumentos tipados) em vez de texto livre -- a OpenAI e a Anthropic suportam isso
Instructor "Pydantic para LLMs" Biblioteca Python que envolve clientes de LLM para retornar instâncias validadas do Pydantic, com repetição automática em caso de falha de validação
Mascaramento de tokens "Filtrar o vocabulário" Definir probabilidades de tokens específicos como zero durante a geração para que o modelo não possa produzi-los
Conformidade com o schema "Corresponde ao formato" O output possui todos os campos obrigatórios, tipos corretos, valores dentro das restrições e nenhum campo extra não permitido
Loop de repetição "Tente novamente até funcionar" Envia erros de validação de volta ao modelo e pede para corrigir o output -- o Instructor faz isso automaticamente, até um limite configurado

Leitura Adicional