Skip to content
This page has been auto-translated and may contain errors.View in English

Saída estruturada ​

docs.scrimba.com

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.

O modelo retorna texto. O seu código precisa de dados: um campo para ler, um valor para basear uma decisão, um registro para armazenar. Este capítulo é a ponte entre essas duas coisas, e o caminho é parar de esperar que o modelo formate tudo bonitinho e começar a restringir o que ele pode produzir.

Aqui existe um recurso real, não um truque de prompt. O mesmo ciclo de amostragem de como os LLMs funcionam pode ser direcionado para forçar a saída a assumir o formato que você quer, o que transforma um gerador de texto impreciso em um componente com o qual o resto do seu sistema pode contar.

Um modelo que retorna texto corrido é um componente que você não consegue compor. No momento em que a saída dele alimenta outro sistema, você precisa de dados com um formato conhecido, e precisa que os modos de falha ao obter esses dados sejam visíveis e tratáveis.

Este capítulo trata de restringir a geração a um schema, o que isso custa em latência, o que isso não garante (valores corretos), e como isso falha nas bordas. O mecanismo por trás é o mesmo que impulsiona o uso de ferramentas: uma chamada de ferramenta é saída estruturada, com a assinatura de uma função ocupando o lugar do schema.

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.

python
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" 18

response_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.

JunoPedindo JSON Pedir JSON só no prompt é instável, porque o modelo apenas prevê texto provável e pode envolvê-lo em blocos de código ou conversa que quebram o parsing. Definir 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.

O reflexo é escrever "responda como JSON" no prompt, em que JSON é o formato de dados de campos e valores que o software troca entre si. Na maioria das vezes funciona, e é exatamente esse o problema: "na maioria das vezes" não é um contrato. O modelo prevê texto provável, e o texto provável às vezes inclui um bloco de código, um preâmbulo tipo "Claro!" ou uma nota no final, tudo isso quebra o json.loads.

O modo JSON é o primeiro recurso real. Definir response_format como um tipo de objeto JSON restringe a geração para que a sintaxe seja JSON válido, não um texto que só parece com isso.

python
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" 18

O formato de response_format mostrado aqui é da OpenAI; outros provedores expõem a mesma ideia sob seu próprio campo, então leve o conceito, não a chave literal. O modo JSON restringe a sintaxe, nunca o formato dos campos. Você descreve os campos no prompt, e o modelo é livre para renomeá-los, remover ou aninhá-los de forma diferente, mesmo continuando a produzir JSON válido.

Há mais uma fresta: o modo JSON garante JSON bem-formado apenas se a resposta terminar. Se você bater no limite de max_tokens no meio de um objeto, recebe uma saída truncada e impossível de interpretar, então o parsing ainda precisa de uma proteção.

JunoPedindo JSON O modo JSON restringe a sintaxe para que o modelo pare de envolver os dados em blocos de código e conversa, mas nunca fixa os campos, então o modelo ainda pode renomeá-los ou omiti-los. A chave response_format é o formato da OpenAI; outros provedores a escrevem de outra forma. E a garantia só vale para uma resposta concluída, então uma resposta truncada por max_tokens chega impossível de interpretar. Proteja o parsing de qualquer forma.

"Responda como JSON" no prompt é uma sugestão, e sugestões falham sob carga: um bloco de código aqui, um preâmbulo lá, um comentário no final quando o modelo está mais falante, cada um deles uma exceção json.loads em produção. O modo JSON (aqui response_format={"type": "json_object"}) substitui a sugestão por uma restrição sobre a sintaxe gerada, de forma que os bytes que voltam sejam interpretáveis como JSON.

python
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" 18

Seja preciso sobre o que isso restringe. O modo JSON fixa a sintaxe, não o schema, e apenas em uma resposta concluída. O modelo ainda escolhe os nomes dos campos, pode omitir um campo obrigatório e pode aninhar como quiser.

A garantia é condicional à geração terminar: se o modelo bater no max_tokens no meio de um objeto, você recebe uma string truncada que não é JSON válido, o que significa que o único modo de falha que o modo JSON não remove é justamente aquele que o seu caminho de retentativa mais precisa tratar. A chave response_format mostrada é da OpenAI; o equivalente em outros provedores existe sob outro nome, então trate o conceito como portável e o campo literal como não portável. Para garantias de formato você precisa de um schema, que é o assunto da próxima seção.

JunoPedindo JSON O modo JSON restringe a sintaxe de uma resposta concluída, nada mais: o modelo ainda nomeia, omite e aninha campos como quiser, e uma geração interrompida por max_tokens entrega uma saída truncada e impossível de interpretar. Esse caso de truncamento é o que o seu tratamento de erro precisa assumir, porque o modo não vai cuidar disso. O formato de response_format é da OpenAI, então leve a ideia, não a chave. Para garantir o formato, você quer um schema.

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.

python
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.

JunoFixando o formato com um schema Um schema é o formulário que o modelo precisa preencher, e com 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.

O modo JSON fixa a sintaxe, mas deixa o modelo livre para renomear, remover ou reaninhar campos. Um schema fecha essa brecha: uma descrição exata de campos e tipos, e com strict: true a saída tem a garantia de corresponder a ela.

python
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]"}

O mecanismo é a decodificação restringida: uma poda de tokens aplicada à etapa de amostragem. A cada posição, o modelo produz sua classificação normal sobre todos os tokens, e então o sistema mascara qualquer token que faria a saída deixar de corresponder ao schema, e o modelo amostra entre os que sobreviveram. Se a gramática diz que o próximo token precisa abrir o campo email, os tokens que começariam qualquer outra coisa são zerados antes do sorteio. O modelo não consegue se desviar do formato porque os movimentos fora do formato são removidos do tabuleiro, não apenas desencorajados no prompt.

Dois recursos práticos. Os nomes de campo são instrução, então nomeie-os pensando no valor que você quer. tempC orienta o modelo para Celsius, enquanto value deixa ele no escuro, e uma descrição no campo orienta ainda mais. E a mesma restrição impõe um enum (uma lista fixa de valores permitidos) ou um intervalo numérico, o que é o que torna a classificação confiável: na posição do rótulo, só sobrevivem à máscara os rótulos que você listou, então o modelo não consegue inventar uma categoria que não está na lista.

JunoFixando o formato com um schema Com strict: true, a decodificação restringida mascara todo token que quebraria o schema antes de o modelo amostrar, então o formato é imposto, não pedido. Aproveite isso: nomes e descrições de campo são instruções, então nomeie tempC e não value, e o modelo preenche melhor. A mesma máscara impõe um enum, e é por isso que a classificação se sustenta: o modelo não consegue inventar um rótulo que não está na sua lista.

O modo JSON te dá bytes interpretáveis; um schema te dá um formato conhecido. Com strict: true, a saída tem a garantia de corresponder ao conjunto de campos e tipos que você declarou, o que é a diferença entre dados que você pode indexar de olhos fechados e dados que precisam ser sondados com cautela.

python
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,
            "schema": {
                "type": "object",
                "properties": {
                    "name": {"type": "string"},
                    "email": {"type": "string"},
                },
                "required": ["name", "email"],
                "additionalProperties": False,
            },
        },
    },
)

contact = json.loads(response.choices[0].message.content)

O mecanismo é a decodificação restringida, e vale a pena entendê-lo do início, porque seus custos não são de graça. O modelo emite uma probabilidade para cada token do seu vocabulário em cada etapa. A decodificação restringida compila o seu schema em uma gramática (um conjunto de regras sobre quais sequências de tokens são válidas), e então, a cada etapa, monta uma máscara que zera a probabilidade de todo token ilegal, de forma que a amostragem só pode recair em um token que mantenha a saída válida até aquele ponto. O formato é imposto por construção, não por pedido.

Essa compilação não é de graça, e ela aparece como latência. A primeira chamada contra um schema novo paga um custo único para construir a gramática, então a latência de início a frio em um schema recém-criado é maior do que em regime estável. Mantenha seus schemas estáveis e reutilizados em vez de gerá-los a cada requisição, ou você paga essa compilação repetidamente.

additionalProperties: false e uma lista completa de required não são decoração: são o que permite o sistema rejeitar campos extras ou faltantes de forma direta, e, em provedores que suportam uma recusa guiada por schema, também são o que dão ao modelo um jeito limpo de retornar uma recusa estruturada em vez de um objeto malformado. Retire-os e você amplia o que conta como válido, o que é o oposto do motivo de você ter recorrido ao modo estrito.

Algumas realidades de produção. O suporte ao modo estrito é irregular: alguns provedores impõem uma gramática real, outros aproximam ou suportam apenas um subconjunto do JSON Schema (sem pattern, aninhamento limitado), então teste o que o seu provedor de fato cumpre em vez de assumir a especificação. A saída parcial e em streaming é o caso mais delicado: enquanto os tokens estão em streaming, você fica com um objeto sintaticamente incompleto nas mãos, então ou você espera terminar antes de interpretar, ou usa um parser incremental que tolere uma estrutura pela metade. E esse é o mesmo mecanismo do uso de ferramentas: o schema de argumentos de uma função é restringido exatamente da mesma forma, então tudo isso se aplica diretamente a tornar chamadas de ferramenta confiáveis.

JunoFixando o formato com um schema A decodificação restringida compila o seu schema em uma gramática e zera todo token ilegal antes de amostrar, então o formato é imposto por construção. Essa compilação custa latência na primeira chamada contra um schema novo, então reutilize schemas em vez de criar um a cada requisição. Mantenha additionalProperties: false e uma lista completa de required, são eles que garantem a rejeição e, em alguns provedores, uma recusa estruturada limpa. O suporte ao modo estrito varia, o streaming te entrega meio objeto, e a mesma máscara comanda os argumentos de chamadas de ferramenta, então o que você aprende aqui compensa duas vezes.

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.

python
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ável

Esta é 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.

JunoValide mesmo assim Um schema garante o formato, nunca a veracidade dos valores, então um registro válido ainda pode conter um valor errado ou vazio, e uma chamada ainda pode chegar truncada. Envolva o parsing em 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.

O modo estrito garante o formato, não o conteúdo. A saída pode corresponder ao schema e ainda estar errada: um e-mail com aparência plausível que na verdade tem um erro de digitação, um número inventado para preencher um campo obrigatório, uma string vazia onde a entrada não tinha nada. E o parsing pode falhar completamente quando uma resposta é truncada pelo max_tokens. Então você valida, sempre.

python
import json

def parse_contact(raw):
    try:
        data = json.loads(raw)
    except json.JSONDecodeError:
        return None, "unparseable"  # truncado ou malformado

    if not data.get("name") or not data.get("email"):
        return None, "empty_field"  # presente, mas em branco
    if "@" not in data["email"]:
        return None, "bad_email"  # formato ok, valor não
    return data, None

contact, error = parse_contact(response.choices[0].message.content)
if error == "unparseable":
    pass  # tente uma vez, provavelmente truncamento: aumente max_tokens ou encurte a entrada
elif error:
    pass  # registre a falha no nível do conteúdo e use um valor alternativo

O padrão é um parsing defensivo que distingue os modos de falha, porque eles exigem tratamentos diferentes. Um JSONDecodeError geralmente indica truncamento, então a solução é aumentar o max_tokens ou reduzir a entrada e tentar novamente. Um valor vazio ou fora do intervalo é uma falha de conteúdo, não de formato, então repetir a mesma chamada raramente ajuda; registre o erro e use um valor alternativo.

Separe "não consegui interpretar" de "interpretei, mas está errado", porque a correção é diferente. Isso se conecta de volta com a alucinação: a decodificação restringida eliminou o modo de falha da saída malformada e deixou intacto o modo de falha do valor errado.

JunoValide mesmo assim O modo estrito fixa o formato, não o conteúdo, então valide sempre: um registro pode estar bem-formado e conter um erro de digitação, um chute ou um campo em branco. Separe as falhas, porque um JSONDecodeError geralmente significa truncamento (aumente o max_tokens ou reduza a entrada e tente de novo), enquanto um valor ruim é uma falha de conteúdo que tentar de novo a mesma chamada não vai resolver. A decodificação restringida eliminou o problema da saída malformada e deixou o valor errado intacto.

A decodificação restringida elimina o modo de falha da saída malformada e não elimina mais nada além disso. O formato é garantido; o conteúdo não é. Um campo obrigatório que a entrada nunca sustentou é preenchido com um chute confiante, um número cai fora de qualquer intervalo razoável, um e-mail se interpreta como string, mas contém um erro de digitação.

A própria garantia de formato se dissolve com o truncamento. Então a validação não é uma boa prática opcional, é a camada que captura o que o schema, por construção, não consegue capturar.

python
import json

def parse_contact(raw, retry):
    try:
        data = json.loads(raw)
    except json.JSONDecodeError:
        return None, "truncated"  # geração incompleta, não um problema de conteúdo

    if not data.get("name") or "@" not in data.get("email", ""):
        return None, "invalid_value"  # o formato se manteve, o valor não
    return data, None

contact, failure = parse_contact(raw, retry=False)
if failure == "truncated":
    pass  # aumente max_tokens ou reduza a entrada, depois tente a chamada novamente
elif failure == "invalid_value":
    pass  # caminho de correção: pergunte de novo passando de volta a saída ruim, ou use um valor alternativo

Três coisas separam um validador que realmente funciona de um try/except que engole tudo:

  • Classifique a falha: truncamento é um problema de orçamento resolvido com uma nova tentativa com mais max_tokens, enquanto um valor inválido é um problema de conteúdo que uma nova tentativa cega vai apenas reproduzir.
  • Construa um caminho de correção para o caso de nível de valor, em vez de contar só com um valor alternativo. Devolver a saída inválida com uma instrução para corrigi-la muitas vezes recupera o resultado em uma chamada extra, o que é mais barato do que falhar a requisição, mas limite as tentativas para que uma entrada persistentemente errada não entre em loop.
  • Limite o custo: cada nova tentativa é outra chamada cobrada e outra rodada de latência, então um orçamento de tentativas faz parte do design, não é um detalhe deixado para depois.

Valide os valores, não só o parsing. Isso é a história de contenção da alucinação aplicada a dados estruturados: a decodificação restringida te dá um envelope limpo e não te diz nada sobre o que está dentro dele, então o envelope é exatamente onde os times deixam de validar e acabam se queimando. Validação de schema, verificações de intervalo e semântica, e um ciclo de correção limitado são as três camadas, e elas capturam coisas diferentes: o schema captura o formato, as verificações capturam valores sem sentido, o ciclo de correção recupera os que são recuperáveis.

JunoValide mesmo assim O modo estrito elimina a saída malformada e deixa os valores errados totalmente intactos, então valide o conteúdo, não só o parsing. Classifique a falha: truncamento é uma correção de orçamento (mais max_tokens, tente de novo), um valor inválido é uma falha de conteúdo que uma nova tentativa cega só reproduz, então construa um caminho de correção que devolva a saída ruim, e limite as tentativas para que uma entrada ruim não entre em loop na sua conta. O envelope limpo é exatamente onde as pessoas deixam de verificar e se queimam.

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.

python
# 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.

JunoDois padrões: classificar e extrair A classificação organiza uma entrada em um de um conjunto fixo de rótulos, travada com um 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.

Quase tudo o que você constrói com saída estruturada se encaixa em um de dois formatos, e nomeá-los ajuda você a escolher o schema certo.

A classificação mapeia uma entrada para um rótulo de um conjunto fixo. O enum carrega todo o trabalho: a decodificação restringida mascara todo token exceto os rótulos listados na posição do rótulo, então o modelo retorna um deles ou nada mais.

python
# classificação: um rótulo de um conjunto fechado
{"type": "string", "enum": ["billing", "technical", "general"]}

A extração retira campos nomeados de um texto livre e os coloca em um registro, como nos exemplos de contato e de chamado. Os dois se combinam: um único schema pode classificar e extrair ao mesmo tempo, o que corresponde à maioria dos recursos reais. Conjuntos fechados recebem um enum; valores abertos recebem um campo tipado.

A decisão que você precisa tomar consciente: quando um valor realmente é um conjunto fixo (status, categoria, prioridade), use um enum para que o modelo não consiga se desviar, e quando é aberto (um nome, um resumo), use um campo tipado comum e aceite que você está confiando o suficiente no valor para validá-lo depois. Misturar os dois, um enum onde o mundo real tem mais casos, ou um campo livre onde um conjunto fechado teria pegado erros, é onde esses recursos silenciosamente começam a se comportar mal.

JunoDois padrões: classificar e extrair Dois formatos cobrem a maior parte disso: a classificação mapeia para um rótulo de um conjunto fechado (o enum faz a imposição) e a extração retira campos nomeados para um registro, e um único schema pode fazer as duas coisas ao mesmo tempo. Escolha com intenção: enum para conjuntos fixos, para que o modelo não se desvie, campo tipado comum para valores abertos que você vai validar. Um enum que está faltando um caso real do mundo é uma fonte silenciosa de rótulos errados.

A saída estruturada se reduz a dois padrões, e a distinção é um recurso de design, não só um vocabulário. A classificação mapeia a entrada para um rótulo de um conjunto fechado, imposto por um enum que a decodificação restringida reduz a uma escolha rígida entre os seus valores. A extração retira campos tipados de um texto livre. A maioria dos schemas de produção combina os dois.

python
# classificação: a decodificação restringida permite apenas estes tokens na posição do rótulo
{"type": "string", "enum": ["billing", "technical", "general"]}

O recurso está em onde você traça a fronteira do conjunto fechado, porque o enum é uma garantia de exatidão com uma borda afiada. Um valor que realmente é limitado (um status, uma prioridade) pertence a um enum para que o modelo não consiga emitir nada fora da lista, e isso transforma uma geração aberta em uma verificação em que o seu código posterior pode confiar.

Mas o enum força uma escolha mesmo quando nenhuma se encaixa: se a entrada real não corresponde a nenhum rótulo, o modelo é obrigado a escolher o errado mais próximo em vez de te avisar que não se encaixa. Um enum sem uma via de escape transforma "não se encaixa" em um rótulo errado dito com confiança. Então, para qualquer classificador que enfrente entradas reais e confusas, adicione uma via de escape explícita, um membro other ou unknown, e leia isso como o sinal de encaminhar para outro lugar.

O mesmo cuidado se aplica à confiança: se uma decisão depende do rótulo, faça o modelo também emitir um campo de confiança ou uma breve justificativa, de forma que uma decisão duvidosa fique visível em vez de ser disfarçada em um valor de enum de aparência limpa. Esses são os mesmos instintos do uso de ferramentas, em que o modelo escolhe uma ferramenta de um conjunto fechado e você enfrenta o mesmo problema idêntico do "e se nenhuma se encaixar".

JunoDois padrões: classificar e extrair A classificação é um conjunto fechado imposto por um enum, a extração são campos tipados extraídos de texto livre, e o enum é uma garantia com uma borda afiada: ele força uma escolha mesmo quando nada se encaixa, disfarçando "não corresponde" em um rótulo errado dito com confiança. Adicione um membro other ou unknown e encaminhe com base nele, e emita um campo de confiança quando uma decisão depender do rótulo. É o mesmo formato de escolher uma ferramenta de um conjunto fechado no uso de ferramentas.

Na prática ​

Transformando um e-mail de suporte confuso em um chamado estruturado, combinando classificação e extração em um único schema:

python
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.

JunoNa prática Uma única chamada com um schema estrito pode classificar um e-mail de duas formas e extrair um resumo ao mesmo tempo, retornando um registro que o seu código pode encaminhar e armazenar. O formato é garantido; os valores ainda merecem verificação. O uso de ferramentas se apoia no mesmo truque mais adiante: os argumentos que um modelo envia para uma ferramenta também são saída estruturada.

Um recurso real geralmente combina os dois padrões em um único schema. Aqui, um e-mail de suporte se transforma em um chamado: duas classificações e uma extração em uma única chamada.

python
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."}

Os dois enums são fortemente restringidos, então category e urgency sempre são seguros para encaminhar. O summary é um campo aberto, então é o único que precisa ser validado antes de confiar nele. Essa divisão, rótulos impostos mais um campo de texto livre verificado, é o formato da maioria dos recursos de extração. A seguir, Embeddings dá ao modelo um sentido diferente: texto transformado em números que você pode comparar por significado, a base para busca e para trabalhar com seus próprios documentos.

JunoNa prática Um único schema estrito pode classificar de duas formas e extrair um resumo em uma única chamada, retornando um registro que você pode encaminhar e armazenar. Os enums são impostos, então os rótulos são seguros para agir sobre eles; o summary aberto é o campo a validar. Essa mistura de rótulos travados e texto livre verificado é o formato do dia a dia de um recurso de extração.

O formato do dia a dia em produção é um composto: classificar em alguns eixos e extrair um campo de texto livre, tudo em um único schema e uma única chamada.

python
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."}

A imposição é irregular entre os campos, e esse é o ponto de design. Os enums são garantias rígidas, então category e urgency são seguros para encaminhar sem verificações adicionais. O summary é uma string irrestrita, então é exatamente onde um valor alucinado ou fora da realidade pode se esconder atrás de um schema aparentemente limpo, e o campo que merece passar por uma validação. Dois rótulos impostos com um campo summary aberto também é o schema mais exposto a truncamento: um resumo longo gerado é o que te leva a bater no max_tokens e transforma o objeto inteiro em algo impossível de interpretar, então dimensione o orçamento para o campo de texto livre, não para os rótulos.

Esse é o mesmo mecanismo do uso de ferramentas: uma chamada de ferramenta é um modelo emitindo argumentos estruturados contra o schema de uma função, restringido exatamente da mesma forma, então os instintos de validação, correção e via de escape de enum daqui se transferem por completo para tornar agentes confiáveis. A partir daqui, Embeddings desloca o modelo para um modo diferente: texto como vetores que você compara por significado, a base para busca e para fundamentar respostas nos seus próprios documentos.

JunoNa prática Um schema estrito, dois enums impostos e um summary aberto: os rótulos são seguros para encaminhar, o campo de texto livre é onde um valor alucinado se esconde e onde uma geração longa faz o max_tokens disparar um truncamento, então orce e valide esse campo especificamente. Esse é o mesmo mecanismo do uso de ferramentas, argumentos restringidos contra o schema de uma função, então todo hábito adquirido aqui, validação, correção, vias de escape de enum, se transfere diretamente para agentes.