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