Tous les articles

Insight Run, générer un plan d'entraînement sans payer deux fois le LLM

Insight Run génère des plans d'entraînement avec un LLM. La génération dure jusqu'à deux minutes, l'app peut être suspendue à tout moment, et un appel coupé est quand même facturé. Elle tourne dans Cloudflare Workflows, par blocs de quatre semaines enregistrés un à un.

Insight Run est une app iOS d’analyse de course à pied, disponible sur l’App Store. Elle génère des plans d’entraînement jusqu’à une course, du 5 km à l’ultra. Cet article décrit comment cette génération est construite.

Plan d'entraînement
Un plan généré, en mode démonstration.

Les contraintes

Un plan de 16 semaines avec des séances détaillées représente beaucoup de texte à produire pour un modèle. En production, la génération prenait 40 secondes en médiane et 99 secondes au 90e centile.

Trois contraintes s’ajoutent à ces durées. iOS suspend l’app peu après son passage en arrière-plan, et une requête de plus d’une minute n’aboutit pas. Quand le client se déconnecte, Cloudflare ne laisse au Worker que 30 secondes (waitUntil) pour finir son travail. Enfin, l’appel au modèle n’est pas streamé : s’il est interrompu, il est quand même facturé, car le fournisseur a généré les tokens.

La génération doit donc continuer sans l’app, et ce qui a déjà été généré ne doit pas être redemandé au modèle.

Un squelette calculé par le code

Le code calcule d’abord un squelette déterministe : les phases (base, développement, pic, affûtage) et le volume de chaque semaine. Pendant la montée en charge, le volume augmente d’au plus 10 % d’une semaine de charge à la suivante, et une semaine sur quatre est allégée. L’affûtage dure d’une à trois semaines selon la distance.

Volume hebdomadaire du squelette pour un semi-marathon en 16 semaines 0 km 25 km 50 km 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 Base Développement Pic Affûtage récupération récupération course 30 50 41
Squelette réel calculé par buildPlanSkeleton pour un semi-marathon en 16 semaines, niveau intermédiaire, 4 jours par semaine. Les semaines 4 et 8, allégées, reviennent à 80 % du volume de la semaine précédente. La semaine 16 inclut les 21,1 km de la course.

Le modèle remplit ensuite les séances, par blocs de quatre semaines au plus. Le prompt lui donne la phase et le volume visé de chaque semaine. À la réception, le code remet les phases du squelette, et reprend son volume quand le modèle n’en donne pas un valide. Les phases et les volumes restent ainsi cohérents d’un bloc à l’autre, même si les blocs sont générés séparément.

Comme les blocs ne dépendent pas les uns des autres, ils peuvent être générés en parallèle.

Le pipeline

L’app crée un job sur le Worker, qui lance un workflow. Le workflow génère les blocs, note la progression dans KV, puis assemble le plan. L’app ne garde pas de connexion ouverte : elle interroge le Worker jusqu’à ce que le plan soit prêt.

Génération d'un plan : l'app crée un job, un workflow génère les blocs en parallèle et livre le plan iPhone job enregistré avant l'appel GET toutes les 3 s POST, GET statut, plan Worker Idempotency-Key Workflow semaines 1 à 4 semaines 5 à 8 semaines 9 à 12 semaines 13 à 16 livrer le plan LLM OpenRouter KV progression cache des blocs
Les blocs sont générés en parallèle et chacun est une étape enregistrée. Si le workflow redémarre, il reprend à la première étape non terminée.

Quatre appels en parallèle

Un Worker Cloudflare peut ouvrir six connexions sortantes simultanées. La génération lance donc au plus quatre tâches en parallèle, et laisse les deux autres connexions aux lectures et écritures KV. Chaque tâche est une boucle : elle prend le prochain bloc pas encore commencé, appelle le modèle, attend la réponse, puis passe au bloc suivant. Il n’y a ainsi jamais plus de quatre appels au modèle en même temps.

Six blocs répartis sur quatre tâches tâche 1 S1-4 S17-20 tâche 2 S5-8 S21-24 tâche 3 S9-12 tâche 4 S13-16 1re vague : quatre blocs en parallèle une tâche libre prend le bloc suivant
Plan de 24 semaines : six blocs, au plus quatre appels au modèle en même temps. Les blocs 5 et 6 sont pris par les premières tâches qui se libèrent.

Après un premier échec, les blocs en attente ne partent plus. Ceux déjà en cours vont jusqu’au bout : ils sont déjà payés, et leur résultat est gardé pour la relance.

Une étape Workflows par bloc

waitUntil ne laisse que 30 secondes après une déconnexion, et ne reprend rien après une erreur. La génération tourne donc dans Cloudflare Workflows. Un workflow est une suite d’étapes : le résultat de chaque étape est enregistré, et une étape qui échoue peut être relancée seule. Chaque bloc devient une étape, nommée d’après ses semaines (weeks 5-8), avec un timeout de cinq minutes et une seule nouvelle tentative, dix secondes après l’échec. À la fin de l’étape, le bloc est noté comme terminé dans KV.

Une dernière étape assemble le plan et le livre. Un bloc terminé n’est jamais regénéré, même si le workflow redémarre, et les blocs notés dans KV donnent à l’app une progression réelle.

Le plan est calculé à partir de la date de création du job, et non de la date d’exécution : une reprise retrouve le même nombre de semaines et le même squelette.

Chaque bloc généré est aussi mis en cache dans KV pendant une heure. La clé est un hash de l’utilisateur et du contenu de la requête : objectif, dates, niveau, squelette. Si un job échoue et que la même génération est relancée, les blocs déjà produits sont repris au lieu d’être payés une deuxième fois. À la livraison, ces entrées sont supprimées, pour qu’une nouvelle demande pour la même course produise un nouveau plan.

Quand un bloc échoue

Chaque réponse est validée avant d’être acceptée : nombre et numéros des semaines, types de séances, course placée dans la dernière semaine. Si elle est rejetée, ou si l’appel échoue, le bloc est redemandé à un second modèle. Quand la réponse était invalide, la raison du rejet est ajoutée au prompt, pour que le modèle ne renvoie pas la même sortie. Si les deux tentatives échouent, Workflows relance l’étape une fois : un bloc peut donc coûter jusqu’à quatre appels.

Tentatives pour un bloc : modèle principal, modèle de repli, puis une relance de l'étape Modèle principal 1er appel échec Modèle de repli + raison du rejet échec Étape relancée une fois, après 10 s 2e passage valide valide échec au 2e passage Bloc accepté phases du squelette appliquées, bloc mis en cache dans KV Plan en échec message d'erreur dans l'app
Les tentatives d'un bloc, d'après generatePlanBlock et la configuration de l'étape. Chaque appel au modèle a un timeout de 75 s. Les blocs déjà terminés ne sont pas regénérés.

L’idempotence, de l’iPhone au serveur

L’app peut être suspendue juste après avoir lancé la génération, ou perdre la réponse à sa requête. Elle crée donc un identifiant de job (un UUID) et l’enregistre sur le téléphone avant l’appel. Cet identifiant part dans l’en-tête Idempotency-Key ; sans UUID valide, le serveur répond 400.

Le Worker en dérive l’identifiant de l’instance de workflow : plan-<hash de l'utilisateur>-<UUID>, l’utilisateur étant l’identifiant que l’app envoie dans l’en-tête X-User-ID. Une requête renvoyée tombe sur l’instance existante au lieu d’en créer une deuxième, et un identifiant de job seul ne donne pas accès au plan d’un autre utilisateur.

Échanges entre l'iPhone, le Worker et le workflow pendant la génération d'un plan iPhone Worker Workflow UUID enregistré POST, Idempotency-Key create, id lié à l'utilisateur 202 running les blocs continuent pendant la suspension GET toutes les 3 s running, 2 blocs sur 4 app suspendue GET à la réactivation complete, avec le plan DELETE, plan enregistré
Le workflow tourne côté serveur pendant la suspension ; l'app récupère le plan à son retour.

L’app interroge ensuite le serveur toutes les trois secondes. Elle ignore les erreurs réseau passagères et abandonne après 20 minutes. Si elle est suspendue, le workflow continue sans elle. Au lancement suivant ou au retour au premier plan, elle reprend les jobs enregistrés et récupère le plan. Si la génération est relancée pour le même objectif et les mêmes dates, l’app rejoint le job en cours au lieu d’en créer un autre.

Une fois le plan enregistré sur le téléphone, l’app supprime le job côté serveur. Un job jamais récupéré expire au bout de 30 jours, selon la durée de conservation de Workflows.

Sur iOS 26 et plus, l’app demande du temps d’exécution avec BGContinuedProcessingTask, et le système affiche une barre de progression. Elle suit les blocs terminés et, entre deux blocs, le temps écoulé, plafonné à 95 % : iOS interrompt une tâche dont la progression stagne.