tva
← Analyses

Offres d’emploi et LLM : construire un pipeline de notation de l’adéquation des candidats

Le filtrage par mots-clés est rapide, peu coûteux et se trompe précisément sur les points qui comptent. Lorsqu’un recruteur définit un profil de candidat idéal — avec une expertise métier, un niveau de maîtrise technologique, un style de communication et une douzaine de préférences contextuelles — aucune approche fondée sur un sac de mots ne peut évaluer fidèlement si une offre d’emploi correspond à ce profil. L’écart sémantique entre « 5+ ans de Python » et « écrit du Python pour des flux de travail d’ingénierie des données en production, avec un usage intensif des entrées/sorties asynchrones » échappe à un filtre par mots-clés. C’est pourtant précisément ce qu’un modèle de langage peut évaluer avec le bon prompt.

Nous avons construit un pipeline de mise en correspondance qui utilise un grand modèle de langage (LLM) pour attribuer un score à une file d’offres d’emploi selon un profil de candidat fixe. Il traite les offres en parallèle, suit les coûts liés aux tokens en temps réel et propose des commandes de pause et de reprise dans une application macOS native conçue avec SwiftUI. Cet article présente la structure du système et les situations où la mise en correspondance par LLM justifie réellement son coût face à des approches plus simples.


L’architecture du prompt

Le cœur du système est un seul prompt de notation, 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 reste fixe pendant toute la session — compétences, niveau d’expérience, préférences métier, contraintes non négociables et notes contextuelles que le recruteur souhaite faire prendre en compte. L’offre fournit l’intitulé du poste, l’entreprise, la description et les exigences, tels qu’ils ont été extraits.

Le modèle renvoie un objet JSON structuré avec deux champs : un score numérique de 0 à 100 et une brève justification des principaux facteurs de correspondance ou d’écart. Imposer une sortie structurée est essentiel ici. Une réponse non structurée exige une analyse syntaxique fragile et introduit des variations dans la présentation du score. Une interface d’appel d’outils ou de fonctions avec un schéma JSON (en mode strict ou de sortie structurée, lorsque le fournisseur le propose) élimine en grande partie cette catégorie de problèmes et simplifie l’agrégation en aval ; il reste toutefois utile de valider l’objet après son analyse syntaxique.

Un choix de conception mérite d’être explicité : le profil doit être détaillé. Les profils courts produisent des scores bruités, car le modèle manque de contexte pour distinguer un 70 d’un 85. Un profil qui explique clairement pourquoi certaines contraintes comptent — et pas seulement ce qu’elles sont — donne au modèle suffisamment d’informations pour attribuer des scores qui différencient utilement les offres. Traitez le profil comme une grille d’évaluation détaillée plutôt que comme 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 un usage interactif. Le concurrent.futures.ThreadPoolExecutor de Python gère cela simplement. Le nombre de workers est fixé selon les limites de débit du fournisseur de l’API — généralement, entre 5 et 10 requêtes simultanées permettent d’obtenir un débit utile sans provoquer d’erreurs de quota.

Cette approche utilise as_completed(), qui renvoie les objets Future dans leur ordre d’achèvement plutôt que de soumission. Les résultats les plus rapides apparaissent donc en premier et l’interface peut se mettre à jour progressivement, sans attendre la fin d’un lot de traitement 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 particulière. Des appels API peuvent échouer — dépassement des limites de débit, problèmes réseau transitoires, refus liés à la politique de contenu. Chaque worker devrait intercepter les exceptions et renvoyer une valeur sentinelle (le code ci-dessus omet cette gestion par souci de concision) ; sinon, future.result() lève à nouveau l’exception du worker dans la boucle de résultats et interrompt le traitement des résultats restants. Une offre dont le score n’a pas pu être calculé est remise en file d’attente pour une nouvelle tentative au lieu d’être abandonnée silencieusement. Un échec visible est bien plus facile à analyser qu’une liste de scores comportant des lacunes inexpliquées.

Suivi des tokens en direct

Les coûts liés aux tokens sont faciles à sous-estimer à grande échelle. Un prompt de 400 tokens envoyé pour 100 offres, avec des réponses de 150 tokens, entraîne rapidement des coûts selon la gamme de modèle choisie. Le pipeline suit les totaux cumulés grâce à un compteur utilisable par plusieurs threads en toute sécurité — un simple entier protégé par un threading.Lock, incrémenté après chaque réponse API à partir des nombres de tokens fournis dans ses métadonnées.

Le frontend SwiftUI interroge ce compteur à intervalles réguliers et affiche les totaux de tokens d’entrée et de sortie, ainsi qu’un coût estimé d’après le tarif par token du modèle utilisé. Le recruteur voit ainsi le coût de la session en temps réel et peut repérer les justifications inhabituellement longues produites à partir du profil — souvent le signe que le prompt doit être précisé ou que le texte de l’offre est plus bruité que prévu.

Une amélioration pratique consiste à estimer le nombre de tokens avant l’envoi, et pas seulement après. Les principaux fournisseurs proposent une bibliothèque de tokenisation ou un point de terminaison de comptage des tokens (par exemple, la bibliothèque tiktoken d’OpenAI ou le point de terminaison de comptage des tokens d’Anthropic). Ce comptage préalable permet au pipeline de signaler un texte d’offre inhabituellement long qui consommerait une part disproportionnée du contexte, ou un prompt complet proche de la limite de contexte du modèle. Détecter cela avant l’appel API évite de payer inutilement pour une requête qui sera tronquée ou rejetée.

Pause et reprise

Lorsqu’un recruteur examine les résultats en cours de session, il peut vouloir suspendre les nouvelles requêtes de notation sans perdre le travail en cours. Le mécanisme de pause et de reprise utilise un threading.Event comme signal partagé. Avant chaque appel API, le thread worker vérifie l’état de l’événement. En pause, il se bloque sur event.wait(). Lorsque l’événement est de nouveau activé, l’exécution reprend.

Cette solution est plus propre que d’interrompre puis de redémarrer les threads, car les appels API en cours peuvent se terminer normalement. Mettre en pause signifie « ne plus lancer de nouveaux appels », plutôt que « tout annuler maintenant ». Cette distinction compte lorsqu’on souhaite terminer le lot en cours avant que l’utilisateur examine les résultats — les lots partiels sont plus difficiles à analyser que les lots complets.

La persistance de l’état entre pause et reprise doit être gérée explicitement. Le pipeline conserve un fichier de point de reprise — une simple liste JSON des identifiants d’offres déjà notées. À la reprise, il ignore les offres déjà présentes dans ce fichier. À condition que les scores et les justifications soient également enregistrés de manière persistante avec le point de reprise, si l’application plante en cours de session, les offres déjà notées n’ont pas besoin de l’être à nouveau au redémarrage. L’idempotence au niveau de la session coûte très peu à mettre en œuvre et évite bien des désagréments.

Le frontend SwiftUI macOS

Nous avons choisi SwiftUI pour le frontend parce que la machine cible fonctionnait sous macOS et que nous voulions une expérience native — réactive, légère et dotée d’une gestion des fenêtres adaptée à macOS. Une interface Electron aurait fonctionné, mais aurait ajouté une complexité inutile à un outil de productivité destiné à un seul utilisateur. 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 mécanisme ObservableObject et @Published de SwiftUI gère simplement la boucle de mise à jour réactive. Un modèle de vue interroge le backend à intervalles réguliers et met à jour les tableaux publiés qui alimentent la liste des résultats et l’affichage du compteur de tokens. La fenêtre principale présente trois panneaux : une liste de résultats par score décroissant, un compteur en temps réel des tokens et du coût estimé, et une barre de commandes avec les boutons Pause/Reprise et Arrêt.

Chaque ligne affiche l’intitulé du poste, le nom de l’entreprise, un badge de score avec un code couleur et la justification du LLM en une ligne. Cliquer sur une ligne ouvre une fiche détaillée contenant la justification complète, les principaux facteurs signalés par le modèle et un lien direct vers l’offre d’origine. Le recruteur consacre l’essentiel de son temps d’examen à cette fiche — le score sert à orienter son attention, pas à prendre une décision finale.

Quand la mise en correspondance par LLM surpasse le filtrage par mots-clés

Le filtrage par mots-clés convient lorsque les exigences sont binaires et explicites : détenir une certification précise, être situé dans une ville donnée ou avoir un nombre minimum d’années d’expérience à un poste. Dans ces cas, un simple filtre s’exécute en quelques millisecondes et ne coûte rien.

Mais la plupart des contraintes intéressantes sont contextuelles. « Recherche quelqu’un qui écrit du code propre et bien testé » est une préférence qui transparaît dans la rédaction d’une offre, plutôt qu’un mot-clé isolé. « Ne souhaite pas de postes dont la pile technologique repose uniquement sur des systèmes anciens sans perspective de modernisation » exige de lire et d’interpréter la description. Un filtre par mots-clés ne peut pas faire ressortir ce signal. Un LLM disposant d’un profil explicite où ces contraintes sont clairement formulées peut les évaluer directement.

L’écart se creuse sur les marchés où les mots-clés sont utilisés à outrance. Lorsque toutes les offres énumèrent les mêmes compétences, quelle que soit leur importance réelle pour le poste, la mise en correspondance par mots-clés produit une distribution presque uniforme où la plupart des offres semblent équivalentes. La notation sémantique dépasse ce bruit en évaluant la cohérence globale de l’offre avec le profil, plutôt que la seule présence de termes.

Quand c’est excessif

La notation par LLM ajoute un coût et une latence réels. Pour un tri à fort volume qui exige peu de nuances — des milliers d’offres aux exigences binaires simples — une chaîne de filtres structurés est plus rapide et moins coûteuse de plusieurs ordres de grandeur. La bonne architecture combine souvent les deux : un préfiltre rapide écarte les incompatibilités évidentes et ramène la file à un sous-ensemble gérable ; le LLM ne note que les offres restantes. Si un préfiltre écarte 80 % des offres, les coûts de notation par LLM baissent à peu près dans la même proportion, et la qualité des correspondances se maintient tant que le préfiltre n’écarte que les incompatibilités évidentes.

L’autre point à considérer est la cohérence. Les LLM introduisent du non-déterminisme : une même offre notée deux fois peut recevoir des scores légèrement différents. Pour la plupart des applications de recrutement, cette variation est acceptable — quelques points dans un sens ou dans l’autre ne changent pas les offres qui méritent d’être examinées. Il faut toutefois en tenir compte pour comparer des scores entre des sessions utilisant différentes versions du modèle ou des prompts sensiblement révisés.

Analyses connexes

Pour aller plus loin