1. Pourquoi des tâches asynchrones dans ChatGPT App
Dans un monde idéal, n’importe quel outil MCP répondrait en quelques centaines de millisecondes. Mais dans la vraie vie, tout ce qui est intéressant est long et lourd :
- analyse d’un gros CSV avec l’historique d’achats d’un utilisateur ;
- agrégation de données depuis plusieurs API externes, dont chacune sommeille parfois ou répond avec 503 ;
- construction de recommandations complexes avec beaucoup d’étapes intermédiaires ;
- génération de gros rapports et présentations.
Si vous essayez de tout faire entrer dans un seul tool‑call synchrone, vous buterez sur trois problèmes.
Premièrement, les délais d’expiration (timeouts). La session ChatGPT, l’infrastructure HTTP, le client MCP — rien de tout cela n’est conçu pour des réponses « dans cinq minutes ». Un serveur qui maintient la connexion trop longtemps semblera « bloqué » aussi bien pour ChatGPT que pour l’utilisateur.
Deuxièmement, la gestion de la charge. Si cent utilisateurs lancent en même temps « le super‑analyseur de cadeaux de Nouvel An », vous ne voulez pas que le serveur MCP garde cent tâches longues de manière synchrone dans des threads HTTP. Il vous faut une couche capable d’absorber les pics, de placer les tâches dans une file et de les traiter avec plusieurs workers.
Troisièmement, l’UX. L’utilisateur clique sur un bouton dans le widget GiftGenius et fixe un seul spinner pendant 40 secondes — la sensation rappelle les vieux services de banque en ligne. Le modèle « réponse rapide + progression + possibilité d’annuler » est bien plus agréable.
Ces problèmes se résolvent avec le schéma général : « lancement → file → arrière‑plan → événements ».
2. Architecture de base async‑job dans le contexte MCP
Prenons notre GiftGenius. Supposons que nous ayons un nouveau scénario lourd : « Analyse approfondie des préférences d’après l’historique d’achats et les réseaux sociaux d’un ami ». Cela peut durer plusieurs minutes, donc :
- L’outil MCP (tool) reçoit les paramètres de la requête de la part du modèle.
- Au lieu de tout calculer immédiatement, il crée un enregistrement Job dans la base.
- Il met la tâche en file d’attente.
- Il répond instantanément à ChatGPT : « Analyse lancée, voici jobId ».
- Un worker en arrière‑plan récupère la tâche dans la file, exécute le travail lourd et envoie en cours de route des événements MCP job.progress et job.partial, puis en fin de traitement — job.completed ou job.failed.
D’un point de vue architectural, cela ressemble à ceci :
flowchart LR
subgraph ChatGPT
U[Utilisateur] --> GPT[Modèle + ChatGPT UI]
end
GPT -->|call_tool analyze_preferences| MCP[Serveur MCP]
subgraph Backend
MCP -->|créer Job| DB[(BD des tâches)]
MCP -->|enqueue| Q[File]
W[Worker] -->|take job| Q
W -->|update status/progress| DB
W -->|MCP events: job.progress/job.completed| MCP
end
MCP -->|SSE events| GPT
Idée importante : le serveur MCP n’est pas nécessairement un monolithe. Souvent, il sert de façade devant votre infrastructure asynchrone interne : il reçoit les tool‑calls, crée les jobs et envoie les événements, tandis que le travail lourd est réalisé par des processus workers séparés.
3. Modèle de données pour une tâche asynchrone
Commençons par un modèle simple Job. Nous utiliserons TypeScript et un serveur Node/MCP hypothétique, afin que vous voyiez immédiatement comment cela s’intègre à votre stack.
Un modèle minimal en mémoire/B.D. peut ressembler à ceci :
// openai/jobs/model.ts
export type JobStatus =
| 'pending'
| 'in_progress'
| 'completed'
| 'failed'
| 'canceled';
export interface GiftJob {
id: string; // jobId
type: 'deep_gift_analysis';
status: JobStatus;
payload: {
recipientProfile: string; // texte/ID du profil
budget: number;
};
result?: unknown; // recommandations finales
error?: string; // cause de l’erreur
attempts: number; // nombre de tentatives
createdAt: Date;
updatedAt: Date;
}
Dans un vrai projet, vous stockerez GiftJob dans Postgres, DynamoDB, Firestore ou ailleurs, mais pour la leçon nous nous concentrons sur les champs :
- status — l’état actuel de la tâche, qui sera reflété à la fois dans les événements et dans l’UX ;
- attempts — un compteur pour les retry ;
- error — pour les logs et le debug ;
- payload — les données d’entrée dont le worker se sert pour le traitement.
4. Un outil MCP qui crée un async‑job
Imaginons un outil start_deep_analysis. Avant, il pouvait tout faire de manière synchrone ; désormais il se contente de mettre la tâche en file et de renvoyer un jobId.
// openai/tools/startDeepAnalysis.ts
import { v4 as uuid } from 'uuid';
import { createJobAndEnqueue } from '../jobs/queue';
// Pseudo‑types pour le SDK MCP
type StartDeepAnalysisInput = {
recipientProfile: string;
budget: number;
};
type StartDeepAnalysisOutput = {
jobId: string;
message: string;
};
export async function startDeepAnalysisTool(
input: StartDeepAnalysisInput
): Promise<StartDeepAnalysisOutput> {
const jobId = uuid();
await createJobAndEnqueue({
id: jobId,
type: 'deep_gift_analysis',
status: 'pending',
payload: {
recipientProfile: input.recipientProfile,
budget: input.budget,
},
attempts: 0,
createdAt: new Date(),
updatedAt: new Date(),
});
return {
jobId,
message: `J'ai lancé une analyse approfondie. ID de la tâche: ${jobId}. J'enverrai des mises à jour au fur et à mesure.`,
};
}
Ici, il est important que :
- l’outil MCP soit rapide : au plus quelques requêtes à la B.D./à la file ;
- il renvoie une réponse structurée avec jobId, que ChatGPT peut utiliser dans son « explication pour l’utilisateur » et que le widget GiftGenius peut conserver dans son widgetState.
Votre JSON Schema pour cet outil décrit simplement jobId comme une chaîne et message comme un texte lisible par l’humain — le modèle comprendra qu’il s’agit de l’identifiant de la tâche et pourra s’y référer dans les étapes suivantes du dialogue.
5. File simple et worker : version pédagogique
Pour éviter d’introduire tout de suite Redis, RabbitMQ et le reste, faisons une file en mémoire simplifiée. En production, bien sûr, ce sera un service séparé (SQS/BullMQ/Cloud Tasks, etc.), mais la logique restera la même.
Commençons par l’ébauche de la file :
// openai/jobs/queue.ts
import type { GiftJob } from './model';
const jobs = new Map<string, GiftJob>(); // « B.D. » en mémoire
export const queue: string[] = []; // file simplifiée par id
export async function createJobAndEnqueue(job: GiftJob) {
jobs.set(job.id, job);
queue.push(job.id);
}
export function getJob(id: string): GiftJob | undefined {
return jobs.get(id);
}
export function updateJob(id: string, patch: Partial<GiftJob>) {
const job = jobs.get(id);
if (!job) return;
const updated: GiftJob = { ...job, ...patch, updatedAt: new Date() };
jobs.set(id, updated);
}
Maintenant, un worker primitif qui regarde périodiquement la file, prend un job et le traite :
// openai/jobs/worker.ts
import { getJob, updateJob } from './queue';
import { emitJobEvent } from './events';
async function processJob(jobId: string) {
const job = getJob(jobId);
if (!job) return;
updateJob(jobId, { status: 'in_progress' });
await emitJobEvent(jobId, 'job.started', {});
try {
// Ici on appelle la logique métier longue
const result = await doDeepGiftAnalysis(job.id, job.payload);
updateJob(jobId, { status: 'completed', result });
await emitJobEvent(jobId, 'job.completed', { resultSummary: summarize(result) });
} catch (err) {
updateJob(jobId, {
status: 'failed',
error: (err as Error).message,
});
await emitJobEvent(jobId, 'job.failed', { error: 'Internal error' });
}
}
Et le worker « cyclique » lui‑même, à lancer au démarrage de l’application :
// openai/jobs/workerLoop.ts
import { queue } from './queue';
import { processJob } from './worker';
export function startWorkerLoop() {
setInterval(async () => {
const jobId = queue.shift(); // en pratique il faut se protéger des conditions de concurrence
if (!jobId) return;
await processJob(jobId);
}, 1000); // on vérifie la file chaque seconde
}
C’est un exemple pédagogique. Dans le monde réel, au lieu de setInterval, il y aura une vraie file qui « réveille » le worker lorsqu’un nouveau message arrive. Mais l’idée générale est claire : le worker est séparé de l’outil MCP, fonctionne en arrière‑plan et communique avec le serveur MCP par des événements.
6. Génération d’événements MCP depuis le worker
Dans les leçons précédentes, vous avez déjà vu le format des événements MCP : type (type), event_id unique, timestamp, job_id et payload. Voyons maintenant comment le worker peut appeler le helper emitJobEvent, qui se charge d’acheminer les événements jusqu’à ChatGPT via le canal SSE du serveur MCP.
Exemple de helper simple :
// openai/jobs/events.ts
import { randomUUID } from 'crypto';
import { sendMcpEvent } from '../mcp/eventBus';
export async function emitJobEvent(
jobId: string,
type: 'job.started' | 'job.progress' | 'job.completed' | 'job.failed',
payload: unknown
) {
const event = {
event_id: randomUUID(),
type,
job_id: jobId,
timestamp: new Date().toISOString(),
payload,
};
await sendMcpEvent(event);
}
Et sendMcpEvent dans le serveur MCP sait déjà comment injecter cet événement dans SSEServerTransport du SDK MCP : par exemple via un bus d’événements local ou Redis Pub/Sub, comme nous l’avons vu au module 12.
Idée clé : le worker ne parle pas directement à ChatGPT. Il parle au serveur MCP, lequel maintient les connexions SSE et transfère les événements aux clients.
7. Progression et résultats partiels depuis le worker
Passons à ce qu’il y a de plus sympa : la progression et les résultats partiels. Dans GiftGenius, une analyse longue peut être découpée en étapes :
- collecte et normalisation des données ;
- construction de segments de base ;
- génération d’idées de cadeaux initiales ;
- classement final et explication textuelle.
À chaque étape, nous pouvons envoyer job.progress et parfois job.partial, pour que l’UI affiche déjà les premiers cadeaux.
Un worker hypothétique :
async function doDeepGiftAnalysis(jobId: string, payload: GiftJob['payload']) {
await emitJobEvent(jobId, 'job.progress', { step: 1, totalSteps: 4 });
const normalized = await collectAndNormalizeData(payload);
await emitJobEvent(jobId, 'job.progress', { step: 2, totalSteps: 4 });
const roughGifts = await generateInitialGifts(normalized);
await emitJobEvent(jobId, 'job.partial', { gifts: roughGifts.slice(0, 3) });
await emitJobEvent(jobId, 'job.progress', { step: 3, totalSteps: 4 });
const finalGifts = await rerankAndBeautify(roughGifts);
await emitJobEvent(jobId, 'job.progress', { step: 4, totalSteps: 4 });
return finalGifts;
}
Le widget, en écoutant les événements, peut d’abord afficher 3 cadeaux « brouillon » avec la mention « Nous affinons encore », puis après job.completed — mettre à jour la liste et enlever l’indicateur de chargement. Tout cela correspond parfaitement aux patterns UX dont nous avons parlé dans la leçon 3.
8. Logique de retry pour les workers
Passons au point le plus sensible : les erreurs et les réessais.
Imaginez que le worker, en traitant la tâche, appelle une API externe de catalogue produit qui répond périodiquement 500 ou 429. Abandonner la tâche à la première erreur serait étrange. Mais la relancer indéfiniment est impossible : vous risqueriez de vous auto‑DDoSser ou de surcharger le service tiers.
Il nous faut une stratégie de retry avec délai exponentiel et limitation du nombre de tentatives.
Commençons par une classification des erreurs, qui nous servira aussi plus tard dans le cours :
- temporaires (transient) — timeouts, 500, 503, 429 ;
- permanentes (permanent) — entrée invalide, ressource inexistante ;
- fatales (bug) — bugs de code, TypeError, exception inattendue.
Il n’est pertinent de réessayer que pour les erreurs temporaires. Les autres doivent être clairement marquées comme 'failed'.
Simplifions avec un helper :
// openai/jobs/retry.ts
export function shouldRetry(error: unknown): boolean {
if (!(error instanceof Error)) return false;
// À titre indicatif : HTTP 5xx ou 429
return /5\d\d|429/.test(error.message);
}
export function getDelayMs(base: number, attempt: number): number {
const jitter = Math.random() * 100; // léger jitter
return base * 2 ** attempt + jitter; // backoff exponentiel
}
Mettons maintenant à jour le worker pour qu’il prenne en compte attempts dans GiftJob :
// openai/jobs/worker.ts
import { getJob, updateJob } from './queue';
import { emitJobEvent } from './events';
import { shouldRetry, getDelayMs } from './retry';
const MAX_ATTEMPTS = 5;
export async function processJob(jobId: string) {
const job = getJob(jobId);
if (!job) return;
updateJob(jobId, { status: 'in_progress' });
try {
const result = await doDeepGiftAnalysis(job.id, job.payload);
updateJob(jobId, { status: 'completed', result });
await emitJobEvent(jobId, 'job.completed', {
resultSummary: summarize(result),
});
} catch (err) {
const attempts = job.attempts + 1;
const error = err as Error;
if (attempts <= MAX_ATTEMPTS && shouldRetry(error)) {
const delay = getDelayMs(1000, attempts); // 1s,2s,4s...
updateJob(jobId, { attempts, status: 'pending', error: error.message });
setTimeout(() => {
// Dans une vraie file, vous réenfileriez le job avec un délai
processJob(jobId);
}, delay);
await emitJobEvent(jobId, 'job.progress', {
retry: attempts,
nextAttemptInMs: delay,
});
} else {
updateJob(jobId, { status: 'failed', error: error.message });
await emitJobEvent(jobId, 'job.failed', {
error: "Échec de l'analyse après plusieurs tentatives",
});
}
}
}
Plusieurs points sont importants ici.
Premièrement, attempts est stocké dans la tâche elle‑même — c’est pratique pour le logging et l’observabilité (sur un graphe, on voit clairement combien de tâches passent avec des retries).
Deuxièmement, à chaque retry nous envoyons job.progress avec l’indication explicite qu’il s’agit de la tentative n°N. Le modèle peut utiliser cette information pour expliquer à l’utilisateur que « le serveur de cadeaux répond de manière instable, je réessaie ».
Troisièmement, nous garantissons que, dans tous les cas, soit job.completed, soit job.failed sera envoyé. Pas de tâches qui « pendent » entre deux états.
L’annulation ('canceled') — autre statut important. Nous ne l’implémentons pas dans les exemples pédagogiques, mais en production il est souvent déclenché par l’utilisateur (bouton « Annuler » dans le widget) ou par un timeout. Dans ce cas, le worker, au prochain prélèvement de la tâche dans la file, voit status : 'canceled', n’exécute pas le traitement, et le serveur MCP envoie l’événement final job.canceled.
9. Idempotence et retry : ne pas marcher deux fois sur le même râteau
Dès que vous introduisez des retries, le risque « de faire deux fois la même chose » apparaît. Dans le commerce, c’est critique (par exemple, un double débit), mais dans GiftGenius aussi il y a des scénarios où le doublon est problématique : envoyer deux fois le même e‑mail à l’ami, dupliquer une écriture dans votre analytique interne, etc.
Il faut donc s’orienter sur deux principes.
Premier : le handler de job doit être idempotent.
Si vous l’appelez plusieurs fois avec le même jobId (dans le cadre d’un retry ou par erreur), le monde ne doit pas se casser. Pour cela :
- tous les effets de bord (écriture en B.D., envoi d’e‑mails, création de commandes) doivent être corrélés à jobId ou à un autre identifiant naturel, afin que le code puisse vérifier rapidement si nous avons déjà effectué cette étape ;
- si job.status est déjà 'completed' ou 'failed', l’appel répété peut être ignoré ou simplement renvoyer le résultat déjà prêt.
Exemple de protection simple :
export async function processJob(jobId: string) {
const job = getJob(jobId);
if (!job) return;
if (job.status === 'completed' || job.status === 'failed') {
// La tâche est déjà terminée (avec succès ou définitivement échouée)
return;
}
// ... le reste du code
}
Deuxième : les événements doivent eux aussi être idempotents.
Nous avons déjà évoqué event_id et le fait que le client peut filtrer les doublons, mais côté serveur il faut aussi être prudent : lors d’un redémarrage du worker ou d’une reprise depuis la file, ne spammez pas le client avec les mêmes job.progress sans nécessité.
10. Où se situent les files et les workers dans votre architecture
Sur le schéma, tout est joli, mais physiquement, où tourne le worker ? Il existe plusieurs options typiques.
Worker intégré : le serveur MCP et le worker — un seul et même processus/déploiement. Il reçoit les tool‑calls et lance aussi la boucle du worker. Avantage : simplicité ; moins de services, déploiement plus facile. Inconvénient : le passage à l’échelle : pour ajouter des workers, il faut mettre à l’échelle l’ensemble du serveur MCP.
Worker dédié : le serveur MCP est un service, les workers en sont un autre. Entre eux — une file et, éventuellement, un Pub/Sub pour les événements. C’est ce qui est souvent décrit avec BullMQ/Redis et les événements MCP : le serveur MCP est abonné au canal Redis 'mcp:events', les workers y publient des événements.
Variante combinée : une instance du serveur MCP exécute aussi un worker, les autres instances ne font que HTTP/SSE. Cela peut être utile si vous déployez sur Vercel ou une autre plate‑forme serverless, où les processus de fond persistants sont peu agréables à gérer.
Dans notre GiftGenius pédagogique, contentons‑nous pour l’instant de la première option : serveur MCP + un worker simple dans le processus. Quand vous aborderez les modules sur la production et la mise à l’échelle, vous pourrez migrer les workers vers un service séparé.
11. Exemple : pipeline async complet de GiftGenius
Réexpliquons, de bout en bout, ce qui se passe quand l’utilisateur écrit dans le chat :
« J’ai besoin d’une sélection de cadeaux complexe pour un fan d’espace, en tenant compte de ses achats passés ».
- Le modèle décide d’appeler l’outil start_deep_analysis avec les paramètres du profil du destinataire et le budget.
- L’outil crée un GiftJob en B.D. avec le statut 'pending', le met en file et renvoie le jobId + un message de confirmation.
- ChatGPT explique à l’utilisateur que l’analyse est lancée et peut transmettre le jobId au widget GiftGenius.
- Le widget s’abonne aux événements pour ce jobId via SSE, affiche une barre de progression et le statut « Nous collectons et analysons les données ».
- Le worker, voyant un nouveau job dans la file, met à jour le statut à 'in_progress' et envoie job.started.
- En cours de traitement, il envoie plusieurs job.progress (étapes) et job.partial (les 2–3 premiers cadeaux).
- Si une API externe tombe en panne en chemin, le worker réessaie avec un backoff exponentiel, met à jour attempts et envoie un événement contenant les informations de nouvelle tentative.
- À la fin, il envoie soit job.completed avec un bref résumé et les recommandations finales, soit job.failed avec une explication compréhensible.
- Le widget met à jour l’UI en fonction de ces événements, et ChatGPT peut formuler un résumé textuel et proposer des suites : « Afficher d’autres idées », « Réduire le budget », « Changer le type de cadeau ».
Du point de vue de l’utilisateur, c’est un processus long « vivant » et maîtrisé. Du point de vue du backend — un pipeline async normal avec file, workers et retry.
12. Petit exercice (pour s’entraîner)
Pour consolider, essayez pour GiftGenius :
- concevoir le schéma de la table jobs pour une vraie B.D. : quels index sont nécessaires, quels champs serviront au filtrage (par utilisateur, par statut, par date de création) ;
- esquisser un type TypeScript pour l’endpoint HTTP /api/jobs/:id, afin que le widget puisse, en dernier recours, faire du polling de statut si SSE est indisponible ;
- décrire la politique de retry : combien de tentatives, délai de base, que faire des tâches qui échouent quand même (simple table dead‑letter ou logging + alerte).
Cet exercice vous servira plus tard, quand, dans les modules sur la production et l’observabilité, nous parlerons de métriques comme « combien de tâches sont restées en statut pending plus de N minutes ».
13. Erreurs typiques avec les tâches asynchrones
Erreur n°1 : tout faire de manière synchrone dans le tool‑call.
Le piège le plus courant — essayer de tout faire tenir dans un seul outil MCP sans file. Tant que les requêtes sont peu nombreuses, cela « semble fonctionner ». Dès que la charge augmente ou que les API externes ralentissent, vous prenez des timeouts, des blocages du chat et un UX très tendu. Toute opération susceptible de durer des dizaines de secondes (ou plus) devrait être conçue d’emblée comme un async‑job avec jobId.
Erreur n°2 : absence d’un modèle Job explicite.
Parfois, les développeurs tentent de s’en sortir avec de « simples messages dans une file », sans stocker l’état des tâches en B.D. Résultat : difficile de répondre aux questions de base : « quel est le statut de la tâche ? », « combien de fois avons‑nous essayé de l’exécuter ? », « pourquoi a‑t‑elle échoué ? ». Un modèle clair Job avec les champs status, attempts, error, createdAt — c’est la base pour le debug, le monitoring et l’UX.
Erreur n°3 : pas de retry ou, au contraire, retries infinis.
Certains ne font aucun retry et échouent au premier 500, d’autres font while (!success) et ne limitent pas le nombre de tentatives. Dans le premier cas, vous perdez beaucoup de tâches à cause d’incidents temporaires ; dans le second — vous créez des « tempêtes » de charge et risquez de bloquer les API externes. Il faut un juste milieu : nombre de tentatives limité + délai exponentiel + séparation des erreurs temporaires et permanentes.
Erreur n°4 : handlers non idempotents.
Si, à chaque tentative, vous créez par exemple un nouvel enregistrement dans un système tiers sans vérifier, exécutez le même paiement ou envoyez le même e‑mail — le retry devient vite un problème. Le handler doit pouvoir comprendre qu’une tâche avec ce jobId a déjà été menée à terme avec succès et ne pas répéter les effets de bord dangereux.
Erreur n°5 : absence d’événements en cas d’erreur.
Il arrive qu’un worker tombe avec une exception inattendue, la loggue dans la console et s’arrête là. L’utilisateur attend indéfiniment job.completed, sans savoir que tout est mort depuis longtemps. Toute branche où le traitement se termine par une erreur doit finalement mener à job.failed et à la mise à jour du statut Job en B.D. Sans cela, vos flux MCP deviennent une « boîte noire » unidirectionnelle.
Erreur n°6 : événements de progression trop fréquents.
La volonté « d’être transparent » et d’envoyer job.progress pour chaque pour cent d’avancement surcharge le réseau, le client et le serveur MCP. Mieux vaut envoyer la progression lors d’un changement d’étape ou d’un saut significatif (par exemple, tous les 10 %), et conserver le reste uniquement dans les logs internes.
Erreur n°7 : utiliser une file en mémoire en production.
L’exemple pédagogique avec queue: string[] et Map est utile pour comprendre l’architecture, mais en production réelle il s’effondrera au premier redémarrage du processus ou crash du serveur. Pour une exploitation sérieuse, il faut des files et stockages externes : SQS, Pub/Sub, RabbitMQ, Redis Streams, etc. Les variantes en mémoire ne conviennent qu’au développement local et aux démos simples.
GO TO FULL VERSION