7. Saída Estruturada com Pydantic
Nos capítulos anteriores, trabalhamos com saídas de LLM como strings de texto bruto. Isso funciona bem para chatbots onde humanos leem as respostas, mas ao construir agentes de IA onde programas precisam analisar e interpretar saídas de LLM, precisamos de dados estruturados e previsíveis. Neste capítulo, você aprenderá como usar schemas Pydantic para fazer o LLM retornar objetos Python estruturados.
7.1) Por Que Saída Estruturada?
O Problema com Saída de LLM em Texto Livre
Vamos começar entendendo por que respostas em texto bruto criam problemas em aplicações reais. Considere este cenário comum:
from langchain_openai import ChatOpenAI
from langchain_core.messages import HumanMessage
llm = ChatOpenAI(model="gpt-4o-mini")
# Perguntar ao LLM sobre um produto
message = HumanMessage(content="""
Extraia as informações do produto deste texto:
"O UltraWidget Pro custa $299.99 e está atualmente em estoque."
""")
response = llm.invoke([message])
print(response.content)Saída:
Nome do Produto: UltraWidget Pro
Preço: $299.99
Disponibilidade: Em estoqueA saída parece boa. Mas agora suponha que você precise usar esses dados em sua aplicação Python. Como você extrai o preço como um número? Como você verifica a disponibilidade programaticamente? Você pode tentar análise de string assim:
# Abordagem de análise frágil
text = response.content
price_line = [line for line in text.split('\n') if 'Preço:' in line][0]
price_str = price_line.split('$')[1]
price = float(price_str) # Frágil - e se o formato mudar?Essa análise parece funcionar. Mas na verdade não funciona. Aqui está o porquê:
Por Que Esta Abordagem Falha:
- O LLM pode formatar a resposta de forma diferente na próxima vez ("Preço: 299.99 USD" ou "Preço de Varejo: $299.99")
Aqui estão exemplos de diferentes saídas que podem ocorrer para o mesmo prompt:
# Exemplo 1
"O produto é UltraWidget Pro, com preço de $299.99, e está disponível."
# Exemplo 2
"Produto: UltraWidget Pro
Custo: 299.99 dólares
Status: Disponível"
# Exemplo 3
"UltraWidget Pro - $299.99 (em estoque)"
# Exemplo 4
"Encontrei o UltraWidget Pro. Custa $299.99 e está atualmente disponível para compra."Quando a resposta do LLM muda, você precisa de lógica de análise completamente diferente. Isso torna difícil construir aplicações confiáveis.
- Respostas de LLM são imprevisíveis: O mesmo prompt pode produzir formatos diferentes toda vez
- Análise de string é mais difícil do que parece: Você precisa lidar com
$, espaços, quebras de linha, vírgulas e muito mais - Nenhuma segurança de tipo: Você não pode ter certeza se
priceé um float, string ou None - Tratamento de erros é difícil: Se o LLM diz "Preço não disponível", sua chamada
float()falha - Não é mantível: Mude o prompt ligeiramente e você reescreve todo o código de análise
A Ideia Central: Python Precisa de Contratos, Não de Prosa
Pense em quando você se comunica com um servidor de API em Python. Quando você chama uma API REST específica, você espera que ela retorne uma resposta JSON definida:
# Você espera esta estrutura
{
"product_name": "UltraWidget Pro",
"price": 299.99,
"in_stock": true
}Ao construir aplicações de IA, você precisa do mesmo princípio. A saída do LLM deve ser retornada como dados com uma estrutura definida, não como texto de forma livre toda vez.
Prosa vs Contrato:
- Prosa: Texto natural de forma livre. Bom para humanos lerem, mas difícil para programas processarem.
- Contrato: Dados com estrutura e tipos definidos. Uma promessa de que "estes campos existirão com estes tipos."
Saída estruturada significa definir um contrato: "LLM, preciso exatamente destes campos, com exatamente estes tipos, em exatamente este formato."
É aqui que o Pydantic entra. Pydantic é a biblioteca de validação de dados mais popular do Python, e o LangChain a usa para receber saídas de LLM em forma estruturada.
A Mudança de Modelo Mental:
- Antes: "LLM, me fale sobre este produto" → Analisar texto imprevisível
- Depois: "LLM, responda em um formato definido" → Receber objeto Python estruturado
Essa mudança de prosa para contratos é fundamental para construir agentes de IA confiáveis. Quando um agente precisa decidir sua próxima ação com base em respostas de LLM (por exemplo, comprar se em estoque, registrar para notificação se não), ele deve receber respostas em um formato definido.
7.2) Sua Primeira Saída Estruturada
Em 7.1, aprendemos por que LLMs devem responder com estrutura definida em vez de texto de forma livre. Agora vamos ver como realmente implementar isso.
A ideia chave: Simplesmente pedir ao LLM "por favor responda neste formato" não é suficiente. Você precisa definir a estrutura de dados exata no código Python e fazer o LangChain passá-la para o LLM. Essa estrutura de dados definida é chamada de schema.
O Que é um Schema?
Um schema é um blueprint que define a estrutura de dados. Ele especifica:
- Quais campos devem estar presentes
- Qual tipo cada campo deve ser (string, número, booleano, etc.)
- Quais restrições se aplicam (opcional vs obrigatório, intervalos válidos, etc.)
Em Python, definimos schemas usando a classe BaseModel do Pydantic. Aqui está o exemplo mais simples possível:
from pydantic import BaseModel
class ProductInfo(BaseModel):
product_name: str
price: float
in_stock: boolEste schema diz: "Um objeto ProductInfo deve ter exatamente três campos: um product_name (string), um price (float) e um in_stock (booleano)."
O Padrão de Três Etapas: Definir, Vincular, Invocar
Usar saída estruturada é simples. Apenas lembre-se de três etapas:
- Definir: Criar um schema com uma classe Pydantic
- Vincular: Conectar o schema ao LLM usando
.with_structured_output() - Invocar: Chamar
.invoke()para obter um objeto tipado
Este é o template padrão que você usará para a maioria das tarefas de extração estruturada:
from langchain_openai import ChatOpenAI
from langchain_core.messages import HumanMessage
from pydantic import BaseModel, Field
# Etapa 1: Definir o schema
class ProductInfo(BaseModel):
product_name: str = Field(description="O nome completo do produto")
price: float = Field(description="Preço em USD")
in_stock: bool = Field(description="Se o produto está disponível")
# Etapa 2: Vincular schema ao LLM
llm = ChatOpenAI(model="gpt-4o-mini")
structured_llm = llm.with_structured_output(ProductInfo)
# Etapa 3: Invocar e obter objeto tipado
message = HumanMessage(content="""
Extraia informações do produto deste texto:
"O UltraWidget Pro custa $299.99 e está atualmente em estoque."
""")
result = structured_llm.invoke([message])
# result agora é um objeto ProductInfo, não uma string
print(type(result)) # <class '__main__.ProductInfo'>
print(result.product_name) # UltraWidget Pro
print(result.price) # 299.99
print(result.in_stock) # TrueO Que Acabou de Acontecer?
- Definição de Schema: Definimos os campos e tipos que queremos
- Vinculação:
.with_structured_output(ProductInfo)configura o LLM para usar saída estruturada - Invocação & Resposta: Quando
.invoke()é chamado, o LangChain passa o JSON Schema para o LLM, e o LLM responde com JSON correspondente a essa estrutura - Conversão Automática: O LangChain converte o JSON para um objeto
ProductInfo- nenhum código de análise necessário
Sem análise. Sem conversão de tipo. Sem erros.
Use este padrão de 3 etapas como seu template. Siga-o sempre que precisar de saída estruturada.
Descrições de Campo: A Chave para Guiar o LLM
No exemplo de definição de schema acima, usamos Field(description="..."). Esta descrição não é apenas documentação. São instruções que o LLM lê e segue.
No uso típico do Pydantic, descrições de Field são opcionais:
# Pydantic regular - descrição é documentação para humanos
class User(BaseModel):
name: str = Field(description="Nome do usuário") # Funciona bem sem elaMas ao trabalhar com LLMs, elas são essenciais:
# Com LLMs - descrição determina comportamento do LLM
class CustomerFeedback(BaseModel):
sentiment: str = Field(
description="Sentimento geral: 'positivo', 'negativo' ou 'neutro'"
)O LLM lê esta descrição e a usa para decidir como responder.
Vamos ver isso em ação:
from langchain_openai import ChatOpenAI
from langchain_core.messages import HumanMessage
from pydantic import BaseModel, Field
class CustomerFeedback(BaseModel):
sentiment: str = Field(
description="Sentimento geral: 'positivo', 'negativo' ou 'neutro'"
)
main_issue: str = Field(
description="A principal reclamação ou preocupação, se houver. Use 'nenhum' se nenhum problema for mencionado."
)
urgency: str = Field(
description="Quão urgente é o problema: 'baixa', 'média' ou 'alta'"
)
llm = ChatOpenAI(model="gpt-4o-mini")
structured_llm = llm.with_structured_output(CustomerFeedback)
message = HumanMessage(content="""
Analise este feedback do cliente:
"O produto funciona bem, mas o envio levou 3 semanas. Já perdi o prazo do meu projeto. Por favor, responda imediatamente."
""")
result = structured_llm.invoke([message])
print(result.sentiment) # negativo
print(result.main_issue) # Envio lento
print(result.urgency) # altaComo as descrições moldam as decisões do LLM:
- Descrição de
sentiment→ LLM aprende que os valores válidos são 'positivo', 'negativo', 'neutro' → Problema de envio causou prazo perdido, então escolhe 'negativo' - Descrição de
main_issue→ LLM é instruído a "encontrar a principal reclamação" → Identifica "envio lento" como o problema - Descrição de
urgency→ LLM aprende que urgência deve ser 'baixa', 'média' ou 'alta' → Vê "Por favor, responda imediatamente" e escolhe 'alta'
O que acontece sem descrições?
sentiment: str # Sem descriçãoO LLM pode retornar "negativo", "ruim", "insatisfeito", "2/5", "decepcionado" em formatos imprevisíveis, tornando difícil para seu código lidar com os valores.
Ponto chave: Descrições de campo são parte do seu código que controla o comportamento do LLM. Escreva-as de forma clara e específica.
Campos Categóricos: Especificando Valores Permitidos
No exemplo acima, o campo sentiment só pode ter três valores: 'positivo', 'negativo' ou 'neutro'. Campos que devem ser um de um conjunto específico de valores são chamados de campos categóricos.
Para campos categóricos, liste todos os valores possíveis na descrição:
sentiment: str = Field(
description="Sentimento: exatamente 'positivo', 'negativo' ou 'neutro' (minúsculas)"
)Ao especificar "exatamente" e "(minúsculas)", enfatizamos que o LLM deve responder com precisamente um desses três valores.
No entanto, não há garantia de que o LLM sempre responderá com um dos valores especificados. É por isso que você deve escrever código defensivo.
Casos onde o LLM retorna valores inesperados:
result.sentiment = "Positivo" # Capitalizado
result.sentiment = "NEGATIVO" # Tudo maiúsculo
result.sentiment = "bom" # Palavra diferenteEscrevendo código defensivo:
allowed = {"positivo", "negativo", "neutro"}
# Converter para minúsculas e verificar
sentiment = result.sentiment.lower()
if sentiment not in allowed:
sentiment = "neutro" # Usar padrão para valores inesperados
# Agora sentiment é garantido ser um dos valores permitidosConclusão chave:
- Especificar valores permitidos na descrição → LLM mais propenso a responder corretamente
- Validar no código → Lidar com valores inesperados com segurança
Nota: O Capítulo 18 mostra padrões mais fortes usando enums Python para aplicação.
Comparação: Análise Manual vs Saída Estruturada
Vamos comparar a mesma tarefa com e sem saída estruturada para ver a diferença:
Análise Manual:
from langchain_openai import ChatOpenAI
from langchain_core.messages import HumanMessage
llm = ChatOpenAI(model="gpt-4o-mini")
message = HumanMessage(content="""
Extraia o nome do produto, preço e disponibilidade de:
"O UltraWidget Pro custa $299.99 e está atualmente em estoque."
Formato: nome | preço | disponibilidade
""")
response = llm.invoke([message])
text = response.content
# Análise manual
parts = text.split('|')
product_name = parts[0].strip()
price_str = parts[1].strip().replace('$', '')
price = float(price_str)
availability = parts[2].strip().lower()
in_stock = 'em estoque' in availability or 'disponível' in availability
print(f"Nome: {product_name}")
print(f"Preço: ${price}")
print(f"Em Estoque: {in_stock}")Saída Estruturada:
from langchain_openai import ChatOpenAI
from langchain_core.messages import HumanMessage
from pydantic import BaseModel, Field
class ProductInfo(BaseModel):
product_name: str = Field(description="O nome completo do produto")
price: float = Field(description="Preço em USD")
in_stock: bool = Field(description="Se o produto está disponível")
llm = ChatOpenAI(model="gpt-4o-mini")
structured_llm = llm.with_structured_output(ProductInfo)
message = HumanMessage(content="""
Extraia informações do produto de:
"O UltraWidget Pro custa $299.99 e está atualmente em estoque."
""")
result = structured_llm.invoke([message])
print(f"Nome: {result.product_name}")
print(f"Preço: ${result.price}")
print(f"Em Estoque: {result.in_stock}")Diferenças Chave:
- Sem Lógica de Análise: A versão estruturada tem zero código de análise
- Segurança de Tipo:
result.priceé garantido ser um float - Código Mais Simples: Sem regex, sem divisão de string, sem conversão manual de tipo
- Validação: Pydantic garante que todos os campos obrigatórios estão presentes
- Manutenibilidade: Mudar o schema é mais fácil do que atualizar lógica de análise
7.3) Considerações de Design de Schema
Agora que você sabe como usar saída estruturada, vamos aprender como projetar bons schemas. Esta seção cobre princípios práticos de design para distinguir campos obrigatórios de opcionais.
Campos Obrigatórios
Por padrão, todos os campos em um modelo Pydantic são obrigatórios. Isso significa que o LLM deve extrair ou inferir um valor para cada campo obrigatório do prompt do usuário e fornecê-lo na resposta.
from pydantic import BaseModel
class ProductInfo(BaseModel):
product_name: str
price: float
in_stock: boolQuando você usa este schema, o LLM tentará encontrar valores para todos os três campos (product_name, price, in_stock) no texto de entrada.
Mas o que acontece quando o prompt não tem informações para um campo obrigatório?
Poderíamos esperar o seguinte comportamento:
- O LLM não consegue encontrar a informação no prompt
- O LLM omite esse campo de sua resposta
- O LangChain não consegue criar uma instância válida de
ProductInfo ValidationErroré levantado
No entanto, isso nem sempre acontece.
A razão é que diferentes LLMs podem lidar com informações ausentes de forma diferente.
Alguns LLMs (como modelos OpenAI) tendem a gerar valores mesmo quando a informação necessária não está presente no prompt. Neste caso, ValidationError não ocorre, mas isso pode causar problemas maiores porque sua aplicação Python pode processar informações fabricadas como se fossem reais.
Abordaremos como resolver este problema na Seção 7.4: Quando as Coisas Dão Errado.
Por enquanto, apenas esteja ciente de que nem todos os LLMs lidam com informações ausentes da mesma forma.
Campos Opcionais
Você pode precisar de campos que podem legitimamente estar presentes ou ausentes, mesmo em casos normais. Por exemplo, uma nota de entrega (delivery_note) pode ou não ser fornecida pelo cliente, mesmo para um pedido válido.
Quando usar Optional:
- Os dados em si podem não existir (por exemplo, quando avaliações anônimas são permitidas, avaliações anônimas não têm nome do avaliador)
- Você quer que o LLM indique explicitamente informações ausentes em vez de fabricar um valor
Para tornar um campo opcional, use o tipo Optional do Python do módulo typing:
from typing import Optional
class ProductReview(BaseModel):
rating: int
review_text: str
reviewer_name: Optional[str] = None # Avaliações anônimas não têm nome do avaliadorNota: Usuários do Python 3.10+ podem usar
str | Noneem vez deOptional[str].
Quando um campo é Optional:
- O LLM pode omiti-lo da resposta se a informação não for encontrada no prompt
- Campos omitidos são definidos para o valor padrão (
None)
Aqui está um exemplo completo:
from langchain_openai import ChatOpenAI
from langchain_core.messages import HumanMessage
from pydantic import BaseModel, Field
from typing import Optional
class ProductInfo(BaseModel):
product_name: str
price: float
in_stock: bool
discount_percentage: Optional[float] = None
warranty_years: Optional[int] = None
llm = ChatOpenAI(model="gpt-4o-mini")
structured_llm = llm.with_structured_output(ProductInfo)
message = HumanMessage(content="""
Extraia informações do produto: "O UltraWidget Pro custa $299.99 e está em estoque."
""")
result = structured_llm.invoke([message])
print(result.product_name) # UltraWidget Pro
print(result.price) # 299.99
print(result.in_stock) # True
print(result.discount_percentage) # None (não mencionado)
print(result.warranty_years) # None (não mencionado)Checklist de Design de Schema
Antes de finalizar seu schema, pergunte a si mesmo:
Seleção de Campo:
- Os campos obrigatórios são realmente essenciais? (O que acontece se este campo estiver ausente do prompt?)
- Os campos opcionais podem legitimamente estar ausentes mesmo em casos normais?
Especificação de Campo:
- Cada campo tem uma descrição clara?
- Os campos categóricos estão explicitamente restritos? (por exemplo, "deve ser exatamente 'A', 'B' ou 'C'")
7.4) Quando as Coisas Dão Errado
Dois problemas podem ocorrer ao usar saída estruturada:
- LLM omite valores de campo obrigatórios → ValidationError ocorre
- LLM fabrica informações ausentes → Nenhum ValidationError, mas seu código processa dados incorretos
Esta seção cobre como lidar com cada um.
Entendendo Erros de Validação
Quando o prompt do usuário não tem informações para campos obrigatórios definidos no schema, o LLM não pode fornecer valores para esses campos. O programa Python então levanta um ValidationError:
from langchain_anthropic import ChatAnthropic
from langchain_core.messages import HumanMessage
from pydantic import BaseModel, ValidationError
class ProductInfo(BaseModel):
product_name: str
price: float
in_stock: bool
llm = ChatAnthropic(model='claude-sonnet-4-5')
structured_llm = llm.with_structured_output(ProductInfo)
# Entrada sem informações obrigatórias
message = HumanMessage(content="""
Extraia informações do produto de: "O widget é ótimo! Altamente recomendado."
""")
try:
result = structured_llm.invoke([message])
print(result)
except ValidationError as e:
print("ValidationError ocorreu")Nota: Quando o prompt não tem informações para campos obrigatórios, alguns LLMs podem fabricar valores e fornecê-los na resposta. Neste caso,
ValidationErrornão ocorrerá, mas um problema maior surge. Cobriremos isso na próxima seção.
Quando o prompt não tem informações para campos obrigatórios e ValidationError ocorre, isso é realmente útil para sua aplicação Python. A aplicação pode detectar que um problema ocorreu e lidar com o erro de forma controlada. Estratégias de recuperação de erros são cobertas no Capítulo 14 (recuperação de erros no nível do agente) e Capítulo 17 (lógica de retry com gerenciamento de estado).
O Problema Maior: LLM Fabricando Informações Ausentes
Como discutimos na Seção 7.3, alguns LLMs exibem comportamento mais perigoso: eles fabricam valores e os fornecem em respostas quando informações estão ausentes do prompt.
Como o problema ocorre:
- Prompt está sem informações obrigatórias
- LLM gera valores de aparência plausível de qualquer forma
ValidationErrorNÃO ocorre- Sua aplicação Python processa dados fabricados como se fossem reais
Exemplo:
class ProductInfo(BaseModel):
product_name: str
price: float
in_stock: bool
message = HumanMessage(content="""
Extraia informações do produto de: "O widget é ótimo!"
""")
# Quando alguns LLMs fabricam valores para product_name, price e in_stock
result = structured_llm.invoke([message])
# Nenhum erro levantado!
print(result.product_name) # "widget" (extraído do texto)
print(result.price) # 0.0 (fabricado!)
print(result.in_stock) # False (fabricado!)
# Problema: Você não pode dizer quais valores são reais vs fabricadosIsso é pior que um ValidationError porque:
- Sua aplicação Python continua executando com dados ruins
- Você não sabe quais campos são reais vs fabricados
- Lógica downstream pode tomar decisões erradas baseadas em dados falsos
Solução: Use Campos Opcionais com Validação
A solução é definir todos os campos obrigatórios como Optional, então usar um validador para verificar que todos os campos obrigatórios têm valores.
from typing import Optional
from pydantic import BaseModel, model_validator, ValidationError
from langchain_openai import ChatOpenAI
from langchain_core.messages import HumanMessage
class ProductInfo(BaseModel):
# Estes são realmente obrigatórios, mas declarados como Optional
# Validação real acontece no validador abaixo
product_name: Optional[str] = None
price: Optional[float] = None
in_stock: Optional[bool] = None
@model_validator(mode='after')
def check_required_fields(self):
"""Valida que todos os campos essenciais estão presentes"""
if self.product_name is None or self.price is None or self.in_stock is None:
raise ValueError("Todos os campos (product_name, price, in_stock) devem ser fornecidos")
return self
llm = ChatOpenAI(model="gpt-4o-mini")
structured_llm = llm.with_structured_output(ProductInfo)
# Testar com dados incompletos
message = HumanMessage(content="""
Extraia informações do produto de: "O widget é ótimo!"
""")
try:
result = structured_llm.invoke([message])
# Se chegarmos aqui, todos os campos são garantidos estar presentes
print(f"Produto: {result.product_name}")
print(f"Preço: ${result.price}")
except ValidationError as e:
# Campos obrigatórios estão ausentes - extração falhou
print(f"Extração incompleta: {e}")O que está acontecendo aqui:
@model_validatoré o decorador do Pydantic que adiciona lógica de validação personalizadamode='after'significa que a validação é executada depois que todos os campos foram analisados- Se qualquer campo for
None, levantamosValueErrorpara sinalizar dados incompletos - Pydantic automaticamente envolve este
ValueErrorem umValidationError
Por que isso funciona:
Quando o prompt está sem informações para campos:
- O LLM não fabrica valores e omite esses campos da resposta
- Neste caso, esses campos se tornam
None - Se esses campos são realmente obrigatórios, o validador Pydantic levanta
ValueError - Pydantic o envolve como
ValidationError
Dessa forma, sua aplicação Python recebe um erro explícito para lidar, em vez de dados fabricados.
Conclusão chave: Quando o prompt não tem informações para campos obrigatórios, obter um ValidationError é perfeitamente normal e esperado. O perigo real são dados fabricados. Use campos Optional com validadores para evitar que o LLM fabrique informações ausentes, enquanto detecta explicitamente quando campos obrigatórios estão ausentes.
Resumo do Capítulo:
Neste capítulo, você aprendeu como transformar saída de LLM em objetos Python confiáveis:
- Por que importa: Análise de texto livre é frágil; saída baseada em schema fornece segurança de tipo
- Como usar: Definir schemas com
BaseModeldo Pydantic → Vincular com.with_structured_output() - Princípios de design: Escolher campos obrigatórios vs opcionais, guiar LLM com descrições de campo
- Lidar com problemas: ValidationError é normal; perigo real são dados fabricados