O que é Saída Estruturada?
Saída Estruturada (Structured Output) é um padrão de projeto de IA para apps com LLM e agentes, do grupo Composição. Exigir que o modelo responda em um schema e validar a resposta.
A Bianca automatizou a leitura de notas de fornecedores. Toda empresa no Brasil é identificada por um CNPJ, catorze dígitos com dois dígitos verificadores no fim.
O prompt dela pedia: devolva o CNPJ do emissor, a data de emissão e o valor total, como dados estruturados. Na primeira semana, funcionou em quase tudo.
Aí começaram os casos estranhos. Uma resposta abriu com uma frase simpática: claro, aqui estão os dados. Outra devolveu o total como texto, com o símbolo da moeda, quando o sistema esperava um número. Uma terceira deu outro nome ao campo.
Cada caso virou mais um remendo no código. O parser ficou maior que a regra de negócio, e ainda falhava algumas vezes por dia. Nesta aula, você vai ver como fazer o modelo entregar dados que o seu código usa sem adivinhar. E por que isso é só metade do trabalho.
A correção começa com uma mudança de cabeça. Pare de pedir um formato no texto do prompt e passe a declarar o formato como parte da chamada.
As APIs atuais aceitam um schema, uma descrição dos campos, dos tipos e dos valores permitidos. E restringem a geração para que a saída siga esse formato. É a diferença entre entregar uma folha em branco para alguém e entregar um formulário com campos rotulados.
Mas aqui está a parte que muita gente perde. O schema garante a forma, não a verdade.
Um CNPJ pode ter catorze dígitos e mesmo assim ser inválido. Então a segunda metade do padrão é conferir as regras de negócio no código. Dígitos verificadores, data no passado, valor positivo. Forma garantida pela API, conteúdo conferido por você.
Vamos seguir uma nota. A entrada é o texto que precisa virar dado.
A chamada leva o schema junto. O modelo só consegue gerar saídas naquele formato.
O SDK transforma a resposta num objeto tipado. Sem expressão regular, sem caçar chaves.
Depois, o seu código confere as regras que o schema não consegue expressar.
Dado válido vai direto para o sistema contábil.
E dado com a forma certa, mas conteúdo suspeito, vai para uma pessoa revisar.
Aqui está uma nota real de uma padaria. Um CNPJ, uma data de emissão em agosto, um total de mil duzentos e trinta e quatro dólares, e dois itens, pão francês e café.
Com devolva os dados no prompt, uma resposta realista tinha três falhas de forma ao mesmo tempo. Texto em volta dos dados, o nome errado do campo e o total como texto com símbolo de moeda. Foram três correções no parser. Na semana seguinte, apareceu uma quarta.
Com um schema declarado, a API só consegue emitir quatro campos. O CNPJ do emissor, a data de emissão, o total como número e a lista de itens. O SDK devolve um objeto.
E aí o código da Bianca ainda tem trabalho. O CNPJ precisa passar na regra dos dígitos verificadores.
Os itens precisam somar o total.
E a data de emissão não pode estar no futuro. Se alguma falhar, a nota vai para revisão, com a lista do que falhou.
Uma pergunta para você. O modelo devolveu um objeto exatamente no schema. O CNPJ tem catorze dígitos. Mas os dígitos verificadores não batem. O que o padrão manda fazer?
Valide os dígitos verificadores no código e mande para revisão humana quando falharem. Forma não é verdade. Um valor inválido de catorze dígitos passa num schema de texto e estraga o cadastro do emissor.
E pedir de novo, várias vezes, até passar? Um retry no mesmo documento costuma devolver a mesma leitura errada. A fila de revisão existe exatamente para quando a fonte está ilegível, ou o modelo leu errado.
Saída estruturada não serve para tudo. Se a saída é texto para uma pessoa ler, como uma resposta de chat, forçar um schema só engessa a escrita. Ninguém vai interpretar aquilo.
Se é um texto livre curto que o código só exibe, mesma coisa.
E se o seu schema seria um campo chamado resposta, do tipo texto, ele não garante nada. Só acrescenta cerimônia.
Agora, o erro mais comum. Toda nota tem um emissor. Mas nem todo contrato de serviço tem multa. Se o campo de multa é obrigatório, o modelo inventa algo como dez por cento padrão, só para não quebrar o schema.
Então marque como opcional o que pode mesmo faltar. E meça. No seu eval, inclua documentos em que o campo não existe, e conte quantas vezes o modelo devolve vazio, e quantas vezes inventa. E prefira uma lista fechada de valores a texto aberto. Campo de texto aberto é como super urgente vai parar na sua coluna de prioridade.
Com schema nativo, a saída quase sempre é válida. Então válida deixa de ser métrica. Ela esconde erro de conteúdo.
Avalie campo a campo, contra valores rotulados. E normalize antes. A nota diz mil duzentos e trinta e quatro dólares com vírgula, e o rótulo guarda o número. Compare texto com texto, e o eval pune a formatação enquanto um CNPJ errado com a pontuação certa passa.
Meça a taxa de invenção em documentos em que o campo realmente não existe.
E acompanhe o que vai para revisão humana, e quanto disso era erro de verdade. Em produção, uma regra de validação que de repente dispara muito costuma ser um fornecedor que mudou o layout.
Versione o schema como você versiona o prompt, e registre os dois em todo trace. Um campo obrigatório novo empurra invenção em documentos antigos. Um campo removido quebra o código que ainda lê o nome velho.
E um ponto de segurança. Saída estruturada elimina uma classe de risco. O seu código para de interpretar texto livre que poderia conter qualquer coisa. Mas os valores dos campos ainda vêm de um modelo que leu um documento de fora. Trate como entrada não confiável. Um campo de descrição copiado numa página web pode levar um script. Um campo colado numa consulta ao banco é injeção. Escape antes de exibir, use parâmetros nas consultas e nunca execute um campo como código.
Na página, a mesma extração vem em quatro versões. Todas começam igual: uma chamada, com o schema junto, que devolve um objeto tipado.
Depois, três checagens no código. Os dígitos verificadores, a data e a soma. O que falha vai para revisão. O resto é gravado.
O que muda entre as versões é o provedor e o sistema de tipos. Em Python, uma classe define o schema e o tipo de uma vez. Em TypeScript, um objeto Zod faz o mesmo, e o compilador conhece cada campo. Tem uma versão no SDK da OpenAI com exatamente o mesmo schema. E o LangChain envolve os dois provedores, então as checagens de negócio não mudam quando o modelo muda.
Então, a ideia para levar. Não peça um formato ao modelo. Declare, e deixe a API segurar a forma. Depois confira o conteúdo no código, porque um formulário perfeito ainda pode ser preenchido errado.
Na próxima aula, a gente usa essa mesma ideia na direção contrária: o modelo preenchendo um formulário que pede para o seu código fazer algo. Ferramentas e function calling. Até lá.
Structured Output
Saída Estruturada (Structured Output) é um padrão de projeto de IA para apps com LLM e agentes, do grupo Composição. Exigir que o modelo responda em um schema e validar a resposta.
Exigir que o modelo responda num schema definido e validar a resposta no código antes de usar. Serve para qualquer saída que outro programa vai ler: extração, classificação, roteamento, chamada de API, dados que vão para o banco.
Um formulário com campos marcados em vez de uma folha em branco.
A Bianca automatizou a leitura de notas fiscais de fornecedores. O prompt pedia: “devolva um JSON com CNPJ do emitente, data de emissão e valor total”. Na primeira semana, deu certo em quase tudo.
Aí começaram os casos estranhos. Uma nota veio com o JSON embrulhado em três crases e a palavra json. Outra trouxe o valor como "R$ 1.234,56", em texto, quando o sistema esperava número. Uma terceira chamou o campo de cnpj_emissor em vez de cnpj_emitente. E numa delas o modelo respondeu “Claro! Aqui está o JSON:” antes das chaves.
Cada caso virou um try, um replace e uma expressão regular no código. O parser cresceu mais que a lógica de negócio, e mesmo assim falhava algumas vezes por dia.
A pergunta que este padrão responde: como fazer o modelo entregar dados que o seu código pode usar sem adivinhar?
Pare de pedir formato no texto do prompt e passe a declarar o formato como parte da chamada. As APIs atuais aceitam um schema JSON (ou uma classe Pydantic, ou um objeto Zod) e restringem a geração para que a saída siga esse schema: nomes de campo, tipos e valores permitidos.
O schema garante a forma, não a verdade. Um CNPJ pode ter 14 dígitos e mesmo assim estar errado. Por isso a segunda metade do padrão é validar regras de negócio no código: dígito verificador, data no passado, valor positivo. Forma garantida pela API, conteúdo conferido por você.
A refatoração que chega aqui é Trocar Texto Livre por Saída Estruturada. Use-a quando o parser de JSON já virou o produto.
flowchart LR
IN(["1. Texto da nota"]):::io --> M(["2. Modelo com schema"]):::llm
M --> P["3. Parse tipado"]:::tool
P --> V{"4. Regras de negócio OK?"}
V -->|"sim"| DB[("5. Sistema contábil")]:::store
V -->|"não"| Q((("6. Fila de revisão"))):::human
A Bianca recebe esta nota (trecho):
EMITENTE: Padaria São Jorge Ltda
CNPJ: 11.222.333/0001-81
Data de emissão: 14/08/2026
Total: R$ 1.234,56
Itens: Pão francês 800un R$ 800,00 | Café 20kg R$ 434,56
Com “devolva um JSON” no prompt, uma resposta realista era um parágrafo (“Claro! Segue o JSON:”), três crases, o campo cnpj_emissor no lugar de issuer_cnpj, e o total como a string "R$ 1.234,56".
Três falhas de forma numa resposta: texto em volta, nome de campo errado, número como string com R$. O parser da Bianca precisava das três correções, e na semana seguinte aparecia uma quarta.
Com schema nativo, a API só consegue emitir os campos issuer_cnpj, issue_date, total, items. O SDK entrega um objeto. Aí o código da Bianca ainda tem trabalho: o CNPJ 11222333000181 precisa passar no dígito verificador, a soma dos itens precisa bater com o total, a data não pode ser futura.
O CNPJ da Padaria São Jorge neste exemplo é ilustrativo. Em produção, use o validador de dígitos; 14 dígitos iguais (111…) já são inválidos pela regra.
schema Invoice {
issuer_cnpj: string # 14 dígitos
issue_date: date
total: float
items: list of { description: string, amount: float }
}
function read_invoice(text):
# 2 e 3. forma garantida pela API
invoice = llm(EXTRACT_PROMPT, text) -> Invoice
# 4. conteúdo conferido no código
errors = []
if not valid_cnpj(invoice.issuer_cnpj): errors.add("cnpj")
if invoice.issue_date > today(): errors.add("data futura")
if abs(sum(invoice.items.amount) - invoice.total) > 0.01: errors.add("soma")
if errors: return human.review(invoice, errors) # 6
return accounting.save(invoice) # 5
resposta: string) que não garante nada. Aí ele só adiciona cerimônia.Toda NF-e tem emitente. Nem todo contrato de prestação tem multa. Se penalty é obrigatório, o modelo inventa “10% padrão” para não quebrar o schema. No eval, isso aparece como taxa de invenção: documentos sem o campo deveriam devolver null.
Marque como opcional o que pode faltar. Use enum para prioridade, produto, tipo de nota. string aberto é o schema largo que deixa "urgentíssima" passar.
Saída estruturada também não é para a resposta de chat que uma pessoa lê. Forçar JSON ali deixa o texto engessado e não ajuda o código, porque ninguém vai parsear.
messages.parse com Pydantic ou Zod).Não avalie “JSON válido”. Com schema nativo isso quase sempre passa e esconde o erro de conteúdo. Avalie issuer_cnpj, total, issue_date contra o rótulo, depois da normalização (só dígitos no CNPJ, data em ISO).
strict: true e o modelo “chama” essa ferramenta com os dados. Útil quando a mesma chamada também usa outras ferramentas.Versione o schema como versiona o prompt. Um campo novo obrigatório empurra invenção nos documentos antigos do eval. Um campo removido quebra o código que ainda lê o nome velho. Guarde schema_version no span junto com a versão do prompt, para um pico de “cnpj inválido” apontar para o deploy certo.
Quando o provedor ainda não restringe a geração, não finja que pediu JSON no texto. Valide, e se falhar, entre no reparo com a mensagem de erro do parser. Isso é outro padrão, e o teto de tentativas dele é o mesmo teto de qualquer laço.
| Aspecto | O que acontece | Como aparece no trace |
|---|---|---|
| Chamadas | Nenhuma a mais; o schema vai na mesma chamada | mesmo span de antes, com o schema anexado |
| Latência | A primeira chamada com um schema novo pode demorar mais | duração maior só na primeira vez |
| Custo | O schema conta como entrada; prompts de formato ficam menores | tokens de entrada parecidos com antes |
| Falha: campo inventado | Campo obrigatório que não existe na fonte vem preenchido | valor plausível sem trecho correspondente na entrada |
| Falha: forma certa, conteúdo errado | CNPJ com 14 dígitos inválidos, data trocada | validação de negócio barrando no span seguinte |
| Falha: schema largo demais | string aceita qualquer coisa |
valores fora do esperado chegando ao banco |
Avalie campo por campo. “O JSON era válido” deixou de ser métrica: com saída estruturada nativa, ele quase sempre é.
null ou inventa?Um caso de eval:
O rótulo precisa do valor canônico, não do texto da nota. R$ 1.234,56 no PDF vira 1234.56 no expected. Se você comparar string com string, o eval pune formatação e deixa passar CNPJ errado com a pontuação certa.
Inclua documentos ruins de propósito: escaneados tortos, campos faltando, dois valores parecidos na mesma página. O caso fácil não ensina o schema. O caso com dois totais (o da capa e o da última página) ensina se o modelo chuta o maior.
Separe no eval os documentos onde o campo existe dos onde ele não existe. Acurácia geral mistura acerto em campo presente com invenção em campo ausente. São dois erros diferentes, com dois consertos: prompt e exemplos para o primeiro, opcionalidade para o segundo.
{ "input": "notas/fornecedor-118.txt", "expected": { "issuer_cnpj": "11222333000181", "issue_date": "2026-08-14", "total": 1234.56 } }
Saída estruturada reduz uma classe de risco: o código para de interpretar texto livre que pode conter qualquer coisa. Mas o conteúdo dos campos ainda vem do modelo, que leu um documento externo. Trate os valores como entrada não confiável: escape antes de exibir, use parâmetros em consultas SQL e nunca execute um campo como código.
O OWASP Top 10 for LLM Applications trata isso como handling inseguro de saída: o modelo não é uma fonte confiável, mesmo quando a forma está certa. Um campo description copiado para HTML é XSS. Um campo query interpolado em SQL é injeção. Schema não sanitiza.
| Prós | Contras |
|---|---|
| Acaba com parser frágil e expressões regulares | O schema vira mais um contrato para versionar |
| O tipo da saída é conhecido pelo código e pelo compilador | Forma certa pode passar a falsa sensação de dado certo |
| Enums impedem valores fora da lista | Campos obrigatórios mal pensados induzem invenção |
| Facilita evals por campo | Não serve para texto livre destinado a pessoas |
As abas extraem a mesma nota com schema e validam CNPJ e soma no código. Python e TypeScript usam o SDK da Anthropic. A aba OpenAI é a mesma chamada em outro provedor. LangChain esconde o envelope para os dois.
Python. Use quando o backend é Python. Pydantic define o schema e o tipo ao mesmo tempo.
from datetime import date
import anthropic
from pydantic import BaseModel
client = anthropic.Anthropic()
class Item(BaseModel):
description: str
amount: float
class Invoice(BaseModel):
issuer_cnpj: str
issue_date: date
total: float
items: list[Item]
def valid_cnpj(cnpj: str) -> bool:
digits = [int(c) for c in cnpj if c.isdigit()]
if len(digits) != 14 or len(set(digits)) == 1:
return False
for size in (12, 13):
weights = [6, 5, 4, 3, 2, 9, 8, 7, 6, 5, 4, 3, 2][-size:]
check = sum(d * w for d, w in zip(digits, weights)) % 11
if digits[size] != (0 if check < 2 else 11 - check):
return False
return True
def read_invoice(text: str) -> Invoice | list[str]:
# 2 e 3. forma garantida pela API
invoice = client.messages.parse(
model="claude-opus-5-5",
max_tokens=4096,
system="Extraia os dados da nota fiscal. CNPJ só com dígitos. Valores em reais, como número.",
messages=[{"role": "user", "content": text}],
output_format=Invoice,
).parsed_output
# 4. conteúdo conferido no código
errors = []
if not valid_cnpj(invoice.issuer_cnpj):
errors.append("cnpj")
if invoice.issue_date > date.today():
errors.append("data futura")
if abs(sum(i.amount for i in invoice.items) - invoice.total) > 0.01:
errors.append("soma")
return errors or invoiceO que você ganhou: objeto tipado, sem parser. O que você perdeu: nada que você tinha antes; a validação de negócio continua sendo sua.
TypeScript. Use quando o backend é Node. Zod define o schema e o tipo.
import Anthropic from "@anthropic-ai/sdk";
import { zodOutputFormat } from "@anthropic-ai/sdk/helpers/zod";
import { z } from "zod";
const client = new Anthropic();
const Invoice = z.object({
issuer_cnpj: z.string(),
issue_date: z.string(), // AAAA-MM-DD
total: z.number(),
items: z.array(z.object({ description: z.string(), amount: z.number() })),
});
type Invoice = z.infer<typeof Invoice>;
function validCnpj(cnpj: string): boolean {
const d = cnpj.replace(/\D/g, "").split("").map(Number);
if (d.length !== 14 || new Set(d).size === 1) return false;
for (const size of [12, 13]) {
const weights = [6, 5, 4, 3, 2, 9, 8, 7, 6, 5, 4, 3, 2].slice(-size);
const check = weights.reduce((acc, w, i) => acc + w * d[i], 0) % 11;
if (d[size] !== (check < 2 ? 0 : 11 - check)) return false;
}
return true;
}
export async function readInvoice(text: string): Promise<Invoice | string[]> {
// 2 e 3. forma garantida pela API
const { parsed_output: invoice } = await client.messages.parse({
model: "claude-opus-5-5",
max_tokens: 4096,
system: "Extraia os dados da nota fiscal. CNPJ só com dígitos. Valores em reais, como número. Data em AAAA-MM-DD.",
messages: [{ role: "user", content: text }],
output_config: { format: zodOutputFormat(Invoice) },
});
if (!invoice) return ["schema"];
// 4. conteúdo conferido no código
const errors: string[] = [];
if (!validCnpj(invoice.issuer_cnpj)) errors.push("cnpj");
if (new Date(invoice.issue_date) > new Date()) errors.push("data futura");
const sum = invoice.items.reduce((acc, i) => acc + i.amount, 0);
if (Math.abs(sum - invoice.total) > 0.01) errors.push("soma");
return errors.length ? errors : invoice;
}O que você ganhou: o compilador conhece cada campo. O que você perdeu: datas chegam como texto e precisam de conversão.
Python + OpenAI. Use quando o backend é Python e o contrato do provedor é com a OpenAI.
from datetime import date
from openai import OpenAI
client = OpenAI() # lê OPENAI_API_KEY do ambiente
def read_invoice(text: str) -> Invoice | list[str]:
# 2 e 3. forma garantida pela API. Invoice é a classe da aba Python.
invoice = client.chat.completions.parse(
model="gpt-4o-mini",
messages=[
{"role": "system", "content": "Extraia os dados da nota fiscal. CNPJ só com dígitos. Valores em reais, como número."},
{"role": "user", "content": text},
],
response_format=Invoice,
).choices[0].message.parsed
if invoice is None:
return ["schema"]
# 4. conteúdo conferido no código
errors = []
if not valid_cnpj(invoice.issuer_cnpj):
errors.append("cnpj")
if invoice.issue_date > date.today():
errors.append("data futura")
if abs(sum(i.amount for i in invoice.items) - invoice.total) > 0.01:
errors.append("soma")
return errors or invoiceO que você ganhou: o mesmo schema Pydantic, noutro SDK. O que você perdeu: o nome choices[0].message é da OpenAI; não deixe ele vazar para o módulo contábil.
Python + LangChain. Use quando a extração precisa da mesma função em mais de um provedor.
from datetime import date
from langchain.chat_models import init_chat_model
MODEL = "openai:gpt-4o-mini" # ou "anthropic:claude-opus-5-5"
reader = init_chat_model(MODEL, temperature=0).with_structured_output(Invoice)
def read_invoice(text: str) -> Invoice | list[str]:
# 2 e 3. o schema é o mesmo objeto Pydantic da aba Python
invoice = reader.invoke([
("system", "Extraia os dados da nota fiscal. CNPJ só com dígitos. Valores em reais, como número."),
("human", text),
])
# 4. conteúdo conferido no código
errors = []
if not valid_cnpj(invoice.issuer_cnpj):
errors.append("cnpj")
if invoice.issue_date > date.today():
errors.append("data futura")
if abs(sum(i.amount for i in invoice.items) - invoice.total) > 0.01:
errors.append("soma")
return errors or invoiceO que você ganhou: a validação de negócio não muda quando o prefixo do modelo muda. O que você perdeu: uma dependência a mais para uma chamada que o SDK já fazia.
Conferido nas documentações da OpenAI e do LangChain 1.x em outubro de 2026.
| Biblioteca | Neste padrão | Continua seu |
|---|---|---|
| SDK da Anthropic | messages.parse com Pydantic ou Zod |
CNPJ, data e soma |
| SDK da OpenAI | chat.completions.parse com o mesmo Pydantic |
As mesmas regras |
| LangChain | with_structured_output |
As mesmas regras, sem o envelope do provedor |
| Instructor ou Pydantic AI | O schema na chamada, se o time já usa um desses | A validação que o schema não expressa |
Responda as 6 perguntas para concluir a aula. Só assistir não conclui: a checagem é o que conta para a trilha e para o certificado.
Saída Estruturada (Structured Output) é um padrão de projeto de IA para apps com LLM e agentes, do grupo Composição. Exigir que o modelo responda em um schema e validar a resposta.
Nos cenários reais Extração de NF-e e contratos, Triagem automática de tickets e Triagem de currículos com controle de viés.