Saída estruturada

Primeiro, a forma do problema. Saída estruturada é a prática de fazer um modelo retornar dados que seu código pode ler diretamente, um campo em que você pode se ramificar em vez de um parágrafo que você tem que analisar manualmente. Este capítulo é sobre tornar isso confiável.
Até agora o modelo entrega texto, o que é bom quando uma pessoa o lê. Mas software não pode fazer muito com um parágrafo. Para agir sobre a resposta de um modelo em código, você precisa dela como dados: um campo que você pode ler, um valor que você pode ramificar, um registro que você pode salvar.
A mudança é pequena mas muda tudo a jusante. Uma vez que o modelo retorna { "sentiment": "negative" } em vez de uma frase sobre como o cliente parece insatisfeito, seu código pode rotear o ticket, atualizar um painel ou enviar um alerta. A parte interessante é como você faz o modelo produzir dados limpos quando tudo que ele faz é prever texto, e a resposta revela uma alavanca que você não sabia que tinha.
Peça por JSON
O primeiro instinto é pedir no prompt: "responda como JSON", o formato de texto simples que o software usa para passar dados, escrito como campos e valores entre chaves. Tente e geralmente funciona, que é a armadilha. O modelo ainda não está fazendo nada além de prever texto provável, e às vezes o texto provável é um code fence, ou um amigável "Claro, aqui vai!" antes dos dados, ou um comentário à direita depois. Qualquer um deles quebra o código que lê o JSON. Pedir gentilmente influencia a previsão em direção ao JSON; não força.
Então os provedores lhe dão uma alavanca real. Ativar modo JSON com response_format faz mais do que pedir: restringe o modelo para que a sintaxe retorne como JSON válido.
import json
response = client.chat.completions.create(
model=MODEL,
messages=[
{"role": "system", "content": 'Extraia a cidade e a temperatura. Responda como JSON: { "city": string, "tempC": number }.'},
{"role": "user", "content": "Está em torno de 18 graus em Brasília agora."},
],
response_format={"type": "json_object"}, # restringe a saída à sintaxe JSON válida
)
data = json.loads(response.choices[0].message.content)
print(data["city"], data["tempC"]) # "Brasília" 18response_format={"type": "json_object"} faz a saída ser um JSON válido, então json.loads não vai engasgar em prosa estranha. Você ainda descreve os campos que quer no prompt, porque modo JSON promete JSON válido, não os campos particulares que você tinha em mente. Ele elimina "não é JSON" de forma alguma, não "JSON com a forma errada". Uma coisa que ainda não pode prometer: se a resposta for cortada no meio, você pode receber meio objeto JSON, então é sintaxe confiável, não uma garantia dura em todos os casos.
response_format para modo JSON restringe a sintaxe para que retorne como JSON válido. Não promete que os campos correspondam ao que você pediu, e uma resposta cortada no meio ainda pode chegar como meio objeto, então descreva a forma no prompt e verifique o que chega. Bloqueie a forma com um schema
Modo JSON mantém a sintaxe válida, mas o modelo ainda pode renomear um campo, descartar um, ou aninhar coisas diferentemente. Para remover essa margem de manobra, dê-lhe um schema: uma descrição exata dos campos e tipos que a saída deve ter, um formulário que o modelo é obrigado a preencher. Com strict: true, a saída é então garantida corresponder a essa forma.
import json
response = client.chat.completions.create(
model=MODEL,
messages=[
{"role": "system", "content": "Extraia os detalhes de contato da mensagem."},
{"role": "user", "content": "Oi, sou Marina Silva, me contacte em [email protected]."},
],
response_format={
"type": "json_schema",
"json_schema": {
"name": "contact",
"strict": True, # força o schema exatamente
"schema": {
"type": "object",
"properties": {
"name": {"type": "string"},
"email": {"type": "string"},
},
"required": ["name", "email"],
"additionalProperties": False,
},
},
},
)
contact = json.loads(response.choices[0].message.content)
# {"name": "Marina Silva", "email": "[email protected]"}Como um schema pode ser uma garantia dura quando o modelo só prevê tokens? Porque o provedor restringe quais tokens ele pode prever. A cada passo o modelo ainda classifica todos os possíveis próximos tokens, mas o sistema remove aqueles que quebrariam seu schema, e o modelo escolhe do que ficou.
Se o schema diz que o próximo campo deve ser email, tokens que iniciariam qualquer outro campo são tirados da mesa antes do modelo escolher. Os tokens fora da forma são removidos, então o modelo não pode se desviar da forma.
Há um efeito mais suave que vale a pena saber. Os nomes de campo que você escolhe se tornam parte do que o modelo lê ao prever o valor, então um nome claro guia uma melhor resposta. Solicitado a preencher tempC, o modelo se inclina para um número em Celsius; solicitado a preencher value, tem muito menos a considerar. Nomear campos claramente é parte instrução, parte schema.
strict: true ele fica com os campos e tipos exatos, porque o sistema remove qualquer token que quebraria a forma antes do modelo escolher. Essa é aplicação, não um pedido educado. Nomeie seus campos claramente também, já que o nome do campo é contexto que o modelo lê ao prever o valor. Levou-me um tempo para confiar que um nome como tempC faz trabalho real. Valide mesmo assim
Um schema restringe a forma, mas trate a saída como dados do mundo externo de qualquer forma. A forma pode ser válida enquanto o conteúdo está errado: um email extraído que é na verdade um typo, um número que o modelo adivinhou, um campo deixado em branco porque a entrada não continha. E a chamada ainda pode falhar de formas comuns, como ser cortada por max_tokens e chegar como JSON truncado. Analise defensivamente: uma forma válida não é um valor correto.
import json
def parse_contact(raw):
try:
data = json.loads(raw)
if not data.get("name") or not data.get("email"):
return None # presente mas vazio
return data
except json.JSONDecodeError:
return None # não é JSON válido (por exemplo, uma resposta truncada)
contact = parse_contact(response.choices[0].message.content)
if not contact:
pass # trate a falha: tente novamente, pergunte novamente, ou mostre um erro amigávelEsta é a lição de alucinação de como LLMs funcionam em uma nova roupagem: a forma estar certa não torna os valores corretos. Um schema garante que você fica com um campo name; não garante que o nome está correto. Um try/except ao redor da análise mais uma verificação dos valores é um seguro barato contra a resposta que é bem-formada e ainda errada.
try/except e verifique os valores antes de confiar neles. Decida de antemão o que acontece quando a validação falha, uma tentativa novamente ou um erro amigável, então não é uma luta depois. Dois padrões: classificar e extrair
A maioria do trabalho de saída estruturada é uma de duas formas.
Classificação ordena uma entrada em um de um conjunto fixo de rótulos. Um enum no schema restringe a saída exatamente àqueles rótulos, usando o mesmo truque de poda de token: na posição do rótulo, apenas os valores permitidos podem ser previstos, então o modelo não pode inventar uma categoria que não está em sua lista.
# fragmento de schema para classificação
{"type": "string", "enum": ["billing", "technical", "general"]}Extração puxa campos específicos de texto livre, como no exemplo de contato anterior: um nome, uma data, um montante, uma lista de nomes de produtos. Juntos, classificar e extrair cobrem uma grande parte do trabalho real de IA: rotear tickets de suporte, marcar conteúdo, transformar emails em registros, ler recibos. Ambos transformam um gerador de texto nebuloso em um componente confiável no qual seu código pode construir.
enum para que o modelo não possa inventar uma categoria fora de sua lista. Extração puxa campos nomeados de texto livre em um registro. Ambos transformam um gerador de texto nebuloso em um componente confiável, e entre os dois cobrem uma grande parte dos recursos práticos de IA. Na prática
Transformando um email de suporte bagunçado em um ticket estruturado, combinando classificação e extração em um schema:
import json
response = client.chat.completions.create(
model=MODEL,
messages=[
{"role": "system", "content": "Transforme o email de suporte em um ticket."},
{"role": "user", "content": email_text},
],
response_format={
"type": "json_schema",
"json_schema": {
"name": "ticket",
"strict": True,
"schema": {
"type": "object",
"properties": {
"category": {"type": "string", "enum": ["billing", "technical", "general"]},
"urgency": {"type": "string", "enum": ["low", "medium", "high"]},
"summary": {"type": "string"},
},
"required": ["category", "urgency", "summary"],
"additionalProperties": False,
},
},
},
)
ticket = json.loads(response.choices[0].message.content)
# {"category": "billing", "urgency": "high", "summary": "Cobrado em dobro pela assinatura do mês passado."}Uma chamada classifica o email de duas formas e extrai um resumo, retornando um registro que seu código pode rotear e armazenar, com a forma garantida e os valores ainda valendo a pena verificar. Até agora tudo flui texto dentro e texto para fora. Depois você dá ao modelo um senso completamente diferente: em Embeddings, texto vira números que você pode comparar por significado, a fundação para busca e para trabalhar com seus próprios documentos.

