tva
← Artículos

Compatibilidad de ofertas de empleo con LLM: un pipeline de puntuación de idoneidad

El filtrado por palabras clave es rápido y barato, pero falla precisamente en los aspectos que importan. Cuando un reclutador define un perfil ideal que incluye conocimientos del sector, dominio de tecnologías, estilo de comunicación y una docena de preferencias contextuales, ningún enfoque de bolsa de palabras puede evaluar fielmente si una oferta de empleo encaja. La diferencia semántica entre «5+ años de Python» y «escribe Python para flujos de ingeniería de datos en producción con uso intensivo de E/S asíncrona» es invisible para ese filtro. Sin embargo, es exactamente lo que puede evaluar un modelo de lenguaje con el prompt adecuado.

Construimos un pipeline que toma un perfil fijo de candidato y puntúa una cola de ofertas de empleo con un LLM. Evalúa ofertas en paralelo, registra los costos de tokens en tiempo real y ofrece controles de pausa y reanudación desde una aplicación nativa de macOS con SwiftUI. Este artículo explica la estructura del sistema y cuándo la evaluación con LLM justifica su costo frente a enfoques más sencillos.


Arquitectura del prompt

El núcleo del sistema es un único prompt de puntuación, que se invoca una vez por oferta. Cada llamada recibe dos entradas: el perfil del candidato y el texto de la oferta. El perfil permanece fijo durante toda la sesión e incluye competencias, nivel de experiencia, preferencias de sector, requisitos innegociables y notas contextuales que el reclutador quiere que el modelo considere. La oferta aporta el título del puesto, la empresa, la descripción y los requisitos tal como se extrajeron.

El modelo devuelve un objeto JSON estructurado con dos campos: una puntuación numérica de 0 a 100 y una breve justificación de los factores clave de la coincidencia o falta de ella. Exigir una salida estructurada es importante. Una respuesta libre requiere un análisis frágil e introduce variaciones en la presentación de la puntuación. Utilizar una interfaz de llamadas a herramientas o funciones con un esquema JSON (en un modo estricto o de salida estructurada, cuando el proveedor lo ofrezca) elimina en gran medida ese tipo de problema y simplifica la agregación posterior; aun así, conviene validar el objeto tras analizarlo.

Conviene explicitar una decisión de diseño: el perfil debe ser detallado. Los perfiles breves producen puntuaciones poco fiables porque el modelo carece de contexto para distinguir un 70 de un 85. Explicar claramente por qué importan ciertas restricciones, además de cuáles son, aporta suficiente información para distinguir las ofertas de forma útil. Trate el perfil como una rúbrica detallada, no como una cadena de filtrado.

Ejecución paralela con ThreadPoolExecutor

Con una cola de 50 o 100 ofertas, las llamadas secuenciales a la API son demasiado lentas para una experiencia interactiva. concurrent.futures.ThreadPoolExecutor de Python resuelve esto de forma sencilla. El número de workers depende de los límites de solicitudes del proveedor: normalmente, entre 5 y 10 solicitudes concurrentes ofrecen una tasa de procesamiento útil sin provocar errores de cuota.

El patrón utiliza as_completed(), que devuelve los futuros en el orden en que terminan, no en el que se enviaron. Así, las evaluaciones más rápidas aparecen primero y la interfaz puede actualizarse progresivamente sin esperar a que termine todo el lote:

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)

El manejo de errores requiere atención explícita. Las llamadas individuales pueden fallar por límites de solicitudes, problemas transitorios de red o rechazos por políticas de contenido. Cada worker debería capturar las excepciones y devolver un valor centinela (el fragmento anterior omite este manejo por brevedad); de lo contrario, future.result() vuelve a lanzar la excepción del worker en el bucle de resultados y detiene el procesamiento de los resultados restantes. Las ofertas que no se pueden puntuar se ponen en cola para un reintento, en vez de descartarse silenciosamente. Un fallo visible es mucho más fácil de interpretar que una lista con huecos inexplicables.

Seguimiento de tokens en tiempo real

Es fácil subestimar los costos de tokens a gran escala. Enviar un prompt de 400 tokens para 100 ofertas, con respuestas de 150 tokens, puede acumular costos rápidamente según el nivel del modelo. El pipeline mantiene totales acumulados mediante un contador seguro para hilos: un entero protegido por threading.Lock, que se incrementa tras cada respuesta con los recuentos de tokens de sus metadatos.

El frontend de SwiftUI consulta el contador periódicamente y muestra los tokens acumulados de entrada y salida, junto con un costo estimado según el precio por token del modelo actual. Esto permite al reclutador seguir el costo de la sesión e identificar justificaciones inusualmente largas, que suelen indicar que conviene precisar el prompt o que el texto de la oferta contiene más ruido del esperado.

Una mejora práctica consiste en estimar los tokens antes de enviar la solicitud, además de contarlos después. Los principales proveedores ofrecen una biblioteca de tokenización o un endpoint de recuento de tokens (por ejemplo, la biblioteca tiktoken de OpenAI o el endpoint de recuento de tokens de Anthropic). Este cálculo previo permite advertir que una oferta es demasiado larga y consumiría una cantidad desproporcionada de contexto, o que el prompt combinado se acerca al límite de contexto del modelo. Detectarlo antes evita gastar en una solicitud que se truncará o rechazará.

Pausa y reanudación

Cuando un reclutador revisa resultados durante una sesión, puede querer pausar las nuevas solicitudes sin perder el trabajo en curso. El mecanismo de pausa y reanudación utiliza threading.Event como señal compartida. Antes de cada llamada a la API, el hilo del worker comprueba su estado. Si está en pausa, se bloquea en event.wait(). Cuando se activa de nuevo el evento, continúa la ejecución.

Este enfoque es más sencillo que terminar y reiniciar hilos, porque permite que las llamadas en curso terminen de forma natural. Pausar significa «dejar de iniciar llamadas», no «cancelarlo todo ahora». La diferencia importa cuando se quiere terminar el lote actual antes de revisar los resultados: los lotes parciales son más difíciles de interpretar que los completos.

También hay que gestionar explícitamente la persistencia del estado entre pausas y reanudaciones. El pipeline mantiene un archivo de punto de control: una lista JSON sencilla con los identificadores de las ofertas ya puntuadas. Al reanudar, omite las que figuran en ella. Siempre que las puntuaciones y justificaciones también se guarden de forma persistente junto con el punto de control, si la aplicación falla durante una sesión, tampoco será necesario volver a puntuarlas al reiniciar. La idempotencia a nivel de sesión cuesta muy poco de implementar y evita molestias considerables.

El frontend de macOS con SwiftUI

Elegimos SwiftUI porque la computadora de destino utilizaba macOS y buscábamos una experiencia nativa: ágil, con poca carga adicional y una gestión de ventanas adecuada. Una interfaz basada en Electron habría servido, pero añadía complejidad innecesaria a una herramienta de productividad para un solo usuario. El backend de Python se ejecuta como proceso local y expone una API HTTP mínima con Flask; la app de SwiftUI se comunica con él a través de localhost.

El patrón de ObservableObject y @Published de SwiftUI gestiona de forma sencilla las actualizaciones reactivas. Un modelo de vista consulta periódicamente el backend y actualiza los arrays publicados que alimentan la lista de resultados y el contador de tokens. La ventana principal muestra tres paneles: resultados ordenados de mayor a menor puntuación, un contador de tokens y costo estimado en tiempo real, y una barra con botones de pausa, reanudación y detención.

Cada fila muestra el título del puesto, la empresa, un indicador de puntuación con código de colores y una justificación de una línea del LLM. Al pulsarla se abre una vista de detalle con la justificación completa, los factores clave que señaló el modelo y un enlace directo a la oferta original. El reclutador dedica la mayor parte del tiempo de revisión a esa vista: la puntuación sirve para dirigir la atención, no para tomar la decisión final.

Cuándo la evaluación con LLM supera al filtrado por palabras clave

El filtrado por palabras clave es adecuado cuando los requisitos son binarios y explícitos: una certificación concreta, residir en una ciudad determinada o un mínimo de años en un puesto. En esos casos, un filtro sencillo se ejecuta en milisegundos y no cuesta nada.

Sin embargo, muchas de las restricciones más relevantes son contextuales. «Buscamos a alguien que escriba código limpio y bien probado» es una preferencia que se refleja en la redacción de la oferta, no en una palabra clave aislada. «No me interesan puestos cuyo stack solo incluya tecnologías heredadas sin una vía de modernización» exige leer e interpretar la descripción. Un filtro por palabras clave no puede detectar esa señal. Un LLM con un perfil explícito que detalle esas restricciones sí puede evaluarlas directamente.

La diferencia aumenta en mercados donde se abusa de las palabras clave. Cuando todas las ofertas enumeran las mismas competencias, sean o no centrales para el puesto, el filtrado produce una distribución casi plana en la que la mayoría parecen equivalentes. La puntuación semántica distingue entre ellas porque evalúa la coherencia global de la oferta con el perfil, no solo la presencia de términos.

Cuándo resulta excesivo

La puntuación con LLM añade costos y latencia reales. Para evaluar grandes volúmenes con pocos matices, como miles de ofertas con requisitos binarios sencillos, una cadena de filtros estructurados es varios órdenes de magnitud más rápida y barata. La arquitectura adecuada suele combinar ambos enfoques: un filtro previo rápido elimina las incompatibilidades evidentes y reduce la cola a un subconjunto manejable; después, el LLM puntúa únicamente las ofertas restantes. Si un filtro previo elimina el 80 % de las ofertas, los costos de puntuación con LLM se reducen aproximadamente en la misma proporción, y la calidad de las coincidencias se mantiene siempre que el filtro solo descarte incompatibilidades evidentes.

También hay que considerar la consistencia. Los LLM introducen no determinismo: la misma oferta puede obtener puntuaciones ligeramente distintas en dos evaluaciones. En la mayoría de las aplicaciones de selección de personal, esa variación es aceptable; unos pocos puntos en cualquier dirección no cambian qué ofertas merece la pena revisar. Aun así, hay que tenerla presente al comparar sesiones con diferentes versiones del modelo o prompts revisados de manera sustancial.

Artículos relacionados

Artículos relacionados