Vous trouverez la version 🇬🇧 anglaise de cet article ici
Choice (une option parmi un ensemble), Noul (probabilité qu’une affirmation soit vraie), Score (position sur une échelle de niveaux décrits).Choice somment à 1, donc une option gagne toujours, même si aucune ne convient. Un Noul est indépendant, lui peut tomber à zéro.Vous avez une fonctionnalité à écrire, et elle demande un peu de bon sens :
Trop subtil pour un if. La flemme (ou pas assez de data) pour faire un classifieur personnalisé. Beaucoup trop simple pour sortir un agent, qui sera coûteux et lent.
Le réflexe, aujourd’hui, c’est d’appeler un LLM et de lui réclamer du JSON. Vous connaissez la chanson :
response = llm.complete(prompt)
data = json.loads(response) # et si ce n'est pas du JSON valide ?
category = data["category"] # et si la clé manque ?
if category not in ALLOWED: # et si le modèle invente une catégorie ?
...
Ça marche, hein. Mais entre le schéma, les retry, le parser un peu laxiste et le prompt qui gonfle à chaque cas tordu, on finit par écrire beaucoup de code pour réparer du texte qu’on n’a jamais voulu.
Aujourd’hui, je vous propose qu’on découvre ensemble Jev, le modèle de TypeSafe. Sa particularité : il ne génère rien. On lui pose des questions typées, il rend des probabilités, et votre code s’en sert directement.
On va voir comment on lui parle, comment lire ce qu’il répond, et on finit par écrire ensemble un petit outil qui cherche une réponse dans un document. Tout ce qui suit a tourné pour de vrai : les sorties affichées ne sont pas des illustrations.
Un LLM, vous lui donnez du texte, il vous rend du texte. À vous de vous débrouiller avec.
Jev, vous lui donnez deux choses :
Et il vous rend une réponse prise dans votre liste, accompagnée d’une probabilité pour chacune des possibilités que vous lui avez données.
C’est tout. Il n’écrit aucune phrase, il ne produit pas de code, il n’explique pas son raisonnement. L’espace des réponses est défini par votre code, pas par ce que le modèle a décidé d’écrire ce jour-là.
TypeSafe range ces modèles dans une famille qu’elle appelle System One, en clin d’œil au Système 1 de Kahneman : le jugement immédiat, celui qui ne délibère pas, par opposition au Système 2 qui prend le temps de raisonner.
Un mot sur ces probabilités. Elles sont calibrées : sur une série de cas où Jev annonce 0,80, il a raison à peu près huit fois sur dix.
C’est une propriété statistique, qui se vérifie sur des groupes de prédictions. Sur un cas isolé, elle ne vous dit pas que la réponse est bonne. Un type garantit une interface, pas une vérité.
Il faut une clé sur console.typesafe.ai, et le SDK (Python 3.10 ou plus) :
pip install typesafe-sdk
export TYPESAFE_API_KEY="..."
Voilà le plus petit programme utile qu’on puisse écrire :
from typesafe_sdk import Noul, TypeSafeClient
client = TypeSafeClient()
response = client.system_one(
state="Bonjour, je n'arrive pas à me connecter depuis 3 jours. Je perds des ventes. Aidez-moi vite !",
questions={"urgent": Noul(instructions="This message conveys urgency or time pressure")},
)
print(response.answers["urgent"].noul) # 0.99
Pas de prompt système, pas de « réponds uniquement en JSON », pas de try/except autour d’un parser. Vous récupérez un flottant.
Au passage : ma question est en anglais alors que l’état est en français. Jev comprend les deux, ce qui permet de garder ses instructions dans la langue de son code.
Il n’y a que trois types de questions. J’ai mis un moment à savoir laquelle prendre quand, alors voici mon aide-mémoire.
| Ce que vous cherchez | Primitive | Ce qui revient |
|---|---|---|
| Une option parmi un ensemble défini | Choice |
l’option retenue, une probabilité par option, une confiance |
| Est-ce que cette affirmation est vraie ? | Noul |
une probabilité entre 0 et 1 |
| Une position sur une échelle ordonnée | Score |
un flottant, une probabilité par niveau, une confiance |
Les trois ensemble, sur un ticket de support :
from typesafe_sdk import Choice, Noul, Score, TypeSafeClient
questions = {
"team": Choice(
instructions="Which team should handle this message?",
criteria={
"billing": "Payment, invoice or subscription problem",
"technical": "Bug, outage or integration problem",
"sales": "Pricing question or new account request",
},
),
"frustration": Score(
instructions="How frustrated does the customer appear?",
criteria=[
"States facts calmly, no complaint",
"Expresses annoyance but stays civil",
"Angry, uses strong language or threatens to leave",
],
),
"urgent": Noul(instructions="This message conveys urgency or time pressure"),
}
ticket = ("Your API has been returning 500 on every call for 3 days. "
"We are losing sales. This is unacceptable, fix it now or we churn.")
res = client.system_one(state=ticket, questions=questions)
Ce que ça donne :
team = technical (confidence 1.00)
probs = {'technical': 1.0, 'billing': 0.0, 'sales': 0.0}
frustration = 2.00 (confidence 1.00)
urgent = 0.99
Et sur un ticket nettement plus tiède (« petite question sur la facture des sièges qu’on a ajoutés, et le bouton d’export rame un peu, rien d’urgent ») :
team = billing (confidence 0.93)
probs = {'billing': 0.95, 'technical': 0.05, 'sales': 0.0}
frustration = 0.21 (confidence 0.68)
urgent = 0.07
Ce frustration à 0,21 n’est pas un niveau, c’est une position entre deux niveaux : le score est la moyenne des numéros de niveau, pondérée par les probabilités. Le client est calme, avec un soupçon d’agacement.
Choice trouve toujours quelque choseC’est une contrainte mathématique, pas un défaut du modèle.
Les probabilités d’un Choice somment à 1. Le modèle doit répartir sa masse de probabilité sur les options que vous lui avez tendues. Donc une option gagne toujours, même quand aucune ne convient.
Un Noul, lui, ne dépend d’aucune autre option. Il peut s’effondrer à 0,03 sans rien demander à personne.
En pratique, dès qu’il existe un cas où « rien ne convient », il faut soit ajouter une option de repli dans le Choice, soit poser un Noul à côté pour tester la présence.
Score décrit des situationsLa documentation donne un conseil que je n’aurais pas trouvé tout seul : décrivez des situations, pas des degrés.
# Mauvais : le modèle n'a rien à quoi se raccrocher
criteria=["Pas grave", "Moyennement grave", "Très grave"]
# Bon : chaque niveau décrit un état du monde
criteria=[
"Cosmetic; no impact to functionality",
"Broken or degraded feature, but a workaround exists",
"Blocking issue; no workaround exists",
]
La raison est technique : chaque niveau est évalué séparément. Le modèle ne voit ni son numéro, ni ses voisins. Écrire « pire que le niveau précédent » ne lui dit donc strictement rien, et coller des chiffres dans les descriptions n’aide pas non plus.
Autre chose : deux distributions différentes peuvent donner le même score. Un 1,0 peut vouloir dire « toute la probabilité sur le niveau 1 », ou « moitié sur 0, moitié sur 2 ». Regardez probabilities et confidence à côté, pas seulement le score.
Chaque réponse Choice et Score porte une confidence entre 0 et 1. Les Noul n’en ont pas, et c’est cohérent : leur probabilité est déjà la mesure.
Ce n’est pas une note de fiabilité. C’est la forme de la distribution résumée en un nombre. Concentrée sur une issue, la confiance monte. Étalée, elle descend.
Une confiance basse ne dit pas « je me trompe ». Elle dit « plusieurs réponses se valent ».
Ça peut signaler un modèle qui patauge. Ça peut aussi vouloir dire que la question était mal posée, que les niveaux se chevauchent, ou tout simplement que plusieurs réponses sont bonnes.
Et le bon seuil n’est pas le même selon ce qu’on déclenche derrière :
action = response.answers["action"]
if action.confidence < 0.5:
route_to_human(message) # le modèle ne sait pas, on n'invente pas
elif action.choice == "check_balance":
show_balance(account_id) # lecture seule, on y va
elif action.choice == "approve_transfer":
if action.confidence > 0.9:
confirm_then_execute(account_id) # irréversible : barre plus haute
else:
ask_user_to_confirm(account_id)
Deux actions du même système, deux seuils différents. C’est votre code qui encode la tolérance au risque.
Les questions indépendantes sur un même état partent dans un seul appel. Elles s’exécutent en parallèle et ne se voient pas entre elles.
J’ai voulu chiffrer l’économie. Même document, même requête, deux façons de faire :
| Tokens entrée | Tokens sortie | Total | |
|---|---|---|---|
| Deux questions, un appel | 1 777 | 547 | 2 324 |
| Deux appels séparés | 3 001 | 550 | 3 551 |
1,53 fois moins cher, pour des réponses rigoureusement identiques. L’état représente l’essentiel des tokens, et un seul appel ne l’envoie qu’une fois.
L’écart grandit vite avec le nombre de questions : le cookbook officiel mesure 12,2x sur treize.
Ça ouvre un usage auquel je ne pensais pas : les questions spéculatives. Vous en posez une dont vous ne lirez la réponse que si une autre va dans ce sens. Une question de plus coûte peu, un aller-retour de plus coûte cher.
Assemblons tout ça. L’idée : on pose une question en langage naturel sur un document, et on récupère les passages qui y répondent. Pas d’embedding, pas de base vectorielle, pas d’index à maintenir.
Trois ingrédients.
D’abord, numéroter les lignes. Jev ne peut désigner que ce qu’on lui a montré :
L013| ## C. Ownership of content
L014| You keep ownership of the Content you upload to the Service.
L015| By uploading Content, you grant us a non-exclusive license...
Ensuite, prendre les identifiants de ligne comme options d’un Choice. « Choisis une option » devient « montre-moi où ». Les descriptions sont à None, exprès : le document porte déjà le texte de chaque identifiant, inutile de le répéter.
Enfin, glisser un Noul dans le même appel pour savoir si le document répond, tout court.
Ça donne le script complet suivant, à copier tel quel :
import sys
from typesafe_sdk import Choice, Noul, NoulCriteria, TypeSafeClient
query, path = sys.argv[1], sys.argv[2]
# 1. Numéroter les lignes non vides (255 au maximum : c'est la limite d'un Choice).
lines = [line.strip() for line in open(path) if line.strip()]
document = "\n".join(f"L{i:03d}| {line}" for i, line in enumerate(lines))
# 2. Les deux questions, dans un seul appel.
response = TypeSafeClient().system_one(
state=document,
questions={
"where": Choice(
instructions=f'Which passage of the document contains the answer to: "{query}"?',
criteria={f"L{i:03d}": None for i in range(len(lines))},
),
"exists": Noul(
instructions=f'Does any part of the document address or answer: "{query}"?',
criteria=NoulCriteria(
true="At least one passage states the answer or directly implies it",
false="No passage of the document addresses this question",
),
),
},
)
# 3. La décision, chez nous.
where = response.answers["where"]
exists = response.answers["exists"].noul
if exists >= 0.70:
verdict = "réponse trouvée"
elif exists < 0.35:
verdict = "absent du document"
else:
verdict = "partiellement abordé"
print(f"exists {exists:.2f} -> {verdict} (confiance du classement {where.confidence:.2f})")
scores = where.probabilities
best = sorted(range(len(lines)), key=lambda i: scores[f"L{i:03d}"], reverse=True)
for i in best[:3]:
print(f" L{i:03d} {scores[f'L{i:03d}']:.2f} {lines[i][:64]}")
Une quarantaine de lignes, dont la moitié sert à l’affichage.
Je l’ai lancé sur des conditions générales d’utilisation fictives. D’abord une question à laquelle elles ne répondent nulle part :
$ python qgrep.py "can I pay in cryptocurrency?" tos.md
exists 0.03 -> absent du document (confiance du classement 0.56)
L025 0.58 ## E. Subscription and billing
L050 0.25 Questions about these terms? Write to legal@lantern.example.
L028 0.12 Prices are stated excluding tax; applicable taxes are added at c
Regardez la meilleure ligne : 0,58. C’est un titre de section, il ne répond à rien du tout. Un système qui n’aurait que le classement vous l’aurait servie sans sourciller.
Le Noul, lui, est à 0,03.
Le classement dit où regarder. Le Noul dit si ça vaut la peine de regarder.
Deuxième essai, sur une question à laquelle le document répond à moitié :
$ python qgrep.py "can a minor sign up with parental consent?" tos.md
exists 0.43 -> partiellement abordé (confiance du classement 1.00)
L008 1.00 You must be 16 or older to open an account on the Service.
Le document fixe un âge minimum, mais ne dit rien de l’accord parental. Jev pointe la bonne ligne avec une confiance de 1,00, et pose quand même le exists à 0,43. Il sait exactement où regarder, et il sait que ça ne suffit pas.
Troisième essai :
$ python qgrep.py "how long do you keep my data after I delete my space?" tos.md
exists 0.99 -> réponse trouvée (confiance du classement 0.46)
L033 0.48 Encrypted backups are kept for a further thirty days, then destr
L032 0.34 When a Space is deleted, its Content is erased from our producti
Confiance à 0,46. Le réflexe, c’est de s’inquiéter. Sauf que les deux lignes répondent réellement : l’une donne le délai en production, l’autre celui des sauvegardes. Elles se partagent la probabilité.
La confiance basse est ici exactement le bon signal, elle dit d’afficher plusieurs résultats au lieu d’un seul. C’est mon code qui en tire la règle, le modèle n’a rien décidé.
Le bloc numéro 3 du script est la seule partie qui décide quoi que ce soit, et elle est chez vous. Les seuils 0,70 et 0,35 sont dans votre fichier, versionnés avec votre code, relus en revue de code.
Changer un seuil ne relance aucune inférence. Si vous stockez les probabilités brutes, vous pouvez même rejouer toute votre politique hors ligne, sur des résultats déjà payés.
Ce n’est pas un remplaçant de LLM. Aucune génération, aucune rédaction, aucune explication. Pour un résumé ou un mail, il vous faut toujours un modèle génératif.
Il ne lit que du texte pour l’instant : chaînes, objets JSON, tableaux de textes. Ni image, ni audio, ni vidéo.
Un Choice plafonne à 255 options. C’est la limite du script plus haut : au-delà de 255 lignes, il faut passer en deux temps, une question qui choisit un bloc de lignes, une seconde qui choisit à l’intérieur.
Et les probabilités calibrées ne sont pas des garanties. Sur un cas isolé, un 0,95 reste un 0,95. Les seuils que je donne ici sont des points de départ, à réévaluer sur vos propres données.
Prenez la plus petite décision de votre application qui tourne aujourd’hui avec un prompt et un parser. Un tri, une détection, un aiguillage. Posez-la en une question typée, lancez-la sur vingt cas réels, et regardez la distribution des confiances avant de câbler quoi que ce soit.
L’outil en lui-même n’a pas grand intérêt. Ce qui change, c’est que le modèle devient une fonction qui rend des probabilités, et qu’on compose ces probabilités comme n’importe quelle autre valeur de son programme.
Les if redeviennent des if. Les seuils sont dans le dépôt, relus en revue de code, modifiables sans rappeler personne. Le modèle apporte le jugement là où le code ne sait pas faire, et le reste, c’est du logiciel.
Ça ne remplacera pas un LLM, et ce n’est pas le but. Mais pour toutes ces petites décisions qu’on bricole aujourd’hui avec un prompt et un parser, je crois que je vais changer mes habitudes.
Soutenir mon travail
Sponsoring open-source
PHPMetrics, AST Metrics et le reste sont librement accessibles. Un petit coup de pouce sur GitHub aide à les faire vivre.
Me sponsoriser sur GitHub
Audit & Formation
Disponible sur Malt
Besoin d'un regard extérieur sur votre code ? J'accompagne les équipes PHP sur l'architecture, les tests et l'industrialisation, au rythme de vos projets.
Me trouver sur Malt© Jean-François Lépine, 2010 - 2026 · Flux RSS