← Back to DevBytes

How to Implement Streaming Responses with Local Models

How to Implement Streaming Responses with Local Models

Streaming responses have become a defining feature of modern AI applications. Instead of waiting for an entire response to be generated before displaying anything, streaming sends tokens to the client as soon as they are produced. When working with local models — running on your own hardware through tools like Ollama, llama.cpp, vLLM, or Hugging Face Transformers — streaming is not only possible but often easier to implement than with cloud APIs, because you have direct access to the inference loop.

This tutorial walks through what streaming responses are, why they matter for local model deployments, how to implement them across several popular local inference stacks, and the best practices that will keep your implementation fast, robust, and user-friendly.

What Is a Streaming Response?

A streaming response is a way of delivering model output incrementally. Rather than the server computing the full completion and returning one large JSON payload, it emits a sequence of small chunks — usually one token (or a few tokens) at a time — over a persistent connection such as Server-Sent Events (SSE), WebSockets, or a chunked HTTP response.

For a local model, the generation loop already produces tokens one at a time. In a non-streaming setup, those tokens are buffered into a single string and returned at the end. In a streaming setup, each token is forwarded to the client immediately. The difference is purely in how the output is delivered, not how it is generated.

Why Streaming Matters for Local Models

Streaming is especially valuable when running models locally, for several reasons:

How Streaming Works Under the Hood

Most autoregressive language models generate text in a loop. At each step, the model takes the current sequence, predicts a probability distribution over the next token, samples a token, appends it to the sequence, and repeats until a stop condition is met. A streaming implementation simply exposes that loop to the caller.

The typical flow is:

Implementing Streaming with Ollama

Ollama is one of the easiest ways to run local models, and it supports streaming out of the box. When you set "stream": true in the request body, the API returns newline-delimited JSON objects, one per token.

import requests

url = "http://localhost:11434/api/generate"
payload = {
    "model": "llama3.2",
    "prompt": "Explain how transformers work in three sentences.",
    "stream": True
}

with requests.post(url, json=payload, stream=True) as resp:
    for line in resp.iter_lines():
        if not line:
            continue
        chunk = line.decode("utf-8")
        # Each line is a JSON object with a "response" field
        import json
        data = json.loads(chunk)
        print(data["response"], end="", flush=True)
print()

Each JSON object contains a "response" field with the new text, plus metadata like "done" and timing statistics. The final object has "done": true and includes the total generation duration and token count.

Implementing Streaming with llama.cpp's Python Bindings

For lower-level control, llama-cpp-python exposes a callback that fires on every token. This is ideal when you want to build your own server or integrate streaming into an existing application.

from llama_cpp import Llama

llm = Llama(
    model_path="./models/llama-3.2-3b-instruct-q4_k_m.gguf",
    n_ctx=4096,
    n_gpu_layers=-1,  # offload all layers to GPU if available
)

def stream_callback(text: str):
    # Called once per generated token
    print(text, end="", flush=True)

response = llm.create_chat_completion(
    messages=[{"role": "user", "content": "Write a haiku about local LLMs."}],
    stream=True,
    stream_callback=stream_callback,
    max_tokens=128,
)
print()

The stream_callback receives each decoded token as a string. You can route that text anywhere — to a websocket, an SSE endpoint, a log file, or a UI component. Because the callback runs on the generation thread, keep it lightweight to avoid slowing down inference.

Implementing Streaming with Hugging Face Transformers

If you load models directly through Transformers, the TextIteratorStreamer class lets you consume tokens from a background thread. This pairs well with FastAPI or any async web framework.

import torch
from threading import Thread
from transformers import AutoModelForCausalLM, AutoTokenizer, TextIteratorStreamer

model_id = "meta-llama/Llama-3.2-3B-Instruct"
tokenizer = AutoTokenizer.from_pretrained(model_id)
model = AutoModelForCausalLM.from_pretrained(
    model_id, torch_dtype=torch.bfloat16, device_map="auto"
)

def generate_stream(prompt: str):
    inputs = tokenizer(prompt, return_tensors="pt").to(model.device)
    streamer = TextIteratorStreamer(
        tokenizer, skip_prompt=True, skip_special_tokens=True
    )
    generation_kwargs = dict(
        inputs=inputs.input_ids,
        streamer=streamer,
        max_new_tokens=256,
        temperature=0.7,
        do_sample=True,
    )
    thread = Thread(target=model.generate, kwargs=generation_kwargs)
    thread.start()

    for text in streamer:
        yield text

    thread.join()

# Example usage
for token in generate_stream("List three benefits of edge AI."):
    print(token, end="", flush=True)
print()

The streamer is a Python iterator, so it composes naturally with generators. Wrapping it in a FastAPI endpoint with StreamingResponse turns this into a full HTTP streaming API.

Exposing Streaming Over HTTP with FastAPI

Server-Sent Events are the most common transport for streaming text to browsers because they work over plain HTTP and are supported natively by the EventSource API. Here is a minimal FastAPI endpoint that wraps any of the generators above.

from fastapi import FastAPI
from fastapi.responses import StreamingResponse
from pydantic import BaseModel

app = FastAPI()

class ChatRequest(BaseModel):
    prompt: str

@app.post("/chat")
def chat(req: ChatRequest):
    def event_stream():
        for token in generate_stream(req.prompt):
            # SSE format: "data: <payload>\n\n"
            payload = token.replace("\n", "\\n")
            yield f"data: {payload}\n\n"
        yield "data: [DONE]\n\n"

    return StreamingResponse(
        event_stream(),
        media_type="text/event-stream",
        headers={"Cache-Control": "no-cache", "X-Accel-Buffering": "no"},
    )

The X-Accel-Buffering: no header is important if you sit behind nginx, which buffers responses by default and would otherwise defeat the purpose of streaming. On the client side, a small JavaScript snippet consumes the stream:

const source = new EventSource("/chat?prompt=hello");
source.onmessage = (event) => {
  if (event.data === "[DONE]") {
    source.close();
    return;
  }
  document.getElementById("output").textContent += event.data;
};

Note that EventSource only supports GET requests. For POST requests with a JSON body, use the fetch API and read the response body as a stream, parsing SSE frames manually.

Handling Errors and Cancellation

Streaming introduces failure modes that batch APIs do not have. A connection can drop mid-stream, the client may cancel, or the model can throw an error after several tokens have already been sent. Your implementation should handle each case gracefully.

Best Practices

To get the most out of streaming with local models, keep these principles in mind:

Performance Considerations

Streaming has a subtle cost: it can reduce overall throughput because the model cannot batch as aggressively when each request is being consumed token-by-token. For single-user local setups this rarely matters, but if you serve multiple concurrent users from one GPU, consider a serving engine like vLLM that supports continuous batching while still exposing a streaming API. vLLM's OpenAI-compatible server streams tokens via SSE and keeps requests batched under the hood, giving you both low latency and high throughput.

Another consideration is token decoding overhead. Decoding one token at a time is less efficient than decoding a full sequence, but the difference is negligible compared to the model forward pass. Do not prematurely optimize the decode path; focus on GPU utilization and memory bandwidth first.

Conclusion

Streaming responses transform the experience of using local models from a frustrating wait into an interactive conversation. Because local inference already produces tokens incrementally, exposing that loop to the client is mostly a matter of choosing the right transport and handling the edge cases. Whether you use Ollama for simplicity, llama.cpp for control, Transformers for flexibility, or vLLM for throughput, the patterns are the same: generate tokens in a loop, forward each one immediately, handle disconnects and errors gracefully, and keep the client informed. With the techniques in this tutorial, you can build local model applications that feel as responsive as their cloud-hosted counterparts while keeping every byte of data on your own hardware.

— Ad —

Google AdSense will appear here after approval

← Back to all articles