Un appel LLM prend entre 500 ms et 30 secondes. Si ton API utilisateur attend ça en synchrone, tu vas voir des timeouts ou des PHP-FPM workers saturés. La solution standard : queue. Et en Symfony, c'est Messenger.

Le flux

Controller dispatch Queue Doctrine / Redis Worker API LLM
Le controller dispatch un message, le worker consume et appelle le LLM. L'utilisateur n'attend pas.

Le message

// src/Message/CallLlm.php
namespace App\Message;

final class CallLlm
{
    public function __construct(
        public readonly string $conversationId,
        public readonly string $prompt,
        public readonly string $model = 'claude-sonnet-4-5',
    ) {}
}

Le handler

// src/MessageHandler/CallLlmHandler.php
namespace App\MessageHandler;

use App\Message\CallLlm;
use App\Service\AnthropicClient;
use Doctrine\ORM\EntityManagerInterface;
use Symfony\Component\Messenger\Attribute\AsMessageHandler;

#[AsMessageHandler]
final class CallLlmHandler
{
    public function __construct(
        private AnthropicClient $client,
        private EntityManagerInterface $em,
    ) {}

    public function __invoke(CallLlm $msg): void
    {
        $response = $this->client->send($msg->prompt, $msg->model);
        // Persiste la réponse, déclenche un event front (Mercure, WebSocket)
        $this->em->getConnection()->executeStatement(
            'UPDATE conversation SET reply = ?, status = "ready" WHERE id = ?',
            [$response, $msg->conversationId]
        );
    }
}

Le controller

public function ask(Request $request, MessageBusInterface $bus): JsonResponse
{
    $conversationId = $this->createConversation($request->get('prompt'));
    $bus->dispatch(new CallLlm($conversationId, $request->get('prompt')));
    return new JsonResponse(['conversationId' => $conversationId, 'status' => 'pending']);
}

Config minimale

# config/packages/messenger.yaml
framework:
    messenger:
        transports:
            async: 'doctrine://default?queue_name=llm'
        routing:
            App\Message\CallLlm: async

Lance le worker : php bin/console messenger:consume async -vv. C'est tout.

Patterns qui ont marché en prod

  • Front polling ou Mercure. Polling toutes les 2 s sur l'endpoint /conversations/{id} si tu débutes. Mercure ou WebSocket quand tu auras le besoin.
  • Retry exponentiel. middleware: [send_after, retry_dlq] pour absorber les 429 et les timeouts API.
  • Worker dédié. Un service systemd qui maintient un worker tournant, pas un cron toutes les minutes.
  • Concurrence limitée. --limit=N et plusieurs workers par instance pour saturer ton budget de tokens, pas plus.