n4nAI

Streaming multiple LLM models concurrently with asyncio

Hands-on tutorial: python asyncio stream multiple models concurrently via one OpenAI-compatible API, merging token streams safely with queues and timeouts.

n4n Team3 min read579 words

Audio narration

Coming soon — every post will get a voice note here.

Orchestrating a python asyncio stream multiple models fan-out lets you compare responses from different LLMs in real time without blocking on the slowest one. This tutorial builds a small concurrent streaming client against an OpenAI-compatible endpoint, showing how to fire off several model requests and merge their token streams as they arrive. You will end up with a pattern that scales to any number of models behind a single API.

Prerequisites

  • Python 3.10 or newer (uses asyncio primitives; TaskGroup optional but we stick to gather for broad compatibility).
  • openai Python package >= 1.0 (pip install openai).
  • An API key for an OpenAI-compatible inference gateway. We’ll use n4n.ai because it exposes 240+ models behind one OpenAI-compatible URL and handles provider fallback automatically, but the code works against any compliant server.
  • Basic comfort with async/await and event loops.

Set the key in your environment:

export N4N_API_KEY="sk-..."

Client setup

Instantiate the async client once. Reusing a single client matters: it keeps a connection pool and avoids TLS handshake overhead per request.

import os
from openai import AsyncOpenAI

client = AsyncOpenAI(
    base_url="https://api.n4n.ai/v1",
    api_key=os.environ["N4N_API_KEY"],
)

If you target a different provider, change base_url and the env var name. The rest of the code is identical.

Streaming one model

A single-model stream is a coroutine that pulls deltas and pushes them into a shared asyncio.Queue. Using a queue decouples production (network I/O) from consumption (printing or aggregating), which is the cleanest way to interleave multiple streams.

import asyncio

async def stream_model(model: str, prompt: str, queue: asyncio.Queue):
    try:
        stream = await client.chat.completions.create(
            model=model,
            messages=[{"role": "user", "content": prompt}],
            stream=True,
            temperature=0.7,
        )
        async for chunk in stream:
            delta = chunk.choices[0].delta.content
            if delta:
                await queue.put((model, delta))
    except Exception as e:
        await queue.put((model, f"\n[error {model}: {e}]\n"))
    finally:
        await queue.put((model, None))  # sentinel marks end of this model

The sentinel (None payload) lets the consumer know when a model finished, without relying on task ordering.

Fan-out concurrency

The core of python asyncio stream multiple models is running stream_model for each model as a separate task and consuming the queue concurrently. asyncio.gather runs them together; the event loop multiplexes the network reads.

async def main():
    models = ["gpt-4o-mini", "llama-3.1-8b-instruct", "mistral-7b-instruct"]
    prompt = "Explain asyncio in one sentence."
    queue: asyncio.Queue = asyncio.Queue()

    producers = [asyncio.create_task(stream_model(m, prompt, queue)) for m in models]

    async def consume():
        pending = len(models)
        while pending:
            model, data = await queue.get()
            if data is None:
                pending -= 1
                print(f"\n--- {model} stream closed ---")
            else:
                print(f"[{model}] {data}", end="", flush=True)

    await asyncio.gather(*producers, consume())

Run it with asyncio.run(main()). The output interleaves tokens from all three models as they arrive, like a multi-column terminal feed.

Expected output at checkpoint

A typical first run prints something like:

[gpt-4o-mini] Asyncio is a Python library for writing concurrent code using the async/await syntax.
[llama-3.1-8b-instruct] asyncio lets you handle many I/O-bound tasks concurrently without threads.
[mistral-7b-instruct] It provides an event loop to schedule coroutines.
[gpt-4o-mini] It uses an event loop to manage tasks.
--- gpt-4o-mini stream closed ---
--- llama-3.1-8b-instruct stream closed ---
--- mistral-7b-instruct stream closed ---

Order varies per run. The key point: no model waits for another to finish before emitting tokens.

Adding timeouts

A stalled provider shouldn’t hang the whole fan-out. Wrap each stream in asyncio.wait_for with a per-model budget.

async def stream_model(model: str, prompt: str, queue: asyncio.Queue, timeout=30.0):
    try:
        await asyncio.wait_for(_stream_inner(model, prompt, queue), timeout=timeout)
    except asyncio.TimeoutError:
        await queue.put((model, f"\n[timeout {model}]\n"))
    finally:
        await queue.put((model, None))

async def _stream_inner(model, prompt, queue):
    stream = await client.chat.completions.create(
        model=model,
        messages=[{"role": "user", "content": prompt}],
        stream=True,
    )
    async for chunk in stream:
        delta = chunk.choices[0].delta.content
        if delta:
            await queue.put((model, delta))

If a model exceeds 30s to first/any chunk, its task is cancelled and the consumer notes the timeout.

Graceful fallback

Even with a gateway that performs automatic fallback when a provider is rate-limited, you should own failure modes client-side. A simple retry with a secondary model keeps the demo robust:

async def stream_with_fallback(primary, fallback, prompt, queue):
    try:
        await _stream_inner(primary, prompt, queue)
    except Exception:
        await queue.put((primary, f"\n[fallback {primary} -> {fallback}]\n"))
        await _stream_inner(fallback, prompt, queue)
    finally:
        await queue.put((primary, None))

When pointed at n4n.ai, the endpoint may already have routed around a dead provider; the client fallback is a second layer for hard API errors.

Full runnable script

import os
import asyncio
from openai import AsyncOpenAI

client = AsyncOpenAI(
    base_url="https://api.n4n.ai/v1",
    api_key=os.environ["N4N_API_KEY"],
)

async def _stream_inner(model, prompt, queue):
    stream = await client.chat.completions.create(
        model=model,
        messages=[{"role": "user", "content": prompt}],
        stream=True,
    )
    async for chunk in stream:
        delta = chunk.choices[0].delta.content
        if delta:
            await queue.put((model, delta))

async def stream_model(model, prompt, queue, timeout=30.0):
    try:
        await asyncio.wait_for(_stream_inner(model, prompt, queue), timeout=timeout)
    except Exception as e:
        await queue.put((model, f"\n[error {model}: {e}]\n"))
    finally:
        await queue.put((model, None))

async def main():
    models = ["gpt-4o-mini", "llama-3.1-8b-instruct", "mistral-7b-instruct"]
    prompt = "Explain asyncio in one sentence."
    queue: asyncio.Queue = asyncio.Queue()
    producers = [asyncio.create_task(stream_model(m, prompt, queue)) for m in models]

    async def consume():
        pending = len(models)
        while pending:
            model, data = await queue.get()
            if data is None:
                pending -= 1
                print(f"\n--- {model} done ---")
            else:
                print(f"[{model}] {data}", end="", flush=True)

    await asyncio.gather(*producers, consume())

if __name__ == "__main__":
    asyncio.run(main())

Operational notes

  • Connection limits: The openai client uses a default connection pool. Streaming many models simultaneously is I/O bound, but if you fan out to 50+ models, set max_connections via httpx limits passed to AsyncOpenAI.
  • Backpressure: asyncio.Queue is unbounded by default. For long-running services, bound it (asyncio.Queue(maxsize=1000)) and await queue.put will exert backpressure on slow consumers.
  • Ordering: Tokens from a single model arrive in order; cross-model order is arbitrary by design. If you need aligned columns, buffer per model and flush on newline.
  • Cost metering: Gateway per-token usage metering (as provided by n4n.ai) still applies to streams; you can read chunk.usage on the final chunk if the provider sends it, but with stream=True usage is often emitted on the last delta.

The python asyncio stream multiple models pattern is not just for side-by-side demos. It powers parallel candidate generation, multi-model voting, and latency hedging in production inference routes. Build the queue-first design once and you can swap models without touching the concurrency logic.

Tagspythonasynciostreamingmulti-model

Written by

n4n Team

The team building n4n — a single OpenAI-compatible API in front of 240+ models, with automatic fallback, load balancing and pay-per-token metering.

More from n4n Team →

All python async/await streaming (asyncio) posts →