Agents résistants au rejouement : éviter les appels à l’LLM dans le flux de travail grâce à une approche temporelle.
Pourquoi un agent qui plante en cours d’exécution ne doit pas relancer son LLM : les activités comme limite de non-déterminisme, les mécanismes de rejouement, les battements de cœur, les tentatives de réessai et les graphes sans état.
Sources: Fixing Non-Deterministic Replay Bugs in AI Agent Loops
Imaginez un agent à l’étape 7 sur 10 lorsque son travailleur meurt : une extinction en raison de manque de mémoire, un événement de maintenance, une panne du noyau. Si l’appel à l’LLM a lieu au sein du flux de travail, la récupération se traduit par une réécriture silencieuse de l’historique. Le flux de travail lance à nouveau le modèle, reçoit une réponse différente, et l’agent se retrouve ailleurs que là où il est mort. Les mêmes tokens sont payés deux fois, et l’exécution récupérée est différente de l’originale.
Ce n’est pas une expérience de pensée. Une nuit, à 02:14, un agent de support a fermé un compte client qui aurait dû bénéficier d’une période de grâce de 30 jours. L’appel à l’outil a réussi. Lorsqu’un ingénieur a rejoué le même message du client, l’agent s’est comporté correctement. L’analyse post-mortem présentait un vide à l’endroit où devait se trouver la cause racine, rempli par les mots « impossible à reproduire ». Une panne non reproductible n’a pas disparu ; c’est une panne qui ne peut plus être étudiée.
La règle qui empêche cela est simple : le code qui orchestre un agent doit être déterministe, or l’LLM ne l’est pas, donc l’LLM doit se trouver derrière une frontière qui n’est pas obligatoire. Temporal est le moteur utilisé pour les exemples, mais le principe est indépendant du framework. Le même principe s’applique à AWS Step Functions, à Prefect et Dagster, à un outil de surveillance qui lit les messages en attente d’une base de données, ainsi qu’à une table d’état personnalisée ; les sections suivantes illustrent cette idée à travers ces outils.
Le bug avant le vocabulaire
Prenons un agent de collecte de données composé de cinq étapes : recherche, évaluation, décision, approbation, confirmation. L’étape de décision appelle un LLM, et l’endroit naturel et tentant pour effectuer cet appel est directement dans la classe du flux de travail :
# ❌ What I almost wrote — LLM call INSIDE the workflow.
@workflow.defn
class SourcingWorkflow:
@workflow.run
async def run(self, brief: SourcingBriefInput) -> SourcingResult:
suppliers = await workflow.execute_activity(research_activity, brief)
scored = await workflow.execute_activity(score_activity, suppliers)
# The LLM call — right here, in the workflow. THIS IS THE BUG.
# If worker dies here, replay will re-call the LLM and mutate state/history.
response = await llm_client.complete(
messages=build_decide_prompt(scored),
temperature=0.0,
)
selected = parse_llm_decision(response.content, scored)
approval = await workflow.wait_condition(...)
# ...create PO, initiate payment
Le texte s’affiche correctement. Un ingénieur senior qui n’aurait jamais vu Temporal le qualifierait de bon. Les problèmes commencent si l’opérateur rencontre une panne une fois que l’appel à l’LLM est terminé et que l’approbation humaine est encore en attente.
La solution consiste à transférer cet appel dans une activité, une fonction que le flux de travail planifie et dont le résultat est enregistré :
# ✅ The fix — LLM call moved into an activity.
@activity.defn
async def decide_activity(scored: list[dict], brief: SourcingBriefInput) -> dict:
"""The LLM call lives here — in the activity, not the workflow."""
from app.agentmesh.llm import get_llm_client
client = get_llm_client()
response = await client.complete(
messages=build_decide_prompt(scored),
temperature=0.0,
)
selected, rationale = parse_llm_decision(response.content, scored)
return {"selected_supplier": selected, "decision_reason": rationale}
@workflow.defn
class SourcingWorkflow:
@workflow.run
async def run(self, brief: SourcingBriefInput) -> SourcingResult:
suppliers = await workflow.execute_activity(research_activity, brief)
scored = await workflow.execute_activity(score_activity, suppliers)
# The LLM call is NOWHERE in the workflow.
# The activity result is recorded. On replay, it's injected.
decision = await workflow.execute_activity(
decide_activity,
args=(scored, brief),
)
Un bloc de code a été déplacé. Lors de la réexécution, l’activité ne s’exécute plus, car le flux de travail reçoit le résultat enregistré et le modèle n’est jamais appelé une seconde fois. La raison pour laquelle cela fonctionne est le véritable sujet d’étude.
La règle : la réexécution doit reproduire l’historique
Pendant que le flux de travail s’exécute, Temporal enregistre chaque étape dans un historique d’événements, un journal qui ne cesse de s’alimenter, avec des entrées telles que « flux de travail démarré », « activité planifiée », « activité terminée », « signal reçu » et « minuterie déclenchée ». Les événements sont immuables, et l’historique constitue la source de vérité.
Après une panne, Temporal rejoue le flux de travail. Un nouvel exécutant lance la fonction du flux de travail à partir de sa première ligne. À chaque appel tel que workflow.execute_activity(...) ou workflow.wait_condition(...), Temporal consulte l’historique. S’il existe un événement correspondant, il insère le résultat enregistré à la place de toute exécution. Si la fonction effectue un appel différent de celui conservé dans l’historique, Temporal génère une erreur de non-déterminisme.
C’est ce qu’on appelle l’origine par événements. L’historique constitue le journal, la fonction de flux de travail correspond à son traitement, et les mêmes événements dans le même ordre, traités par la même fonction, produisent toujours le même état. La documentation de Temporal l’exprime clairement : le code de flux de travail doit être déterministe pour permettre la réexécution, tandis que les opérations non déterministes telles que les appels API, les invocations de modèles de langage grand public et les requêtes de base de données doivent être intégrées dans des activités. Une activité s’exécute une seule fois, son résultat est enregistré dans l’historique, et à partir de là, les réexécutions le lisent à cet endroit.
Tout ceux qui ont travaillé avec Postgres ont déjà vu cette même structure. Le journal d’écriture préalable représente les enregistrements, les fichiers de table constituent une vue matérialisée, et la récupération réexécute le journal. Un manuel sur l’origine par événements énonce la même règle : mêmes événements, même ordre, même état final, sans horloges, sans IDs aléatoires ni lectures en fonction de l’heure au sein du traitement.
À quoi ressemble une violation
Lorsque le LLM est intégré au flux de travail, deux problèmes surviennent en même temps.
La facture double. Un appel qui coûtait trois cents la première fois coûte à nouveau trois cents lors de sa répétition. Une équipe a mesuré que les tentatives de répétition non contrôlées consommaient environ deux dollars en tokens en trente secondes pour une seule conversation, tandis que la fonction de rupture de circuit réduisait ce coût à un cent, soit une différence de 200 fois.
Le déroulement diverge. L’exécution initiale a choisi le fournisseur B. La répétition a choisi le fournisseur A. L’historique ne correspond plus, Temporal déclenche son erreur de non-déterminisme, et le flux de travail est bloqué.
Ces événements ne sont pas rares. Environ 0,8 % des appels à Anthropic et OpenAI échouent sur une période de trente jours, ce qui signifie qu’un agent effectuant 12 appels au modèle par tâche fait face à un taux d’échec proche de 9,2 % par tâche. Si le LLM fait partie du flux de travail, chaque tentative de réessai le lance à nouveau. S’il fait partie d’une activité, la tentative de réessai reçoit le résultat déjà enregistré. Les propres écrits de Temporal clarifient bien la distinction : le flux de travail constitue la couche d’orchestration et doit être déterministe afin que les agents puissent surmonter les pannes de processus, tandis que les activités sont là où le travail réel a lieu, y compris les appels au LLM, les invocations d’outils et les requêtes API, et elles peuvent être aussi imprévisibles que nécessaire.
Pourquoi une température nulle n’est pas une protection
Quelqu’un s’oppose toujours en affirmant que la température a été réglée à 0. Cela ne sert à rien, pour plusieurs raisons :
- Une température nulle correspond à une décodage gourmand, et non au déterminisme. Selon une analyse, mille requêtes identiques ont produit 80 résultats différents, en raison de différences en virgule flottante liées au traitement par lots sur les GPU.
- Un même nom de modèle ne signifie pas forcément le même modèle. Les fournisseurs mettent à jour les modèles en silence, de sorte que le modèle utilisé mardi peut ne pas correspondre à celui de mercredi.
- Le fournisseur peut être indisponible pendant la répétition des opérations. Le flux de travail reste alors bloqué en attente.
- Même une réponse identique au niveau des octets coûte de l’argent. Une seconde inférence est néanmoins facturée.
Un développeur qui écrit sous le nom de Tisha a bien décrit le problème : les équipes passent des semaines à essayer de forcer un modèle à être déterministe au niveau binaire, échouent, et en concluent que le système est imprévisible, alors que le déterminisme réel se trouve à un niveau supérieur, dans l’orchestration. Elles ciblaient le mauvais niveau.
L’activité en tant que membrane
Le changement est structurel. À l’intérieur d’une activité, tout est autorisé. À l’extérieur, tout doit être déterministe.
Un flux de travail peut appeler des activités, attendre des signaux, définir des compteurs à retardement et créer des branches, à condition que les conditions de ces branches dépendent uniquement des résultats des activités et des entrées du flux de travail. Sont interdits les appels de modèles, les requêtes HTTP, la lecture de l’horloge système, la génération de nombres aléatoires, ainsi que tout ce qui pourrait produire un résultat différent sur une autre machine à un autre moment. Tout cela est intégré dans les activités.
Considérez cela comme une anatomie. Ce que le flux de travail exécute, ce n’est pas l’agent lui-même, mais plutôt la coordination autour de lui, tandis que le modèle n’est qu’un service auquel l’agent se réfère. Le cerveau est imprévisible, la colonne vertébrale doit être stable, et le cerveau ne se trouve pas à l’intérieur de la colonne vertébrale.
À quoi cela ressemble dans une base de code réelle
Les extraits simplifiés ci-dessus cachent un détail. Dans une base de code réelle, l’appel à l’LLM se trouve à l’intérieur de nœuds d’un graphe, et ce graphe s’exécute à l’intérieur d’une activité, ce qui signifie qu’il y a deux limites, et non une :
Temporal Workflow (deterministic — replayed)
└── Activity: run_graph_until_interrupt (non-deterministic — recorded once)
└── LangGraph StateGraph
└── decide_node (async function)
└── get_llm_client().complete() ← the LLM call
Le flux de travail lui-même est une simple orchestration, une séquence d’appels execute_activity et wait_condition et rien de plus. La ligne execute_activity constitue la frontière. Ce qui se passe à l’intérieur de l’activité est enregistré, tandis que ce qui se passe à l’extérieur est rejoué :
@workflow.defn
class SourcingWorkflow:
@workflow.run
async def run(self, brief: SourcingBriefInput) -> SourcingResult:
# ↓ This is the boundary. The activity runs once. Result is recorded.
graph_result = await workflow.execute_activity(run_graph_until_interrupt, brief, ...)
# Wait for human signal — a Temporal primitive, not an LLM call.
# On replay, the signal is injected from history.
await workflow.wait_condition(lambda: self._approval_received, timeout=timedelta(hours=24))
# ↓ Another boundary. Resume activity runs once. Result is recorded.
resume_result = await workflow.execute_activity(resume_graph, self._approval_data, ...)
# ↓ Side effects — each is its own activity with its own retry policy.
po_result = await workflow.execute_activity(create_po_activity, args=(...), ...)
Rien à l’intérieur ne fait appel à un client LLM, ne lance de requête HTTP, ne lit l’heure actuelle ni ne génère un nombre aléatoire. Mêmes entrées, même historique, même chemin d’exécution, à chaque fois.
L’activité construit le graphe et l’invoque, et c’est cette seule ligne graph.ainvoke() qui engendre le non-déterminisme : appels aux LLM, appels aux outils et requêtes en mémoire. L’activité renvoie un dictionnaire qui est enregistré, et la réexécution saute l’ensemble du processus :
@activity.defn
async def run_graph_until_interrupt(brief: SourcingBriefInput) -> dict:
graph = build_sourcing_graph(checkpointer=await get_checkpointer())
config = {"configurable": {"thread_id": activity.info().workflow_id}}
# ↓ Everything inside this call is non-deterministic. It runs ONCE.
# The result dict is recorded in the event history. On replay, it's injected.
final_state = await graph.ainvoke({"brief": brief, "suppliers": [], "attempts": 0, ...}, config)
return {"paused": True, "selected_supplier": selected, "suppliers": final_state.get("suppliers", [])}
En bas de la pile, le nœud du graphe est l’endroit où le modèle effectue réellement son calcul. Il construit la requête à partir des fournisseurs évalués et des décisions antérieures, puis effectue l’appel nécessaire :
async def decide_node(state: AgentState) -> dict:
scored = state.get("scored_suppliers", [])
messages = build_decide_prompt(brief.item, brief.quantity, brief.budget, scored, past_decisions)
# ↓ THE LLM CALL. This is the non-determinism that must never be in the workflow.
response = await get_llm_client().complete(messages=messages, temperature=0.0, max_tokens=1000)
selected, rationale = parse_llm_decision(response.content, scored)
return {"selected_supplier": selected, "decision_reason": rationale, "cost_incurred": response.cost_usd} # ↓ THE LLM CALL. This is the non-determinism that must never be in the workflow.
response = await get_llm_client().complete(messages=messages, temperature=0.0, max_tokens=1000)
Cela correspond à trois niveaux en dessous du flux de travail : l’activité, le graphe, le nœud, puis le LLM. Lors de la réexécution, aucun de ces éléments n’est exécuté, car le résultat de l’activité est injecté. Le flux de travail est déterministe. L’activité, le graphe et le modèle ne le sont pas, mais le non-déterminisme reste confiné aux limites de l’activité. Le flux de travail ne voit jamais que un dictionnaire enregistré, le même à chaque réexécution.
Où les frontières deviennent floues
Le schéma semble clair sur le papier, mais il devient flou à certains endroits dans la pratique. Le cas sur plusieurs jours mérite une attention particulière, car son échec est subtil et deux couches de persistance peuvent s’éloigner l’une de l’autre sans avertissement.
Lorsque le graphe s’arrête à un nœud d’approbation grâce à la méthode interrupt() de LangGraph, l’activité se termine et renvoie un dictionnaire au flux de travail. Ce dernier appelle ensuite workflow.wait_condition() pour attendre un signal humain. À ce moment-là, deux copies de l’état existent dans deux systèmes :
Temporal event history LangGraph checkpoint (Postgres)
└── ActivityCompleted(result) └── graph state at interrupt()
paused: True node: "approve"
selected: SupplierB selected: SupplierB
suppliers: [A, B, C] suppliers: [A, B, C]
Tous deux sont stockés dans Postgres, mais ce sont des tables distinctes alimentées par des chemins de code différents à des moments différents. Le point de contrôle de LangGraph est enregistré par l’activité lorsque l’interruption se produit ; une fois que l’activité revient, Temporal ajoute son événement de fin d’activité. Cela entraîne deux écritures, deux systèmes et un état logique unique.
Les problèmes surviennent lorsque l’un des enregistrements écrits survit tandis que l’autre disparaît. Une solution consiste à utiliser le thread_id comme workflow_id Temporal, qui reste stable lors des redémarrages contrairement au run_id ; la table de points de contrôle doit conserver les données aussi longtemps que l’historique de Temporal ; enfin, l’activité de reprise doit appeler graph.ainvoke(None, config), car la transmission de None indique à LangGraph de charger depuis le point de contrôle plutôt que de recommencer à zéro. Une erreur dans l’un de ces éléments provoque des dérives dans les couches. Le workflow ne plante pas, mais il reprend depuis un état incorrect.
La meilleure solution : rendre l’état du graphe sans état
La conception intuitive permet au checkpointer Postgres de LangGraph de suivre l’état des nœuds d’agent en même temps que l’historique de Temporal. L’analyse des modes de défaillance lors de pauses de plusieurs jours révèle un piège caché, qui correspond à un problème classique de double écriture entre deux bases de données. Si un travailleur s’arrête juste après que LangGraph a enregistré son point de contrôle, mais avant que Temporal n’ait enregistré la fin de l’opération, les moteurs d’état sont en désaccord, et le flux de travail reprend silencieusement à partir d’un état incohérent.
Plutôt que d’ajouter continuellement des règles de synchronisation concernant la mise en correspondance des threads, la conservation des données et les rechargements, la solution la plus propre consiste à supprimer le deuxième moteur d’états. Sans son point de contrôle, LangGraph devient une unité de raisonnement sans états, fonctionnant en mémoire au sein de l’activité. Temporal gère tout l’état à long terme, l’historique d’exécution ainsi que les pauses dues à l’intervention humaine grâce à workflow.wait_condition(). LangGraph se contente de fonctionner via son cycle de raisonnement, de renvoyer un dictionnaire de résultats à l’activité, puis de terminer. Résultat : pas d’écritures doubles, pas de risque de désynchronisation et une interface d’exploitation plus simple.
Aucun outil ne devient redondant. LangGraph reste performant pour le routage rapide de graphes en mémoire, l’appel d’outils et les boucles de raisonnement au niveau des nœuds, tandis que Temporal excelle en termes de durabilité sur plusieurs jours, de récupération en cas de panne et d’orchestration distribuée. Un LangGraph sans état intégré à une activité Temporal durable évite qu’ils ne se concurrencent pour la persistance et conserve les forces de chacun.
Une contrainte dans de nombreux systèmes
Les frameworks d’orchestration ne sont qu’un des domaines où apparaît le contrat de rejouement. Tout système qui se rétablit en rejouant des événements comporte ces trois éléments : un enregistrement des événements, une fonction pure qui les transforme en état, et tout ce qui est imprévisible, conservé de l’autre côté d’une arête enregistrée.
Les agents rendent les choses encore plus difficiles. Dans un registre classique basé sur des événements, la fonction apply reste pure, car créditer un compte de dix dollars produit toujours le même résultat. La logique métier d’un agent consiste à demander au LLM ce qu’il faut faire, et la réponse diffère à chaque fois. Si le cerveau se trouve dans le chemin de réexécution, celle-ci devient impossible, donc le cerveau doit se trouver en dehors de ce chemin.
Temporal a été choisi comme plateforme d’exemple parce qu’il applique strictement le contrat : son SDK génère une erreur de non-déterminisme en cas de violation, au lieu de permettre simplement un exécution divergente. Step Functions l’applique gratuitement, car son langage de description d’état rend impossible l’inclusion accidentelle d’une appel à un LLM dans la machine à états. Avec un système de files d’attente ou une consultation personnalisée, l’application de ces règles est manuelle. Rien ne bloque un appel à un LLM au sein du cycle de consultation, et rien n’alerte à ce sujet. La contrainte est identique dans tous ces cas. La seule question est de savoir qui détecte la violation : le framework, ou bien la personne de garde à 3 heures du matin.
La même distinction existe dans d’autres ensembles d’agents. Certains l’imposent, comme Temporal et le Microsoft Agent Framework. D’autres fournissent les primitives et laissent la mise en œuvre au développeur, comme LangGraph et AutoGen. D’autres encore ignorent complètement cette question, comme Celery et CrewAI. La question pratique est de savoir quelle partie du contrat de rejouement doit être implémentée manuellement.
Les données issues d’analyses externes indiquent la même chose. Un rapport sur la migration de Celery vers Temporal soulignait un risque élevé d’exécutions multiples en cas de tentatives répétées, surtout pour les tâches qui consomment beaucoup de temps de traitement sur GPU. Un projet Temporal avec CrewAI a abouti à l’architecture décrite ci-dessus, en utilisant le moteur d’orchestration comme source fiable de l’état et le framework d’agents comme unité de raisonnement sans état appelée depuis le sein d’une activité. De plus, dans une comparaison entre AutoGen et CrewAI, AutoGen a terminé les tâches avec une latence p95 de 2,1 secondes contre 4,7 secondes, et a réussi 94 % du temps contre 83 %, ce qui montre bien que la manière dont un framework gère les échecs se reflète dans les chiffres. Le Microsoft Agent Framework intègre un système de stockage de points de contrôle basé sur des fichiers ainsi que des superétapes tolérantes aux pannes, ce qui constitue sa propre façon d’offrir la même garantie.
Le prix du déterminisme
La frontière des activités n’est pas libre. La simplicité en ligne disparaît, car la version incorrecte comporte moins de lignes et est plus facile à lire. L’accès en ligne à l’état disparaît également, car une activité ne voit que ce qui lui est transmis, et non les mécanismes internes du flux de travail. De plus, trois cas limites apparaissent, qui n’existent pas avec un LLM en ligne. Il s’agit de véritables décisions d’ingénierie, et non de compromis philosophiques.
Cas limite un : activités longues et intervalles de surveillance
Une appel à un LLM peut durer 30 secondes. Un graphe d’agent à plusieurs tours avec des appels d’outils peut prendre cinq minutes. La valeur par défaut de start_to_close_timeout chez Temporal est de 10 secondes. Si une activité dépasse ce délai, Temporal la marque comme échouée et tente à nouveau, ce qui entraîne une nouvelle invocation du modèle, le paiement à nouveau des tokens et la réexécution du travail non déterministe qui avait été soigneusement isolé.
La solution consiste en un délai de battement cardiaque. Toute activité qui dure plus de quelques secondes doit appeler activity.heartbeat() à intervalles réguliers, ce qui indique à Temporal qu’elle est toujours active. Si le processus cesse de fonctionner, Temporal utilise le dernier battement cardiaque pour déterminer où en est l’activité, de sorte qu’une activité pouvant être sauvegardée puisse reprendre, tandis que toutes les autres doivent redémarrer à zéro :
@activity.defn
async def run_graph_until_interrupt(brief: SourcingBriefInput) -> dict:
# Long-running: graph may run 5+ minutes with multiple LLM calls
for node in graph.stream(initial_state, config):
activity.heartbeat() # ← "I'm alive, don't timeout me"
# ... process node output
Il y a un piège. Un battement de cœur ne sauve en rien l’avancement interne de l’activité ; il ne fait que repousser l’expiration du délai. Si le travailleur s’arrête au milieu du traitement, la tentative de réessai reprend l’activité depuis le début et l’LLM est exécuté à nouveau, car le résultat n’est enregistré que lorsque l’activité est terminée. Pour un graphique de cinq minutes, un travailleur qui s’arrête à la quatrième minute entraîne avec lui quatre minutes de travail non protégé.
C’est pourquoi l’exemple divise le graphique en deux activités : l’une se poursuit jusqu’à l’interruption et l’autre reprend après celle-ci. Chacune est suffisamment courte pour s’achever avant l’expiration de son délai, et chaque résultat est enregistré de manière indépendante. Cette division vise une meilleure granularité dans la durabilité, et non l’organisation du code.
Cas limite deux : tokens en streaming
Lorsque le LLM est intégré directement dans un flux de travail, les tokens peuvent être transmis directement dans la réponse HTTP de l’utilisateur. Lorsqu’il fait partie d’une activité, les tokens sont envoyés vers la sortie de cette activité, soit une valeur de retour unique enregistrée dans l’historique. Rien n’atteint l’utilisateur avant que l’activité ne soit terminée.
La diffusion en flux constitue l’objection la plus fréquemment soulevée contre ce modèle, et elle est justifiée. Trois approches existent, dont la complexité augmente progressivement. La première consiste à considérer que la sortie est une décision et non un flux de chat, ce qui convient à l’agent de génération. La deuxième ajoute un canal secondaire pour des interactions à plusieurs tours, comme une interview où l’utilisateur doit voir chaque réponse. Ce canal secondaire peut être une extrémité de type server-sent events soutenue par Redis : l’activité publie des tokens dans Redis, l’extrémité les diffuse à l’utilisateur, tandis que Temporal ne conserve que le résultat final. Un utilisateur qui interrompt la session peut revenir plus tard et demander au flux de travail son résultat. Les tokens présents dans le flux sont éphémères ; c’est le résultat stocké qui reste permanent.
Cas limite trois : les politiques de tentative diffèrent
Les tentatives temporelles de réessai échouent automatiquement les activités, mais l’échec a des significations différentes selon les activités. Une recherche en lecture seule et un effet secondaire ne devraient pas être réessayés de la même manière. Pour un effet secondaire tel que la création d’une commande d’achat, la politique est stricte : il n’y a pas de réessai possible, et une clé d’idempotence assure la sécurité :
# Side effect — strict, non-retryable. Idempotency key handles safety.
po_result = await workflow.execute_activity(
create_po_activity,
args=(supplier, item, quantity, price),
retry_policy=workflow.RetryPolicy(
initial_interval=timedelta(seconds=1),
maximum_attempts=1, # ← don't retry. Idempotency key prevents duplicates.
non_retryable_error_types=["DuplicatePOError"],
),
)
# Verification — aggressive retry, but with backoff for eventual consistency
po_verification = await workflow.execute_activity(
verify_po_exists,
args=(po_result["po_id"],),
retry_policy=workflow.RetryPolicy(
initial_interval=timedelta(seconds=2), # ← give the DB time to sync
maximum_attempts=5,
),
)
La politique de tentative répétée fait partie du contrat de rejouage. Lorsqu’une activité échoue et est relancée, les tentatives sont enregistrées dans l’historique, et lors du rejouage, Temporal injecte le résultat final sans relancer les tentatives infructueuses. Cependant, une activité qui crée une commande d’achat avec maximum_attempts=3 dispose de trois tentatives pour la créer, et en l’absence de clé d’idempotence, cela signifie trois commandes. Les deux mécanismes coopèrent : la politique décide quand relancer, et la clé détermine ce qui se passe lors de ces relances.
Ces sont les coûts de la durabilité. Il ne s’agit pas d’une question de déterminisme versus non-déterminisme. Il s’agit plutôt de durabilité avec une certaine indirection, par opposition à une simplicité irréversible ; un agent de production devrait donc choisir la durabilité en raison des pannes de production. Les discussions sur la fiabilité des agents restent souvent au mauvais niveau, se concentrant sur le modèle : des prompts plus efficaces, une couverture d’évaluation plus large, un mécanisme de sauvegarde plus intelligent au prochain trimestre. Tout cela rencontre un plafond infranchissable.
La seule règle à retenir
De tout ce qui existe dans le système, seul le LLM résiste au réexécution, et c’est la raison pour laquelle il doit être placé là où le réexécution ne peut jamais l’atteindre. La FAQ de Temporal le précise encore : il faut toujours encadrer les appels au LLM dans une activité. Toujours, et non pas généralement ou lorsque le temps le permet, car la seule fois où cela est omis, c’est en cas de panne à 3 heures du matin, lorsque le réexécution produit un résultat différent et que quelqu’un de de garde doit gérer un agent bloqué en état d’échec pendant la nuit.
En l’absence de contrat de réexécution, les trois couches — harnais, boucle et graphe — n’ont qu’une fonction organisationnelle. Elles aident à penser un système, mais ne permettent pas à celui-ci de survivre. Avec le contrat, elles deviennent structurelles : la boucle reprend à partir de la dernière étape terminée, les activités étant injectées à partir de l’historique ; l’état du graphe est stocké en un seul endroit et chargé par l’activité de reprise ; les outils ne sont pas réexécutés, car les résultats sont enregistrés et la clé d’idempotence constitue une deuxième ligne de défense. Les couches restent séparées en cas de panne, et ce, non seulement par principe.
Une limite persiste. Les garanties temporelles assurent que le flux de travail se réexécute correctement après une panne. Elles ne garantissent pas que la logique métier puisse être réexécutée en toute sécurité, et cette question relève du concepteur. L’idempotence est le prochain problème à résoudre.