Patterns async avancés pour LangChain : Optimisation des performances et parallélisation

tl;dr: L'async permet d'exécuter plusieurs opérations LLM en parallèle et de multiplier le throughput par 5-10x. Ce guide couvre async/await avec LangChain, concurrent execution, batch processing, streaming, backpressure, semaphores, queues, et profiling de performance. Essentiel pour systèmes haute performance.

Les applications LLM sont I/O-bound : elles passent la majorité du temps à attendre les réponses des APIs. L’async permet d’exécuter plusieurs requêtes en parallèle et de multiplier le throughput par 5-10x. Dans cet article, nous couvrons les patterns async avancés pour maximiser les performances de vos applications LangChain.

Diagramme d’architecture LangChain illustrant la programmation asynchrone avancée pour le développement d’applications IA

Pourquoi l’async est critique ?

Comparaison sync vs async

# ❌ Synchrone : LENT (10 requêtes × 2s = 20s)
import time
from langchain_openai import ChatOpenAI

llm = ChatOpenAI()

start = time.time()
results = []
for i in range(10):
    result = llm.invoke(f"Question {i}")  # Bloque pendant 2s
    results.append(result)
print(f"Temps total: {time.time() - start:.1f}s")  # ~20s

# ✅ Asynchrone : RAPIDE (10 requêtes en parallèle = 2s)
import asyncio
from langchain_openai import ChatOpenAI

llm = ChatOpenAI()

async def process_async():
    start = time.time()
    tasks = [
        llm.ainvoke(f"Question {i}")
        for i in range(10)
    ]
    results = await asyncio.gather(*tasks)  # Parallèle !
    print(f"Temps total: {time.time() - start:.1f}s")  # ~2s
    return results

asyncio.run(process_async())

Gain : 10x plus rapide ! 🚀


Async/Await basics avec LangChain

Méthodes async natives

# chains/async_chain.py
import asyncio
from langchain_openai import ChatOpenAI
from langchain.prompts import ChatPromptTemplate

class AsyncChain:
    """Chain avec support async natif"""

    def __init__(self):
        self.llm = ChatOpenAI(temperature=0)
        self.prompt = ChatPromptTemplate.from_template("Réponds: {query}")
        self.chain = self.prompt | self.llm

    async def ainvoke(self, query: str) -> str:
        """Invoke asynchrone (non-bloquant)"""
        result = await self.chain.ainvoke({"query": query})
        return result.content

    async def abatch(self, queries: list[str]) -> list[str]:
        """Batch asynchrone (parallèle)"""
        results = await self.chain.abatch([{"query": q} for q in queries])
        return [r.content for r in results]

    async def astream(self, query: str):
        """Stream asynchrone (tokens en temps réel)"""
        async for chunk in self.chain.astream({"query": query}):
            yield chunk.content

Utilisation

async def main():
    chain = AsyncChain()

    # Single invoke
    response = await chain.ainvoke("Quelle est la capitale de la France ?")
    print(response)

    # Batch parallèle
    queries = [
        "Capitale de l'Allemagne ?",
        "Capitale de l'Italie ?",
        "Capitale de l'Espagne ?"
    ]
    responses = await chain.abatch(queries)
    for q, r in zip(queries, responses):
        print(f"{q}{r}")

    # Streaming
    async for chunk in chain.astream("Explique la photosynthèse"):
        print(chunk, end="", flush=True)

# Exécuter
asyncio.run(main())

Concurrent execution avec asyncio.gather

Exécution parallèle de multiples chains

# processing/concurrent_executor.py
import asyncio
from typing import List, Dict
import time

class ConcurrentExecutor:
    """Exécute plusieurs chains en parallèle"""

    def __init__(self, chain):
        self.chain = chain

    async def execute_parallel(
        self,
        queries: List[str],
        max_concurrent: int = 10
    ) -> List[Dict]:
        """
        Exécute queries en parallèle avec limite de concurrence

        Args:
            queries: Liste de queries
            max_concurrent: Nombre max de requêtes simultanées
        """
        # Semaphore pour limiter concurrence
        semaphore = asyncio.Semaphore(max_concurrent)

        async def process_with_limit(query: str, index: int) -> Dict:
            async with semaphore:
                start = time.time()
                try:
                    result = await self.chain.ainvoke(query)
                    return {
                        "index": index,
                        "query": query,
                        "result": result,
                        "latency_ms": (time.time() - start) * 1000,
                        "success": True
                    }
                except Exception as e:
                    return {
                        "index": index,
                        "query": query,
                        "error": str(e),
                        "success": False
                    }

        # Créer toutes les tasks
        tasks = [
            process_with_limit(query, i)
            for i, query in enumerate(queries)
        ]

        # Exécuter en parallèle
        results = await asyncio.gather(*tasks, return_exceptions=False)
        return results

Utilisation

async def main():
    chain = AsyncChain()
    executor = ConcurrentExecutor(chain)

    # 100 queries en parallèle (max 10 simultanées)
    queries = [f"Question numéro {i}" for i in range(100)]

    start = time.time()
    results = await executor.execute_parallel(
        queries,
        max_concurrent=10
    )
    total_time = time.time() - start

    # Statistiques
    successful = sum(1 for r in results if r["success"])
    avg_latency = sum(r["latency_ms"] for r in results if r["success"]) / successful

    print(f"Total: {total_time:.1f}s")
    print(f"Succès: {successful}/{len(queries)}")
    print(f"Latence moyenne: {avg_latency:.0f}ms")
    print(f"Throughput: {len(queries)/total_time:.1f} req/s")

asyncio.run(main())

Batch processing optimisé

Batch avec retry et backoff

# processing/batch_processor.py
import asyncio
from typing import List, Dict, Callable
import time

class AsyncBatchProcessor:
    """Traitement par batch avec retry et optimisations"""

    def __init__(
        self,
        chain,
        batch_size: int = 10,
        max_retries: int = 3,
        retry_delay: float = 1.0
    ):
        self.chain = chain
        self.batch_size = batch_size
        self.max_retries = max_retries
        self.retry_delay = retry_delay

    async def process_batch(
        self,
        items: List[str],
        callback: Callable = None
    ) -> List[Dict]:
        """
        Process items en batches avec retry

        Args:
            items: Liste d'items à traiter
            callback: Fonction appelée après chaque item
        """
        results = []

        # Découper en batches
        for i in range(0, len(items), self.batch_size):
            batch = items[i:i + self.batch_size]
            batch_results = await self._process_single_batch(batch)
            results.extend(batch_results)

            # Callback optionnel
            if callback:
                callback(len(results), len(items))

        return results

    async def _process_single_batch(
        self,
        batch: List[str]
    ) -> List[Dict]:
        """Process un seul batch avec retry"""
        tasks = [
            self._process_with_retry(item, index)
            for index, item in enumerate(batch)
        ]
        return await asyncio.gather(*tasks)

    async def _process_with_retry(
        self,
        item: str,
        index: int
    ) -> Dict:
        """Process un item avec retry"""
        for attempt in range(self.max_retries + 1):
            try:
                start = time.time()
                result = await self.chain.ainvoke(item)
                return {
                    "index": index,
                    "item": item,
                    "result": result,
                    "latency_ms": (time.time() - start) * 1000,
                    "attempts": attempt + 1,
                    "success": True
                }
            except Exception as e:
                if attempt == self.max_retries:
                    return {
                        "index": index,
                        "item": item,
                        "error": str(e),
                        "attempts": attempt + 1,
                        "success": False
                    }

                # Exponential backoff
                await asyncio.sleep(self.retry_delay * (2 ** attempt))

Utilisation avec progress callback

async def main():
    chain = AsyncChain()
    processor = AsyncBatchProcessor(
        chain,
        batch_size=10,
        max_retries=3
    )

    items = [f"Item {i}" for i in range(100)]

    # Callback pour progress
    def progress_callback(completed, total):
        progress = (completed / total) * 100
        print(f"Progrès: {completed}/{total} ({progress:.1f}%)", end="\r")

    results = await processor.process_batch(items, callback=progress_callback)

    print(f"\nTraité: {len(results)} items")
    print(f"Succès: {sum(1 for r in results if r['success'])}")

asyncio.run(main())

Streaming async avancé

Stream processing avec buffering

# streaming/async_streamer.py
import asyncio
from typing import AsyncIterator
from collections import deque

class AsyncStreamer:
    """Streaming avec buffering intelligent"""

    def __init__(self, chain, buffer_size: int = 100):
        self.chain = chain
        self.buffer_size = buffer_size

    async def stream_with_buffer(
        self,
        query: str
    ) -> AsyncIterator[str]:
        """Stream avec buffer pour smoothing"""
        buffer = deque(maxlen=self.buffer_size)

        async for chunk in self.chain.astream({"query": query}):
            buffer.append(chunk.content)

            # Yield par petits groupes
            if len(buffer) >= 10:
                yield "".join(buffer)
                buffer.clear()

        # Yield le reste du buffer
        if buffer:
            yield "".join(buffer)

    async def stream_multiple(
        self,
        queries: List[str]
    ) -> AsyncIterator[Dict]:
        """Stream plusieurs queries en parallèle"""
        tasks = []
        for i, query in enumerate(queries):
            task = self._stream_single(query, i)
            tasks.append(task)

        # Yield dès qu'une query produit un chunk
        for task in asyncio.as_completed(tasks):
            result = await task
            yield result

    async def _stream_single(
        self,
        query: str,
        query_id: int
    ) -> Dict:
        """Stream une seule query"""
        chunks = []
        async for chunk in self.chain.astream({"query": query}):
            chunks.append(chunk.content)

        return {
            "query_id": query_id,
            "query": query,
            "response": "".join(chunks)
        }

Backpressure et rate limiting

Contrôle du débit avec asyncio.Queue

# processing/rate_limited_processor.py
import asyncio
from asyncio import Queue
import time

class RateLimitedProcessor:
    """Processor avec rate limiting et backpressure"""

    def __init__(
        self,
        chain,
        max_requests_per_second: int = 10,
        queue_size: int = 1000
    ):
        self.chain = chain
        self.max_rps = max_requests_per_second
        self.queue = Queue(maxsize=queue_size)
        self.results = []

    async def add_request(self, query: str):
        """Ajoute une requête à la queue (peut bloquer si pleine)"""
        await self.queue.put(query)

    async def process_queue(self):
        """Process la queue avec rate limiting"""
        interval = 1.0 / self.max_rps  # Intervalle entre requêtes

        while True:
            try:
                # Récupérer la prochaine requête
                query = await asyncio.wait_for(
                    self.queue.get(),
                    timeout=1.0
                )

                # Process
                start = time.time()
                result = await self.chain.ainvoke(query)
                self.results.append({
                    "query": query,
                    "result": result,
                    "latency_ms": (time.time() - start) * 1000
                })

                # Rate limiting
                elapsed = time.time() - start
                if elapsed < interval:
                    await asyncio.sleep(interval - elapsed)

            except asyncio.TimeoutError:
                # Queue vide, continuer
                continue
            except Exception as e:
                print(f"Erreur: {e}")
                continue

    async def run(self, queries: List[str]):
        """Lance le processing avec rate limiting"""
        # Démarrer le processor
        processor_task = asyncio.create_task(self.process_queue())

        # Ajouter toutes les queries
        for query in queries:
            await self.add_request(query)

        # Attendre que la queue soit vide
        await self.queue.join()

        # Arrêter le processor
        processor_task.cancel()

        return self.results

Patterns avancés

Worker pool pattern

# processing/worker_pool.py
import asyncio
from typing import List, Callable

class WorkerPool:
    """Pool de workers async"""

    def __init__(
        self,
        worker_func: Callable,
        num_workers: int = 5
    ):
        self.worker_func = worker_func
        self.num_workers = num_workers
        self.queue = asyncio.Queue()
        self.results = []

    async def worker(self, worker_id: int):
        """Worker qui process les items de la queue"""
        while True:
            try:
                item = await self.queue.get()

                if item is None:  # Signal d'arrêt
                    break

                result = await self.worker_func(item)
                self.results.append(result)

                self.queue.task_done()

            except Exception as e:
                print(f"Worker {worker_id} error: {e}")
                self.queue.task_done()

    async def process(self, items: List) -> List:
        """Process items avec le pool de workers"""
        # Démarrer les workers
        workers = [
            asyncio.create_task(self.worker(i))
            for i in range(self.num_workers)
        ]

        # Ajouter les items à la queue
        for item in items:
            await self.queue.put(item)

        # Attendre que tout soit traité
        await self.queue.join()

        # Arrêter les workers
        for _ in range(self.num_workers):
            await self.queue.put(None)

        await asyncio.gather(*workers)

        return self.results

Pipeline pattern

# processing/pipeline.py
import asyncio
from typing import Callable, List

class AsyncPipeline:
    """Pipeline de transformations async"""

    def __init__(self, stages: List[Callable]):
        self.stages = stages

    async def process_item(self, item):
        """Process un item à travers tous les stages"""
        result = item
        for stage in self.stages:
            result = await stage(result)
        return result

    async def process_batch(self, items: List) -> List:
        """Process un batch en parallèle"""
        tasks = [self.process_item(item) for item in items]
        return await asyncio.gather(*tasks)

# Utilisation
async def stage1(item):
    # Enrichissement
    await asyncio.sleep(0.1)
    return f"Stage1({item})"

async def stage2(item):
    # Validation
    await asyncio.sleep(0.1)
    return f"Stage2({item})"

async def stage3(item):
    # LLM processing
    await asyncio.sleep(0.5)
    return f"Stage3({item})"

pipeline = AsyncPipeline([stage1, stage2, stage3])
results = await pipeline.process_batch(["item1", "item2", "item3"])

Performance profiling

Mesurer les performances

# profiling/async_profiler.py
import asyncio
import time
from typing import Dict, List
from dataclasses import dataclass
from statistics import mean, stdev

@dataclass
class ProfileResult:
    total_time: float
    num_requests: int
    successful: int
    failed: int
    avg_latency: float
    std_latency: float
    min_latency: float
    max_latency: float
    throughput: float  # requests/second

class AsyncProfiler:
    """Profile les performances async"""

    @staticmethod
    async def profile_chain(
        chain,
        queries: List[str],
        max_concurrent: int = 10
    ) -> ProfileResult:
        """Profile une chain avec différentes charges"""
        semaphore = asyncio.Semaphore(max_concurrent)
        results = []

        async def timed_invoke(query: str) -> Dict:
            async with semaphore:
                start = time.time()
                try:
                    result = await chain.ainvoke(query)
                    latency = time.time() - start
                    return {
                        "success": True,
                        "latency": latency
                    }
                except Exception as e:
                    return {
                        "success": False,
                        "error": str(e)
                    }

        # Exécuter
        start = time.time()
        tasks = [timed_invoke(q) for q in queries]
        results = await asyncio.gather(*tasks)
        total_time = time.time() - start

        # Analyser
        successful = [r for r in results if r["success"]]
        failed = [r for r in results if not r["success"]]
        latencies = [r["latency"] for r in successful]

        return ProfileResult(
            total_time=total_time,
            num_requests=len(queries),
            successful=len(successful),
            failed=len(failed),
            avg_latency=mean(latencies) if latencies else 0,
            std_latency=stdev(latencies) if len(latencies) > 1 else 0,
            min_latency=min(latencies) if latencies else 0,
            max_latency=max(latencies) if latencies else 0,
            throughput=len(queries) / total_time
        )

    @staticmethod
    async def benchmark_concurrency(
        chain,
        query: str,
        concurrency_levels: List[int] = [1, 5, 10, 20, 50]
    ) -> Dict[int, ProfileResult]:
        """Benchmark différents niveaux de concurrence"""
        results = {}

        for concurrency in concurrency_levels:
            print(f"Testing concurrency: {concurrency}...")

            # Créer N requêtes identiques
            queries = [query] * 100

            result = await AsyncProfiler.profile_chain(
                chain,
                queries,
                max_concurrent=concurrency
            )

            results[concurrency] = result

            print(f"  Throughput: {result.throughput:.1f} req/s")
            print(f"  Latence: {result.avg_latency*1000:.0f}ms")

        return results

Utilisation

async def main():
    chain = AsyncChain()

    # Profile simple
    queries = [f"Question {i}" for i in range(100)]
    result = await AsyncProfiler.profile_chain(
        chain,
        queries,
        max_concurrent=10
    )

    print(f"Total: {result.total_time:.1f}s")
    print(f"Throughput: {result.throughput:.1f} req/s")
    print(f"Latence moyenne: {result.avg_latency*1000:.0f}ms ± {result.std_latency*1000:.0f}ms")
    print(f"Min/Max: {result.min_latency*1000:.0f}ms / {result.max_latency*1000:.0f}ms")

    # Benchmark concurrence
    benchmark_results = await AsyncProfiler.benchmark_concurrency(
        chain,
        "Test query",
        concurrency_levels=[1, 5, 10, 20, 50]
    )

    # Afficher courbe
    print("\nConcurrence vs Throughput:")
    for concurrency, result in benchmark_results.items():
        print(f"{concurrency:3d}{result.throughput:6.1f} req/s")

asyncio.run(main())

Best practices

Toujours utiliser async pour I/O

# ✅ BON : Async pour appels LLM
async def process_queries(queries):
    tasks = [chain.ainvoke(q) for q in queries]
    return await asyncio.gather(*tasks)

# ❌ MAUVAIS : Sync (bloque le thread)
def process_queries_sync(queries):
    return [chain.invoke(q) for q in queries]

Limiter la concurrence

# ✅ BON : Semaphore pour limiter
semaphore = asyncio.Semaphore(10)  # Max 10 simultanés

async def limited_invoke(query):
    async with semaphore:
        return await chain.ainvoke(query)

# ❌ MAUVAIS : Concurrence illimitée (peut surcharger l'API)
tasks = [chain.ainvoke(q) for q in huge_list]  # 10 000 en parallèle !

Gérer les timeouts

# ✅ BON : Timeout global
try:
    result = await asyncio.wait_for(
        chain.ainvoke(query),
        timeout=30.0
    )
except asyncio.TimeoutError:
    # Gérer le timeout

# ❌ MAUVAIS : Pas de timeout (peut bloquer indéfiniment)
result = await chain.ainvoke(query)

Utiliser return_exceptions dans gather

# ✅ BON : Continue même si certaines tasks échouent
results = await asyncio.gather(
    *tasks,
    return_exceptions=True
)

# Filtrer les erreurs
successful = [r for r in results if not isinstance(r, Exception)]
errors = [r for r in results if isinstance(r, Exception)]

# ❌ MAUVAIS : S'arrête à la première erreur
results = await asyncio.gather(*tasks)  # Lève exception

Checklist performance

Avant le déploiement

  • Async activé : Toutes les opérations I/O sont async
  • Concurrence limitée : Semaphore ou rate limiter
  • Batch processing : Requêtes groupées quand possible
  • Timeouts : Définis à tous les niveaux
  • Error handling : return_exceptions=True dans gather
  • Profiling : Performance mesurée avec différentes charges
  • Optimal concurrency : Niveau de concurrence testé
  • Monitoring : Métriques de throughput et latence

En production

  • Throughput : > 50 req/s (ajuster selon besoin)
  • Latence P95 : < 3s
  • Error rate : < 1%
  • Memory usage : Stable (pas de leak)
  • CPU usage : < 70% en moyenne
  • Scaling : Horizontal scaling testé

Ressources complémentaires

Documentation

Articles connexes


Conclusion

L’async est essentiel pour la performance des applications LLM. Les gains sont massifs :

  • 5-10x throughput plus élevé
  • Meilleure utilisation des ressources
  • Expérience utilisateur améliorée

Les patterns clés :

  1. asyncio.gather pour parallélisation
  2. Semaphores pour limiter concurrence
  3. Queues pour backpressure
  4. Worker pools pour processing distribué
  5. Profiling pour optimisation

Avec ces patterns, vous construisez des systèmes haute performance capables de gérer des milliers de requêtes par seconde.

Prochaine étape : Optimiser les coûts avec tracking et budgets !


Retour à la Formation LangChain | Module suivant : Gestion et Optimisation des Coûts →