Aller au contenu
English

Limiter la boucle

  • Leçon 13
  • 45 min
  • Module 4 : Appeler des outils en toute sécurité

Ce que vous saurez faire

Assurez-vous que la boucle d’outils se termine toujours, peu importe ce que fait le modèle. Assurez-vous aussi qu’aucun outil ne peut modifier une commande ou déplacer de l’argent sans approbation humaine.

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

  • Le projet après Exécuter dans le code de l’application : assistant/tools.py terminé et tests/test_tools.py. python -m pytest affiche 49 passed, 1 deselected (49 tests réussis, 1 désélectionné).
  • Si vous avez sauté cette leçon, copiez l’instantané end-of-m03 en suivant Réinitialiser le projet. Faites ensuite les étapes des deux leçons précédentes de ce module. Ou copiez end-of-m04 et comparez-le aux étapes de cette leçon.

Un gérant de magasin demande à un nouveau collègue : « Appelez le fournisseur pour savoir quand les hamacs arrivent. » Le collègue appelle. Personne ne répond. Il rappelle, encore et encore, tout l’après-midi. La question était claire; il manquait une règle d’arrêt.

Un bon gérant ajoute trois règles. Appelez au maximum trois fois. Arrêtez après vingt minutes. Si vous obtenez deux fois la même réponse, arrêtez et dites-le-moi. Et une autre règle, pour un autre type d’appel : n’acceptez jamais de payer quoi que ce soit sans ma signature.

Le problème : le modèle décide quand la boucle se termine

Section intitulée « Le problème : le modèle décide quand la boucle se termine »

Dans la dernière leçon, la boucle a fait un tour : le modèle a demandé un outil, reçu le résultat et répondu. Mais rien n’oblige le modèle à répondre. Après chaque résultat d’outil, il peut demander un autre outil. L’application continue tant qu’il en demande.

Aucune boucle enregistrée du cours n’a mal tourné. Sur les 69 billets de soutien, 46 ont nécessité un appel au modèle et 23 en ont nécessité deux. Aucune boucle n’a répété un appel. L’échec ci-dessous est donc construit. TermeAmiraLa développeuse de Larkfield. Elle écrit le code des outils du service d’assistance dans les cours. a écrit à la main deux réponses où le modèle demande deux fois la même commande. Le projet les conserve sous le modèle constructed-example.

call 1 -> get_order({"order_id":"LK-581106"}) ok: shipped
call 2 -> get_order({"order_id":"LK-581106"}) the same call again
call 3 -> ...

Un modèle qui se répète une fois se répétera souvent encore, car il voit presque la même conversation chaque fois. Une boucle sans limite continue alors jusqu’à ce qu’un événement extérieur l’interrompe. Chaque appel renvoie toute la conversation : le deuxième appel de T-80008 contenait déjà 2 181 jetons (tokens) d’entrée. Cinquante appels enverraient plus de 100 000 jetons. C’est toute la limite de jetons du 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 de TermeLarkfieldUne boutique en ligne d’articles pour la maison. Ce n’est pas une vraie entreprise. Tous les cours Nextia utilisent son service d’assistance et ses données comme exemple.En savoir plus L'IA dans les produits de tous les joursBases d'une carrière en IA : rôles, compétences et feuilles de route (x-ratelimit-limit-tokens: 100000 dans les en-têtes de réponse enregistrés). Un seul billet bloqué empêcherait de traiter les 44 autres de la journée.

Une boucle limitée a des limites que le code impose, pour qu’elle se termine toujours. Le modèle choisit ce qu’il demande. Le code choisit combien de temps la conversation peut durer. Le projet utilise quatre limites, une pour chaque façon dont une boucle peut devenir incontrôlable :

Limite Valeur dans le projet Ce qu’elle arrête
Étapes au maximum 4 appels au modèle un modèle qui ne donne jamais de réponse finale
Temps au maximum 60 s pour toute la boucle des appels lents dont les durées s’accumulent
Taille du résultat au maximum 2 000 caractères par résultat d’outil un résultat énorme qui remplit le contexte et gonfle la facture
Appels répétés le même outil avec les mêmes arguments deux fois un modèle bloqué

Les valeurs viennent des enregistrements. Les vraies boucles ont nécessité au maximum 2 appels au modèle; 4 laisse donc une marge. L’appel médian a pris 1,55 s; 60 s est donc généreux. Le plus long résultat d’outil réel comptait 350 caractères. Quand une limite arrête la boucle, le résultat est failed : aucune réponse n’est enregistrée. Une personne traite le billet, comme pour tout autre échec.

Suivez la boucle dans l’image à partir du haut. Chaque chemin se termine dans une case : une réponse ou un arrêt.

Schéma de la boucle d'outils. Avant chaque appel au modèle : 4 appels au modèle déjà faits? Oui : arrêt max_steps. Non : appeler le modèle. Des appels d'outils dans la réponse? Non : réponse finale, les deux barrières. Oui : même appel et mêmes arguments qu'avant? Oui : arrêt repeated_call. Non : run_tool, les vérifications puis la lecture; couper le résultat à 2 000 caractères; plus de 60 s depuis le début? Oui : arrêt time. Non : retour à la première vérification. Chaque arrêt mène à : résultat failed, aucune réponse enregistrée, une personne prend le billet.
La boucle d'outils limitée du projet (assistant/loop.py). Trois arrêts et une réponse : chaque chemin se termine.
Description textuelle du diagramme

La boucle commence par une requête qui contient l’outil get_order.

  1. Avant chaque appel au modèle : 4 appels au modèle ont-ils déjà été faits? Si oui, arrêter : max_steps.
  2. Appeler le modèle. La réponse contient-elle des appels d’outils? Sinon, c’est la réponse finale : elle passe aux deux barrières.
  3. Si oui, pour chaque appel : est-ce le même outil avec les mêmes arguments qu’un appel précédent? Si oui, arrêter : repeated_call.
  4. Sinon : run_tool fait les vérifications, puis la lecture. Le résultat est coupé à 2 000 caractères.
  5. Plus de 60 secondes se sont-elles écoulées depuis le début? Si oui, arrêter : time. Sinon, revenir à l’étape 1.

Chaque arrêt mène au même résultat : failed. Aucune réponse n’est enregistrée, et une personne prend le billet.

Voici la boucle du projet. Lisez-la à côté de l’image : chaque return (retour) correspond à une case.

assistant/loop.py
"""The tool loop: the model proposes calls, the application decides, and the loop always ends."""
import json
import time
from dataclasses import dataclass, field
from typing import Callable
from .orders import OrderBook
from .providers import Completion
from .tools import run_tool
@dataclass
class Limits:
max_steps: int = 4 # model calls in one loop
max_seconds: float = 60.0 # wall-clock time for the whole loop
max_result_chars: int = 2000 # a tool result longer than this is cut before the model sees it
@dataclass
class Step:
name: str
arguments: str
outcome: str # "ok", "refused: <code>" or "stopped: repeated call"
@dataclass
class LoopResult:
completion: Completion | None # the final answer, or None when the loop was stopped
completions: list[Completion] = field(default_factory=list)
steps: list[Step] = field(default_factory=list)
looked_up: dict[str, dict] = field(default_factory=dict) # orders that the application really returned
stopped: str = "" # "", "max_steps", "repeated_call" or "time"
def run_tool_loop(complete: Callable[[dict], Completion], request: dict, customer_id: str, book: OrderBook,
limits: Limits = Limits(), clock: Callable[[], float] = time.monotonic) -> LoopResult:
result = LoopResult(None)
messages = list(request["messages"])
seen = set()
start = clock()
for _ in range(limits.max_steps):
completion = complete({**request, "messages": list(messages)}) # a copy: the list grows below
result.completions.append(completion)
if not completion.tool_calls:
result.completion = completion
return result
messages.append({"role": "assistant", "content": completion.text, "tool_calls": [
{"id": c.id, "type": "function", "function": {"name": c.name, "arguments": c.arguments}}
for c in completion.tool_calls]})
for call in completion.tool_calls:
signature = (call.name, call.arguments.replace(" ", ""))
if signature in seen:
result.steps.append(Step(call.name, call.arguments, "stopped: repeated call"))
result.stopped = "repeated_call"
return result
seen.add(signature)
output, outcome = run_tool(call.name, call.arguments, customer_id, book)
result.steps.append(Step(call.name, call.arguments, outcome))
if output["ok"]:
result.looked_up[output["order"]["order_id"]] = output["order"]
text = json.dumps(output, ensure_ascii=False)[:limits.max_result_chars]
messages.append({"role": "tool", "tool_call_id": call.id, "content": text})
if clock() - start > limits.max_seconds:
result.stopped = "time"
return result
result.stopped = "max_steps"
return result

Quatre détails méritent un deuxième regard :

  • for _ in range(limits.max_steps) est la limite d’étapes. Une boucle while True ferait confiance au modèle pour s’arrêter.
  • La signature d’un appel comprend son nom et ses arguments sans espaces. Ainsi, {"order_id": "LK-581106"} et {"order_id":"LK-581106"} comptent comme le même appel.
  • looked_up conserve seulement les commandes renvoyées par run_tool. La règle d’affaires status_not_from_lookup l’utilise : le modèle ne peut pas affirmer un état que le code n’a pas vu.
  • complete et clock sont des paramètres. Le projet transmet le complete du fournisseur et l’horloge réelle. Un test peut transmettre un faux modèle et une fausse horloge. Ainsi, aucun test n’attend 60 secondes.

La vérification des appels répétés détecte un modèle bloqué, pas un modèle qui échoue. Deux identifiants erronés différents ne sont pas une répétition. La limite d’étapes les arrête.

L’explorateur contient maintenant la boucle construite. Choisissez Repeated call (constructed) (appel répété, construit), puis parcourez-la étape par étape et comparez-la à Own order (sa propre commande).

Explorer

Une boucle qui se termine par une réponse, et une boucle que le code arrête

T-80008, client C-81265: “Hello, where is my order LK-581106? The confirmation email said the garden shed shipped on Monday, but I have no delivery date yet.”

Exemple construit. Aucun modèle n’a écrit ces réponses : aucune boucle enregistrée n’a répété un appel, donc celle-ci a été écrite à la main pour tester la limite.

Appels au modèle
2
Jetons jusqu’ici
aucun (construit)
Résultat de l’outil
ok, stopped: repeated call
Résultat
failed

Étape 8 sur 8. Aucune réponse à vérifier : The tool loop was stopped: repeated_call.

  1. 1 Application → modèle Appel au modèle 1 : 2 messages (system, user) et la description de get_order

    Construit : aucun appel au modèle n’a été fait.

  2. 2 Modèle Propose un appel

    get_order({"order_id":"LK-581106"})
  3. 3 Application Vérifie la proposition, puis lit la commande

    • réussie Le nom de l’outil est get_order
    • réussie Les arguments sont du JSON avec une seule clé, order_id
    • réussie order_id est LK- suivi de 6 chiffres
    • réussie La commande existe
    • réussie La commande appartient au client de ce billet
  4. 4 Application → modèle Renvoie le résultat de l’outil

    {
     "ok": true,
     "order": {
      "order_id": "LK-581106",
      "status": "shipped",
      "ordered_on": "2026-10-01",
      "shipped_on": "2026-10-05",
      "delivered_on": null,
      "shipping_fee": 0,
      "total": 899,
      "currency": "CAD",
      "items": [
       {
        "product": "garden shed",
        "quantity": 1,
        "unit_price": 899
       }
      ]
     }
    }
  5. 5 Application → modèle Appel au modèle 2 : 4 messages (system, user, assistant, tool) et la description de get_order

    Construit : aucun appel au modèle n’a été fait.

  6. 6 Modèle Propose un appel

    get_order({"order_id":"LK-581106"})
  7. 7 Application Arrête la boucle : appel répété

    The same call with the same arguments was already made.

  8. 8 Application Aucune réponse pour les deux barrières : failed

    The tool loop was stopped: repeated_call.

Own order : un enregistrement réel (gpt-6-luna comme chat-small sur Azure). Repeated call : construit; aucun modèle ne l'a écrit.
Description textuelle de l’explorateur

Le même explorateur que dans la leçon Exécuter dans le code de l’application, avec deux boucles : sa propre commande (T-80008, réelle) et appel répété (T-80008, construit).

Appel répété, construit : appel au modèle 1 avec 2 messages; aucun jeton, car aucun modèle n’a été appelé. Le modèle propose get_order avec LK-581106. Les cinq vérifications réussissent et l’application renvoie la commande : shipped (expédiée). Appel au modèle 2 avec 4 messages. Le modèle propose de nouveau get_order avec LK-581106. L’application arrête la boucle : appel répété; le même appel avec les mêmes arguments a déjà été fait. Il n’y a pas de réponse finale. Résultat : failed, « The tool loop was stopped : repeated_call. » (la boucle d’outils a été arrêtée : appel répété).

Sa propre commande, réelle : deux appels au modèle, la commande renvoyée une fois, une réponse finale avec l’état shipped. Les deux barrières sont franchies : valid. 4 224 jetons en entrée, 123 en sortie.

La boucle construite s’arrête à l’étape 7, avant la deuxième consultation. Aucune réponse n’atteint les barrières; rien ne peut donc être enregistré.

  1. Créez assistant/loop.py avec le code ci-dessus.

  2. Ouvrez assistant/analyse.py. Ajoutez ces deux importations, en ordre alphabétique avec les autres :

    from .loop import Limits, LoopResult, Step, run_tool_loop
    from .tools import TOOLS
  3. Dans Outcome, ajoutez le champ steps après problems. Dans to_dict, ajoutez la clé tool_steps après la clé problems :

    assistant/analyse.py (Outcome)
    problems: list[Problem] = field(default_factory=list)
    steps: list[Step] = field(default_factory=list)
    "tool_steps": [asdict(s) for s in self.steps],
  4. Remplacez analyse_ticket par cette version :

    assistant/analyse.py (analyse_ticket)
    def analyse_ticket(ticket: Ticket, provider, model: str, prompt: str = "v2", structured: bool = True,
    tools: bool = True, book: OrderBook | None = None, limits: Limits = Limits(),
    reasoning_effort: str = "") -> Outcome:
    book = book or OrderBook()
    request = build_request(ticket, model, prompt, response_format() if structured else None,
    TOOLS if tools else None, reasoning_effort)
    try:
    if tools:
    result = run_tool_loop(provider.complete, request, ticket.customer_id, book, limits)
    else:
    completion = provider.complete(request)
    result = LoopResult(completion, [completion])
    except ProviderError as error:
    return Outcome(ticket.ticket_id, "failed", None, error=str(error))
    if result.stopped:
    return Outcome(ticket.ticket_id, "failed", None, steps=result.steps, completions=result.completions,
    error=f"The tool loop was stopped: {result.stopped}.")
    verdict = validate(result.completion, ticket, book, result.looked_up)
    return Outcome(ticket.ticket_id, "valid" if verdict.valid else "rejected", verdict.analysis, verdict.problems,
    result.steps, result.completions)
  5. Ouvrez assistant/__main__.py. Ajoutez --no-tools sous la ligne qui ajoute --save, et --tools sous la ligne many = … :

    one.add_argument("--no-tools", action="store_true", help="do not offer the order lookup")
    many.add_argument("--tools", action="store_true", help="offer the order lookup (default: off)")
  6. Dans le même fichier, transmettez le réglage et les options :

    assistant/__main__.py (changed lines)
    options = dict(model=model, prompt=args.prompt, structured=not args.no_schema,
    reasoning_effort=settings.reasoning_effort)
    outcome = analyse_ticket(ticket, provider, tools=not args.no_tools, **options)
    outcome = analyse_ticket(ticket, provider, tools=args.tools, **options)

    Le premier est dans la branche analyse, le deuxième dans la boucle de eval. Mettez aussi à jour la chaîne de documentation en haut : analyse utilise maintenant des outils. eval n’en utilise aucun, sauf si vous ajoutez --tools.

  7. Copiez tests/test_loop.py et tests/test_analyse.py de l’instantané end-of-m04 dans votre dossier tests. Procédez comme pour les enregistrements dans Décrire un outil. Ils testent les boucles enregistrées, la répétition construite, la limite d’étapes et la limite de temps.

  8. Exécutez les tests :

    Fenêtre de terminal
    python -m pytest
  9. Exécutez le billet de démonstration, puis la boucle construite :

    Fenêtre de terminal
    python -m assistant analyse T-80008
    python -m assistant analyse T-80008 --model constructed-example

L’étape 8 affiche 59 passed, 1 deselected (59 tests réussis, 1 désélectionné). La dernière ligne de la première commande de l’étape 9 :

valid | chat-small | prompt v2 | 2 call(s) | 4224 tokens in, 123 out

La deuxième commande affiche les étapes de la boucle et s’arrête (réponses construites, donc 0 jeton) :

"tool_steps": [
{
"name": "get_order",
"arguments": "{\"order_id\":\"LK-581106\"}",
"outcome": "ok"
},
{
"name": "get_order",
"arguments": "{\"order_id\":\"LK-581106\"}",
"outcome": "stopped: repeated call"
}
],
"error": "The tool loop was stopped: repeated_call."
}
failed | constructed-example | prompt v2 | 2 call(s) | 0 tokens in, 0 out

Essayez aussi Termechat-strongLe nom que le code de Larkfield donne à un modèle hébergé plus grand : gpt-6.1-sol sur Azure. Il est souvent plus exact que chat-small et coûte environ 14 à 18 fois plus par appel.En savoir plus Les éléments d'une requêteCréer des applications fiables avec les API de LLM : python -m assistant analyse T-80008 --model chat-strong. La véritable erreur HTTP 400 de Décrire un outil se termine maintenant par failed | chat-strong | prompt v2 | 0 call(s) | 0 tokens in, 0 out, plutôt que par un plantage.

Chaque limite ci-dessus protège une lecture. Une lecture qui échoue gaspille du temps et des jetons; vous pouvez la répéter. Une écriture est différente : un remboursement, une commande annulée, un courriel à un client. Arrêter une boucle ne permet pas d’annuler une écriture erronée.

L’assistant de Larkfield n’a aucun outil d’écriture, et c’est voulu. La règle 8 de la politique de TermeGraceLa responsable du service d’assistance de Larkfield. Elle écrit la politique de support et décide ce qu’est une bonne réponse à un client. précise qu’il rédige seulement des brouillons, sans jamais rembourser ni modifier une commande. L’outil d’écriture le plus sûr est celui qui n’existe pas.

Mais les billets demandent des écritures. T-80003 dit : « Veuillez rembourser les 129,00 dollars sur ma carte aujourd’hui. Je préférerais ne pas le retourner, il est lourd. » Supposons que Larkfield veuille un jour que l’assistant prépare des remboursements. La conception doit conserver la signature du gérant dans l’histoire du téléphone : une personne approuve avant tout changement. L’image présente une conception pour cela, pas du code du projet.

Une conception absente du projet. Le modèle propose request_refund pour LK-428830, 129,00. L'assistant fait les mêmes vérifications que pour une lecture. Il enregistre une demande en attente avec l'état waiting (en attente); aucun argent ne bouge. Un agent voit la demande en attente avec la commande, la règle de la politique et la raison du modèle. Si l'agent approuve, son action envoie le remboursement au système de paiement avec l'identifiant de demande comme clé d'idempotence. Le remboursement est effectué une seule fois. Si l'agent refuse, la demande est fermée avec une raison.
Une conception pour une étape d'approbation. L'assistant de ce cours n'a aucun outil d'écriture; voici à quoi un tel outil ressemblerait.
Description textuelle du diagramme

Une conception seulement : l’assistant de ce cours n’a aucun outil d’écriture.

  1. Le modèle propose request_refund avec la commande LK-428830 et le montant 129,00.
  2. L’assistant fait les mêmes vérifications que pour une lecture : le format, la commande du client, un montant ne dépassant pas le total de la commande.
  3. Il enregistre une demande en attente avec l’état « waiting » (en attente). Aucun argent ne bouge.
  4. Un agent humain voit la demande en attente dans l’application de soutien, avec la commande, la règle et la raison du modèle.
  5. Si l’agent approuve, son action envoie le remboursement au système de paiement, avec l’identifiant de demande comme clé d’idempotence. Le système de paiement l’effectue une seule fois.
  6. Si l’agent refuse, la demande est fermée, avec une raison.

Trois idées soutiennent cette conception :

  • L’outil du modèle crée seulement une proposition. request_refund écrit une ligne avec l’état « waiting ». Il n’a aucun accès au système de paiement.
  • Une personne approuve dans l’application, pas dans la conversation. L’agent voit la commande, la règle et la raison, puis appuie sur un bouton. Le modèle ne peut pas y appuyer, et aucun texte de billet ne peut y accéder.
  • L’écriture s’exécute une seule fois. L’identifiant de demande est transmis au système de paiement comme clé d’idempotence. Si la même approbation arrive deux fois, le système rembourse une seule fois. Gérer les limites du fournisseur explique pourquoi les requêtes répétées en ont besoin.

Pour T-80003, la politique de Grace donne la réponse : aucun remboursement avant le retour de l’article. Un agent refuserait la demande en quelques secondes. L’étape d’approbation prend du temps à cet agent pour chaque écriture. C’est pourquoi vous la concevez seulement pour les écritures, jamais pour les lectures.

Erreur courante

« Le modèle signalera une demande de remboursement à une personne »

Symptôme : TermeTomásL’ingénieur en IA de Larkfield. Il propose souvent un raccourci rapide, et la leçon montre pourquoi il échoue. soutient qu’une étape d’approbation demande trop de travail : « La requête dit qu’un remboursement hors politique nécessite une personne. Le modèle définit needs_human, et l’agent le voit. » Amira exécute tout le 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 avec la consultation activée : python -m assistant eval --tools. Le score affiche "needs_human_true_caught": "8/9". Le cas manqué est T-80003, la demande de remboursement. Avec la consultation, 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 a défini needs_human à faux. Sans la consultation, le même modèle l’a défini à vrai.

Cause : la réponse était correcte (« nous ne pouvons pas rembourser le parasol sans le recevoir en retour »). Mais l’indicateur repose sur un jugement du modèle, et un petit changement dans la conversation l’a modifié. Ici, le changement était un résultat d’outil supplémentaire. Un jugement qui change avec le contexte ne peut pas être la seule protection avant un transfert d’argent.

Correction : placez l’approbation dans la structure, pas dans le jugement du modèle. Un outil d’écriture crée seulement une demande en attente. Seule l’action d’une personne dans l’application la termine. Un indicateur manqué coûte alors une minute à un agent, pas 129 dollars. Pour les indicateurs à ne jamais manquer, ajoutez une règle d’affaires dans le code, comme au module 3 pour les mots liés à la sécurité.

Ce que cela signifie pour un vrai service d’IA : un modèle peut rédiger, trier et suggérer. Toute action difficile à annuler nécessite un contrôle indépendant de la capacité du modèle à réussir cette fois-ci.

L’évaluation complète avec la consultation activée (enregistrements réels) montre aussi un coût en qualité. L’équipe reste à 67 sur 68, mais l’accord sur needs_human passe de 67 à 66 sur 69. Avec 69 billets, un seul billet ne prouve pas une tendance. Mais T-80003 est le billet le plus important pour Grace; l’équipe conserve donc la conception avec approbation.

Ajoutez un deuxième outil en lecture seule : une consultation des billets précédents du client. Utilisez des données synthétiques : trois ou quatre billets inventés dans un dictionnaire Python. Chacun contient un customer_id, une date, une équipe, un état et un court résumé. Au moins une entrée doit appartenir à un autre client.

  1. Créez assistant/ticket_tools.py avec une description GET_TICKET (les quatre parties), une fonction get_ticket(ticket_id, customer_id, records) et une fonction run_ticket_tool(name, arguments, customer_id, records).
  2. Utilisez les mêmes règles que pour get_order. Vérifiez le format de l’identifiant : T- et 5 chiffres. Refusez les clés supplémentaires. Refusez le billet d’un autre client avec les mêmes mots que pour un billet absent.
  3. Écrivez tests/test_ticket_tools.py avec un test pour un billet du client, un pour celui d’un autre client et un pour des arguments incorrects.
  4. N’ajoutez pas GET_TICKET à TOOLS. Le simulateur n’a aucun enregistrement pour une requête avec deux outils; testez donc les fonctions directement.
Afficher un exemple de réponse
assistant/ticket_tools.py
"""Module practice: a read-only lookup of the customer's earlier tickets.
The records are synthetic: made up for this practice, like every ticket of the course.
The rules are the same as for get_order: the model proposes, this code decides.
"""
import json
import re
from .tools import ToolRefused
TICKET_ID = re.compile(r"^T-\d{5}$")
PAST_TICKETS = { # synthetic records
"T-50001": {"customer_id": "C-81265", "opened_on": "2026-09-02", "team": "delivery", "status": "closed",
"summary": "A garden hose arrived two days late. The customer was told the new date."},
"T-50002": {"customer_id": "C-81265", "opened_on": "2026-09-20", "team": "payment", "status": "closed",
"summary": "The customer asked for a copy of the invoice. It was sent."},
"T-50003": {"customer_id": "C-38852", "opened_on": "2026-09-28", "team": "delivery", "status": "open",
"summary": "A question about the delivery address of a garden shed."},
}
GET_TICKET = {
"type": "function",
"function": {
"name": "get_ticket",
"description": ("Look up one of this customer's earlier Larkfield support tickets by its ticket ID. "
"Read-only. Returns the date, the team, the status and a short summary. "
"Tickets of other customers are not available."),
"strict": True,
"parameters": {
"type": "object",
"properties": {"ticket_id": {"type": "string", "description": "The ticket ID, T- and 5 digits."}},
"required": ["ticket_id"],
"additionalProperties": False,
},
},
}
def get_ticket(ticket_id: str, customer_id: str, records: dict = PAST_TICKETS) -> dict:
"""Return the ticket if it exists and belongs to customer_id; otherwise raise ToolRefused."""
if not isinstance(ticket_id, str) or not TICKET_ID.match(ticket_id):
raise ToolRefused("invalid_argument", "The ticket ID must be T- followed by 5 digits.")
record = records.get(ticket_id)
if record is None:
raise ToolRefused("not_found", f"No ticket {ticket_id} is available for this customer.")
if record["customer_id"] != customer_id:
raise ToolRefused("other_customer", f"No ticket {ticket_id} is available for this customer.")
return {"ticket_id": ticket_id, **{k: v for k, v in record.items() if k != "customer_id"}}
def run_ticket_tool(name: str, arguments: str, customer_id: str, records: dict = PAST_TICKETS) -> tuple[dict, str]:
"""Run one proposed call. Return (the result for the model, the outcome for the log)."""
try:
if name != "get_ticket":
raise ToolRefused("unknown_tool", f"There is no tool named {name}.")
try:
args = json.loads(arguments)
except json.JSONDecodeError:
raise ToolRefused("invalid_argument", "The arguments are not valid JSON.") from None
if not isinstance(args, dict) or set(args) != {"ticket_id"}:
raise ToolRefused("invalid_argument", "get_ticket takes exactly one argument: ticket_id.")
return {"ok": True, "ticket": get_ticket(args["ticket_id"], customer_id, records)}, "ok"
except ToolRefused as refused:
return {"ok": False, "error": str(refused)}, f"refused: {refused.code}"
tests/test_ticket_tools.py
import pytest
from assistant.ticket_tools import run_ticket_tool
def test_a_customer_gets_their_own_earlier_ticket():
result, outcome = run_ticket_tool("get_ticket", '{"ticket_id": "T-50001"}', "C-81265")
assert outcome == "ok" and result["ticket"]["status"] == "closed"
assert "customer_id" not in result["ticket"]
def test_another_customers_ticket_is_refused_like_a_missing_one():
other, other_outcome = run_ticket_tool("get_ticket", '{"ticket_id": "T-50003"}', "C-81265")
missing, missing_outcome = run_ticket_tool("get_ticket", '{"ticket_id": "T-59999"}', "C-81265")
assert other_outcome == "refused: other_customer" and missing_outcome == "refused: not_found"
assert other["error"].replace("T-50003", "X") == missing["error"].replace("T-59999", "X")
@pytest.mark.parametrize("arguments", ['{"ticket_id": "T-5001"}', '{"ticket_id": "T-50001", "customer_id": "C-38852"}',
"not json"])
def test_bad_arguments_are_refused_before_the_records(arguments):
result, outcome = run_ticket_tool("get_ticket", arguments, "C-81265")
assert result["ok"] is False and outcome == "refused: invalid_argument"

python -m pytest tests/test_ticket_tools.py affiche 5 passed (5 tests réussis). L’outil réutilise ToolRefused; le journal contient donc les mêmes codes de résultat pour les deux outils. Pour proposer cet outil à un modèle en direct, la boucle devrait aussi appeler run_ticket_tool pour le nom get_ticket.

Montrez deux choses avec un seul test : l’application bloque un identifiant non autorisé, et la boucle arrête un appel répété. Écrivez tests/test_module_check.py. Utilisez un modèle programmé : une fonction qui retourne toujours le même appel d’outil construit. Faites-lui demander la commande LK-615204 du voisin, avec l’identifiant client C-20417 de Tom.

Afficher un exemple de réponse
tests/test_module_check.py
"""Module check: block an unauthorized identifier and stop a repeated-call loop."""
import json
from assistant.loop import run_tool_loop
from assistant.orders import OrderBook
from assistant.providers import Completion
def scripted_call(order_id):
"""A constructed model response that asks for get_order (no model wrote it)."""
data = {"choices": [{"finish_reason": "tool_calls", "message": {"content": None, "tool_calls": [
{"id": "call_1", "type": "function", "function": {"name": "get_order",
"arguments": json.dumps({"order_id": order_id})}}]}}]}
return Completion.from_response(data, 0.0)
def test_the_neighbours_order_is_blocked_and_the_repeat_stops_the_loop():
def model(request):
return scripted_call("LK-615204") # asks for the neighbour's order every time
result = run_tool_loop(model, {"messages": []}, "C-20417", OrderBook())
assert [s.outcome for s in result.steps] == ["refused: other_customer", "stopped: repeated call"]
assert result.stopped == "repeated_call" and result.completion is None
assert result.looked_up == {}

python -m pytest tests/test_module_check.py affiche 1 passed (1 test réussi). Le premier appel est refusé, donc looked_up reste vide : la commande du voisin n’est jamais entrée dans la conversation. Le deuxième appel est identique, donc la boucle s’arrête avant de l’exécuter.

Vous avez terminé le module quand :

  • python -m pytest réussit, avec vos nouveaux tests.
  • Vous pouvez expliquer, pour T-80001, quelle vérification a refusé l’appel. Vous pouvez aussi expliquer pourquoi le modèle a reçu les mêmes mots que pour une commande introuvable.
  • Vous pouvez expliquer quelle limite arrête un modèle qui demande quatre identifiants erronés différents. Vous pouvez aussi expliquer pourquoi la vérification des appels répétés ne l’arrête pas.

Réinitialisation. Si votre projet ne fonctionne plus, renommez-le et copiez l’instantané end-of-m04 en suivant Réinitialiser le projet :

Fenêtre de terminal
mv ticket-assistant ticket-assistant-old
cp -R nextia-learning-labs/C08/snapshots/end-of-m04 ticket-assistant

Sous Windows, dans PowerShell : Rename-Item ticket-assistant ticket-assistant-old, puis Copy-Item -Recurse nextia-learning-labs\C08\snapshots\end-of-m04 ticket-assistant. Recréez l’TermeEnvironnement virtuelUn dossier avec son propre Python et ses propres paquets, pour un seul projet. Les paquets installés là ne changent pas les autres projets.En savoir plus Isoler les dépendancesPython pour l'ingénierie de l'IA en pratique, comme l’indique le README des instantanés. python -m pytest affiche alors 59 passed, 1 deselected (59 tests réussis, 1 test désélectionné). L’instantané contient les étapes guidées de ce module, mais pas l’exercice du module. Ces étapes ont été testées sur macOS; les commandes Windows n’ont pas été testées.

Vérifiez votre compréhension

Un modèle propose get_order avec LK-000001, puis LK-000002, puis LK-000003, puis LK-000004. Quelle limite arrête la boucle, et après combien d'appels au modèle?

Afficher la réponse

La limite d’étapes. Les quatre appels ont des arguments différents, donc la vérification des appels répétés ne trouve jamais de correspondance. Chaque appel est refusé (not_found), et la boucle continue. Après le quatrième appel au modèle et son refus, range(4) est épuisé. La boucle se termine avec stopped: max_steps (arrêt : nombre maximal d’étapes). Le résultat est failed (échec), et une personne prend en charge le billet de soutien. Le test test_the_step_limit_stops_a_model_that_never_answers vérifie ce cas avec une limite de 3.

Vérifiez votre compréhension

Pourquoi un outil en lecture seule peut-il s'exécuter sans l'approbation d'une personne, contrairement à un outil de remboursement?

Afficher la réponse

Une lecture incorrecte coûte du temps et des jetons. Les vérifications garantissent qu’elle lit seulement ce que le client peut voir. Vous pouvez la répéter ou l’ignorer. Une écriture incorrecte, comme un remboursement, modifie des montants ou des données et est difficile ou impossible à annuler. Un outil d’écriture crée donc seulement une demande en attente. Une personne l’approuve dans l’application, à un endroit inaccessible aux sorties du modèle et au texte des billets.

Vérifiez votre compréhension

Prédisez : vous définissez Limits(max_result_chars=50) et exécutez T-80008 avec le simulateur. Qu'arrive-t-il au message de l'outil, et pourquoi le simulateur peut-il ensuite échouer?

Afficher la réponse

Le résultat de l’outil pour LK-581106 dépasse 50 caractères. La boucle le coupe donc après 50 caractères, au milieu du JSON. Le modèle verrait un résultat invalide. Avec le simulateur, la deuxième requête diffère maintenant de celle enregistrée, car le message de l’outil fait partie de la requête. Le simulateur répond donc No recording for this request (aucun enregistrement pour cette requête), et le résultat est failed. Les résultats réels comptaient au plus 350 caractères. C’est pourquoi le projet utilise 2 000.

Récapitulatif

  • Le modèle décide s’il demande un autre outil, donc le code doit décider quand la boucle se termine. Une boucle limitée possède des limites que le code fait respecter.
  • Les limites du projet : au plus 4 appels au modèle, 60 s, 2 000 caractères par résultat d’outil, et aucun appel identique répété. Un arrêt donne le résultat failed : rien n’est enregistré, et une personne prend en charge le billet.
  • Aucune boucle enregistrée n’a répété un appel ni nécessité plus de 2 appels au modèle. La boucle à appels répétés de cette leçon est construite, et les tests l’utilisent.
  • analyse utilise maintenant la recherche, et --no-tools la désactive. eval --tools évalue les 69 billets avec cette recherche : équipe 67 sur 68, needs_human (intervention humaine requise) 66 sur 69. Il a manqué T-80003, la demande de remboursement.
  • L’assistant n’a aucun outil d’écriture. Une écriture nécessiterait une étape d’approbation. L’outil du modèle crée une demande en attente, une personne l’approuve dans l’application. L’écriture s’exécute ensuite une seule fois avec une clé d’idempotence.

Vous avez terminé le module 4. Ensuite, l’assistant maintient une conversation avec un agent, dans les limites d’un budget de jetons.

Leçon suivante : Gérer l'état de la conversation