<overview> Un decision model répond à des questions typées sur l'état et retourne des probabilités au lieu de texte. Il remplace le pattern courant de prompting d'un LLM, parsing de son texte, et branchement sur le résultat.
Noul(instructions=...)— question binaire, retourne une probabilité de ouiChoice(instructions=..., criteria={...})— choisit un label, retourne la distribution complète plus la confianceScore(instructions=..., criteria=[...])— note selon une grille ordonnée, retourne une valeur attendue plus la confiance
TypeSafeClassifier est un LangChain Runnable[ClassifierRequest, ClassifierResponse], donc il s'insère dans un nœud comme n'importe quel autre runnable. Jusqu'à 32 questions partagent une requête et sont répondues indépendamment — ton code les combine.
Utilise-en un quand un nœud génère du texte uniquement pour que tu en extraies une décision : routage, triage, filtrage, guardrails, ou classification par item sur un batch.
N'en utilise pas quand la sortie du nœud est le produit (résumés, brouillons, code) ou quand le jugement nécessite un raisonnement multi-étapes. Un decision model classe ; il ne pense pas. </overview>
Install et câblage
langchain-typesafe est alpha (0.0.1a3) et TypeSafeClassifier est marqué @beta — épingle-le et attends des changements.
uv add langchain-typesafe
Trois façons d'atteindre un modèle. Le classifier POSTs à {base_url}/v1/systemone avec Authorization: Bearer {api_key}, donc changer de fournisseur ne demande que des arguments du constructeur :
import os
from langchain_typesafe import TypeSafeClassifier
# 1. TypeSafe directement (Jev). Lit TYPESAFE_API_KEY quand api_key est omis.
classifier = TypeSafeClassifier(model="jev-latest")
# 2. SemIf, hébergé sur la LangSmith Gateway. Note : clé LangSmith, pas une clé TypeSafe.
classifier = TypeSafeClassifier(
model="semif-qwen3.5-4b",
api_key=os.environ["LANGSMITH_API_KEY"],
base_url="https://gateway.smith.langchain.com",
)
# 3. Jev via la Gateway (BYOK). Le préfixe `typesafe/` route vers une
# TYPESAFE_API_KEY stockée dans les secrets du workspace LangSmith. Sans ce secret
# tout id `typesafe/*` retourne 424 Failed Dependency -- avant même que le nom du modèle soit
# validé, donc un 424 ne confirme pas que l'id est réel.
classifier = TypeSafeClassifier(
model="typesafe/jev-1.13.0",
api_key=os.environ["LANGSMITH_API_KEY"],
base_url="https://gateway.smith.langchain.com",
)
Le pattern de conversion
Pose chaque question sur une page/item dans une seule requête, mets la réponse typée dans l'état, et laisse une fonction ordinaire la router. Le routeur est du Python standard — testable sans toucher un réseau.
from typing import TypedDict
from langchain_typesafe import ClassifierResponse, Noul, Score, TypeSafeClassifier
from langgraph.graph import StateGraph, START, END
QUESTIONS = {
"relevant": Score(
instructions="How relevant is this ticket to a billing problem?",
criteria=["Unrelated.", "Possibly related.", "Directly about billing."],
),
"angry": Noul(instructions="Is the customer expressing anger?"),
}
class State(TypedDict):
text: str
answers: ClassifierResponse
route: str
classifier = TypeSafeClassifier(model="jev-latest")
def classify(state: State) -> dict:
# Une requête, chaque question. Elles sont répondues indépendamment.
return {"answers": classifier.invoke(
{"state": state["text"], "questions": QUESTIONS}
)}
def route(state: State) -> str:
a = state["answers"]
if a.nouls["angry"].noul > 0.7:
return "escalate"
if a.scores["relevant"].score < 0.5:
return "close"
return "handle"
Lecture des réponses : response.nouls[id].noul, response.choices[id].choice, response.scores[id].score. Chaque vue est indexée par ton id de question ; response.answers les contient tous.
Trois pièges quand on lit les réponses
Ceux-ci causent un routage silencieux incorrect, pas d'exceptions.
1. Score.score est une valeur attendue, pas un niveau. C'est une moyenne pondérée par probabilité sur la grille et est routinièrement fractionnelle. score == 0 ne déclenche presque jamais — un item « pas responsif » atterrit à 0.07, pas 0. Toujours comparer contre une bande.
if a.scores["relevant"].score < 0.5: # correct
if a.scores["relevant"].score == 0: # FAUX -- presque jamais vrai
2. La confiance mesure la forme de la distribution, pas la justesse. Sur un Score, la confiance rapporte à quel point la distribution de la grille est concentrée. Un item se tenant clairement entre deux niveaux obtient une faible confiance même quand le modèle en est entièrement clair. Une règle confidence < X -> escalate en couverture escalade donc les items que le modèle a déjà décidés. Conditionne sur la confiance uniquement dans le milieu ambigu :
if score < NOT_RELEVANT: # décisif -- lui fais confiance
return "close"
if score < RELEVANT or confidence < MIN_CONF: # ambigu -- escalade
return "human_review"
return "handle"
3. Les seuils ne se transfèrent pas entre modèles. L'étalonnage est parte du modèle. La même politique sur les mêmes items route différemment sur Jev vs SemIf vs un adaptateur LLM. Re-affine les seuils chaque fois que tu changes de modèle, et épingle l'id du modèle.
La formulation des questions domine la précision
Une question vague produit des réponses confiantes mais incorrectes, et aucun seuil ne le corrige. Utilise criteria pour dire ce que signifie chaque résultat, y compris ce qui ne devrait pas compter.
Dans un cas mesuré, « Is this a confidential communication with a lawyer? » a noté un mémo finance ordinaire à 0.798. La récrire pour nommer le test réel — écrit par ou pour un avocat, avec une exclusion explicite pour le contenu financier et comptable — a déplacé la même page à 0.005 tandis qu'une page véritablement protégée a tenu à 0.991.
Noul(
instructions=(
"Was this written by or to a lawyer, or does it convey a lawyer's legal "
"advice? Answer no for ordinary business or accounting discussion, even "
"when the subject is litigation-sensitive."
),
criteria=NoulCriteria(
true="A named attorney is author or recipient, or it relays legal advice.",
false="Business or accounting content with no attorney involved.",
),
)
Avant de blâmer le modèle, réécris la question et re-mesure.
Auditer un agent existant
Pour trouver où un decision model s'insère, cherche ceux-ci dans la base de code — vois references/conversion-playbook.md pour la walkthrough complète.
| Signal | Quoi chercher |
|---|---|
| Generate-then-parse | Un appel LLM dont la sortie est immédiatement regex'd, json.loads'd, ou string-matched dans une branche |
| Classifieurs prompts | Prompts contenant « respond with one of », « answer yes or no », « rate from 1 to 5 » |
| Sampling pour le coût | Commentaires ou configs qui ne vérifient que les N premiers items parce que tout vérifier coûte trop cher |
| Règles fragiles | Listes de mots-clés ou regexes remplaçant le jugement sémantique |
| Re-lecture du contexte | Le même document renvoyé à un modèle pour chaque question séparée |
Les deux derniers importent le plus : les jugements sémantiques bon marché changent ce que tu peux construire, pas juste la facture. Si évaluer chaque item devenait abordable, quoi arrêterais-tu d'échantillonner ?
Attentes
Mesuré sur un batch de 24 items, graphe LangGraph identique et politique de routage, seul le classifier changé :
| par item | tokens (6 items) | notes | |
|---|---|---|---|
| Jev 1.13.0 | 0,27s | 3 648 in / 318 out | rapporte l'usage |
| SemIf 4B | 0,49s | non rapporté | hébergé sur la Gateway |
| Claude Sonnet 5 | 2,87s | 7 930 in / 864 out | via structured output |
Le routage a convenu sur 4–5 de 6 items entre moteurs ; les désaccords se sont regroupés sur les items véritablement limites. Traite ceci comme une forme, pas des benchmarks — mesure sur ta propre charge de travail.
Si tu compares contre une baseline LLM, utilise method="json_schema" pour que la comparaison soit juste. Le with_structured_output de LangChain par défaut à method="function_calling", qui injecte un schéma d'outil dans chaque requête — 556/35 tokens versus 228/12 pour le chemin natif output_config.format sur la même sonde à un champ.
Batching avec Send
La classification est par item et indépendante, donc fan out avec Send et laisse chaque item router sur son propre.
from langgraph.types import Send
def fan_out(state):
return [Send("classify_item", {"text": t}) for t in state["items"]]
builder.add_conditional_edges(START, fan_out, ["classify_item"])
Le fan-out cache la latence, donc il flatte le plus les classifiers lents : dans la run ci-dessus, Sonnet a gagné 8x de la concurrence et Jev seulement 1,4x — pourtant Jev a quand même fini en premier. Compare le débit, pas le multiple de speedup.
Skills connexes
- langgraph-fundamentals — StateGraph,
Send,Command, conditional edges - langgraph-human-in-the-loop —
interrupt()pour la branche d'escalade ci-dessus - langchain-middleware — structured output quand tu as besoin d'un LLM, pas d'un classifier