tech

Le problème N+1 dans les intégrations OpenAI : construire un pipeline Laravel scalable

25 March 2026
6 min de lecture
5 vues
Sébastien Muler

Le problème N+1 dans les intégrations OpenAI : construire un pipeline Laravel scalable

L'intégration d'une IA générative dans une application web est devenue une étape presque incontournable. On installe le client PHP OpenAI, on câble un contrôleur Laravel, et en quelques heures, on a un produit fonctionnel. Parfait en local, parfait pour les dix premiers utilisateurs — jusqu'au jour où le trafic explose.

Les logs se remplissent de 429 Too Many Requests, la facture OpenAI s'envole, et les workers PHP-FPM s'épuisent à attendre des réponses qui ne viennent pas assez vite. Ce scénario, décrit par Ameer Hamza dans son article sur DEV.to, est l'équivalent IA du classique problème N+1 des bases de données.

Dans cet article, nous reprenons ces enseignements et les adaptons à notre contexte PHP/Symfony — et plus largement aux bonnes pratiques applicables à tout projet PHP sérieux.


L'approche naïve : l'appel synchrone direct

La grande majorité des intégrations débutent avec un contrôleur qui appelle l'API OpenAI directement dans la requête HTTP :

public function store(Request $request): JsonResponse
{
    $article = Article::findOrFail($request->article_id);

    // Appel bloquant directement dans le contrôleur
    $response = OpenAI::chat()->create([
        'model' => 'gpt-4o',
        'messages' => [
            ['role' => 'user', 'content' => 'Résume cet article : ' . $article->content]
        ],
    ]);

    $article->update(['summary' => $response->choices[0]->message->content]);

    return response()->json($article);
}

Ce code fonctionne. Le problème, c'est qu'il bloque le worker PHP pendant toute la durée de l'appel API — souvent entre 3 et 15 secondes. Avec 50 requêtes simultanées, tous vos workers sont occupés. Les nouvelles requêtes s'accumulent en file d'attente. L'application devient inutilisable.

Par ailleurs, si deux utilisateurs demandent le résumé du même article, vous effectuez deux appels identiques à l'API. C'est exactement la logique du N+1 : répéter inutilement des opérations coûteuses au lieu de les mutualiser.


Première correction : le cache sémantique

La solution la plus immédiate est de ne jamais interroger l'API deux fois pour la même entrée. En Laravel comme en Symfony, un cache applicatif bien placé suffit à éliminer la majorité des appels redondants.

public function getSummary(Article $article): string
{
    $cacheKey = 'ai_summary_' . md5($article->id . $article->updated_at);

    return Cache::remember($cacheKey, now()->addDays(7), function () use ($article) {
        $response = OpenAI::chat()->create([
            'model' => 'gpt-4o',
            'messages' => [
                ['role' => 'user', 'content' => 'Résume cet article : ' . $article->content]
            ],
        ]);

        return $response->choices[0]->message->content;
    });
}

La clé de cache est construite à partir de l'identifiant et de la date de mise à jour de l'article. Ainsi, toute modification du contenu invalide automatiquement le cache et force une nouvelle génération.

En Symfony, le composant Cache offre une approche similaire avec CacheItemPoolInterface ou le tag-based cache pour une invalidation plus fine.

Règle d'or : si le résultat d'un appel LLM est déterministe pour une entrée donnée, il doit être mis en cache.


Deuxième correction : déléguer aux queues

Le cache résout la redondance, mais pas le blocage. Pour cela, la réponse architecturale est claire : les appels à des APIs lentes n'ont rien à faire dans le cycle requête/réponse HTTP.

En Laravel, on crée un Job dédié :

class GenerateArticleSummaryJob implements ShouldQueue
{
    use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;

    public int $tries = 3;
    public int $backoff = 60; // secondes entre les tentatives

    public function __construct(private Article $article) {}

    public function handle(): void
    {
        $response = OpenAI::chat()->create([
            'model' => 'gpt-4o',
            'messages' => [
                ['role' => 'user', 'content' => 'Résume cet article : ' . $this->article->content]
            ],
        ]);

        $this->article->update([
            'summary' => $response->choices[0]->message->content,
            'summary_generated_at' => now(),
        ]);
    }

    public function failed(Throwable $exception): void
    {
        // Notification, log, ou marquage de l'article en erreur
        Log::error('Génération du résumé échouée', [
            'article_id' => $this->article->id,
            'error' => $exception->getMessage(),
        ]);
    }
}

Le contrôleur devient alors non-bloquant :

public function store(Request $request): JsonResponse
{
    $article = Article::findOrFail($request->article_id);
    GenerateArticleSummaryJob::dispatch($article);

    return response()->json([
        'message' => 'Résumé en cours de génération.',
        'status' => 'pending'
    ], 202);
}

Le code HTTP 202 Accepted indique explicitement au client que la ressource est en cours de traitement — une pratique REST souvent négligée mais sémantiquement correcte.

En Symfony, le composant Messenger remplit exactement ce rôle avec ses Message, Handler et Transport configurables.


Troisième correction : gérer le rate limiting

Même avec un système de queues, rien n'empêche de saturer le quota OpenAI si plusieurs workers traitent des jobs en parallèle. Il faut contrôler le débit applicativement.

Laravel propose un mécanisme natif de throttling sur les jobs via Redis::throttle :

public function handle(): void
{
    Redis::throttle('openai-api')
        ->allow(50)   // 50 requêtes
        ->every(60)   // par minute
        ->then(function () {
            // Appel OpenAI ici
        }, function () {
            // Remettre le job en attente
            $this->release(30);
        });
}

Cette approche garantit que, quelle que soit la charge en queue, on ne dépasse jamais les limites de l'API. En cas de dépassement, le job est simplement remis en attente — sans perte de données, sans erreur côté utilisateur.

On peut également centraliser ce contrôle dans un service dédié OpenAIService qui encapsule la logique de retry, de rate limiting et de cache, plutôt que de disperser cette responsabilité dans chaque job.


Conclusion : l'IA scalable, c'est d'abord de l'architecture

Le problème N+1 des intégrations IA n'est pas un problème d'IA — c'est un problème d'architecture web classique appliqué à un nouveau type de dépendance externe.

Les trois principes à retenir :

  1. Cachez les résultats dès qu'un appel LLM est reproductible pour une entrée identique.
  2. Déportez en queue tout appel susceptible de durer plus d'une seconde.
  3. Contrôlez le débit pour ne jamais saturer les quotas API, même sous charge.

Ces principes s'appliquent en Laravel, en Symfony, ou dans tout framework PHP sérieux. La puissance de l'IA générative ne justifie pas de sacrifier la robustesse de votre architecture. Au contraire, c'est précisément parce que ces APIs sont lentes, coûteuses et soumises à quotas qu'elles exigent une conception encore plus rigoureuse que vos requêtes SQL habituelles.


Cet article s'appuie sur les analyses d'Ameer Hamza publiées sur DEV.to, adaptées et enrichies dans une perspective PHP/Symfony par l'équipe MulerTech.

Partager cet article