Skip to content

5. Measure It

An LLM endpoint is the slowest and most expensive dependency BoxOffice has, and its latency has a shape of its own: a long wait for the first token (the model reads the whole prompt), then a stream of tokens at a roughly constant speed. Users judge the first part — an empty bubble — much more harshly than the second. This step measures both, and the cost, under concurrency.

# Experiment What you record
A latency under concurrency 1, 2, 4 TTFT p50/p95, total p50/p95, tokens/s, requests/min
B retrieval quality hit@5 and MRR (step 2)
C answer quality share of grounded answers over 10 questions
D model choice (optional) A and C again with a larger local model or the Claude route, plus US$ per request

Why not k6 this time?

k6 measures the time to the first byte (http_req_waiting). A streaming server sends its HTTP headers as soon as the response starts — long before the first token — so that number says nothing about what the fan sees. The script below speaks Server-Sent Events and stops the TTFT clock at the first event: token:

scripts/assistant_load.py
# /// script
# requires-python = ">=3.12"
# dependencies = ["httpx>=0.28"]
# ///
"""Load test of the assistant's STREAMING endpoint: time to first token (TTFT), total time, tokens/s.

k6 measures the time to the first *byte*, and a streaming server sends its headers before the first
token, so k6's numbers are not TTFT. This script reads the Server-Sent Events and stops the TTFT
clock at the first "token" event.

    uv run scripts/assistant_load.py --concurrency 1 2 4 --requests 8
"""
import argparse
import asyncio
import json
import statistics
import time

import httpx

QUESTIONS = [
    "Is there a festival at Stadium São Paulo? When, and how much are the tickets?",
    "Which plays are there in Curitiba?",
    "I want to see a match in Recife. What do you have?",
    "How much is the cheapest concert in Salvador?",
    "Tem algum festival em Porto Alegre?",
]


async def one(http: httpx.AsyncClient, url: str, question: str) -> dict:
    start = time.monotonic()
    ttft, done, event = None, None, None
    async with http.stream("POST", f"{url}/assistant/chat/stream", json={"message": question}) as r:
        r.raise_for_status()
        async for line in r.aiter_lines():
            if line.startswith("event: "):
                event = line[7:]
            elif line.startswith("data: "):
                if event == "token" and ttft is None:
                    ttft = time.monotonic() - start
                elif event == "done":
                    done = json.loads(line[6:])
    total = time.monotonic() - start
    out_tokens = done["usage"]["completion_tokens"] if done else 0
    stream_time = total - (ttft or total)
    return {"ttft": ttft, "total": total, "tokens": out_tokens,
            "tps": out_tokens / stream_time if stream_time > 0 else 0.0,
            "cost": done["usage"]["cost_usd"] if done else 0.0}


def pct(values: list[float], p: float) -> float:
    values = sorted(values)
    return values[min(len(values) - 1, int(round(p / 100 * (len(values) - 1))))] if values else float("nan")


async def run(url: str, concurrency: int, requests: int) -> None:
    queue = asyncio.Queue()
    for i in range(requests):
        queue.put_nowait(QUESTIONS[i % len(QUESTIONS)])
    results, errors = [], 0

    async def worker(http):
        nonlocal errors
        while not queue.empty():
            q = queue.get_nowait()
            try:
                results.append(await one(http, url, q))
            except (httpx.HTTPError, KeyError, json.JSONDecodeError):
                errors += 1

    started = time.monotonic()
    async with httpx.AsyncClient(timeout=600) as http:
        await asyncio.gather(*(worker(http) for _ in range(concurrency)))
    wall = time.monotonic() - started
    ttfts = [r["ttft"] for r in results if r["ttft"] is not None]
    totals = [r["total"] for r in results]
    print(f"| {concurrency:>11} | {len(results):>8} | {errors:>6} | {pct(ttfts, 50):8.2f} | {pct(ttfts, 95):8.2f} "
          f"| {pct(totals, 50):9.2f} | {pct(totals, 95):9.2f} | {statistics.fmean(r['tps'] for r in results) if results else 0:9.1f} "
          f"| {60 * len(results) / wall:7.2f} | {sum(r['cost'] for r in results) / max(1, len(results)):10.6f} |")


async def main() -> None:
    ap = argparse.ArgumentParser()
    ap.add_argument("--url", default="http://localhost:8088")
    ap.add_argument("--concurrency", type=int, nargs="+", default=[1, 2, 4])
    ap.add_argument("--requests", type=int, default=8, help="requests per concurrency level")
    args = ap.parse_args()
    print("| concurrency | requests | errors | TTFT p50 | TTFT p95 | total p50 | total p95 | tokens/s | req/min | USD/request |")
    print("|---|---|---|---|---|---|---|---|---|---|")
    for c in args.concurrency:
        await run(args.url, c, args.requests)


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

A. Latency under concurrency

uv run scripts/assistant_load.py --concurrency 1 2 4 --requests 8

A test run with qwen2.5:1.5b in Docker, on the CPU of a laptop that was also running other stacks (2 requests per level, to keep it short):

| concurrency | requests | errors | TTFT p50 | TTFT p95 | total p50 | total p95 | tokens/s | req/min | USD/request |
|---|---|---|---|---|---|---|---|---|---|
|           1 |        2 |      0 |     5.05 |    13.17 |    148.02 |    197.87 |       0.8 |    0.35 |   0.000000 |
|           2 |        2 |      0 |     3.37 |   127.23 |    126.89 |    213.07 |       2.8 |    0.56 |   0.000000 |

With native Ollama on an Apple Silicon laptop (GPU through Metal, the full stack running), a test run with 8 requests per level, after one warm-up question (the first call loads the model: about 20 s):

| concurrency | requests | errors | TTFT p50 | TTFT p95 | total p50 | total p95 | tokens/s | req/min | USD/request |
|---|---|---|---|---|---|---|---|---|---|
|           1 |        8 |      0 |     0.19 |     0.32 |      3.52 |      4.53 |      64.0 |   19.27 |   0.000000 |
|           2 |        8 |      0 |     1.42 |     3.74 |      3.24 |      5.11 |      64.1 |   34.40 |   0.000000 |
|           4 |        8 |      0 |     4.64 |    10.62 |      8.30 |     11.15 |      64.0 |   24.65 |   0.000000 |

Tens of times faster, and with a different shape: each answer still decodes at about 64 tokens/s, but the requests queue in front of the one model, so TTFT grows with concurrency and requests per minute stop growing after 2.

Read it as a capacity problem, with the formulas of the AI systems class:

  • TTFT grows with the prompt (≈ 760 tokens here: system + five retrieved events + question) and with the queue in front of the model: at concurrency 2, the second request waited for the first.
  • tokens/s is the decode speed of the hardware. A CPU gives a few tokens per second; a laptop GPU (native Ollama on Apple Silicon) tens; a hosted frontier model, around a hundred.
  • Little's Law applies as everywhere: requests in flight = arrival rate × time per request. With ~2 minutes per answer, one fan per minute already keeps two requests in flight. That is why LLM serving needs batching, GPUs and admission control — or a hosted API.

If your numbers are this slow

That is the lesson, not a bug — but you can do better on the same laptop: run Ollama natively (macOS), close other containers, ask short questions, and lower RETRIEVE_K (fewer context tokens, faster prefill; check that hit@k does not drop). On a CPU, measure --concurrency 1 2 --requests 2, as the test run did: at concurrency 4 the requests queue behind one another past the gateway's 120 s timeout (litellm.Timeout in docker compose logs assistant, counted in errors).

B. Retrieval quality

Record hit@5 and MRR from step 2. Then lower --k to 1 and see which questions fail.

C. Answer quality

Ask the ten questions below (the five of assistant_load.py and five of your own about BoxOffice) with /assistant/chat, and count grounded: true:

curl -s -X POST localhost:8088/assistant/chat -H 'Content-Type: application/json' \
  -d '{"message":"Which plays are there in Curitiba?"}' | jq '{grounded, citations, answer}'

In the test run, qwen2.5:1.5b answered with correct facts from the context but without citations in both questions tried, so both were grounded: false — the guard did its job, and the model is the weak link. Over ten questions, another test run counted 1 of 10 grounded with qwen2.5:1.5b and 5 of 10 with qwen2.5:3b. Grounding rate is a property of the model + prompt; measure it before you trust any assistant.

D. Model choice (optional)

Repeat A and C with a larger local model (docker compose exec ollama ollama pull qwen2.5:3b, or ollama pull qwen2.5:3b with native Ollama, then LOCAL_MODEL_TAG=qwen2.5:3b docker compose up -d --wait assistant) or with the Claude route (step 1, costs money: the USD/request column shows how much). Compare TTFT, grounding rate and cost per answer.

Results

Copy into results/lab12.md and fill in. Write down where Ollama ran (container CPU, native GPU…).

Metric Small local model Larger local model or Claude
A · TTFT p50 / p95 at concurrency 1 (s)
A · TTFT p50 / p95 at concurrency 4 (s)
A · Total p50 / p95 at concurrency 1 (s)
A · Tokens/s (decode)
A · Requests per minute at concurrency 4
B · hit@5 / MRR —
C · Grounded answers (out of 10)
D · US$ per request / per 1 000 requests 0

Below the table: for the BoxOffice flash sale (2 million fans, maybe 1% asking the assistant), which setup would you run, how many requests in flight would it need (Little's Law), and what would it cost per hour?

Back to the overview Quiz