langgraph-decision-models

Par langchain-ai · langchain-skills

INVOQUER CETTE SKILL lors du routage d'un agent LangGraph avec un modèle de décision (TypeSafe Jev, SemIf) à la place d'un LLM, ou lors de l'audit d'un agent existant pour détecter les appels LLM qui ne produisent qu'une décision de routage. Couvre langchain-typesafe Noul/Choice/Score, la lecture correcte des réponses, la conception de seuils, et le câblage LangSmith Gateway.

npx skills add https://github.com/langchain-ai/langchain-skills --skill langgraph-decision-models

<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 oui
  • Choice(instructions=..., criteria={...}) — choisit un label, retourne la distribution complète plus la confiance
  • Score(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

Skills similaires