Matching d'emplois par LLM : construire un pipeline de scoring de leads
Le filtrage par mots-clés est rapide, peu coûteux, et inexact exactement là où ça compte. Quand un recruteur définit un profil de candidat idéal — spécifiant une expertise de domaine, une profondeur technologique, un style de communication, et une douzaine de préférences contextuelles — aucune approche bag-of-words ne peut évaluer si une offre donnée correspond fidèlement à ce profil. L'écart sémantique entre “5+ ans Python” et “écrit Python pour des workflows de data engineering en production avec une forte utilisation de I/O async” est invisible pour un filtre de mots-clés. C'est en revanche précisément ce qu'un modèle de langue peut évaluer avec le bon prompt.
Nous avons construit un pipeline de matching qui prend un profil de candidat fixe et score une file d'offres d'emploi par rapport à lui en utilisant un LLM, exécutant les matchings en parallèle, suivant les coûts en tokens en temps réel, et exposant des contrôles pause/reprise depuis une application native SwiftUI macOS. Cet article couvre la structure du système et les cas où le matching LLM justifie genuinement son coût par rapport aux approches plus simples.
L'architecture du prompt
Le cœur du système est un unique prompt de scoring, appelé une fois par offre d'emploi. Chaque appel reçoit deux entrées : le profil du candidat et le texte de l'offre. Le profil est fixe pour toute la session — il inclut les compétences, le niveau d'expérience, les préférences de domaine, les contraintes non négociables, et toutes les notes contextuelles que le recruteur souhaite que le modèle prenne en compte. L'offre fournit le titre du poste, l'entreprise, la description et les exigences tels qu'extraits.
Le modèle retourne un objet JSON structuré avec deux champs : un score numérique de 0 à 100, et un bref raisonnement expliquant les facteurs clés du match ou du mismatch. Imposer une sortie structurée est essentiel ici. Une réponse non structurée nécessite un parsing fragile et introduit de la variance dans la présentation du score. Utiliser une interface d'appel d'outil ou de function calling pour imposer un schéma JSON élimine entièrement cette classe de problèmes et rend l'agrégation en aval triviale.
Un choix de conception qui mérite d'être explicité : le profil doit être verbeux. Les profils courts produisent des scores bruités parce que le modèle n'a pas suffisamment de contexte pour distinguer un 70 d'un 85. Un profil qui articule clairement pourquoi certaines contraintes importent — pas seulement lesquelles elles sont — donne au modèle suffisamment de signal pour scorer avec une discrimination utile. Traitez le profil comme une rubrique détaillée plutôt qu'une chaîne de filtrage.
Exécution parallèle avec ThreadPoolExecutor
Avec une file de 50 ou 100 offres, les appels API séquentiels sont trop lents pour être utiles de manière interactive. Le concurrent.futures.ThreadPoolExecutor de Python gère cela proprement. Le nombre de workers est défini selon les limites de débit du fournisseur API — généralement entre 5 et 10 requêtes concurrentes pour atteindre un débit significatif sans déclencher d'erreurs de quota.
Le pattern utilise as_completed(), qui retourne les futures dans l'ordre où ils se terminent plutôt que l'ordre de soumission. Cela signifie que les matchings les plus rapides remontent en premier et que l'interface peut se mettre à jour progressivement plutôt que d'attendre un batch entier :
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)
La gestion des erreurs mérite une attention explicite ici. Les appels API individuels peuvent échouer — erreurs de limite de débit, problèmes réseau transitoires, rejets de politique de contenu. Chaque worker attrape les exceptions et retourne une valeur sentinelle plutôt de laisser l'exception se propager et annuler l'exécuteur entier. Une offre qui échoue au scoring est mise en file d'attente pour réessai plutôt que silencieusement abandonnée. L'échec transparent est bien plus facile à raisonner qu'une liste de scores avec des lacunes inexpliquées.
Suivi des tokens en direct
Les coûts en tokens à grande échelle sont faciles à sous-estimer. Un prompt de 400 tokens envoyé à 100 offres, avec des réponses de 150 tokens, s'accumule rapidement selon le niveau du modèle. Le pipeline suit les totaux cumulatifs en utilisant un compteur thread-safe — un simple entier protégé par un threading.Lock, incrémenté après chaque réponse API en utilisant les décomptes de tokens des métadonnées de réponse.
Le frontend SwiftUI interroge ce compteur sur un timer, affichant les tokens d'entrée cumulatifs, les tokens de sortie, et un coût estimé basé sur le tarif par token du modèle actuel. Cela donne au recruteur une idée en direct du coût de session et aide à identifier quand le profil produit des raisonnements inhabituellement longs — souvent un signal que le prompt lui-même doit être resserré ou que le texte de l'offre est plus bruité que prévu.
Un raffinement pratique : estimer les décomptes de tokens avant d'envoyer, pas seulement après. La plupart des SDK LLM exposent une méthode de tokenisation ou un endpoint de comptage séparé. Le pré-comptage permet au pipeline d'avertir quand le texte d'une offre est inhabituellement long et consommerait un contexte disproportionné, ou quand le prompt combiné approche de la limite de contexte du modèle. Détecter cela avant l'appel API évite les dépenses inutiles sur une requête qui serait tronquée.
Pause et reprise
Quand un recruteur examine des résultats en cours de session, il peut vouloir mettre en pause les nouvelles requêtes de scoring sans perdre le travail déjà en cours. Le mécanisme pause/reprise utilise un threading.Event comme signal partagé. Avant chaque appel API, le thread worker vérifie l'état de l'événement. S'il est en pause, il bloque sur event.wait(). Quand l'événement est à nouveau déclenché, l'exécution continue.
C'est plus propre qu'interrompre et redémarrer les threads parce que les appels API en cours sont autorisés à se terminer naturellement. Mettre en pause signifie “arrêter de démarrer de nouveaux appels” plutôt que “tout annuler maintenant.” La distinction importe quand vous voulez que le batch actuel se termine avant que l'utilisateur examine les résultats — les batches partiels sont plus difficiles à raisonner que les complets.
La persistance de l'état à travers pause/reprise vaut la peine d'être gérée explicitement. Le pipeline maintient un fichier de checkpoint — une simple liste JSON d'IDs d'offres déjà scorées. À la reprise, le pipeline saute toute offre déjà dans le checkpoint. Cela signifie aussi que si l'application plante en cours de session, aucune offre déjà scorée n'a besoin d'être rescorée au redémarrage. L'idempotence au niveau de la session coûte presque rien à implémenter et évite bien des désagréments.
Le frontend SwiftUI macOS
Nous avons choisi SwiftUI pour le frontend parce que la machine cible était macOS et nous voulions une expérience native — réactive, légère, avec une gestion de fenêtres macOS appropriée. Un shell Electron aurait fonctionné mais ajoutait une complexité inutile pour un outil de productivité à usage unique. Le backend Python s'exécute comme un processus local et expose une API HTTP minimale avec Flask ; l'application SwiftUI communique avec lui via localhost.
Le pattern ObservableObject et @Published de SwiftUI gère proprement la boucle de mise à jour réactive. Un view model interroge le backend sur un timer, mettant à jour les tableaux publiés qui pilotent la liste de résultats et l'affichage du compteur de tokens. La fenêtre principale affiche trois panneaux : une liste de résultats triée par score décroissant, un compteur de tokens et coût estimé en direct, et une barre de contrôle avec les boutons Pause/Reprise et Arrêt.
Chaque ligne de résultat affiche le titre du poste, le nom de l'entreprise, un badge de score avec code couleur, et le raisonnement en une ligne du LLM. Appuyer sur une ligne ouvre une fiche de détail avec le raisonnement complet, les facteurs clés signalés par le modèle, et un lien direct vers l'offre originale. La fiche de détail est l'endroit où la majeure partie du temps d'examen du recruteur est consacrée — le score est un signal pour diriger l'attention, pas une décision finale.
Quand le matching LLM surpasse le filtrage par mots-clés
Le filtrage par mots-clés est le bon outil quand les exigences sont binaires et explicites : doit détenir une certification spécifique, doit être situé dans une ville spécifique, doit avoir un nombre minimum d'années dans un rôle. Pour ceux-ci, un simple filtre s'exécute en millisecondes et ne coûte rien.
Mais en réalité, la plupart des contraintes intéressantes sont contextuelles. “Cherche quelqu'un qui écrit un code propre et bien testé” est une préférence qui transparaît dans la façon dont une offre est rédigée, pas comme un mot-clé discret. “Pas intéressé par des rôles où le stack est purement legacy sans voie de modernisation” nécessite de lire et d'interpréter la description. Un filtre de mots-clés ne peut pas faire remonter ce signal. Un LLM auquel on fournit un profil explicite avec ces contraintes clairement énoncées peut les évaluer directement.
L'écart se creuse davantage dans les marchés avec une inflation significative des mots-clés. Quand chaque offre d'emploi liste les mêmes compétences indépendamment de leur centralité réelle dans le rôle, le matching par mots-clés produit une distribution quasi-plate où la plupart des offres semblent équivalentes. Le scoring sémantique perce ce bruit parce qu'il évalue la cohérence globale de l'offre par rapport au profil, pas seulement la présence de termes.
Quand c'est excessif
Le scoring LLM ajoute un coût et une latence réels. Pour le screening à volume élevé et faible nuance — des milliers d'offres avec des exigences binaires simples — une chaîne de filtres structurés est plus rapide et moins chère d'ordres de grandeur. La bonne architecture combine souvent les deux approches : un pré-filtre rapide supprime les non-matchs évidents, réduisant la file à un sous-ensemble gérable, et le scoring LLM n'est appliqué qu'à ce qui reste. Pré-filtrer 80% des offres avant le scoring LLM réduit drastiquement le coût sans réduire significativement la qualité des matchs.
L'autre considération est la cohérence. Les LLM introduisent du non-déterminisme. La même offre scorée deux fois peut recevoir des scores légèrement différents. Pour la plupart des applications de recrutement, cette variance est acceptable — un écart de quelques points dans un sens ou l'autre ne change pas quelles offres valent d'être examinées. Mais il vaut la peine d'en être conscient lors de la comparaison de scores à travers des sessions utilisant différentes versions du modèle ou des itérations de prompt significativement révisées.
Ressources connexes
- Dynamic Island for Timer Apps: Implementation and Lessons — le travail SwiftUI Live Activity qui a informé l'architecture du frontend macOS de ce pipeline
- Widget Flags: Why ISO Country Codes Beat Locale-Dependent Names — un autre cas où une hypothèse subtile dans la gestion des données causait des erreurs silencieuses à grande échelle