Tooling

Client code: streaming, async, protocols

The server streams tokens. The client needs to consume them without blocking, handle errors mid-stream, and do it all concurrently. Here is how SSE, WebSockets, and async Python fit together.

Most tutorials about LLM inference focus on the server. Fair enough: that's where the GPU lives and where the hard optimization happens. But the client code matters more than people think. A bad client can block on a streaming response and waste the low-latency advantage of token-by-token delivery. A synchronous client sending requests one at a time will never saturate your server. And a client that doesn't handle mid-stream errors will leave users staring at a broken response.

Today I want to walk through the three layers of building a good inference client: the streaming protocol, the async transport, and the patterns that make production clients robust.

Server-Sent Events (SSE)

The dominant protocol for streaming LLM responses is Server-Sent Events over HTTP. The OpenAI API uses it, vLLM uses it, and most serving frameworks follow the same format. SSE is simple: the server holds the HTTP connection open and sends lines prefixed with data:, each containing a JSON chunk with one or more tokens.

# What an SSE stream looks like on the wire
data: {"choices":[{"delta":{"content":"Hello"},"index":0}]}

data: {"choices":[{"delta":{"content":" world"},"index":0}]}

data: {"choices":[{"delta":{"content":"!"},"index":0}]}

data: [DONE]

Each data: line is followed by a blank line (the SSE event separator). The client reads line by line, parses each JSON payload, extracts the token delta, and appends it to the growing response. The stream ends with data: [DONE].

The simplest Python client using requests:

import requests, json

def stream_chat(prompt, base_url="http://localhost:8000"):
    resp = requests.post(
        f"{base_url}/v1/chat/completions",
        json={
            "model": "llama-3.1-70b",
            "messages": [{"role": "user", "content": prompt}],
            "stream": True,
        },
        stream=True,
    )
    resp.raise_for_status()

    for line in resp.iter_lines(decode_unicode=True):
        if not line or not line.startswith("data: "):
            continue
        payload = line[len("data: "):]
        if payload == "[DONE]":
            break
        chunk = json.loads(payload)
        token = chunk["choices"][0]["delta"].get("content", "")
        print(token, end="", flush=True)

This works for demos but has a problem: requests is synchronous. While you're reading one stream, the entire thread is blocked. You can't send a second request concurrently.

Going async with httpx or aiohttp

For production clients, you need async I/O. Python's asyncio lets you run many concurrent requests on a single thread, each consuming its own SSE stream independently.

import httpx, asyncio, json

async def stream_chat_async(prompt, client, base_url="http://localhost:8000"):
    tokens = []
    async with client.stream(
        "POST",
        f"{base_url}/v1/chat/completions",
        json={
            "model": "llama-3.1-70b",
            "messages": [{"role": "user", "content": prompt}],
            "stream": True,
        },
    ) as resp:
        resp.raise_for_status()
        async for line in resp.aiter_lines():
            if not line or not line.startswith("data: "):
                continue
            payload = line[len("data: "):]
            if payload == "[DONE]":
                break
            chunk = json.loads(payload)
            token = chunk["choices"][0]["delta"].get("content", "")
            tokens.append(token)
    return "".join(tokens)

async def main():
    async with httpx.AsyncClient(timeout=120) as client:
        prompts = ["Explain transformers", "What is KV cache?", "How does batching work?"]
        tasks = [stream_chat_async(p, client) for p in prompts]
        results = await asyncio.gather(*tasks)
        for r in results:
            print(r[:100], "...")

asyncio.run(main())

With httpx.AsyncClient, all three requests run concurrently. Each one reads its SSE stream as tokens arrive, without blocking the others. The connection pool inside httpx handles TCP reuse and connection limits.

WebSockets: the alternative

Some inference servers (notably TensorRT-LLM's Triton backend) support WebSocket streaming instead of SSE. WebSockets are bidirectional and have lower per-message overhead than SSE (no HTTP headers per chunk). The tradeoff: WebSockets require more complex client code, don't work through all proxies, and aren't cacheable.

import websockets, asyncio, json

async def stream_ws(prompt, ws_url="ws://localhost:8000/v1/stream"):
    async with websockets.connect(ws_url) as ws:
        await ws.send(json.dumps({
            "prompt": prompt,
            "max_tokens": 256,
        }))
        async for msg in ws:
            data = json.loads(msg)
            if data.get("done"):
                break
            print(data["token"], end="", flush=True)

For most deployments, SSE over HTTP is the right choice. It's simpler, better supported by load balancers and CDNs, and the per-token overhead of SSE framing is negligible compared to the actual token generation time.

gRPC: structured and typed

For internal service-to-service calls (not user-facing), gRPC with server-side streaming offers strong typing via protobuf, connection multiplexing via HTTP/2, and built-in deadline propagation. NVIDIA Triton Inference Server exposes a gRPC API, and some teams use gRPC between their API gateway and the inference backend.

The downside: gRPC streaming is harder to debug (binary protocol), requires protobuf schema management, and doesn't play well with browser clients. Use it for backend-to-backend, SSE for client-facing.

Production patterns

Beyond the basic streaming loop, production clients need several things:

import asyncio

async def bounded_inference(prompts, client, max_concurrent=32):
    semaphore = asyncio.Semaphore(max_concurrent)

    async def limited(prompt):
        async with semaphore:
            return await stream_chat_async(prompt, client)

    return await asyncio.gather(*[limited(p) for p in prompts])
Client-side TTFT

When measuring TTFT from the client, remember that it includes network latency, not just server-side prefill time. If your server reports 200 ms TTFT but the client sees 350 ms, the 150 ms gap is network and proxy overhead. Measure both to isolate the bottleneck.

The OpenAI Python SDK as a reference

The openai Python package is worth studying as a reference client implementation. It handles SSE parsing, async/sync modes, retry logic, streaming type safety, and timeout configuration. If you're building a client for a custom inference endpoint, start with httpx (which the OpenAI SDK uses internally) and add the patterns above.

The server does the hard work of generating tokens. The client's job is to not waste that work: consume tokens as they arrive, handle failures gracefully, and keep the pipeline full with concurrent requests. Get these right and your inference stack feels fast end to end.

Up next in the Deep Implementation phase: building the autoregressive decoder loop in PyTorch, where we go from calling APIs to implementing the generation loop ourselves.