Pular para conteúdo

5. Meça

Um endpoint de LLM é a dependência mais lenta e mais cara que o BoxOffice tem, e a sua latência tem uma forma própria: uma espera longa pelo primeiro token (o modelo lê o prompt inteiro), depois um fluxo de tokens a uma velocidade mais ou menos constante. Os usuários julgam a primeira parte — um balão vazio — com muito mais rigor que a segunda. Este passo mede as duas, e o custo, sob concorrência.

# Experimento O que você registra
A latência com concorrência 1, 2, 4 TTFT p50/p95, total p50/p95, tokens/s, requisições/min
B qualidade da recuperação hit@5 e MRR (passo 2)
C qualidade das respostas fração de respostas grounded em 10 perguntas
D escolha do modelo (opcional) A e C de novo com um modelo local maior ou com a rota do Claude, mais US$ por requisição

Por que não o k6 desta vez?

O k6 mede o tempo até o primeiro byte (http_req_waiting). Um servidor de streaming envia os seus headers HTTP assim que a resposta começa — muito antes do primeiro token —, então esse número não diz nada sobre o que o fã vê. O script abaixo fala Server-Sent Events e para o relógio do TTFT no primeiro 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. Latência sob concorrência

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

Uma execução de teste com qwen2.5:1.5b no Docker, na CPU de um notebook que também rodava outras stacks (2 requisições por nível, para ser curta):

| 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 |

Com o Ollama nativo num notebook Apple Silicon (GPU via Metal, com a pilha toda rodando), uma execução de teste com 8 requisições por nível, depois de uma pergunta de aquecimento (a primeira chamada carrega o modelo: cerca de 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 |

Dezenas de vezes mais rápido, e com outra forma: cada resposta ainda decodifica a cerca de 64 tokens/s, mas as requisições fazem fila diante do único modelo, então o TTFT cresce com a concorrência e as requisições por minuto param de crescer depois de 2.

Leia isso como um problema de capacidade, com as fórmulas da aula de sistemas de IA:

  • O TTFT cresce com o prompt (≈ 760 tokens aqui: system + cinco eventos recuperados + pergunta) e com a fila na frente do modelo: com concorrência 2, a segunda requisição esperou pela primeira.
  • tokens/s é a velocidade de decodificação do hardware. Uma CPU dá alguns tokens por segundo; uma GPU de notebook (Ollama nativo no Apple Silicon), dezenas; um modelo de fronteira hospedado, uns cem.
  • A Lei de Little vale como em todo lugar: requisições em andamento = taxa de chegada × tempo por requisição. Com ~2 minutos por resposta, um fã por minuto já mantém duas requisições em andamento. É por isso que servir LLMs precisa de batching, GPUs e controle de admissão — ou de uma API hospedada.

Se os seus números estão lentos assim

Essa é a lição, não um bug — mas dá para melhorar no mesmo notebook: rode o Ollama nativamente (macOS), feche outros contêineres, faça perguntas curtas e diminua o RETRIEVE_K (menos tokens de contexto, prefill mais rápido; confira que o hit@k não cai). Numa CPU, meça --concurrency 1 2 --requests 2, como a execução de teste: com concorrência 4, as requisições fazem fila umas atrás das outras além do timeout de 120 s do gateway (litellm.Timeout em docker compose logs assistant, contado em errors).

B. Qualidade da recuperação

Registre o hit@5 e o MRR do passo 2. Depois diminua o --k para 1 e veja quais perguntas falham.

C. Qualidade das respostas

Faça as dez perguntas abaixo (as cinco do assistant_load.py e cinco suas sobre o BoxOffice) com /assistant/chat, e conte os 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}'

Na execução de teste, o qwen2.5:1.5b respondeu com fatos corretos do contexto, mas sem citações, nas duas perguntas testadas, então as duas ficaram grounded: false — o guarda fez o seu trabalho, e o modelo é o elo fraco. Em dez perguntas, outra execução de teste contou 1 de 10 ancoradas com o qwen2.5:1.5b e 5 de 10 com o qwen2.5:3b. A taxa de ancoragem é uma propriedade do modelo + prompt; meça-a antes de confiar em qualquer assistente.

D. Escolha do modelo (opcional)

Repita A e C com um modelo local maior (docker compose exec ollama ollama pull qwen2.5:3b, ou ollama pull qwen2.5:3b com o Ollama nativo, depois LOCAL_MODEL_TAG=qwen2.5:3b docker compose up -d --wait assistant) ou com a rota do Claude (passo 1, custa dinheiro: a coluna USD/request mostra quanto). Compare TTFT, taxa de ancoragem e custo por resposta.

Resultados

Copie para results/lab12.md e preencha. Anote onde o Ollama rodou (CPU do contêiner, GPU nativa…).

Métrica Modelo local pequeno Modelo local maior ou Claude
A · TTFT p50 / p95 com concorrência 1 (s)
A · TTFT p50 / p95 com concorrência 4 (s)
A · Total p50 / p95 com concorrência 1 (s)
A · Tokens/s (decodificação)
A · Requisições por minuto com concorrência 4
B · hit@5 / MRR —
C · Respostas ancoradas (de 10)
D · US$ por requisição / por 1.000 requisições 0

Abaixo da tabela: para a venda relâmpago do BoxOffice (2 milhões de fãs, talvez 1% perguntando ao assistente), qual configuração você rodaria, quantas requisições em andamento ela precisaria (Lei de Little), e quanto custaria por hora?

Voltar à visão geral Quiz