← Back to DevBytes

Streaming Responses in Production with llama.cpp: Complete Guide

Streaming Responses in Production with llama.cpp: Complete Guide

Streaming responses have become a critical feature for any production-grade LLM application. When users interact with a chatbot or text generation service, waiting several seconds for a complete response creates a poor experience. Streaming solves this by delivering tokens incrementally as the model generates them, dramatically reducing perceived latency and improving user engagement. llama.cpp — the popular C/C++ inference engine for LLaMA and other transformer models — provides robust, battle-tested streaming capabilities that are well-suited for production workloads.

In this guide, we will explore what streaming is, why it matters in production, how llama.cpp implements it, and how to build reliable streaming pipelines using the C API, the built-in HTTP server, and Python bindings. We will also cover best practices for deployment, error handling, and performance tuning.

What Is Response Streaming?

Response streaming is the practice of sending model output to the client incrementally — token by token or chunk by chunk — rather than waiting for the entire generation to complete before sending a single response. In a non-streaming setup, a request that generates 500 tokens at 30 tokens per second would make the user wait over 16 seconds before seeing anything. With streaming, the first token arrives within a fraction of a second, and subsequent tokens flow continuously.

Streaming is typically implemented using Server-Sent Events (SSE), WebSockets, or chunked HTTP transfer encoding. In the llama.cpp ecosystem, the most common transport is SSE, which works well over standard HTTP and is supported by virtually all modern clients.

Why Streaming Matters in Production

Beyond the obvious user experience improvement, streaming has several production-critical benefits:

How llama.cpp Handles Streaming

llama.cpp provides streaming at multiple levels. At the lowest level, the core C API exposes a callback mechanism that fires after each token is decoded. At higher levels, the built-in HTTP server (llama-server) exposes streaming endpoints that conform to OpenAI-compatible API conventions, making integration straightforward.

The key insight is that llama.cpp's generation loop is inherently sequential: it predicts one token at a time, appends it to the context, and repeats. This makes streaming a natural fit — the model is already producing tokens one at a time internally. The only question is how to expose those tokens to the consumer.

The Token Callback Mechanism

The C API provides llama_decode for running inference on a batch of tokens. After each decode call, you can extract the logits for the last token, sample the next token, and convert it to text using llama_token_to_piece. This conversion is where you hook in your streaming logic — each time you convert a token to its text piece, you can send it to the client immediately.

Setting Up llama.cpp for Streaming

Before diving into code, ensure you have llama.cpp built and available. Clone the repository and build it:

git clone https://github.com/ggerganov/llama.cpp
cd llama.cpp
cmake -B build -DLLAMA_CURL=ON
cmake --build build --config Release -j

For this guide, we assume you have a quantized model file, such as a GGUF format model. You can download one from Hugging Face, for example:

wget https://huggingface.co/TheBloke/Llama-2-7B-Chat-GGUF/resolve/main/llama-2-7b-chat.Q4_K_M.gguf

Implementing Streaming with the C API

Let's build a minimal C program that loads a model and streams tokens to stdout. This example demonstrates the fundamental pattern that underlies all streaming in llama.cpp.

#include "llama.h"
#include <cstdio>
#include <cstring>
#include <string>
#include <vector>

int main(int argc, char ** argv) {
    if (argc < 2) {
        fprintf(stderr, "Usage: %s <model.gguf> [prompt]\n", argv[0]);
        return 1;
    }

    llama_backend_init();

    // Initialize model parameters
    llama_model_params model_params = llama_model_default_params();
    model_params.n_gpu_layers = 0; // Set higher for GPU offloading

    llama_model * model = llama_model_load_from_file(argv[1], model_params);
    if (!model) {
        fprintf(stderr, "Failed to load model\n");
        return 1;
    }

    // Initialize context parameters
    llama_context_params ctx_params = llama_context_default_params();
    ctx_params.n_ctx = 2048;
    ctx_params.n_batch = 512;
    ctx_params.no_perf = true;

    llama_context * ctx = llama_init_from_model(model, ctx_params);

    // Tokenize the prompt
    const char * prompt = (argc > 2) ? argv[2] : "Hello, how are you?";
    const int n_prompt = -llama_tokenize(model, prompt, strlen(prompt), NULL, 0, true, true);

    std::vector<llama_token> tokens(n_prompt);
    llama_tokenize(model, prompt, strlen(prompt), tokens.data(), tokens.size(), true, true);

    // Evaluate the prompt
    llama_batch batch = llama_batch_init(512, 0, 1);
    for (int i = 0; i < n_prompt; i++) {
        llama_batch_add(batch, tokens[i], i, {0}, false);
    }
    llama_batch_add(batch, tokens[n_prompt - 1], n_prompt - 1, {0}, true);

    llama_decode(ctx, batch);
    llama_batch_clear(batch);

    // Generation loop with streaming
    const int n_predict = 256;
    llama_token new_token_id;

    for (int i = 0; i < n_predict; i++) {
        float * logits = llama_get_logits_ith(ctx, batch.n_tokens - 1);

        llama_sampler * sampler = llama_sampler_init_greedy();
        new_token_id = llama_sampler_sample(sampler, ctx, batch.n_tokens - 1);
        llama_sampler_free(sampler);

        // Check for end of generation
        if (new_token_id == llama_token_eos(model)) {
            break;
        }

        // Convert token to text and STREAM it immediately
        char buf[128];
        int n = llama_token_to_piece(model, new_token_id, buf, sizeof(buf), 0, true);
        if (n > 0) {
            fwrite(buf, 1, n, stdout);
            fflush(stdout); // Critical: flush after each token
        }

        // Prepare next batch
        llama_batch_clear(batch);
        llama_batch_add(batch, new_token_id, n_prompt + i, {0}, true);
        llama_decode(ctx, batch);
    }

    printf("\n");
    llama_batch_free(batch);
    llama_free(ctx);
    llama_model_free(model);
    llama_backend_free();
    return 0;
}

The critical line is fflush(stdout) — without flushing, the output buffer will hold tokens and defeat the purpose of streaming. In a network context, the equivalent is flushing your HTTP response writer or SSE event stream after each token.

Compiling the Example

g++ -std=c++17 -I./include -I./ggml/include \
    stream_example.cpp build/src/libllama.a \
    build/ggml/src/libggml.a \
    -lpthread -ldl -lm -o stream_example

./stream_example llama-2-7b-chat.Q4_K_M.gguf "Tell me a story"

Streaming via the HTTP Server (llama-server)

For most production deployments, you will use llama-server, the built-in HTTP server that ships with llama.cpp. It provides an OpenAI-compatible API with native streaming support via SSE. This is the recommended approach for production because it handles connection management, batching of concurrent requests, and proper HTTP semantics for you.

Start the server with your model:

./build/bin/llama-server \
    -m llama-2-7b-chat.Q4_K_M.gguf \
    --host 0.0.0.0 \
    --port 8080 \
    -c 4096 \
    -np 4 \
    -t 8

Here, -c 4096 sets the context window, -np 4 allows 4 parallel slots for concurrent requests, and -t 8 uses 8 CPU threads. Adjust these based on your hardware.

Making a Streaming Request

To get a streaming response, set "stream": true in your JSON request body. The server will respond with SSE-formatted chunks:

curl -N http://localhost:8080/v1/chat/completions \
  -H "Content-Type: application/json" \
  -d '{
    "model": "llama-2-7b-chat",
    "messages": [
      {"role": "user", "content": "Write a short poem about the ocean."}
    ],
    "stream": true,
    "max_tokens": 200,
    "temperature": 0.7
  }'

The -N flag in curl disables output buffering, so you can see tokens arrive in real time. The response will look like this:

data: {"id":"chatcmpl-123","object":"chat.completion.chunk","created":1699999999,"model":"llama-2-7b-chat","choices":[{"index":0,"delta":{"role":"assistant"},"finish_reason":null}]}

data: {"id":"chatcmpl-123","object":"chat.completion.chunk","created":1699999999,"model":"llama-2-7b-chat","choices":[{"index":0,"delta":{"content":"The"},"finish_reason":null}]}

data: {"id":"chatcmpl-123","object":"chat.completion.chunk","created":1699999999,"model":"llama-2-7b-chat","choices":[{"index":0,"delta":{"content":" ocean"},"finish_reason":null}]}

data: {"id":"chatcmpl-123","object":"chat.completion.chunk","created":1699999999,"model":"llama-2-7b-chat","choices":[{"index":0,"delta":{"content":" whispers"},"finish_reason":null}]}

data: {"id":"chatcmpl-123","object":"chat.completion.chunk","created":1699999999,"model":"llama-2-7b-chat","choices":[{"index":0,"delta":{},"finish_reason":"stop"}]}

data: [DONE]

Each data: line is a JSON object containing a delta — the incremental text generated since the last chunk. The stream terminates with data: [DONE]. Your client should parse each line, extract choices[0].delta.content, and append it to the displayed text.

Client-Side SSE Parsing Example (JavaScript)

Here is a browser-compatible JavaScript example for consuming the streaming endpoint:

async function streamChat(prompt) {
    const response = await fetch('http://localhost:8080/v1/chat/completions', {
        method: 'POST',
        headers: { 'Content-Type': 'application/json' },
        body: JSON.stringify({
            model: 'llama-2-7b-chat',
            messages: [{ role: 'user', content: prompt }],
            stream: true,
            max_tokens: 500
        })
    });

    const reader = response.body.getReader();
    const decoder = new TextDecoder();
    let buffer = '';
    let fullText = '';

    while (true) {
        const { done, value } = await reader.read();
        if (done) break;

        buffer += decoder.decode(value, { stream: true });
        const lines = buffer.split('\n');
        buffer = lines.pop(); // Keep incomplete line in buffer

        for (const line of lines) {
            if (!line.startsWith('data: ')) continue;
            const data = line.slice(6);
            if (data === '[DONE]') {
                console.log('Stream complete.');
                return fullText;
            }
            try {
                const parsed = JSON.parse(data);
                const delta = parsed.choices[0]?.delta?.content;
                if (delta) {
                    fullText += delta;
                    // Update your UI here
                    document.getElementById('output').textContent = fullText;
                }
            } catch (e) {
                console.error('Parse error:', e);
            }
        }
    }
    return fullText;
}

Note the buffer handling: SSE chunks can arrive split across TCP packet boundaries, so you must accumulate partial lines and only process complete ones. This is a common source of bugs in streaming clients.

Python Integration with llama-cpp-python

If you prefer Python, the llama-cpp-python package provides a high-level interface with streaming support. Install it with server extras:

pip install llama-cpp-python[server]

Streaming with the Python API Directly

from llama_cpp import Llama

llm = Llama(
    model_path="llama-2-7b-chat.Q4_K_M.gguf",
    n_ctx=4096,
    n_gpu_layers=0,  # Increase for GPU offloading
    n_threads=8,
    verbose=False
)

# The stream=True parameter returns an iterator
response = llm.create_chat_completion(
    messages=[
        {"role": "user", "content": "Explain quantum computing in three sentences."}
    ],
    max_tokens=200,
    temperature=0.7,
    stream=True
)

for chunk in response:
    delta = chunk["choices"][0]["delta"]
    if "content" in delta:
        print(delta["content"], end="", flush=True)

print()  # Final newline

Running the Python Server with Streaming

The llama-cpp-python package includes an OpenAI-compatible server that supports streaming out of the box:

python -m llama_cpp.server \
    --model llama-2-7b-chat.Q4_K_M.gguf \
    --host 0.0.0.0 \
    --port 8000 \
    --n_ctx 4096

You can then use any OpenAI-compatible client library to connect with streaming:

from openai import OpenAI

client = OpenAI(base_url="http://localhost:8000/v1", api_key="not-needed")

stream = client.chat.completions.create(
    model="llama-2-7b-chat",
    messages=[{"role": "user", "content": "Write a haiku about debugging."}],
    stream=True
)

for chunk in stream:
    content = chunk.choices[0].delta.content
    if content:
        print(content, end="", flush=True)
print()

Best Practices for Production Streaming

1. Always Flush Your Output

The most common streaming bug is forgetting to flush. Whether you are writing to stdout, an HTTP response, or a WebSocket, you must flush after each token. In Python, use flush=True in print or call sys.stdout.flush(). In C, call fflush(). In web frameworks, call the appropriate flush method on your response object.

2. Handle Partial SSE Lines

Never assume that one read() call returns exactly one SSE event. TCP does not preserve message boundaries. Always buffer incoming bytes, split on newlines, and process only complete lines. Keep the remainder in your buffer for the next iteration.

3. Implement Proper Cancellation

In production, users will cancel requests. Your server must detect a closed client connection and stop generating tokens. With llama-server, this is handled automatically — when the client disconnects, the server frees the slot. If you are building a custom server, check the connection state in your generation loop:

for chunk in llm.create_chat_completion(messages=messages, stream=True, max_tokens=500):
    if client_disconnected():
        break  # Stop generation, free resources
    yield format_sse(chunk)

4. Use Parallel Slots Wisely

The -np flag in llama-server controls how many requests can be processed simultaneously. Each slot consumes memory proportional to the context size. On a machine with 16GB RAM and a 7B Q4 model (~4GB), you might use 2-4 slots with 4096 context each. Monitor memory usage and adjust accordingly. Too many slots will cause swapping and destroy performance.

5. Set Appropriate Batch Sizes

The -b (batch) parameter controls how many tokens are processed in a single decode call during prompt evaluation. Larger batches speed up prompt processing but use more memory. A value of 512 is a good default. For streaming, the batch size during generation is always 1 (one token at a time), so this mainly affects time-to-first-token for long prompts.

6. Monitor Time-to-First-Token (TTFT)

TTFT — the time from request to the first token appearing — is the most important streaming metric. Users will tolerate slow total generation if the first token arrives quickly. Optimize TTFT by using GPU offloading (-ngl), keeping prompts concise, and ensuring your server is not overloaded. A TTFT under 500ms is excellent; under 2 seconds is acceptable for most use cases.

7. Add Heartbeat Events for Long Prompt Processing

If prompt evaluation takes several seconds (common with long system prompts), the client may appear to hang before the first token. Consider sending SSE comment lines as heartbeats:

: processing prompt...

data: {"choices":[{"delta":{"content":"First"}}]}

SSE comment lines start with a colon and are ignored by standard EventSource parsers, but they keep the connection alive and signal to the client that the server is working.

8. Handle Multi-Byte Characters Correctly

llama.cpp's llama_token_to_piece can return partial UTF-8 byte sequences for a single token. If you are sending raw bytes over a text-based transport, you must buffer partial UTF-8 sequences and only emit complete characters. The Python bindings handle this automatically, but if you are working in C or another low-level language, be aware of this:

// Accumulate bytes and only emit when we have a complete UTF-8 char
std::string utf8_buffer;

void emit_token(const char * buf, int n) {
    utf8_buffer.append(buf, n);
    // Try to emit complete UTF-8 sequences
    while (!utf8_buffer.empty()) {
        int char_len = utf8_len(utf8_buffer[0]);
        if ((int)utf8_buffer.size() < char_len) break; // Need more bytes
        send_to_client(utf8_buffer.data(), char_len);
        utf8_buffer.erase(0, char_len);
    }
}

9. Log Token-Level Metrics

In production, log per-request metrics including TTFT, tokens-per-second, total tokens generated, and whether the request was cancelled. This data is invaluable for capacity planning and debugging performance issues. llama-server logs some of this by default; for custom setups, instrument your generation loop.

10. Use Reverse Proxies Carefully

If you place nginx or another reverse proxy in front of llama-server, ensure buffering is disabled for streaming endpoints. In nginx, add:

location /v1/ {
    proxy_pass http://localhost:8080;
    proxy_buffering off;
    proxy_cache off;
    proxy_set_header Connection '';
    proxy_http_version 1.1;
    chunked_transfer_encoding on;
}

Without proxy_buffering off, nginx will buffer the entire response before forwarding it, completely defeating streaming.

Conclusion

Streaming responses are essential for any production LLM deployment, and llama.cpp provides excellent support at every level — from the low-level C API with its token-by-token callback pattern, to the built-in HTTP server with OpenAI-compatible SSE streaming, to the Python bindings that make integration trivial. The key to a reliable streaming pipeline is attention to detail: always flush output, handle partial SSE lines and multi-byte characters correctly, implement proper cancellation, and configure your reverse proxy to pass data through without buffering. By following the patterns and best practices in this guide, you can build a streaming LLM service that feels instant and responsive to your users while remaining robust under production load.

— Ad —

Google AdSense will appear here after approval

← Back to all articles