Python & AI Tutorials Logo
LangChain & LangGraph

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:

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

A 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:

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

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

  1. Respostas de LLM são imprevisíveis: O mesmo prompt pode produzir formatos diferentes toda vez
  2. Análise de string é mais difícil do que parece: Você precisa lidar com $, espaços, quebras de linha, vírgulas e muito mais
  3. Nenhuma segurança de tipo: Você não pode ter certeza se price é um float, string ou None
  4. Tratamento de erros é difícil: Se o LLM diz "Preço não disponível", sua chamada float() falha
  5. 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:

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

Análise Manual

Saída Estruturada

Quebra Frequentemente

Seguro por Tipo

Saída de Texto do LLM

Lógica de String Frágil

Objeto Python Tipado

Erros em Tempo de Execução

Código Confiável

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:

python
from pydantic import BaseModel
 
class ProductInfo(BaseModel):
    product_name: str
    price: float
    in_stock: bool

Este 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:

  1. Definir: Criar um schema com uma classe Pydantic
  2. Vincular: Conectar o schema ao LLM usando .with_structured_output()
  3. 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:

python
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)  # True

O Que Acabou de Acontecer?

  1. Definição de Schema: Definimos os campos e tipos que queremos
  2. Vinculação: .with_structured_output(ProductInfo) configura o LLM para usar saída estruturada
  3. 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
  4. 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:

python
# Pydantic regular - descrição é documentação para humanos
class User(BaseModel):
    name: str = Field(description="Nome do usuário")  # Funciona bem sem ela

Mas ao trabalhar com LLMs, elas são essenciais:

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

python
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)      # alta

Como 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?

python
sentiment: str  # Sem descrição

O 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:

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

python
result.sentiment = "Positivo"    # Capitalizado
result.sentiment = "NEGATIVO"    # Tudo maiúsculo
result.sentiment = "bom"        # Palavra diferente

Escrevendo código defensivo:

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

Conclusão chave:

  1. Especificar valores permitidos na descrição → LLM mais propenso a responder corretamente
  2. 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:

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

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

  1. Sem Lógica de Análise: A versão estruturada tem zero código de análise
  2. Segurança de Tipo: result.price é garantido ser um float
  3. Código Mais Simples: Sem regex, sem divisão de string, sem conversão manual de tipo
  4. Validação: Pydantic garante que todos os campos obrigatórios estão presentes
  5. 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.

python
from pydantic import BaseModel
 
class ProductInfo(BaseModel):
    product_name: str
    price: float
    in_stock: bool

Quando 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:

  1. O LLM não consegue encontrar a informação no prompt
  2. O LLM omite esse campo de sua resposta
  3. O LangChain não consegue criar uma instância válida de ProductInfo
  4. 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:

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

Nota: Usuários do Python 3.10+ podem usar str | None em vez de Optional[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:

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

  1. LLM omite valores de campo obrigatórios → ValidationError ocorre
  2. 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:

python
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, ValidationError nã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:

  1. Prompt está sem informações obrigatórias
  2. LLM gera valores de aparência plausível de qualquer forma
  3. ValidationError NÃO ocorre
  4. Sua aplicação Python processa dados fabricados como se fossem reais

Exemplo:

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

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

python
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 personalizada
  • mode='after' significa que a validação é executada depois que todos os campos foram analisados
  • Se qualquer campo for None, levantamos ValueError para sinalizar dados incompletos
  • Pydantic automaticamente envolve este ValueError em um ValidationError

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 BaseModel do 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