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

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.

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 coisas que estão explicitamente no texto:

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

Inferência coisas que não estão escritas mas podem ser derivadas:

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

Um schema 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 com as configurações iniciais

Definindo o Schema 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 schema. A distinção explícita entre “extraído diretamente” e “inferido” no campo senioridade faz diferença na qualidade da resposta.

O 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 e a descrição da vaga como argumentos separados. A vaga é passada via prompt, o schema 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

VAGA = """
Engenheiro(a) de ML Sênior
- Experiência com sistemas de recomendação ou ranqueamento em produção
- Domínio de PyTorch ou TensorFlow
- Experiência com infraestrutura de ML (serving, monitoramento, pipelines)
- Diferenciais: Ray, ONNX, otimização de inferência
"""

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 schema garante estrutura, não verdade. O modelo vai entregar um JSON válido com os campos certos e os tipos certos. Mas se o currículo for ambíguo, o score pode variar entre chamadas, e a inferência de senioridade é tão boa quanto o modelo que você usa. Valide os campos críticos para a sua lógica, especialmente os inferidos.

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

description não é decoração. O modelo lê os Field(description=...) como instruções. Para campos inferidos especialmente, como senioridade e score_adequacao —, 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