Workflows agênticos raramente trabalham com texto livre do começo ao fim. Em algum momento, a saída de um modelo precisa alimentar outro passo do pipeline, uma consulta a banco de dados, uma chamada de API, uma decisão de roteamento. E aí o problema aparece pois o modelo quase sempre responde no formato certo, mas “quase sempre” não é o suficiente quando há lógica dependendo disso.

A solução inicial é instruir o modelo via prompt “responda em JSON com os campos X, Y e Z”. Funciona na maior parte das vezes. Mas uma instrução é um pedido, não uma garantia. Basta o modelo adicionar um comentário antes do bloco, usar uma chave diferente, ou omitir um campo opcional, e o json.loads() lá na frente quebra.

A Saída Estruturada (Structured Output) resolve isso na camada da API pois você fornece um esquema JSON e a resposta é garantidamente válida contra ele. Não há regex, não há try/except de parsing, não há torcer para o modelo ser consistente.

Neste artigo vamos explorar essa funcionalidade com um exemplo sobre extração de informações estruturadas de currículos para uma pipeline de triagem de vagas.

Como Funciona

Vamos mostrar como usar este recurso no Gemini, mas ele está disponível em outros provedores de modelos também. Vamos assumir que você conhece o método generate_content do Gemini. Pois bem, para gerar uma saída estruturada, basta chamar este método com uma configuração específica.

A configuração exige dois campos no config da chamada:

  • response_mime_type: sempre "application/json"
  • response_json_schema: o esquema que a resposta deve seguir
resposta = client.models.generate_content(
    model="gemini-2.5-flash",
    contents="...",
    config={
        "response_mime_type": "application/json",
        "response_json_schema": { ... }
    }
)

Isso é diferente de escrever “responda em JSON” no prompt. O modelo não tem escolha sobre o formato, a API impõe o esquema antes de retornar a resposta.

Há duas formas de definir o esquema, com um dicionário JSON puro ou com uma classe Pydantic. Na prática, Pydantic é quase sempre a melhor opção, a classe serve de documentação, os tipos são verificados em tempo de execução e o método .model_json_schema() faz a conversão automaticamente.

Vamos mostrar como usar Pydantic em um problema específico, que explicamos na próxima seção.

O Problema: Triagem de Currículos

Triagem de currículos é um bom caso de uso para Saída Estruturada porque o dado de entrada (o currículo) é um texto desestruturado, e o que precisamos como saída mistura dois tipos de operação distintos.

Extração fiel de coisas que estão explicitamente no texto:

  • Empresas, cargos, períodos de cada experiência
  • Habilidades técnicas mencionadas

Inferência de coisas que não estão escritas mas podem ser derivadas usando a capacidade dos LLMs:

  • Nível de senioridade (o candidato nem sempre escreve “sou sênior”)

Um esquema bem definido força essa distinção a existir no código. Os campos de extração têm tipos diretos (str, list); os campos de inferência têm tipos enumerados ou numéricos com intervalos claros.

Clique aqui para abrir o código deste artigo no Google Colab

Definindo o Esquema com Pydantic

class Experiencia(BaseModel):
    empresa: str = Field(description="Nome da empresa.")
    cargo: str = Field(description="Cargo ou título do candidato nessa empresa.")
    periodo: str = Field(description="Período de trabalho, ex: '2019–2022'.")
    descricao: str = Field(description="Breve descrição das responsabilidades.")

class CurriculoEstruturado(BaseModel):
    nome: str = Field(description="Nome completo do candidato.")
    experiencias: list[Experiencia] = Field(
        description="Lista de experiências profissionais em ordem cronológica inversa."
    )
    habilidades_tecnicas: list[str] = Field(
        description="Habilidades técnicas mencionadas explicitamente no currículo."
    )
    senioridade: Literal["júnior", "pleno", "sênior", "staff/principal"] = Field(
        description=(
            "Nível de senioridade inferido a partir do tempo de experiência e "
            "complexidade dos cargos. Não extraído diretamente do texto."
        )
    )

O Literal no campo senioridade não é só uma questão de validação, ele vira um enum no JSON Schema gerado, o que significa que o modelo só pode retornar um desses quatro valores. Sem Literal, o modelo poderia escrever “senior” (sem acento), “Sênior”, “nível sênior” e você teria que normalizar na mão.

O Field(description=...) também merece atenção pois o modelo lê esses campos como instruções embutidas no esquema. A distinção explícita entre “extraído diretamente” e “inferido” no campo senioridade faz diferença na qualidade da resposta.

Um Currículo de Exemplo

CURRICULO = """
Lucas Mendes
lucas.mendes@email.com | linkedin.com/in/lucasmendes | São Paulo, SP

EXPERIÊNCIA PROFISSIONAL

Engenheiro de Machine Learning — NovaTech AI (2022–presente)
Liderança técnica de um time de 4 engenheiros no desenvolvimento de modelos de
recomendação para e-commerce. Implementação de pipelines de treinamento e serving
com PyTorch e Ray. Redução de latência de inferência em 40% via quantização e ONNX.

Cientista de Dados — Banco Meridional (2019–2022)
Desenvolvimento de modelos de crédito (XGBoost, LightGBM) para pessoa física e
jurídica. Criação de feature store em Spark. Trabalho próximo com times de risco
e regulatório para garantir explicabilidade dos modelos (SHAP, LIME).

Analista de Dados — StartLog (2017–2019)
Análise exploratória e dashboards em Python e Tableau. Automatização de relatórios
operacionais reduzindo trabalho manual em ~60%.

FORMAÇÃO
Bacharelado em Ciência da Computação — USP (2013–2017)

HABILIDADES TÉCNICAS
Python, PyTorch, TensorFlow, Scikit-learn, XGBoost, LightGBM, Spark, Ray,
SQL, dbt, Airflow, Docker, Kubernetes, ONNX, SHAP, Tableau
"""

Extraindo as Informações

A função recebe o currículo como argumento. O esquema permanece o mesmo independente da posição.

def extrair_curriculo(curriculo: str) -> CurriculoEstruturado:
    prompt = f"""
Analise o currículo abaixo e extraia as informações solicitadas.

CURRÍCULO:
{curriculo}
"""
    for tentativa in range(3):
        try:
            resposta = client.models.generate_content(
                model=MODEL_ID,
                contents=prompt,
                config={
                    "response_mime_type": "application/json",
                    "response_json_schema": CurriculoEstruturado.model_json_schema(),
                    "temperature": 0.1,
                }
            )
            return CurriculoEstruturado.model_validate_json(resposta.text)
        except Exception as e:
            if tentativa < 2:
                print(f"Tentativa {tentativa + 1} falhou, aguardando 10s...")
                time.sleep(10)
            else:
                raise e

temperature=0.1 faz sentido aqui pois extração de informações é uma tarefa determinística, não criativa. Você quer que o modelo leia o que está escrito, não que interprete criativamente.

Execução

resultado = extrair_curriculo(CURRICULO)

# Acessando os campos diretamente — sem parsing manual
print(f"Candidato: {resultado.nome}")
print(f"Senioridade: {resultado.senioridade}")
print(f"\nHabilidades ({len(resultado.habilidades_tecnicas)} encontradas):")
for h in resultado.habilidades_tecnicas:
    print(f"  - {h}")

print(f"\nExperiências:")
for exp in resultado.experiencias:
    print(f"  [{exp.periodo}] {exp.cargo} @ {exp.empresa}")

Saída real:

Candidato: Lucas Mendes
Senioridade: sênior

Habilidades (16 encontradas):
  - Python
  - PyTorch
  - TensorFlow
  - Scikit-learn
  - XGBoost
  - LightGBM
  - Spark
  - Ray
  - SQL
  - dbt
  - Airflow
  - Docker
  - Kubernetes
  - ONNX
  - SHAP
  - Tableau

Experiências:
  [2022–presente] Engenheiro de Machine Learning @ NovaTech AI
  [2019–2022] Cientista de Dados @ Banco Meridional
  [2017–2019] Analista de Dados @ StartLog

Clique aqui para abrir o código do pipeline real

Limitações que Valem a Pena Conhecer

O esquema garante estrutura, não verdade. O modelo vai entregar um JSON válido com os campos certos e os tipos certos. Mas o campo de senioridade depende da (incerta) capacidade do modelo de qualificar apropriadamente a experiência do candidato. Vale a pena criar um prompt detalhando os critérios que o modelo deve usar, neste caso.

Esquemas muito aninhados podem ser rejeitados. A API suporta um subconjunto do JSON Schema completo. Se você encontrar erros de validação ao definir o esquema, simplifique pois nomes de propriedade mais curtos e menos níveis de aninhamento costumam resolver. Uma boa heurística é testar o esquema com json.dumps(SuaClasse.model_json_schema(), indent=2) antes de fazer a chamada.

Atenção: description não é decoração. O modelo lê os Field(description=...) como instruções (ou seja, parte do prompt). Para campos inferidos — especialmente senioridade —, uma descrição clara sobre como inferir faz diferença mensurável na qualidade do resultado.

Conclusão

Saída Estruturada muda onde fica a responsabilidade de garantir o formato. Em vez de o código tentar se defender contra respostas malformadas, a API assume essa responsabilidade antes de retornar qualquer coisa.

Para pipelines de extração de informação, isso tem uma consequência prática direta pois você pode tratar a saída do modelo como dado confiável imediatamente, sem uma camada extra no meio.

Recursos Adicionais

Clique aqui para abrir o código deste artigo no Google Colab