Saída estruturada


Primeiro, o formato do problema. Saída estruturada é a prática de fazer um modelo retornar dados que o seu código consegue ler diretamente, um campo em que você pode basear uma decisão em vez de um parágrafo que você precisa interpretar manualmente. Este capítulo trata de tornar isso confiável.
Até agora o modelo devolve texto, o que funciona bem quando é uma pessoa que lê. Mas o software não consegue fazer muita coisa com um parágrafo. Para agir sobre a resposta de um modelo dentro do código, você precisa dela como dados: um campo que você pode ler, um valor que serve de base para uma decisão, um registro que você pode salvar.
A mudança é pequena, mas transforma tudo o que vem depois. Quando o modelo retorna { "sentiment": "negative" } em vez de uma frase dizendo que o cliente parece insatisfeito, o seu código pode encaminhar o chamado, atualizar um painel ou disparar um alerta. A parte interessante é como fazer o modelo produzir dados limpos quando tudo o que ele faz é prever texto, e a resposta revela um recurso que você talvez não soubesse que tinha.
Pedindo JSON
O primeiro instinto é pedir no prompt: "responda como JSON", o formato de texto simples que o software usa para trocar dados, escrito como campos e valores dentro de chaves. Você tenta e, na maioria das vezes, funciona, e é exatamente aí que está a armadilha. O modelo continua fazendo apenas uma coisa, prever texto provável, e às vezes o texto provável é um bloco de código, ou um simpático "Claro, aqui está!" antes dos dados, ou um comentário no final. Qualquer um desses casos quebra o código que lê o JSON. Pedir educadamente inclina a previsão para o JSON; não a força.
Por isso os provedores oferecem um recurso real. Ativar o modo JSON com response_format faz mais do que pedir: restringe o modelo para que a sintaxe volte como JSON válido.
import json
response = client.chat.completions.create(
model=MODEL,
messages=[
{"role": "system", "content": 'Extract the city and the temperature. Reply as JSON: { "city": string, "tempC": number }.'},
{"role": "user", "content": "It's about 18 degrees in Oslo right now."},
],
response_format={"type": "json_object"}, # restringe a saída a uma sintaxe JSON válida
)
data = json.loads(response.choices[0].message.content)
print(data["city"], data["tempC"]) # "Oslo" 18response_format={"type": "json_object"} faz a saída ter sintaxe JSON válida, então json.loads não vai travar com texto solto. Você ainda precisa descrever os campos que quer no prompt, porque o modo JSON garante um JSON válido, não os campos específicos que você tinha em mente. Ele descarta "não é JSON de forma alguma", não "JSON com o formato errado". Uma coisa que ele ainda não pode garantir: se a resposta for cortada no meio, você pode receber meio objeto JSON, então é uma sintaxe confiável, não uma garantia absoluta em todos os casos.
response_format para o modo JSON restringe a sintaxe para que ela volte como JSON válido. Isso não garante que os campos correspondam ao que você pediu, e uma resposta cortada na metade ainda pode chegar como meio objeto, então descreva o formato no prompt e verifique o que chega. Fixando o formato com um schema
O modo JSON mantém a sintaxe válida, mas o modelo ainda pode renomear um campo, removê-lo ou aninhar as coisas de forma diferente. Para eliminar essa margem de manobra, forneça 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 passa a ter a garantia de corresponder a esse formulário.
import json
response = client.chat.completions.create(
model=MODEL,
messages=[
{"role": "system", "content": "Extract the contact details from the message."},
{"role": "user", "content": "Hi, I'm Mara Lin, reach me at [email protected]."},
],
response_format={
"type": "json_schema",
"json_schema": {
"name": "contact",
"strict": True, # aplica o schema com exatidão
"schema": {
"type": "object",
"properties": {
"name": {"type": "string"},
"email": {"type": "string"},
},
"required": ["name", "email"],
"additionalProperties": False,
},
},
},
)
contact = json.loads(response.choices[0].message.content)
# {"name": "Mara Lin", "email": "[email protected]"}Como um schema pode ser uma garantia absoluta se o modelo só prevê tokens? Porque o provedor restringe quais tokens ele tem permissão de prever. A cada passo, o modelo ainda classifica todos os tokens possíveis, mas o sistema remove os que quebrariam o seu schema, e o modelo escolhe entre o que restou.
Se o schema diz que o próximo campo precisa ser email, os tokens que começariam qualquer outro campo são retirados da mesa antes de o modelo escolher. Os tokens fora do formato são removidos, então o modelo não consegue fugir do formato.
Há um efeito mais sutil que vale conhecer. Os nomes de campo que você escolhe passam a fazer parte do que o modelo lê ao prever o valor, então um nome claro ajuda a chegar a uma resposta melhor. Pedindo para preencher tempC, o modelo tende a um número em Celsius; pedindo para preencher value, ele tem muito menos com o que trabalhar. Nomear os campos com clareza é, em parte, instrução, em parte, schema.
strict: true ele recebe exatamente os campos e tipos certos, porque o sistema remove qualquer token que quebraria o formato antes de o modelo escolher. Isso é imposição, não um pedido educado. Nomeie seus campos com clareza também, já que o nome do campo é contexto que o modelo lê ao prever o valor. Levei um tempo para confiar que um nome como tempC faz diferença real. Valide mesmo assim
Um schema restringe o formato, mas trate a saída como dados vindos do mundo externo mesmo assim. O formato pode estar válido enquanto o conteúdo está errado: um e-mail extraído que na verdade tem um erro de digitação, um número que o modelo chutou, um campo deixado em branco porque a entrada não continha essa informação. E a chamada ainda pode falhar de formas comuns, como ser cortada pelo max_tokens e chegar como JSON truncado. Faça o parsing de forma defensiva: um formato válido 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, peça de novo, ou mostre um erro amigávelEsta é a lição da alucinação de como os LLMs funcionam, com uma nova roupagem: o formato estar certo não faz os valores estarem certos. Um schema garante que você vai receber um campo name; não garante que o nome esteja correto. Um try/except em volta do parsing, mais uma verificação dos valores, é um seguro barato contra a resposta que está bem-formada e mesmo assim errada.
try/except e verifique os valores antes de confiar neles. Decida de antemão o que fazer quando a validação falha, uma nova tentativa ou um erro amigável, para não ser um sufoco depois. Dois padrões: classificar e extrair
A maior parte do trabalho com saída estruturada se reduz a dois formatos.
A classificação organiza uma entrada em um de um conjunto fixo de rótulos. Um enum no schema restringe a saída exatamente a esses rótulos, usando o mesmo truque de poda de tokens: na posição do rótulo, só os valores permitidos podem ser previstos, então o modelo não consegue inventar uma categoria que não está na sua lista.
# fragmento de schema para classificação
{"type": "string", "enum": ["billing", "technical", "general"]}A extração retira campos específicos de um texto livre, como no exemplo do contato mais acima: um nome, uma data, um valor, uma lista de nomes de produtos. Juntas, classificação e extração cobrem boa parte dos recursos reais de IA: encaminhar chamados de suporte, marcar conteúdo, transformar e-mails em registros, ler recibos. As duas transformam um gerador de texto impreciso em um componente confiável em que o seu código pode se apoiar.
enum para que o modelo não consiga inventar uma categoria fora da sua lista. A extração retira campos nomeados de um texto livre e os coloca em um registro. As duas transformam um gerador de texto impreciso em um componente confiável, e juntas cobrem boa parte dos recursos práticos de IA. Na prática
Transformando um e-mail de suporte confuso em um chamado estruturado, combinando classificação e extração em um único schema:
import json
response = client.chat.completions.create(
model=MODEL,
messages=[
{"role": "system", "content": "Turn the support email into a 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": "Double-charged for last month's subscription."}Uma única chamada classifica o e-mail de duas formas e extrai um resumo, retornando um registro que o seu código pode encaminhar e armazenar, com o formato garantido e os valores ainda merecendo verificação. Até aqui, tudo fluiu como texto entrando e texto saindo. A seguir, você vai dar ao modelo um sentido completamente diferente: em Embeddings, texto se transforma em números que você pode comparar por significado, a base para busca e para trabalhar com seus próprios documentos.

