AI API Streaming in Python: SSE, AsyncIO, and Real-Time UIs

AI API streaming in Python uses Server-Sent Events (SSE) over AsyncIO to deliver tokens as the model generates them. The pattern matters: streaming moves perceived latency from full-generation time (2-10s) to first-token time (180-420ms) — a 5-10x UX improvement. This guide covers the OpenAI SDK streaming, raw SSE parsing, AsyncIO fan-out to multiple models, and real-time UI integration with backpressure handling.

Why Streaming Changes Everything

Without streaming, a 500-token response at 40ms/token means users watch a spinner for 20 seconds. With streaming, the first tokens render in under 400ms and content flows continuously. Perceived latency drops even though total generation time is identical.

ModeTime to First Visible ContentUX
Blocking request2-20s (full generation)Spinner fatigue
Streaming SSE180-420msFeels instant

Basic Streaming with the OpenAI SDK

The OpenAI Python SDK handles SSE parsing internally — iterate the stream and read deltas:

from openai import OpenAI

client = OpenAI(
    api_key="your-drai-key",
    base_url="https://api.dr-ai.top/v1"
)

stream = client.chat.completions.create(
    model="gpt-5-mini",
    messages=[{"role": "user", "content": "Explain SSE in one paragraph"}],
    stream=True,
    max_tokens=300,
)

full = []
for chunk in stream:
    delta = chunk.choices[0].delta.content
    if delta:               # None on keep-alive chunks
        full.append(delta)
        print(delta, end="", flush=True)

print("".join(full))

Two details everyone hits: delta can be None (skip, don't crash), and always set max_tokens — uncapped streams run for minutes.

Raw SSE Parsing (No SDK)

Under the hood, SSE is just text/event-stream with data: lines. Parsing it yourself matters for custom clients and debugging:

import httpx, json

async def raw_sse(url, headers, payload):
    async with httpx.AsyncClient(timeout=None) as client:
        async with client.stream("POST", url, 
                                  headers=headers, json=payload) as r:
            async for line in aiter_lines(r):
                if not line.startswith("data: "):
                    continue
                data = line[6:]
                if data == "[DONE]":
                    break
                chunk = json.loads(data)
                delta = (chunk["choices"][0]
                         .get("delta", {}).get("content"))
                if delta:
                    yield delta

async def aiter_lines(response):
    async for raw in response.aiter_lines():
        yield raw

AsyncIO: Concurrent Streams from Multiple Models

The killer AsyncIO pattern — stream multiple models simultaneously and take the first good answer (or race for latency):

import asyncio

async def stream_one(model, prompt):
    try:
        stream = await client.chat.completions.create(
            model=model, prompt=prompt, stream=True, timeout=8)
        return (model, stream)
    except Exception:
        return (model, None)

async def race_models(prompt, models):
    tasks = [stream_one(m, prompt) for m in models]
    # as_completed: yield whichever model starts producing first
    for coro in asyncio.as_completed(tasks):
        model, stream = await coro
        if stream:
            async for delta in stream:
                yield delta
            return  # first successful stream wins

This hedging pattern costs 2-3x tokens on the racing requests but cuts p99 latency dramatically — standard practice in latency-critical search and autocomplete.

Fan-Out: One Request, Many Consumers

Serve one upstream stream to N connected clients (team collaboration, live previews) with per-client queues and backpressure:

class StreamHub:
    def __init__(self):
        self.subscribers = []   # list[asyncio.Queue]

    def subscribe(self):
        q = asyncio.Queue(maxsize=100)  # backpressure bound
        self.subscribers.append(q)
        return q

    async def broadcast(self, delta):
        dead = []
        for q in self.subscribers:
            try:
                q.put_nowait(delta)      # drop for slow clients
            except asyncio.QueueFull:
                dead.append(q)           # or await q.put() to block
        for q in dead:
            self.subscribers.remove(q)

# FastAPI endpoint per subscriber
@app.get("/stream")
async def stream_endpoint(request):
    q = hub.subscribe()
    async def generate():
        while True:
            delta = await q.get()
            if delta is SENTINEL:
                break
            yield f"data: {json.dumps({'delta': delta})}\n\n"
    return StreamingResponse(generate())

The bounded queue is the key design choice: slow consumers get drops (chat) or disconnection (financial data) instead of unbounded memory growth.

Mid-Stream Error Recovery

Streams die at token 500 of 1,000. Handle it deliberately:

async def resilient_stream(messages, min_tokens=50):
    collected, attempts = [], 0
    while attempts < 3:
        try:
            stream = await client.chat.completions.create(
                model="gpt-5-mini", messages=messages, stream=True)
            async for chunk in stream:
                delta = chunk.choices[0].delta.content
                if delta:
                    collected.append(delta)
                    yield delta
            return
        except Exception as e:
            attempts += 1
            if len(collected) >= min_tokens:
                # enough context: continue, don't restart
                messages.append({"role": "assistant",
                                 "content": "".join(collected)})
                messages.append({"role": "user",
                                 "content": "continue exactly where you stopped"})
            await asyncio.sleep(2 ** attempts * 0.5)
    yield "\n[generation failed after retries]"

Backpressure: Don't Outrun Your Consumer

If your UI renders slower than the model generates, the unconsumed buffer grows. For UIs, coalesce deltas — render at 30-60fps, not per-token:

import time

async def throttled_render(q, fps=30):
    buffer, last = [], 0.0
    while True:
        delta = await q.get()
        buffer.append(delta)
        now = time.monotonic()
        if now - last >= 1/fps:
            render("".join(buffer))     # batch to one paint
            buffer, last = [], now

Checklist: Production Streaming

DrAI normalizes SSE across all 18+ providers — Anthropic, DeepSeek, and Gemini streams all arrive as standard OpenAI chunks, so this code works unchanged for every model. Get a free key at ai.dr-ai.top and see the SSE vs WebSocket deep-dive, latency optimization guide, and error handling patterns for the surrounding production stack.

Streaming doesn't have to be expensive — see per-model pricing on the DrAI pricing page.

Want one API key for GPT-5, Claude 4, DeepSeek, and 15+ models?

Free tier available. OpenAI-compatible. Automatic failover.

Get Your Free API Key →

📚 Related Reading

Streaming AI Responses: Server-Sent Events vs WebSocket ImplementationComplete guide to streaming AI responses with SSE and WebSocket. Python and JavaScript implemen... AI API Latency Optimization: From 3 Seconds to 300msFive levers cut AI API latency: model selection, prompt compression, streaming, response cachin... AI Chatbot Integration Guide: Add GPT-5 to Your App in 10 MinutesComplete guide to adding an AI chatbot to your application. Python and JavaScript examples with...
🌐 English