tva
← Artigos

Compatibilidade entre vagas e candidatos com LLM: um pipeline de pontuação de aderência

A filtragem por palavras-chave é rápida, barata e falha justamente nos aspectos que importam. Quando um recrutador define um perfil de candidato ideal — especificando conhecimento do setor, domínio das tecnologias, estilo de comunicação e uma dúzia de preferências contextuais — nenhuma abordagem baseada em palavras isoladas (bag-of-words) consegue avaliar se um determinado anúncio de vaga corresponde a esse perfil fielmente. A lacuna semântica entre “5+ anos de Python” e “escreve Python para fluxos de trabalho de engenharia de dados em produção com uso intenso de I/O assíncrono” é invisível para um filtro de palavras-chave. É, no entanto, precisamente o que um modelo de linguagem pode avaliar com o prompt certo.

Construímos um pipeline de avaliação de compatibilidade que recebe um perfil de candidato fixo e pontua uma fila de anúncios de vagas em relação a ele usando um LLM, executando as avaliações em paralelo, rastreando custos de tokens em tempo real e oferecendo controles de pausa e retomada em um aplicativo macOS nativo em SwiftUI. Este artigo explica como o sistema está estruturado e onde a avaliação de compatibilidade por LLM justifica seu custo em relação a abordagens mais simples.


A arquitetura do prompt

O núcleo do sistema é um único prompt de pontuação, chamado uma vez por anúncio de vaga. Cada chamada recebe duas entradas: o perfil do candidato e o texto do anúncio. O perfil é fixo para toda a sessão — inclui habilidades, nível de experiência, preferências de setor, restrições inegociáveis e quaisquer notas contextuais que o recrutador deseja que o modelo considere. O anúncio fornece o cargo, empresa, descrição e requisitos conforme coletados.

O modelo retorna um objeto JSON estruturado com dois campos: uma pontuação numérica de 0 a 100 e uma breve justificativa explicando os principais fatores na compatibilidade ou incompatibilidade. Exigir uma saída estruturada é importante aqui. Uma resposta não estruturada exige uma análise sintática frágil e introduz variância em como a pontuação é apresentada. Usar uma interface de chamada de ferramenta ou chamada de função com um esquema JSON (em modo estrito ou de saída estruturada, quando o provedor oferecer essa opção) elimina em grande parte essa classe de problema e simplifica a agregação posterior; ainda assim, vale a pena validar o objeto após a análise sintática.

Uma escolha de design que vale a pena explicitar: o perfil deve ser detalhado. Perfis curtos produzem pontuações ruidosas porque o modelo tem contexto insuficiente para distinguir um 70 de um 85. Um perfil que articula claramente por que certas restrições importam — não apenas o que são — dá ao modelo sinal suficiente para pontuar com diferenciação útil. Trate o perfil como um conjunto detalhado de critérios de avaliação em vez de uma string de filtro.

Execução paralela com ThreadPoolExecutor

Com uma fila de 50 ou 100 anúncios, chamadas de API sequenciais são lentas demais para serem úteis interativamente. O concurrent.futures.ThreadPoolExecutor do Python lida com isso de forma limpa. O número de workers é definido com base nos limites de requisições do prestador de API — tipicamente entre 5 e 10 requisições concorrentes proporcionam um volume significativo de processamento sem acionar erros de cota.

O padrão usa as_completed(), que retorna objetos Future na ordem em que terminam em vez da ordem em que foram submetidos. Isso significa que as avaliações mais rápidas surgem primeiro e a UI pode ser atualizada progressivamente em vez de esperar por um lote inteiro:

from concurrent.futures import ThreadPoolExecutor, as_completed

with ThreadPoolExecutor(max_workers=8) as executor:
    futures = {
        executor.submit(score_listing, listing, profile): listing
        for listing in listings
    }
    for future in as_completed(futures):
        listing = futures[future]
        score, rationale = future.result()
        update_results(listing, score, rationale)

O tratamento de erros merece atenção explícita aqui. Chamadas de API individuais podem falhar — erros de limite de requisições, problemas de rede transitórios, rejeições de política de conteúdo. Cada worker deve capturar exceções e retornar um valor sentinela (o trecho acima omite esse tratamento por brevidade); caso contrário, future.result() relança a exceção do worker no laço de resultados e interrompe o processamento dos resultados restantes. Um anúncio cuja avaliação falha entra em fila para nova tentativa em vez de ser descartado silenciosamente. Uma falha explícita é muito mais fácil de analisar do que uma lista de pontuações com lacunas inexplicadas.

Acompanhamento de tokens em tempo real

Os custos de tokens em escala são fáceis de subestimar. Um prompt de 400 tokens enviado para 100 anúncios, com respostas de 150 tokens, gera um custo acumulado rapidamente dependendo do nível do modelo. O pipeline rastreia totais acumulados usando um contador seguro para acesso concorrente — um simples inteiro protegido por um threading.Lock, incrementado após cada resposta de API usando as contagens de tokens dos metadados da resposta.

O frontend SwiftUI consulta esse contador periodicamente por meio de um temporizador, exibindo tokens de entrada cumulativos, tokens de saída e um custo estimado com base no preço por token do modelo atual. Isso dá ao recrutador uma visão em tempo real do custo da sessão e ajuda a identificar quando o perfil está produzindo justificativas incomumente longas — frequentemente um sinal de que o próprio prompt precisa de ajuste ou que o texto do anúncio contém mais ruído do que o esperado.

Um refinamento prático: estimar as contagens de tokens antes de enviar, não apenas depois. Os principais provedores oferecem uma biblioteca de tokenização ou um endpoint de contagem de tokens (por exemplo, a biblioteca tiktoken da OpenAI ou o endpoint de contagem de tokens da Anthropic). A pré-contagem permite que o pipeline avise quando o texto de um anúncio é incomumente longo e consumiria contexto desproporcional, ou quando o prompt combinado se aproxima do limite de contexto do modelo. Detectar isso antes da chamada de API evita gastos desperdiçados em uma requisição que será truncada ou rejeitada.

Pausa e retomada

Quando um recrutador está revisando resultados no meio de uma sessão, ele pode querer pausar novas requisições de pontuação sem perder o trabalho já em andamento. O mecanismo de pausa/retomada usa um threading.Event como sinal compartilhado. Antes de cada chamada de API, a thread do worker verifica o estado do evento. Se pausado, bloqueia em event.wait(). Quando o evento é sinalizado novamente, a execução continua.

Isso é mais limpo do que matar e reiniciar threads porque as chamadas de API em andamento têm permissão para ser concluídas naturalmente. Pausar significa “parar de iniciar novas chamadas” em vez de “abortar tudo agora.” A distinção importa quando você quer que o lote atual termine antes de o usuário revisar os resultados — lotes parciais são mais difíceis de analisar do que lotes completos.

A persistência de estado entre pausa/retomada vale a pena ser tratada explicitamente. O pipeline mantém um arquivo de checkpoint — uma simples lista JSON de IDs de anúncios já pontuados. Na retomada, o pipeline pula qualquer anúncio já registrado no checkpoint. Desde que as pontuações e justificativas também sejam persistidas junto com o checkpoint, isso significa que, se o aplicativo travar no meio de uma sessão, nenhum anúncio já pontuado precisa ser avaliado novamente na reinicialização. A idempotência no nível de sessão custa quase nada para implementar e evita muitos transtornos.

O frontend macOS em SwiftUI

Escolhemos SwiftUI para o frontend porque a máquina de destino era macOS e queríamos uma experiência nativa — responsiva, de baixa sobrecarga, com gerenciamento de janelas macOS adequado. Um shell Electron teria funcionado, mas teria acrescentado complexidade desnecessária para uma ferramenta de produtividade de usuário único. O backend Python é executado como um processo local e expõe uma API HTTP mínima usando Flask; o aplicativo SwiftUI se comunica com ele via localhost.

O padrão ObservableObject e @Published do SwiftUI lida com o loop de atualização reativa de forma limpa. Um view model consulta o backend periodicamente por meio de um temporizador, atualizando os arrays publicados que alimentam a lista de resultados e o mostrador do contador de tokens. A janela principal mostra três painéis: uma lista de resultados ordenada por pontuação decrescente, um contador em tempo real de tokens e custo estimado, e uma barra de controle com botões de Pausar/Retomar e Parar.

Cada linha de resultado mostra o cargo, nome da empresa, um indicador de pontuação identificado por cores e a justificativa de uma linha do LLM. Tocar em uma linha abre um painel de detalhes com a justificativa completa, os principais fatores sinalizados pelo modelo e um link direto para o anúncio original. O painel de detalhes é onde a maior parte do tempo de revisão do recrutador é gasta — a pontuação é um sinal para direcionar a atenção, não uma decisão final.

Quando a avaliação de compatibilidade por LLM supera a filtragem por palavras-chave

A filtragem por palavras-chave é a ferramenta certa quando os requisitos são binários e explícitos: deve ter uma certificação específica, deve estar localizado em uma cidade específica, deve ter um número mínimo de anos em uma função. Nesses casos, um filtro simples é executado em milissegundos e não custa nada.

Mas na realidade, a maioria das restrições interessantes é contextual. “Procurando alguém que escreve código limpo e bem testado” é uma preferência que aparece em como uma descrição de vaga é escrita, não como uma palavra-chave discreta. “Não interessado em funções onde o conjunto de tecnologias é exclusivamente legado sem caminho de modernização” requer ler e interpretar a descrição. Um filtro de palavras-chave não consegue detectar esse sinal. Um LLM com um perfil explícito com essas restrições claramente delineadas pode avaliá-las diretamente.

A lacuna se amplia ainda mais em mercados com inflação significativa de palavras-chave. Quando todo anúncio de vaga lista as mesmas habilidades independentemente de serem realmente centrais para a função, a correspondência por palavras-chave produz uma distribuição quase plana onde a maioria dos anúncios parece equivalente. A pontuação semântica rompe esse ruído porque avalia a coerência geral do anúncio em relação ao perfil, não apenas a presença de termos.

Quando é excessivo

A pontuação por LLM adiciona custo e latência reais. Para triagem de alto volume e pouca complexidade contextual — milhares de anúncios com requisitos binários diretos — uma cadeia de filtros estruturados é mais rápida e mais barata por ordens de magnitude. A arquitetura correta frequentemente combina ambas as abordagens: um pré-filtro rápido remove incompatibilidades óbvias, reduzindo a fila para um subconjunto gerenciável, e a pontuação por LLM é aplicada apenas ao que resta. Se um pré-filtro remover 80% dos anúncios, os custos de pontuação por LLM caem aproximadamente na mesma proporção, e a qualidade da correspondência se mantém desde que o pré-filtro descarte apenas incompatibilidades claras.

A outra consideração é a consistência. Os LLMs introduzem não determinismo. O mesmo anúncio pontuado duas vezes pode receber pontuações levemente diferentes. Para a maioria das aplicações de recrutamento, essa variância é aceitável — uma variação de alguns pontos em qualquer direção não muda quais anúncios valem a pena revisar. Mas é preciso considerar essa variação ao comparar pontuações entre sessões que usaram versões de modelo diferentes ou iterações de prompt significativamente revisadas.

Artigos relacionados

Leitura complementar