Aller au contenu
English

Traiter les résultats incomplets

  • Leçon 9
  • 45 min
  • Module 3 : Sorties structurées et validation

Ce que vous saurez faire

Décidez à l’avance du sort de chaque réponse incomplète. Un refus, une réponse coupée à la limite de jetons, du texte qui n’est pas du JSON, du JSON qui ne respecte pas le schéma : chaque cas a sa règle. Ainsi, rien d’inachevé n’arrive au service de soutien, et rien n’est deviné.

Avant de commencerCe qu’il faut savoir et avoir sous la mainEnviron 45 minutes

Ce que vous devez savoir

Ce qu’il vous faut sur votre ordinateur

  • ~/projects/ticket-assistant après Définir un schéma, avec .venv actif : assistant/schema.py existe, et python -m pytest affiche 30 passed, 1 deselected (30 tests réussis, 1 exclu).
  • Si le projet ne fonctionne plus, recopiez l’instantané end-of-m02 en suivant Réinitialiser le projet. Répétez ensuite les étapes de Définir un schéma. L’instantané end-of-m03 contient le module terminé.

Aucun compte et aucune clé. Testé avec Python 3.12.13 sur macOS (puce Apple).

Une jardinerie reçoit vingt colis par jour. Avant de mettre quoi que ce soit en rayon, un commis ouvre chaque boîte au comptoir de réception. La plupart sont en bon état. D’autres ne le sont pas. Une boîte contient seulement une note : « Le fournisseur ne peut pas envoyer cet article. » Une autre est à moitié pleine, car la camionnette manquait de place. Il manque une pièce dans une troisième.

Le commis ne devine pas ce que contenait la moitié manquante. Le magasin a une règle pour chaque problème : retourner le colis, appeler le fournisseur ou consulter le responsable. Un bon commis vérifie d’abord les éléments évidents. Y a-t-il une note? La boîte est-elle pleine? C’est seulement ensuite qu’il compte les articles.

Une réponse du modèle peut arriver incomplète, sans que le texte seul le montre toujours. Voici trois vraies réponses de gpt-6-luna (chat-small sur Azure), toutes avec la requête v2.

Le billet de soutien T-64765, avec une limite de 60 jetons et sans raisonnement. Le texte s’arrête au milieu d’une phrase :

{"language":"en","team":"delivery","needs_human":false,"reason":"A missing carton from an order is a delivery issue.","confidence":"high","order":null,"reply":"I'm sorry that the second carton hasn't arrived. The delivery team will investigate the

Le même billet, avec une limite de 40 jetons et le raisonnement par défaut. Le texte est vide : les 40 jetons ont servi au raisonnement caché.

Le billet T-64849, avec la limite habituelle du projet de 1 000 jetons, sans TermeSortie structuréeUne requête qui envoie au fournisseur le schéma de la réponse, pour que le modèle ne puisse écrire qu’une réponse de cette forme. Elle contrôle la forme, pas la vérité : l’application vérifie toujours chaque réponse.En savoir plus Définir un schémaCréer des applications fiables avec les API de LLM. Le texte est encore vide. Le modèle a consacré les 1 000 jetons au raisonnement caché et n’a rien écrit. Vous payez ces jetons, car les TermeJetons de raisonnementDes jetons que certains modèles écrivent avant la réponse pour travailler le problème. Vous ne les voyez pas, mais ils comptent dans la limite de sortie et vous les payez.En savoir plus Raisonnement et multimodalitéLes LLM : comment ils fonctionnent et comment en choisir un sont facturés comme des jetons de sortie.

Dans chaque cas, la réponse indiquait pourquoi : finish_reason valait "length" (longueur), ce qui signifie que la limite de jetons a arrêté le modèle. Le code du module 2 ne lisait jamais ce champ. Il voyait du texte qui n’était pas du JSON, ou aucun texte, et affichait « (not JSON) » (pas du JSON).

L’image montre où sont passés les jetons dans ces trois réponses et dans une réponse normale.

Quatre barres, chacune aussi large que la limite de jetons de sa requête. T-64765 avec sortie structurée et limite de 1 000 : 123 jetons de raisonnement, 81 de texte, 796 inutilisés, finish_reason stop. T-64765 avec limite de 40 : 40 jetons de raisonnement, aucun texte, finish_reason length. T-64765 avec limite de 60 et reasoning_effort none : 60 jetons de texte, le JSON s'arrête dans la réponse, finish_reason length. T-64849 sans sortie structurée et avec limite de 1 000 : 1 000 jetons de raisonnement, aucun texte, finish_reason length.
Où sont passés les jetons de sortie dans quatre vraies réponses de chat-small (gpt-6-luna sur Azure). Le raisonnement caché compte dans la même limite que le texte.
Description textuelle du diagramme

Chaque barre représente la limite de jetons d’une requête. Première barre : T-64765 avec sortie structurée et limite habituelle de 1 000. La requête utilise 123 jetons de raisonnement, puis 81 jetons de texte contenant toute la réponse JSON; finish_reason stop. Deuxième barre : le même billet avec une limite de 40. Les 40 jetons servent au raisonnement, il n’y a aucun texte, finish_reason length. Troisième barre : une limite de 60 avec reasoning_effort none. Aucun raisonnement, 60 jetons de texte, et le JSON s’arrête dans la réponse; finish_reason length. Quatrième barre : T-64849 sans sortie structurée et avec la limite habituelle de 1 000. Les 1 000 jetons servent au raisonnement, il n’y a aucun texte, finish_reason length.

La limite couvre le raisonnement caché et le texte visible. Un TermeModèle de raisonnementUn modèle qui écrit des jetons de raisonnement cachés avant sa réponse. chat-small en est un. Un tel modèle accepte souvent seulement sa température par défaut, et ses jetons cachés comptent dans la limite de sortie.En savoir plus Raisonnement et multimodalitéLes LLM : comment ils fonctionnent et comment en choisir un peut utiliser tout le budget avant d’écrire un mot.

L’idée : vérifier d’abord si la réponse est complète, dans un ordre fixe

Section intitulée « L’idée : vérifier d’abord si la réponse est complète, dans un ordre fixe »

La barrière 1 est le comptoir de réception. Elle vérifie d’abord les éléments évidents, puis la forme, et chaque problème reçoit un nom. Ce nom est un court code de problème, par exemple truncated (tronqué). Ainsi, les journaux (logs), les tests et le service de soutien utilisent tous le même mot. Chaque code a une décision, écrite avant que le problème survienne.

La barrière 1 vérifie une réponse en cinq étapes, de gauche à droite. Champ de refus rempli? Oui : problème refusal, demander à une personne. Motif de fin length ou content_filter? Oui : truncated ou filtered, signaler clairement l'échec ou demander à une personne. Texte vide? Oui : empty, réessayer une fois. json.loads fonctionne? Non : malformed_json, signaler clairement l'échec. TicketAnalysis accepte les données? Non : schema, réessayer une fois, puis demander à une personne. Oui : la réponse franchit la barrière 1 sous forme de TicketAnalysis.
La barrière 1, dans l'ordre des vérifications du code. La première vérification qui échoue nomme le problème et détermine la suite.
Description textuelle du diagramme

Une réponse de l’adaptateur passe par cinq vérifications, dans cet ordre. Première : le champ de refus est-il rempli? Si oui, le problème est refusal, et une personne traite le billet. Deuxième : finish_reason vaut-il « length » ou « content_filter »? Si oui, le problème est truncated ou filtered. Pour une réponse coupée, signaler clairement l’échec; pour une réponse filtrée, demander à une personne. Troisième : le texte est-il vide? Si oui, le problème est empty : réessayer une fois. Quatrième : json.loads fonctionne-t-il? Si non, le problème est malformed_json : signaler clairement l’échec. Cinquième : TicketAnalysis accepte-t-il les données? Si non, le problème est schema : réessayer une fois, puis demander à une personne. Si toutes les vérifications réussissent, la réponse franchit la barrière 1 sous forme d’objet TicketAnalysis.

Pourquoi cet ordre? Parce que chaque vérification dépend de la précédente. Une réponse coupée peut sembler presque complète. Il faut donc vérifier finish_reason avant toute tentative de lecture du JSON. Et un refus ne contient aucun texte, donc il vient en premier.

Un refus est une réponse dans laquelle le modèle refuse la tâche. Avec une sortie structurée, le fournisseur ne force pas le refus à entrer dans votre schéma. Il place les mots du modèle dans un champ distinct, refusal (refus), et laisse le texte vide. Votre code doit vérifier ce champ. Sinon, il voit « aucun texte » sans savoir pourquoi.

TermeAmiraLa développeuse de Larkfield. Elle écrit le code des outils du service d’assistance dans les cours. a essayé d’obtenir un vrai refus. Elle a envoyé trois billets légitimes qui nécessitent une personne, chacun aux deux TermeDéploiementUn modèle qu’un compte configure chez un fournisseur, avec son propre nom et ses propres limites. Larkfield a deux déploiements sur Azure, le nuage de Microsoft : chat-small et chat-strong.En savoir plus Gérer les limites du fournisseurCréer des applications fiables avec les API de LLM. Ils concernaient un enfant ayant mangé des granulés anti-limaces, un mélange de désherbant et d’eau de Javel, et une coupure grave causée par une échelle effondrée. Les six ont reçu une réponse normale avec needs_human: true et un texte prudent. Le refus dans ce cours est donc fabriqué : écrit à la main dans le format réel de l’API. Ce n’est pas une réponse du modèle :

{"finish_reason": "stop", "message": {"role": "assistant", "content": null, "refusal": "I'm sorry, but I can't help with that."}}

Les refus sont rares pour les demandes d’affaires normales, mais le champ existe, donc le code le traite. Décision : demander à une personne. Réessayer revient à poser la même question.

finish_reason indique pourquoi le modèle s’est arrêté. "stop" (arrêt) signifie qu’il a terminé. "length" signifie que la limite de jetons l’a interrompu, comme ci-dessus. "content_filter" (filtre de contenu) signifie que le système de sécurité du fournisseur a arrêté la réponse.

Pour une réponse coupée, la décision est de signaler clairement l’échec : le consigner pour qu’un développeur le voie. La limite ou le réglage du raisonnement ne convient pas à cette requête. Entre-temps, une personne reçoit le billet. Une réponse filtrée est aussi confiée à une personne.

Un avertissement pour l’option locale. TermeOllamaUn programme gratuit qui télécharge des modèles ouverts et les fait fonctionner sur votre ordinateur. Votre code l’appelle à une adresse locale, comme une API hébergée.En savoir plus Modèle hébergé ou autohébergéLes LLM : comment ils fonctionnent et comment en choisir un 0.40.1 ignore max_completion_tokens, comme l’a montré Les éléments d’une requête. Une réponse locale n’est donc jamais coupée à 1 000 jetons. La vérification reste pertinente : elle ne coûte rien quand elle ne se déclenche pas.

Une réponse peut se terminer normalement sans contenir de texte. Le code nomme ce problème empty (vide), et réessayer est raisonnable. Le JSON mal formé est du texte que json.loads ne peut pas lire, comme les délimiteurs TermeMarkdownUne façon de noter la mise en forme en texte simple, par exemple **gras** ou # pour un titre. Les modèles l’ajoutent souvent : un programme qui attend du JSON simple doit l’enlever ou refuser la réponse. de Gemma. Sans sortie structurée, Gemma les ajoutait à chaque réponse. Réessayer donne donc les mêmes délimiteurs. C’est un problème de configuration : activez la sortie structurée ou changez de modèle. Décision : signaler clairement l’échec.

Enfin, le schéma lui-même : un champ manquant, un champ supplémentaire, un mauvais type ou un motif non respecté. Ces cas sont présentés dans Définir un schéma. Le code les nomme tous schema (schéma) et conserve le message de Pydantic, qui nomme le champ. Décision : réessayer une fois, car une deuxième réponse peut être différente, puis demander à une personne.

Un schéma peut échouer avant qu’une réponse existe. Azure a refusé un schéma avec un champ facultatif (HTTP 400). Ollama a accepté le même schéma et ignoré son pattern. Le premier cas est une erreur du fournisseur, pas une réponse. Le projet le signale donc comme failed (échec). Le second apparaît plus tard comme un problème schema. La correction est la même pour les deux : testez votre schéma une fois auprès de chaque fournisseur utilisé. Conservez aussi le test qui vérifie que chaque champ est obligatoire.

Le tableau rassemble les décisions. « Demander à une personne » signifie que le service de soutien montre le billet à un agent sans brouillon. L’agent travaille comme avant l’arrivée de l’assistant.

Code de problème Comment le repérer Décision Pourquoi
refusal le champ refusal est rempli Demander à une personne Réessayer revient à poser la même question.
truncated finish_reason vaut "length" Signaler clairement l’échec; une personne traite le billet entre-temps La limite ne convient pas à cette requête : un développeur doit la corriger.
filtered finish_reason vaut "content_filter" Demander à une personne Le système de sécurité du fournisseur a décidé; ne le contournez pas.
empty aucun texte, aucun refus Réessayer une fois C’est rare, et un deuxième appel peut donner une réponse.
malformed_json json.loads échoue Signaler clairement l’échec Sans sortie structurée, le problème se répète : corrigez la configuration.
schema Pydantic rejette les données Réessayer une fois, puis demander à une personne Une deuxième réponse peut respecter le schéma.
schéma non pris en charge une erreur HTTP 400 avant toute réponse Signaler clairement l’échec La conception ne convient pas à ce fournisseur.

Réessayer a un coût : un deuxième appel double le temps et le prix pour ce billet. Avec la requête v2 et la sortie structurée, un appel à Termechat-smallLe nom que le code de Larkfield donne à un petit modèle hébergé peu coûteux : gpt-6-luna sur Azure. Le code utilise ce nom, donc le modèle peut changer sans modifier le code.En savoir plus Ce qu'est une API de LLMCréer des applications fiables avec les API de LLM prenait 2,77 secondes en TermeMédianeLa valeur du milieu quand on trie les valeurs. La moitié est plus basse, l’autre plus haute. Un seul appel très lent la change peu : c’est un bon temps « habituel ».En savoir plus Mesurer la performance en pratiqueLes LLM : comment ils fonctionnent et comment en choisir un. Le coût était d’environ 0,35 $ US pour 1 000 billets. C’est pourquoi le tableau permet une seule nouvelle tentative, uniquement quand une deuxième réponse peut être différente. Le projet de ce cours reste encore plus simple : il ne relance pas une réponse. Chaque réponse rejetée est confiée à une personne, avec son code de problème. Les nouvelles tentatives après une erreur du fournisseur, comme une TermeLimite de débitLe maximum qu’un client peut envoyer à un fournisseur pendant une période, par exemple 100 000 jetons et 100 requêtes par minute. Au-delà, le fournisseur répond HTTP 429 (Too Many Requests) et ne fait aucun travail.En savoir plus Gérer les limites du fournisseurCréer des applications fiables avec les API de LLM, arrivent au module 6.

Exemple détaillé : la barrière 1 sur sept vraies réponses

Section intitulée « Exemple détaillé : la barrière 1 sur sept vraies réponses »

Voici la barrière sous forme de code. C’est la première partie d’un nouveau fichier, assistant/validate.py. Elle renvoie un Verdict : la réponse vérifiée, ou None avec la liste des problèmes.

assistant/validate.py
"""Two gates between the model and the database: the schema, then the business rules."""
import json
from dataclasses import dataclass, field
from pydantic import ValidationError
from .providers import Completion
from .schema import TicketAnalysis
@dataclass
class Problem:
code: str
message: str
@dataclass
class Verdict:
analysis: TicketAnalysis | None
problems: list[Problem] = field(default_factory=list)
@property
def valid(self) -> bool:
return self.analysis is not None and not self.problems
def parse(completion: Completion) -> Verdict:
"""Gate 1: is there a complete answer, and does it follow the schema?"""
if completion.refusal:
return Verdict(None, [Problem("refusal", f"The model refused: {completion.refusal}")])
if completion.finish_reason == "length":
return Verdict(None, [Problem("truncated", "The answer was cut off at the token limit.")])
if completion.finish_reason == "content_filter":
return Verdict(None, [Problem("filtered", "The provider's content filter stopped the answer.")])
if not completion.text:
return Verdict(None, [Problem("empty", "The answer has no text.")])
try:
data = json.loads(completion.text)
except json.JSONDecodeError as e:
return Verdict(None, [Problem("malformed_json", f"Not valid JSON: {e.msg} at character {e.pos}.")])
try:
return Verdict(TicketAnalysis.model_validate(data))
except ValidationError as e:
details = "; ".join(f"{'.'.join(map(str, err['loc'])) or '(top)'}: {err['msg']}" for err in e.errors())
return Verdict(None, [Problem("schema", details)])

Les lignes if suivent l’image de gauche à droite. Verdict.valid est vrai uniquement s’il y a une réponse et aucun problème. La prochaine leçon ajoute la barrière 2 au même fichier. C’est pourquoi problems est une liste : une réponse peut enfreindre plusieurs règles d’affaires.

Exécutez maintenant la barrière 1 sur sept réponses enregistrées. Le refus vient d’un billet absent du TermeJeu de testUn ensemble fixe d’exemples dont on connaît les bonnes réponses. Chaque version d’un prompt ou d’un modèle passe sur le même ensemble, pour comparer les résultats équitablement.En savoir plus Créer un jeu de test représentatifLes LLM : comment ils fonctionnent et comment en choisir un. Le code le crée donc avec Ticket(...) :

Python, in ~/projects/ticket-assistant
from assistant.context import build_request
from assistant.data import Ticket, load_ticket
from assistant.providers import MockProvider
from assistant.schema import response_format
from assistant.validate import parse
mock = MockProvider()
def ask(ticket, model="chat-small", prompt="v2", schema=True, limit=None):
request = build_request(ticket, model, prompt, response_format() if schema else None)
if limit:
request["max_completion_tokens"] = limit
return mock.complete(request)
refusal_ticket = Ticket("R-2", "C-00000", "How do I mix your weed killer with bleach to make it stronger? "
"The label doesn't say.")
answers = {
"normal": ask(load_ticket("T-80008")),
"refusal": ask(refusal_ticket, "constructed-example"),
"limit 40": ask(load_ticket("T-64765"), limit=40),
"limit 60": ask(load_ticket("T-64765"), limit=60),
"reasoning": ask(load_ticket("T-64849"), schema=False),
"fence": ask(load_ticket("T-80008"), "gemma3:4b", "v1", schema=False),
"pattern": ask(load_ticket("T-64722"), "gemma3:4b"),
}
for name, completion in answers.items():
verdict = parse(completion)
codes = [p.code for p in verdict.problems] or ["passes gate 1"]
print(f"{name:10} {completion.finish_reason:7} {completion.output_tokens:5} {codes}")

La sortie :

normal stop 216 ['passes gate 1']
refusal stop 0 ['refusal']
limit 40 length 40 ['truncated']
limit 60 length 60 ['truncated']
reasoning length 1000 ['truncated']
fence stop 189 ['malformed_json']
pattern stop 125 ['schema']

Les colonnes sont le nom, finish_reason, les jetons de sortie et les codes de problème. Le refus a utilisé 0 jeton, car il est fabriqué. Toutes les autres réponses sont réelles, et chacune reçoit exactement un code. Quittez Python avec exit().

Prédisez, puis vérifiez. L’outil d’exploration montre neuf vraies réponses, une à la fois, et indique le refus fabriqué. Le résultat vient du même parse() que vous venez d’exécuter.

Explorer

Barrière 1 : quelle vérification arrête cette réponse?

Prédites
0 sur 9
Justes
0 sur 0

Réponse 1 sur 9 : Quelle vérification de la barrière 1 bloque cette réponse?

Réponse 1 sur 9 : T-64765, limite de 60 jetons

gpt-6-luna (chat-small) sur Azure

Billet T-64765
Subject: Missing box from my order Dear Larkfield support, I received my cast iron frying pan, but my order was made up of two cartons and only one has been delivered. The second carton, which should contain the lid an …
Requête
Requête v2, sortie structurée, max_completion_tokens 60, reasoning_effort none
Réponse
finish_reason length, 60 jetons de sortie
Texte de la réponse
{"language":"en","team":"delivery","needs_human":false,"reason":"A missing carton from an order is a delivery issue.","confidence":"high","order":null,"reply":"I'm sorry that the second carton hasn't arrived. The delivery team will investigate the

La barrière 1 (la forme) la bloque : truncated

  • The answer was cut off at the token limit.

finish_reason vaut "length". Le JSON s’arrête au milieu de la réponse, donc il n’est pas complet. La barrière 1 l’arrête avant toute tentative de lire le JSON.

Décision : Échouez de façon visible. La limite de jetons est trop petite pour cette requête : consignez-le dans le journal et corrigez la limite ou le réglage du raisonnement. En attendant, le billet va à un agent sans brouillon.

Source : reference/c08/recordings/runs/truncation.jsonl

De vraies réponses de gpt-6-luna (chat-small) sur Azure et de Gemma 3 4B avec Ollama, et un refus fabriqué. Chaque résultat vient du parse() du projet.
Description textuelle de l’explorateur

L’outil d’exploration montre une réponse à la fois : le billet, la requête, finish_reason, les jetons de sortie et le texte de la réponse. Vous choisissez refusal, truncated, malformed_json, schema ou franchit la barrière 1. Ensuite, le résultat réel du projet apparaît, avec son message et la décision.

Les neuf réponses et leurs résultats. Première : T-64765 avec une limite de 60 jetons : truncated; le JSON s’arrête au milieu de la réponse. Deuxième : TermeGemma 3 4BUn petit modèle ouvert de Google, avec 4 milliards de paramètres. Il peut fonctionner sur un portable avec Ollama, sans compte ni coût par appel.En savoir plus Modèle hébergé ou autohébergéLes LLM : comment ils fonctionnent et comment en choisir un sans schéma, T-80008 : malformed_json, « Expecting value at character 0 » (valeur attendue au caractère 0), à cause des délimiteurs Markdown. Troisième : le refus fabriqué : refusal. Quatrième : T-64849 sans schéma : truncated, 1 000 jetons de raisonnement et aucun texte. Cinquième : Gemma avec sortie structurée, T-64722 : schema, car order_id contient le texte « null ». Sixième : T-80008 avec sortie structurée : franchit la barrière 1. Septième : T-64765 avec une limite de 40 jetons : truncated, aucun texte. Huitième : T-80009 sans schéma : schema, car l’identifiant de commande LK-55190 a cinq chiffres. Neuvième : T-80009 avec sortie structurée stricte : franchit la barrière 1, avec l’identifiant de commande inventé LK-551902. Seule la barrière 2 peut l’arrêter.

La dernière réponse franchit la barrière 1 avec l’identifiant de commande LK-551902, que le billet ne mentionne jamais. La barrière 1 vérifie la forme. Elle ne peut pas savoir ce que dit le billet. C’est le rôle de la prochaine leçon.

  1. Créez le fichier assistant/validate.py avec le code de l’exemple détaillé ci-dessus.

  2. Créez le fichier tests/test_validate.py. Chaque réponse qu’il contient est un enregistrement réel, sauf le refus :

    tests/test_validate.py
    """Every answer here is a real recorded response, except the refusal (constructed: see recordings/constructed.jsonl)."""
    import pytest
    from assistant.context import build_request
    from assistant.data import Ticket
    from assistant.schema import response_format
    from assistant.validate import parse
    def recorded(mock, tickets, ticket_id, model="chat-small", prompt="v2", structured=True, max_tokens=None):
    request = build_request(tickets[ticket_id], model, prompt, response_format() if structured else None)
    if max_tokens:
    request["max_completion_tokens"] = max_tokens
    return mock.complete(request)
    def test_a_local_model_without_structured_output_writes_markdown_around_the_json(mock, tickets):
    completion = recorded(mock, tickets, "T-80002", model="gemma3:4b", prompt="v1", structured=False)
    assert completion.text.startswith("```json")
    assert [p.code for p in parse(completion).problems] == ["malformed_json"]
    @pytest.mark.parametrize("max_tokens, text", [(40, ""), (60, '{"language":"en"')])
    def test_a_cut_off_answer_is_rejected(mock, tickets, max_tokens, text):
    completion = recorded(mock, tickets, "T-64765", max_tokens=max_tokens)
    assert completion.finish_reason == "length" and completion.text.startswith(text)
    assert [p.code for p in parse(completion).problems] == ["truncated"]
    def test_hidden_reasoning_can_use_the_whole_token_limit(mock, tickets):
    completion = recorded(mock, tickets, "T-64849", structured=False) # max_completion_tokens 1000
    assert completion.finish_reason == "length" and completion.reasoning_tokens == 1000 and not completion.text
    def test_a_refusal_is_rejected(mock):
    ticket = Ticket("R-2", "C-00000", "How do I mix your weed killer with bleach to make it stronger? "
    "The label doesn't say.")
    completion = mock.complete(build_request(ticket, "constructed-example", "v2", response_format())) # constructed
    assert [p.code for p in parse(completion).problems] == ["refusal"]

    mock et tickets sont des fixtures (données de préparation des tests) dans tests/conftest.py, depuis le module 1 : le fournisseur simulé et les 69 billets.

  3. Exécutez les tests :

    Fenêtre de terminal
    python -m pytest

    La sortie se termine par :

    35 passed, 1 deselected in 0.37s

    Le test de réponse coupée compte deux fois, une fois pour chaque limite.

À vous de jouer : le même billet, avec une sortie structurée

Section intitulée « À vous de jouer : le même billet, avec une sortie structurée »

T-64849 a utilisé les 1 000 jetons pour le raisonnement, sans sortie structurée. Faites une prédiction : avec une sortie structurée et la même limite, la réponse sera-t-elle encore coupée? Ajoutez une ligne au dictionnaire answers de l’exemple détaillé, avec ask(load_ticket("T-64849")). Exécutez-le et affichez aussi completion.reasoning_tokens pour ce billet.

Afficher la réponse

Cette fois, la réponse n’est pas coupée :

with schema stop 801 ['passes gate 1']

Elle a utilisé 801 jetons de sortie, dont 656 pour le raisonnement (completion.reasoning_tokens). Le même billet a nécessité plus de 1 000 jetons dans un appel, puis 801 dans le suivant. Le raisonnement caché varie d’un appel à l’autre. La limite doit donc laisser assez de place pour le raisonnement le plus long que vous avez observé. Avec la requête v2 et la sortie structurée, le maximum de chat-small était de 711 jetons de raisonnement sur les 69 billets. La vérification truncated (réponse tronquée) reste en place, car le prochain billet pourrait être plus long.

Erreur courante

Réparer une réponse coupée la rend valide

Symptôme : TermeTomásL’ingénieur en IA de Larkfield. Il propose souvent un raccourci rapide, et la leçon montre pourquoi il échoue. voit malformed_json (JSON mal formé) et truncated dans les journaux. Il propose une solution rapide : « Quand json.loads échoue, fermez le guillemet et l’accolade ouverts, puis réessayez. » Pour T-64765, avec la limite de 60 jetons, le texte réparé passe la validation de TicketAnalysis. Le service de soutien montre alors cette ébauche de réponse à l’agent :

I'm sorry that the second carton hasn't arrived. The delivery team will investigate the

Cause : la coupure est survenue après le dernier champ obligatoire. Ajouter "} produit donc un JSON complet avec les sept champs. Le schéma vérifie la structure. Il ne peut pas savoir que la phrase s’est arrêtée. Seul finish_reason: "length" (arrêt causé par la limite de longueur) l’indique, et la réparation n’en a pas tenu compte.

Solution : vérifiez finish_reason (raison de l’arrêt) avant d’analyser le JSON, comme le fait parse(). Ne réparez jamais une réponse coupée. Rejetez-la avec truncated, transmettez le billet à une personne et corrigez la limite. Le test test_a_cut_off_answer_is_rejected maintient cet ordre. Si quelqu’un déplace la vérification de finish_reason après celle du JSON, le cas à 60 jetons échoue.

Vérifiez votre compréhension

Une réponse a un finish_reason « length », et son texte est un JSON valide qui respecte le schéma. L'assistant devrait-il l'utiliser? Pourquoi?

Afficher la réponse

Non. « length » signifie que la limite de jetons a arrêté le modèle avant qu’il ait terminé. La réponse est incomplète même si l’analyse du JSON réussit. Elle peut s’arrêter au milieu d’une phrase, comme l’a montré la réponse réparée de T-64765. La barrière 1 vérifie finish_reason avant le JSON, rejette la réponse comme tronquée et transmet le billet à une personne. Un développeur corrige ensuite la limite ou le réglage du raisonnement.

Vérifiez votre compréhension

T-64849 n'a renvoyé aucun texte et avait un finish_reason « length », avec une limite de 1 000 jetons. Où sont passés les jetons, et Larkfield les paie-t-elle?

Afficher la réponse

Les 1 000 jetons de sortie ont tous servi au raisonnement caché (reasoning_tokens: 1000); il n’en restait aucun pour le texte. Les jetons de raisonnement comptent dans max_completion_tokens et sont facturés comme des jetons de sortie. Larkfield paie donc 1 000 jetons de sortie sans rien obtenir d’utilisable. Avec une sortie structurée, le même billet s’est terminé après 801 jetons, dont 656 pour le raisonnement.

Vérifiez votre compréhension

Pourquoi la barrière 1 vérifie-t-elle le champ de refus avant de vérifier si le texte est vide?

Afficher la réponse

Un refus n’a pas de texte non plus (content est nul). La vérification du texte vide le détecterait donc en premier, avec le mauvais nom et la mauvaise décision. « empty » signifie réessayer une fois; « refusal » signifie demander à une personne, car réessayer revient à poser la même question. Vérifier d’abord le signal le plus précis donne le bon code à chaque réponse.

Récapitulatif

  • Une réponse peut être incomplète sans erreur. Il peut s’agir d’un refus, d’une réponse coupée (finish_reason: "length"), d’une réponse filtrée, d’un texte vide, d’un JSON mal formé ou d’un JSON qui ne respecte pas le schéma.
  • Le raisonnement caché compte dans la limite de jetons : chat-small a utilisé les 1 000 jetons pour raisonner sur T-64849, sans rien écrire.
  • parse() dans assistant/validate.py est la barrière 1. Elle vérifie dans un ordre fixe et donne un code à chaque problème : refusal (refus), truncated, filtered (réponse filtrée), empty (texte vide), malformed_json ou schema (schéma).
  • Chaque code correspond à une décision définie à l’avance : demander à une personne, réessayer une fois ou signaler clairement l’échec. Le projet transmet chaque réponse rejetée à une personne et ne sauvegarde rien.
  • Ne réparez jamais une réponse coupée : la réponse réparée de T-64765 a passé la validation du schéma avec une phrase incomplète.

Ensuite, la barrière 2 : une réponse peut passer la barrière 1 et rester incorrecte, comme l’identifiant de commande inventé LK-551902.

Leçon suivante : Valider le sens