Introduction
ChatGPT, c’est bien pour demander une information de connaissance générale ou la traduction d’un texte. Tant que l’on pose soi-même les questions à OpenAI, on peut prendre connaissance de l’output et l’utiliser selon ses besoins. L’utilisateur peut traduire un mail, rendre une demande d’augmentation plus formelle, ou encore chercher une correction à un DM de physique. En connaissant les bases de prompt engineering, il est même possible de demander à GPT-4o de se prendre pour un coach sportif avant de lui demander de proposer un programme de remise en forme.
Mais voilà, dans tous ces cas de figure, il est nécessaire d’assumer la charge mentale de prendre connaissance de la réponse et de choisir ensuite l’action à mener.
Et ça, en tant que personne de l’IT, évangéliste du DevOps, du MLOps et du LLMOps, c’est non !
Pendant longtemps, l’automatisation a été le fil conducteur de la tech. La révolution qu’a été ChatGPT ne s’inscrit pas de manière évidente dans ce courant. En effet, quand ChatGPT renvoie un long bloc de texte, il est difficile d’y appliquer des algorithmes basiques, tel que la logique conditionnelle (if/else) ou des boucles de traitement (for/while). J’ajouterai même que, sans automatisation, ChatGPT n’est autre qu’un outil de recherche un peu plus sympathique à utiliser que Google.
La question que l’on va explorer dans cet article est donc la suivante : “Comment rendre les sorties des LLMs utilisables par des algorithmes de traitement afin de tirer partie de manière automatique et industrielle de leur puissance ?”
Exemple de cas d'usage : étude de CVs
Pour illustrer les différentes méthodes qu’il existe, je vais partir d’un cas d’usage standard dans le monde de l’entreprise : l’étude des CVs. Dans ce cas, on souhaite interroger et extraire d’une banque de CV les compétences qui nous intéressent dans un contexte précis.
Par exemple, je travaille dans un cabinet de conseil et un commercial vient vers moi avec une demande client de compétences peu habituelles pour l’entreprise : « j’ai besoin d’un responsable IT qui a plus de 15 ans d’expérience, qui a déjà déployé et maintenu un parc de machines et serveurs Windows avec plusieurs centaines de stations de travail en opération. Dans l’idéal, il a fait ça aux environs de 2010 pour pouvoir gérer la fin de service de ce genre de flotte aujourd’hui. Et il a aussi une expérience de management d’équipes IT ».
L’entreprise dispose déjà d’une base de CV avec, pour chacun d’eux, une extraction textuelle du contenu ainsi que des labels ajoutés au fil du temps et à la main. Cependant, ces labels sont assez superficiels et ne couvrent pas toutes les compétences, expériences et réalisations que l’on veut rechercher. Nous allons voir ici comment extraire les informations demandées depuis le texte des CVs.
Pour simuler notre fil rouge, on va utiliser un dataset de CV anonymisés disponible sur Kaggle.
import kagglehub
# Download latest version
path = kagglehub.dataset_download("snehaanbhawal/resume-dataset")
df = pd.read_csv(os.path.join(path, "Resume/Resume.csv"))
df.head()
ID | Resume_str | Resume_html | Category | |
0 | 16852973 | HR ADMINISTRATOR/MARKETING ASSOCIATE... | <div class=“fontsize fontface vmargins hmargin… | HR |
1 | 22323967 | HR SPECIALIST, US HR OPERATIONS … | <div class=“fontsize fontface vmargins hmargin… | HR |
2 | 33176873 | HR DIRECTOR Summary Over 2… | <div class=“fontsize fontface vmargins hmargin… | HR |
3 | 27018550 | HR SPECIALIST Summary Dedica… | <div class=“fontsize fontface vmargins hmargin… | HR |
4 | 17812897 | HR MANAGER Skill Highlights … | <div class=“fontsize fontface vmargins hmargin… | HR |
On a une liste de CV avec, pour chacun d’eux, un tag (qui représente la catégorie du CV) et une extraction de son contenu. C’est sur cette base que nous allons travailler. Pour notre cas d’usage, on va se concentrer sur la catégorie “IT”.
df.Category.unique()
resume_IT = df[df.Category == 'INFORMATION-TECHNOLOGY']
len(resume_IT)
Dans le jeu de données, on a 120 CVs dans la catégorie IT. Voici le contenu de l’un d’entre eux:
random_resumeIT = resume_IT.Resume_str.values.tolist().pop()
random_resumeIT
Méthodes
Prompt engineering suivi de code
L’approche classique pour répondre à notre besoin par les LLMs est de poser directement la question à la machine :
En Python, cela donne :
DEFAULT_SYSTEM = """
You are an HR assistant, you will get asked questions about resume.
Unfortunately, the only data available is the raw text data.
"""
def ask_claude_(prompt):
body=json.dumps({
"system": DEFAULT_SYSTEM,
"messages":[
{"role": "user", "content": prompt}
],
"temperature": 0.7,
"top_p": 0.1,
"top_k": 1,
"stop_sequences": [],
"max_tokens":500,
"anthropic_version": "bedrock-2023-05-31"
})
response = client.invoke_model(
body=body,
modelId="anthropic.claude-3-haiku-20240307-v1:0",
)
parsed_response = json.loads(
response.get("body").read().decode()
)
text_content = parsed_response.get("content")[0].get("text")
return text_content
prompt_template = """
Tu es un assistant RH qui doit recommander un profil à recruter. Voici la liste des compétences recherchées :
- au moins 15 ans d'expérience
- expérience de management
- Déploiement d'un parc windows de machine de travail
- Déploiement et administration Windows Server
- Taille du parc de l'ordre de grandeur du millier
- Expérience aux alentours de 2010
Voici le CV du candidat :
{resume}
Dans ce CV, y a-t-il les compétences demandées?
"""
prompt = prompt_template.format(resume=random_resumeIT)
answer = ask_claude_(prompt)
answer
Ce qui donne le résultat suivant :
Le problème de cette approche est qu’on ne peut pas faire grand chose de cette sortie. Dans notre exemple, le LLM développe et nuance sa réponse mais il force un utilisateur humain à analyser la réponse pour pouvoir continuer le processus de travail. Comment faire, après avoir appelé un LLM, pour vérifier si la réponse est simplement “oui” ou “non” ?
L’approche classique par prompt engineering est d’adapter le prompt pour y rajouter des consignes en output. On peut ajouter à la fin de notre prompt :
prompt_template_directed = prompt_template + """
Réponds seulement par oui ou non et ne développe pas.
"""
prompt = prompt_template_directed.format(resume=random_resumeIT)
answer = ask_claude_(prompt)
answer
Je peux maintenant, avec du code simple, vérifier si la réponse est positive ou négative.
En utilisant la même technique, on peut aussi commencer à extraire des données dans un format structuré :
prompt_template_data_extraction = prompt_template + """
Réponds entre balise <nom_de_la_compétence>yes/no</nom_de_la_compétence>
"""
prompt = prompt_template_data_extraction.format(resume=random_resumeIT)
answer = ask_claude_(prompt)
answer
Cette méthode permet effectivement de traiter la sortie avec du code, mais elle reste toutefois limitée. Effectivement, quand on a besoin de n’extraire que quelques champs et dans une liste à un seul niveau de profondeur, on ne devrait pas avoir de problème. Par contre, si on a besoin d’extraire des tables ou tout autre type d’objet qui requiert une hiérarchie entre les informations (table > colonne > ligne), alors on accroît la difficulté à parser l’output et le risque pour le LLM d'halluciner, et donc de produire du contenu inutilisable. En plus, on a un contrôle assez faible sur les types de données (str, int, float, liste, object). Il faudrait pouvoir extraire le résultat sous la forme d’un format comme JSON.
On peut essayer de directement demander au LLM de nous sortir un JSON :
prompt_template_json = prompt_template.format(resume=random_resumeIT) + """
Réponds dans un JSON :
{
"compétence_A": <Le candidat a la compétence A>,
"compétence_B": <Le candidat a la compétence B>,
"compétence_C": <Le candidat a la compétence C>,
...
}
Réponds juste avec le JSON
"""
prompt = prompt_template_json
answer = ask_claude_(prompt)
json.loads(answer)
On a une sortie au format JSON avec tous les champs recherchés présents, mais absolument rien ne garantit une structure du JSON valide ainsi que la présence de tous les champs demandés. En plus de ça, c’est le modèle qui choisit le nom des clés. Pour une utilisation à grande échelle, cela rendrait impossible la comparaison de plusieurs CVs puisque la datamodel n’est pas partagé entre toutes les extractions.
Les outputs contraints
On en arrive aux outputs contraints. Pour résoudre le problème précédemment posé, on va avoir besoin d’aller modifier la manière avec laquelle une IA produit des suites de mots / tokens.
Rappel du fonctionnement d’un LLM
Pour comprendre comment fonctionne cette fonctionnalité des LLMs, revenons au fondement de ceux-ci.
Les LLMs comme Claude, ChatGPT ou encore Gemini sont des modèles auto régressifs, c’est-à-dire qu’il se servent des tokens utilisés en entrée et de ceux qu’ils ont déjà générés dans des étapes précédentes pour générer les suivants. Cela signifie que le LLM ne peut prédire les tokens que de manière itérative et unitaire. C’est pour cela que la plupart des UI de LLM affichent des tokens les uns après les autres et pas forcément à une vitesse constante.
Cela veut aussi dire qu’un LLM ne peut pas générer toute une séquence cohérente d’un seul coup.
Ce qu’il se passe en réalité, c’est que le modèle prédit seulement le token suivant puis l’ajoute à la séquence d’entrée. Cette nouvelle séquence est alors donnée en entrée au modèle pour prédire le token suivant, et ainsi de suite jusqu’à arriver au token d’arrêt ou au maximum de tokens permis par le modèle.

Par défaut, pour une séquence d’entrée donnée, le LLM va attribuer une probabilité pour chaque token de son vocabulaire d’être le prochain token. Il va ensuite choisir le token le plus probable. Donc par récurrence, pour une même séquence d'entrée, c’est toujours la même séquence qu’on aura en sortie.
Cette propriété est un peu embêtante car, dans la plupart des cas d’usage, on ne veut pas une sortie fixe et déterministe : on veut avoir de la sérendipité. C’est pourquoi, dans les cas d’usage réels, on ne prend pas vraiment le token avec la plus haute probabilité mais on fait plutôt un tirage au sort parmi tous les tokens pondérés selon la probabilité attribuée par le modèle. Cette partie de la génération de texte s’appelle le sampling.
Les paramètres top_p
, top_k
et temperature
sont les trois principaux paramètres qui contrôlent le sampling.
top_k
permet de limiter le nombre de tokens candidats au tirage aux k plus probables. Cela permet d’éviter d’orienter la réponse vers des séquences peu probables en cas de plusieurs tirages de tokens avec de petite probabilité.
top_p
contrôle également le nombre de tokens candidats au tirage au sort, mais en utilisant les probabilités cumulées des tokens. Concrètement, les candidats au tirage sont triés du plus probable au moins probable et on sélectionne les tokens candidats jusqu'à ce que la somme de leur probabilité atteigne la valeur de top_p
.
La temperature
permet de corriger les probabilités calculées par le modèle en lissant les différences entre les tokens (température haute) ou au contraire en les accentuant (température basse). Ainsi, en choisissant une haute température, les tokens initialement prédits comme peu probables le deviennent un peu plus. C’est comme ça qu’on obtient un modèle plus créatif.
En jouant avec ces paramètres, on peut donc permettre au modèle d’être plus ou moins créatif.


Les schémas ci-dessus donnent un aperçu du fonctionnement des paramètres temperature, top_p et top_k
. Comme démontré dans le schéma, les probabilités de générer le token suivant sont directement impactées par ces paramètres. Le paramètre temperature
commence par lisser ou accentuer les probabilités initialement prédites par le modèle pour le rendre plus ou moins créatif. Ensuite, les paramètres top_p
et top_k
viennent filtrer le nombre de token qui vont pouvoir participer au tirage au sort.
Les grammaires formelles
On vient de parler d’une chaîne de tokens représentant un langage et qui sont générés un par un. Par ailleurs, on cherche à produire quelque chose qui est valide : notre output doit être au format JSON.
C’est là qu’interviennent les grammaires formelles.
Selon Wikipédia : “Une grammaire formelle est un formalisme permettant de définir une syntaxe et donc un langage formel, c’est-à-dire un ensemble de mots admissibles sur un alphabet donné”.
Autrement dit, une grammaire formelle permet de déterminer si une combinaison d’éléments issus d’un alphabet est valide ou non. Dans notre contexte, l’alphabetest l’ensemble des tokens que le LLM peut produire, et la grammaire que l’on cherche permet de valider qu’on a bien un JSON.
La petite subtilité des grammaires formelles est qu’elles sont reconnaissables par une machine de Turing. Rapidement, cela veut dire qu’on peut construire une machine capable de lire une séquence caractère par caractère qui change d’état selon le caractère qui est lu. Si, lorsque le dernier caractère est lu, la machine termine dans un état valide, alors la grammaire est validée. Sinon elle ne l’est pas.
Cette capacité de validation tombe bien car cela veut dire que, pour un alphabet donné, on peut vérifier caractère par caractère que l’on a toujours une chance d’arriver à un état terminal. En trouvant un alphabet et en étant capable de traduire notre format cible en grammaire formelle, on pourrait savoir, pour chaque caractère de l’alphabet généré, s'il respecte notre format cible.
Application pour former un JSON
On est capable de choisir un token parmi un pool de tokens les plus probables et on a un outil capable de valider des séquences. Comment mettre tout ça ensemble ? Et bien c’est simple, à l’instar des paramètres top_p
et top_k
, on va filtrer les tokens qui ne respectent pas la grammaire du JSON à chaque étape de génération. Ce filtrage peut faire peur mais il ne faut pas oublier que cela se fait en O(1) pour une vérification, donc on arrive à O(V*n) avec n le nombre de token générés et V la taille du vocabulaire.
Avec ça, on est capable de ne générer que des tokens valides et de ne pas arrêter la génération tant qu’on est pas sur un état valide de la grammaire formelle décrivant un objet JSON.
Voilà, on a réussi à forcer le LLM à générer un JSON valide sans risque d’invalidité lié aux hallucinations du LLM.
S’assurer de la cohérence
Bon c’est un peu décevant : en effet, j’ai réussi a générer un JSON qui est parsable et qui respecte la syntaxe JSON. Mais à ce stade, je n’ai pas la moindre certitude que tous les champs que j’ai demandé dans le JSON seront présents, ni que leur type sera celui que j’ai demandé.
Fort heureusement, il existe une solution : on est capable de générer des grammaires formelles dynamiquement à partir d’un schéma JSON.
En Python, la manière la plus simple est d’utiliser les modèles Pydantic. La classe BaseModel de Pydantic peut effectivement être utilisée pour construire des schémas complexes contenant plusieurs types d’objets imbriqués et ce schéma peut être exporté et utilisé comme une grammaire formelle.
Désormais, je peux aussi ajouter à ma grammaire les clés des objets, les brackets des listes que je désire etc … et le LLM est en quelque sorte forcé de me les remplir.
Cela ne me protège pas des hallucinations sur le contenu mais ça le force à remplir les champs que je veux de la manière que je veux en lui laissant un peu moins de marge de manœuvre. Cela me permet de traîter l’output avec beaucoup plus de certitudes.
Ouverture vers le function calling
Petite parenthèse : on parle beaucoup des agents en ce moment. C’est exactement comme cela que ça fonctionne, on demande au LLM de choisir s'il veut appeler une fonction ou répondre par du texte. S'il choisit la fonction, il doit générer un texte décrivant le nom de la fonction et les paramètres utilisés pour l’appeler dans un format structuré qui ne peut accepter que certaines valeurs. On fournit ainsi au LLM la capacité d’appeler une fonction et ainsi d’agir sur son environnement : c’est un agent.
Hands on
Maintenant que l’on a rassemblé tous les éléments théoriques, on va pouvoir répondre à la demande de notre supérieur.
On va s’appuyer sur une librairie Python high level utilisables avec les principaux provider LLMs, à savoir instructor. La particularité est que par défaut, la librairie ne retourne pas un JSON mais un objet python dérivé de pydantic.BaseModel
. Pas de soucis, on peut passer de l’un à l’autre avec la fonction BaseModel.from_dict
et BaseModel.json
La manière standard avec Pydantic :
from anthropic import AnthropicBedrock
import instructor
# Toujours notre prompt de recherche d'information
base_prompt = """
Tu es un assistant RH qui doit recommander un profil à recruter.
Tu as une liste d'information à remplir,
Voici le CV du candidat :
{resume}
Correspond-t-il à ce qu'on cherche ?
"""
# Initialisation des clients pour appeler les API bedrock
instructor_bedrock_client = AnthropicBedrock(aws_region="eu-west-3")
ins_client = instructor.from_anthropic(instructor_bedrock_client, mode=instructor.Mode.ANTHROPIC_TOOLS)
# La description des champs que l'on souhaite extraire
# La classe est dérivée de BaseModel de pydantic
# C'est à partir de cette classe que le LLM va remplir le JSON
class ResumeExtract(BaseModel):
years_of_experience: int = Field(..., description="How many years candidate of experience the candidate can justify")
management_experience: bool = Field(..., description="Does the candidate have a management experience ?")
managed_people: Optional[int] = Field(..., description="How many people the candidate Managed ? Leave blank if no info")
windows_workstation: str = Field(..., description="Does the candidate have experience managing a fleet of windows workstations ? If yes, Describe the experience ")
windows_server: str = Field(..., description="Does the candidate have experience managing a fleet of windows server machines ? If yes, Describe the experience ")
number_of_machine: Optional[int] = Field(..., description="How many machines did the candidate manage ? Leave blank if not specified")
experience_early_2010s: bool = Field(..., description="Does the relevant experience takes place in the early 2010's years ?")
biggest_achievement: str = Field(..., description="Description of the biggest achievement regarding the targeted skills")
score: int = Field(..., description="A score on a ten scale you would give to the candidate for the targeted skills. Try as severe as possible")
# Pour un CV, retourne une instance complétée de ResumeExtract
def extractInfoFromResume(resume: str) -> ResumeExtract:
resp = ins_client.messages.create(
model="anthropic.claude-3-haiku-20240307-v1:0",
max_tokens=1024,
temperature=0,
messages=[
{
"role": "user",
"content": base_prompt.format(resume=resume),
}
],
response_model=ResumeExtract,
)
return resp
# Pour tous les CVs, appelle la fonction d'extract
extracted_data: list[tuple[int, ResumeExtract]] = list()
for id_, resume in resume_IT[["ID", "Resume_str"]].values:
extracted_data.append((id_, extractInfoFromResume(resume)))
Ce code nous permet simplement d’automatiser l’extraction d’informations dans un format structuré. On peut donc facilement appliquer ce code à tous nos CVs pour en extraire toutes les informations nécessaires.
À noter que l'on a sorti du prompt les informations que l’on cherche pour les définir seulement dans la classe ResumeExtract.
Maintenant, on peut appliquer une règle sur ce que l’on vient d’extraire. Mettons que je souhaite sortir les CVs les mieux notés par score et nombre de machines administrées :
extracted_data_no_none = list(filter(lambda y: y[1].number_of_machine is not None, extracted_data))
sorted(extracted_data_no_none, key=lambda x: (x[1].score, x[1].number_of_machine), reverse=True)[:5]
Le code s’est exécuté en 9 minutes et 13 secondes en utilisant Haïku. (9 * 60 + 13) / 120 = 4.6.
On a donc un temps de traitement de 4.6 secondes par CV modulo les temps d’envoi et de réception de la donnée entre ma machine et le cloud provider.
À noter que cela pourrait très facilement être parallélisé pour gagner du temps.
Finalement, au niveau des CVs remontés, on retrouve des profils hautement qualifiés, comme demandé par notre patron.
Au vu du faible niveau de détail fourni dans les champs demandés et de la relative faiblesse du modèle utilisé, on peut s’attendre à quelques approximations dans les informations extraites. Par exemple, le premier CV, donc celui qui mentionne le plus de machine, parle de 10000 postes mais il s’agit en fait de 10000 clients d’un produit SaaS. Par ailleurs, ses expériences sur Windows viennent de son poste où il a passé le plus de temps (nous ne l’avions pas précisé).
À contrario, une bonne surprise est la capacité de Haïku à s’appuyer sur son general knowledge. Le deuxième CV de la liste ne fait jamais mention de Windows mais seulement de “Microsoft SQL server” et “d'environnement Microsoft”. Le LLM a pu faire le lien avec Windows.
Exemple de retour JSON
L’exemple ci-dessus montre une utilisation très haut niveau des outputs contraints. C’est un peu dommage d’avoir discuté de la génération JSON et de ne pas l’observer directement (même si je vous assure que c’est utilisé par instructor).
Voici, ci-dessous, un petit exemple de comment appeler directement Bedrock pour le retour JSON. Malheureusement, il se repose sur un hack. Vous vous souvenez quand je vous disais qu’on peut demander au LLM de générer un appel à une fonction ? Et bien c’est cette syntaxe qu’on va devoir utiliser pour faire directement du JSON output avec Bedrock.
Concrètement, on fait croire au LLM qu’il va appeler une fonction qui prend en argument d’entrée les valeurs à extraire du CV. On n'appellera jamais aucune fonction mais on récupèrera simplement les arguments.
# Selection du CV 21283365
top_score_resume = df[df['ID'] == 21283365].Resume_str.values[0]
client.converse(
modelId="anthropic.claude-3-haiku-20240307-v1:0",
messages = [{
'role': 'user',
'content': [{"text": base_prompt.format(resume = top_score_resume)}],
}],
toolConfig={
'tools': [
{
'toolSpec': {
'name': 'ResumeInfoExtractor',
'description': 'assert that all fields specified are in the resume',
'inputSchema': {
# Dump du model JSON de la classe ResumeExtract
'json': json.loads(json.dumps(ResumeExtract.model_json_schema()))
}
}
},
],
}
)
On obtient ce retour :
{"ResponseMetadata": {"RequestId": "4e71faf5-3083-40e3-9058-0b9555935c95",
"HTTPStatusCode": 200,
"HTTPHeaders": {"date": "Tue, 07 Jan 2025 13:14:59 GMT",
"content-type": "application/json",
"content-length": "821",
"connection": "keep-alive",
"x-amzn-requestid": "4e71faf5-3083-40e3-9058-0b9555935c95"},
"RetryAttempts": 0},
"output": {"message": {"role": "assistant",
"content": [{"toolUse": {"toolUseId": "tooluse_mdsLF-3qSBKOgqkNAwPHcQ",
"name": "ResumeInfoExtractor",
"input": {"years_of_exeprience": 16,
"management_experience": True,
"managed_people": 10,
"windows_workstation": "Expérience dans la gestion d'un parc de postes de travail Windows",
"windows_server": "Expérience dans la gestion d'un parc de serveurs Windows",
"number_of_machine": 10000,
"experience_early_2010s": True,
"biggest_achievement": "Mise en place de la première infrastructure SaaS de l"entreprise, incluant la conception d"un centre de données, la création de politiques de cybersécurité et la constitution d"une équipe de support technique.",
"score": 9}}}]}},
"stopReason": "tool_use",
"usage": {"inputTokens": 2163, "outputTokens": 305, "totalTokens": 2468},
"metrics": {"latencyMs": 3356}}
Dans le champ input, on retrouve l’extraction telle que voulue.
Conclusion et ouvertures
On a vu ici les output constraint et les principes théoriques qui permettent leur fonctionnement. À ce jour, les principaux usages sont l’extraction d’information dans des formats structurés et l’appel de fonction pour donner une capacité d'action aux modèles de langages.
Vous saurez maintenant, via ces petits rappels sur la théorie du langage des computer sciences, que les LLMs sont capables d’un peu plus que leur capacité de conversation et de génération d’images.