درس 7 — Router et tenir la charge

Le test de charge avec asyncio

6 دقائق قراءة

هذا المحتوى غير مترجم بعد. تُعرض النسخة الأصلية.

Le test de charge avec asyncio

Pourquoi asyncio

Un appel LLM passe presque tout son temps à attendre le réseau. asyncio permet de lancer des dizaines d'appels qui attendent en même temps dans un seul processus, sans threads. Un sémaphore borne le nombre de requêtes « en vol » : c'est la concurrence du test.

sequenceDiagram
  participant B as "bench.py"
  participant S as "Sémaphore (4)"
  participant API as "API Gemini"
  B->>S: "16 tâches créées (gather)"
  S->>API: "requêtes 1 à 4"
  API-->>S: "réponse 2"
  S->>API: "requête 5 (une place s'est libérée)"
  API-->>S: "429 sur la requête 3"
  Note over S: "attente 1 s + gigue, puis nouvel essai"
  S->>API: "requête 3 (2e essai)"

Le code

files/bench.py (extrait)
async def run_fixed(client, model, questions, concurrency):
    sem = asyncio.Semaphore(concurrency)  # limite le nombre de requêtes en vol

    async def one(q):
        async with sem:
            c = await R.ask(client, model, q["question"])
            ...
            return row(q, res)
    return await asyncio.gather(*(one(q) for q in questions))

Le débit est le nombre de requêtes divisé par la durée murale (wall time) du lot :

files/bench.py (extrait)
"debit_rps": len(rows) / wall,

Réessayer correctement

Sous charge, l'API renvoie 429 (quota ou limite de débit atteinte) ou 503 (surcharge momentanée). La règle : réessayer avec une attente exponentielle (1 s, 2 s, 4 s…) et une gigue aléatoire, pour que des centaines de clients ne réessaient pas tous à la même milliseconde.

files/router.py (extrait)
async def with_retry(fn, tries=5):
    for i in range(tries):
        try:
            return await fn()
        except Exception as e:  # google.genai.errors.APIError porte .code
            code = getattr(e, "code", None)
            if code not in (429, 500, 503, 504) or i == tries - 1:
                raise
            await asyncio.sleep(2 ** i + random.random())
CodeSignificationRéessayer ?
400requête invalide (schéma, taille)non : corriger
403droits, API non activéenon
429limite de débit ou quotaoui, avec attente
500, 503, 504erreur ou surcharge côté serveuroui, avec attente

Vous doublez la concurrence de 4 à 8 et le débit ne bouge pas, mais le p95 double. Que se passe-t-il le plus probablement ?

Lire le débit du banc

Sur notre banc, à concurrence 4, flash-lite a traité 3,89 requêtes par seconde contre 1,97 pour flash : avec des requêtes deux fois plus courtes, on en sert deux fois plus avec la même concurrence (loi de Little : concurrence ≈ débit × latence, soit 4 ≈ 3,89 × 0,9 s environ).

ConcurrenceLatence p50Débit mesuréConcurrence ÷ latence
flash41,81 s1,97 req/s2,2
flash-lite40,86 s3,89 req/s4,7

Les écarts entre les deux dernières colonnes viennent de la dispersion des latences et de la fin du lot (les dernières requêtes tournent avec moins de 4 en vol).

Le support attend un pic de 10 questions par seconde, avec une latence p95 de 1,3 s. Quelle concurrence prévoir au minimum ?

Exercice

Ajoutez au banc une option --rps qui envoie les requêtes à cadence fixe (par exemple 5 par seconde), au lieu de « dès qu'une place se libère ». Pourquoi est-ce un test plus réaliste ?

Voir la solution
async def run_open_loop(client, model, questions, rps):
    async def one(q, delay):
        await asyncio.sleep(delay)                 # départ planifié, indépendant des réponses
        c = await R.ask(client, model, q["question"])
        return c
    return await asyncio.gather(*(one(q, i / rps) for i, q in enumerate(questions)))

Le banc à sémaphore est un test « en boucle fermée » : si le serveur ralentit, il envoie moins, ce qui masque la saturation. Les vrais clients arrivent sans attendre les autres (« boucle ouverte ») : si le système ralentit, la file grossit et la latence explose. C'est ce que montre le test à cadence fixe.

Résultat obtenu

Exécution réelle du 2026-10-15, python bench.py --concurrency 4 (les éventuels réessais sont inclus dans les latences ; extrait) :

stratégie                        TTFT p50    p95  lat p50    p95  req/s  qualité    coût $ appels
big (gemini-3.8-flash)               1.57   2.02     1.81   2.57   1.97     100%   0.01560     16
small (gemini-3.5-flash-lite)        0.76   1.23     0.86   1.35   3.89      94%   0.00683     16

Avec 16 requêtes à concurrence 4, c'est un test de fumée, pas de capacité : il valide la mécanique et donne des ordres de grandeur. Un vrai test de charge se fait sur des centaines de requêtes, avec un quota dédié, pas sur une clé partagée.

Je sais faire

  • Écrire un banc asyncio à concurrence bornée par un sémaphore
  • Réessayer 429 et 5xx avec attente exponentielle et gigue, et pas les 4xx
  • Relier concurrence, débit et latence par la loi de Little
  • Distinguer test en boucle fermée et test à cadence fixe
التالي