Aller au contenu
English

Un état de tâche explicite

  • Leçon 4
  • 50 min
  • Module 2 : Représenter l'état et les outils

Ce que vous saurez faire

Gardez tout ce qu’une tâche sait, décide et fait de travers dans des champs typés que votre code peut vérifier. Ainsi, le texte d’un modèle ne peut jamais passer pour un fait.

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

Ce que vous devez savoir

Ce qu’il vous faut sur votre ordinateur

Cette leçon n’ajoute aucun fichier au projet. Vous lisez l’état d’exécutions réelles dans une session Python. Chaque décision de modèle sur cette page est un enregistrement : gemma3:4b dans Ollama, ou gpt-6-luna sur Azure sous chat-small. Vous n’avez besoin ni de compte ni de clé. Les modèles et les cadres d’agents changent vite. Les chiffres de ce module sont donc un instantané; la méthode reste valable. Testé avec Python 3.12.13 sur macOS. Les commandes Windows ne sont pas testées.

Une cliente apporte un vélo à un atelier de réparation. Pendant les deux jours suivants, elle et le mécanicien échangent vingt textos. « Les freins grincent. » « Peut-être la roue arrière aussi? » « Pouvez-vous aussi regarder la chaîne? » « Les nouvelles plaquettes de frein coûtent 30 dollars, ça vous va? » « Oui, allez-y. »

Le troisième jour, un autre mécanicien prend le vélo en charge. Il pourrait lire les vingt messages et essayer de comprendre où en est le travail. Ou il pourrait lire la fiche de travail sur le guidon. Elle contient quelques lignes fixes : le travail, les vérifications faites, les pièces approuvées et l’argent dépensé. La dernière ligne indique l’état : en attente de pièces.

Les messages gardent une trace de la discussion. La fiche de travail garde une trace du travail. « Peut-être la roue arrière aussi? » figure dans les messages, mais personne n’a vérifié la roue arrière. Seule la fiche de travail indique ce qui est vrai.

L’agent de ce cours communique avec son modèle par messages. Chaque appel au modèle renvoie le message système, le billet de soutien et toutes les étapes précédentes. Après quelques étapes, cet historique de conversation contient tout ce qui s’est passé, sous forme de texte. Tomás fait donc une suggestion : « L’historique de conversation, c’est l’état. On n’a besoin de rien d’autre. »

Regardez d’abord une exécution réelle. Le billet T-80001 vient de Tom. Sa voisine lui a demandé de vérifier sa commande à elle, LK-615204. gemma3:4b, un petit modèle local, a exécuté la boucle de l’agent pour ce billet. Voici ses trois premières étapes :

  1. Il a demandé get_order avec LK-615204. L’application a refusé : No order LK-615204 is available for this customer. (Aucune commande LK-615204 n’est disponible pour ce client.) La commande appartient à une autre cliente.
  2. Il a demandé get_customer_orders, les commandes de Tom. Tom n’en a aucune, donc le résultat était une liste vide. Dans la même réponse, il a rédigé un brouillon qui mentionne LK-615204.
  3. Il a demandé get_customer_orders de nouveau.

Supposons maintenant que votre code doit répondre à une question : quelles commandes cette tâche a-t-elle vraiment vues? La politique de Grace exige cette réponse avant toute action. Une action sur une commande qu’aucun outil n’a renvoyée n’est jamais permise. Si l’historique de conversation sert d’état, le code doit trouver la réponse dans le texte. Le texte mentionne LK-615204 cinq fois : dans le billet, l’appel, le refus et deux brouillons de réponse. Une recherche d’identifiants de commande dans l’historique trouve exactement une commande : celle de la voisine.

Une vérification fondée sur l’historique considérerait donc la commande de la voisine comme connue. C’est le contraire de la réalité. L’application a refusé cette commande, et aucun outil n’a renvoyé de commande.

L’état d’une tâche représente ce que l’application sait de cette tâche maintenant. Il comprend son objectif, ses découvertes, sa progression, ses décisions et ses erreurs. Un état de tâche explicite inscrit chacun de ces éléments dans un champ typé distinct. « Explicite » signifie que le code n’a rien à déduire du texte. Il suffit de lire un champ pour obtenir chaque réponse.

Le projet garde cet état dans TaskState, un modèle Pydantic dans resolver/state.py. Le schéma montre deux fois la même exécution de T-80001 : comme historique de conversation, puis comme état de tâche.

À gauche : l'historique de conversation de l'exécution, 8 messages. Le message système; le message utilisateur contenant le billet, qui mentionne LK-615204; l'appel get_order de l'assistant pour LK-615204; le refus, qui mentionne LK-615204; l'appel get_customer_orders de l'assistant avec un brouillon de réponse qui mentionne LK-615204; le résultat vide; le même appel de nouveau; le résultat vide. À droite : l'état de la tâche. Objectif : le texte du billet. État stopped, raison de l'arrêt max_steps, étape 3. Plan : ask_customer. Preuves : trois résultats : get_order other_customer, puis get_customer_orders ok avec une liste vide, deux fois. seen_orders : aucune. Proposition : None. Erreurs : aucune. Utilisation : 3 appels au modèle, 6 104 jetons en entrée, 9,981 s.
Une exécution réelle de T-80001 (gemma3:4b avec Ollama, arrêtée après 3 étapes), comme historique de conversation et comme état de tâche. L'historique mentionne LK-615204 cinq fois. L'état ne contient aucune commande renvoyée par un outil.
Description textuelle du diagramme

Le côté gauche présente les 8 messages de l’historique de conversation. La commande LK-615204 de la voisine apparaît dans le billet, l’appel du modèle, le refus et deux brouillons de réponse.

Le côté droit présente les champs de l’état de la tâche. L’état est stopped, après 3 étapes, à cause de la limite d’étapes. Les preuves contiennent trois résultats d’outils, mais aucun n’est une commande. Le champ seen_orders est donc vide. La proposition est None, il n’y a aucune erreur, et l’utilisation compte 3 appels au modèle, 6 104 jetons en entrée et 9,981 secondes.

Lisez le côté droit comme une fiche de travail. Les preuves sont une liste des résultats d’outils que l’application a vraiment reçus. Le modèle ne peut rien y ajouter. Seul le code de la boucle ajoute une ligne Evidence, après avoir exécuté un outil. seen_orders() lit les identifiants de commande uniquement dans les résultats réussis. Pour T-80001, cette méthode renvoie un ensemble vide, ce qui correspond à la réalité.

Voici les champs de TaskState à cette étape du projet :

Champ Ce qu’il contient Qui y écrit
goal, attachments le texte du billet et ses pièces jointes le programme d’exécution, une fois, à partir de la tâche
status, step, stop_reason la progression de la tâche, le nombre d’appels au modèle, la raison de fin de la boucle la boucle et le programme d’exécution
plan le plan actuel du modèle (mode structuré seulement) la boucle, à partir de la réponse du modèle
evidence chaque résultat d’outil : outil, arguments, ok, code, données, identifiants de commande la boucle, après avoir exécuté un outil
proposal la résolution, après sa validation par le code la boucle, seulement si les vérifications réussissent
errors les erreurs du fournisseur, pour la trace et pour une personne la boucle
usage les appels au modèle, les jetons, le coût, le temps du modèle la boucle, après chaque appel
events tout ce qui s’est passé, dans l’ordre (la trace) la boucle
messages l’historique de conversation : le contexte de travail du prochain appel au modèle la boucle

L’historique de conversation est toujours là, dans messages. Le modèle en a besoin, car il ne voit rien d’autre. Mais le code ne prend jamais de décision en le lisant. Au module 4, l’état gagne deux champs, approval et operations : qui a approuvé quoi, et quelles écritures ont eu lieu.

Un petit exemple détaillé : trois étapes de T-80001

Section intitulée « Un petit exemple détaillé : trois étapes de T-80001 »

Suivez l’état de l’exécution de gemma3:4b, étape par étape. Chaque ligne montre les champs après cette étape :

Après l’étape Le modèle a demandé L’application a répondu evidence (codes) seen_orders() status
0 aucune aucune running
1 get_order LK-615204 refus other_customer aucune running
2 get_customer_orders succès, une liste vide other_customer, ok aucune running
3 get_customer_orders succès, une liste vide other_customer, ok, ok aucune stopped

Trois choses changent, et chaque changement est visible. step compte les appels au modèle. evidence gagne une ligne par appel d’outil, avec un code qui indique ce qui s’est passé. status change une fois, à la fin, parce qu’une limite de 3 étapes a arrêté cette exécution. Rien dans le tableau ne dépend de ce que le modèle a écrit. Les brouillons de réponse qui mentionnent LK-615204 sont dans l’historique, pas dans l’état.

Faites ces étapes dans le dossier du projet, avec l’environnement virtuel actif.

  1. Démarrez Python :

    Fenêtre de terminal
    python
  2. Exécutez T-80001 avec les décisions enregistrées de gemma3:4b. La limite de 3 étapes garde l’exécution courte :

    from resolver.config import Settings, make_provider
    from resolver.data import load_task
    from resolver.loop import Limits, accept_resolution
    from resolver.runner import run_task
    task = load_task("T-80001")
    provider = make_provider(Settings.from_env())
    state = run_task(task, "agent_structured", "gemma3:4b", provider.complete, limits=Limits(max_steps=3))
    state.status, state.stop_reason, state.step

    Python affiche :

    ('stopped', 'max_steps', 3)
  3. Regardez l’historique de conversation. Comptez les mentions de la commande de la voisine :

    [m["role"] for m in state.messages]
    history = " ".join(m["content"] for m in state.messages)
    history.count("LK-615204")
    ['system', 'user', 'assistant', 'user', 'assistant', 'user', 'assistant', 'user']
    5

    Dans ce mode structuré, chaque résultat d’outil retourne au modèle sous forme de message user (utilisateur). Avec les appels d’outils, c’est un message tool (outil). Le code qui lit l’historique doit connaître les deux formats. Le code qui lit l’état n’en a pas besoin.

  4. Interrogez maintenant l’état :

    [(e.step, e.tool, e.ok, e.code, e.order_ids) for e in state.evidence]
    state.seen_orders()
    [(1, 'get_order', False, 'other_customer', []), (2, 'get_customer_orders', True, 'ok', []), (3, 'get_customer_orders', True, 'ok', [])]
    set()
  5. Comparez avec la méthode de Tomás : une recherche d’identifiants de commande dans l’historique.

    import re
    set(re.findall(r"LK-\d{6}", history))
    {'LK-615204'}
  6. Donnez à la vérification de la boucle une proposition qui agit sur la commande de la voisine. Cette proposition est construite : aucun modèle ne l’a écrite.

    refund = {"outcome": "resolve", "rule": "W3", "reply": "We will refund the shed.", "reason": "lost",
    "actions": [{"tool": "request_refund", "order_id": "LK-615204", "amount": 899.0,
    "reason": "RFD-LATE", "payment_id": None}]}
    accept_resolution(state, refund)
    'request_refund on LK-615204: that order was not returned by a tool in this task. Look it up first; never act on an order that you have not seen.'
  7. Affichez les champs qu’une personne lirait en premier :

    print(state.model_dump_json(include={"goal", "status", "step", "plan", "stop_reason", "errors", "usage"}, indent=2))

    La sortie commence par l’objectif (le texte du billet) et se termine par l’utilisation : "model_calls": 3, "input_tokens": 6104, "output_tokens": 302, "cost_usd": 0.0, "model_seconds": 9.981. Un modèle local ne coûte rien, donc le coût est de 0,0. Le temps est le temps enregistré du modèle.

  8. Tapez exit() pour quitter Python.

accept_resolution utilise seen_orders(). Cette fonction refuse donc le remboursement, avec une raison que le modèle peut lire. Une vérification qui lit l’historique le laisserait passer.

Le champ status ne peut prendre que quelques valeurs. Chaque valeur a un sens fixe, et seuls certains changements sont permis. C’est une machine à états : un ensemble fixe d’états et les transitions permises entre eux. Elle répond à « où en est ce billet? » pour chaque billet, en un mot.

Une machine à états. Un billet arrive : running. Depuis running : une limite arrête la boucle sans proposition : stopped, puis une personne prend le billet. Reply_only ou ask_customer, rien à changer : done. Hand_to_person : handed_to_person. Resolve avec des actions proposées : waiting_approval. Depuis waiting_approval : une personne approuve : executing (en pointillé, module 4); une personne refuse : handed_to_person. Depuis executing : chaque écriture faite une fois : done; une écriture échoue : handed_to_person.
Les états d'une tâche. L'état en pointillé, executing, et les transitions depuis waiting_approval arrivent au module 4. Dans ce module, une tâche se termine dans stopped, done, handed_to_person ou waiting_approval.
Description textuelle du diagramme

Une tâche commence dans running. Elle quitte running de quatre façons possibles :

  1. Une limite arrête la boucle avant toute proposition : stopped. Une personne prend le billet.
  2. La proposition est reply_only ou ask_customer, donc rien ne change : done.
  3. La proposition est hand_to_person : handed_to_person.
  4. La proposition est resolve, avec des actions : waiting_approval.

À partir du module 4 : une personne approuve (executing) ou refuse (handed_to_person). Depuis executing, la tâche passe à done quand chaque écriture a été faite une fois, et à handed_to_person si une écriture échoue.

Le programme d’exécution fixe le statut à partir de l’état, jamais du texte du modèle. Voici le code, dans resolver/runner.py :

resolver/runner.py (end of run_task)
if state.proposal is None:
state.status = "stopped"
elif state.proposal.actions:
state.status = "waiting_approval"
elif state.proposal.outcome == "hand_to_person":
state.status = "handed_to_person"
else:
state.status = "done"

Remarquez la première ligne. Une exécution qui s’arrête sans proposition ne disparaît pas sous forme d’erreur. Elle a son propre état, et une personne reçoit le billet. L’évaluation du module 1 compte un arrêt de la même façon : comme un transfert à une personne.

Chaque personne chez Larkfield lit un champ différent. Grace approuve les changements et veut voir exactement ce qui va se passer. Elle lit proposal, que le code a validé, et evidence, qui montre la source de chaque fait. Omar demande combien coûte un billet, et usage répond pour chaque tâche. Mei est responsable des services de commandes et de paiements. Elle lit les codes dans evidence (service_timeout, service_unavailable) pour voir quand ses services ont fait échouer une tâche.

L’état rend aussi les modules suivants possibles. Au module 4, le flux de travail s’arrête à waiting_approval (en attente d’approbation) et peut attendre plusieurs jours. Un point de contrôle enregistre TaskState dans un fichier SQLite. L’exécution reprend plus tard à partir des champs enregistrés. Un historique de conversation en mémoire disparaît quand le processus se termine.

Quand utiliser un état explicite, et ce qu’il coûte

Section intitulée « Quand utiliser un état explicite, et ce qu’il coûte »

Utilisez un état typé quand une tâche comporte plusieurs étapes, ou quand le code doit vérifier ses décisions. Utilisez-le aussi quand la tâche peut changer quelque chose, ou doit s’arrêter et reprendre plus tard. Le flux de travail de résolution des billets réunit ces quatre conditions.

Vous n’en avez pas besoin pour un seul appel au modèle, comme la rédaction d’une réponse. Dans ce cas, la requête et la réponse forment toute la tâche. L’assistant de billets du cours Créer des applications fiables avec les API de LLM gardait seulement une conversation. Cela suffisait pour la rédaction.

Le coût, c’est du code et de la discipline. Chaque champ a besoin d’un responsable d’écriture, et ce responsable doit toujours être votre code, pas le modèle. Quand vous ajoutez un champ, vous devez décider qui le remplit et quand. Vous gardez aussi les messages, car le modèle en a besoin. L’état est donc un ajout de données, pas un remplacement. En retour, chaque vérification lit un champ, et chaque personne lit le champ dont elle a besoin.

Erreur courante

« L'historique de conversation, c'est l'état, on n'a pas besoin d'une base de données »

Symptôme : Tomás écrit la vérification « ne jamais agir sur une commande jamais vue » comme une recherche dans l’historique. Chaque numéro LK- dans les messages compte comme une commande vue. Pour T-80001, la recherche renvoie {'LK-615204'}, la commande de la voisine. Une proposition de remboursement de 899,00 dollars sur cette commande passe maintenant la vérification.

Cause : l’historique mélange trois types de texte : ce que le client a écrit, ce que le modèle a écrit et ce que l’application a renvoyé. Une recherche ne peut pas les distinguer. Un refus mentionne la commande qu’il refuse, et un brouillon du modèle peut mentionner n’importe quelle commande.

Correction : enregistrez les faits dans des champs typés au moment où ils se produisent. La boucle ajoute une ligne Evidence après chaque appel d’outil, avec ok et les identifiants des commandes réellement renvoyées. Les vérifications lisent state.seen_orders(), jamais les messages.

Ce que cela signifie pour un vrai service d’IA : une conversation est une entrée pour le modèle, pas un registre pour l’application. Tout ce dont dépend une vérification, une personne ou un processus ultérieur appartient à un champ que seul votre code remplit.

Faites une prédiction avant l’exécution. Le billet T-90103 concerne la jardinière en cèdre fissurée, et le client a joint une photo. Exécutez-le avec l’agent par défaut (chat-small, appels d’outils) dans une session Python, comme dans les étapes ci-dessus :

s = run_task(load_task("T-90103"), "agent", "chat-small", provider.complete)
s.status, s.step, s.seen_orders(), s.proposal.outcome, len(s.proposal.actions)

Quelle sera la valeur de status, et pourquoi?

Afficher la réponse
('waiting_approval', 2, {'LK-640436'}, 'resolve', 2)

Le modèle a fait une lecture (get_order LK-640436), puis proposé une résolution avec deux actions : reship_item et create_return_label. La résolution contient des actions, donc le programme d’exécution fixe waiting_approval. Rien n’est écrit : les actions restent des propositions jusqu’à leur approbation par une personne, au module 4. seen_orders() contient LK-640436 parce que get_order l’a renvoyée.

Vérifiez votre compréhension

Le modèle écrit dans son brouillon de réponse : « J'ai vérifié la commande LK-640436 et elle a été livrée. » Aucun outil n'a retourné LK-640436 pendant cette tâche. LK-640436 apparaît-elle dans state.seen_orders()? Expliquez.

Afficher la réponse

Non. seen_orders() lit seulement les preuves, et seulement les lignes où ok vaut vrai. Après avoir exécuté un outil, la boucle ajoute une ligne de preuve contenant les identifiants des commandes retournées. Une phrase du modèle se trouve dans les messages, pas dans les preuves. La boucle refuse donc toute action ultérieure sur LK-640436 jusqu’à ce qu’un outil retourne cette commande.

Vérifiez votre compréhension

Une exécution se termine parce que la limite d'étapes arrête la boucle. Que valent status et proposal, et qu'arrive-t-il au billet?

Afficher la réponse

proposal vaut None (aucune valeur), car le modèle n’a jamais fourni de résolution valide. Le programme d’exécution définit status (état) à stopped (arrêté). Le billet de soutien est transmis à une personne. L’arrêt n’est pas caché : stop_reason (raison de l’arrêt) indique pourquoi (max_steps, limite d’étapes). Les preuves montrent ce que l’exécution a trouvé avant l’arrêt. L’évaluation compte une exécution arrêtée comme une transmission à une personne.

Vérifiez votre compréhension

Pourquoi le projet conserve-t-il les messages dans TaskState si aucune vérification ne les lit?

Afficher la réponse

Le modèle en a besoin. Un modèle ne conserve rien entre deux requêtes. Chaque appel au modèle doit donc inclure la conversation jusqu’ici : le billet, les appels précédents et leurs résultats. Les messages constituent le contexte de travail du modèle. Le module 4 les utilise aussi pour reprendre une boucle après un point de contrôle. Mais ils servent d’entrée au modèle, pas de faits pour le code.

Récapitulatif

  • Un historique de conversation garde une trace des échanges. Il mélange le texte du client, celui du modèle et les résultats de l’application. Une vérification ne peut donc pas s’y fier.
  • Un état de tâche explicite conserve chaque fait dans un champ typé. Les champs sont l’objectif, les preuves, l’étape, le plan, la proposition, les erreurs, l’utilisation, l’état et la raison de l’arrêt. Dans le module 4, l’approbation et les écritures s’y ajoutent.
  • Seule l’application écrit les preuves. seen_orders() les lit, et accept_resolution refuse une action sur toute autre commande. Pour T-80001, l’historique mentionne cinq fois la commande du voisin; l’état ne contient aucune commande.
  • status est une petite machine à états : running (en cours), puis stopped, done (terminé), handed_to_person (transmis à une personne) ou waiting_approval (en attente d’approbation). Un arrêt a son propre état, et une personne reçoit le billet.

Ensuite, vous rendez les outils eux-mêmes stricts : chaque appel doit respecter un contrat avant son exécution.

Leçon suivante : Contrats d'outils