Skip to content

English · Español

Lab 03 — Cost tracker + prueba de carga + pulido del dashboard

Objetivo: terminar el dashboard. Hacer prueba de carga del servidor. Comitear números.

Tiempo estimado: 2-3 horas.

Prerrequisito: Labs 00-02 completos; métricas + trazas fluyendo; esqueleto de dashboard del Lab 01.


Lo que produces

  • src/observability/cost.py — la clase CostTracker + dos nuevos histograms de Prometheus.
  • Servidor de la Fase 33 cableado para que cada petición registre coste vía CostTracker.
  • infra/grafana/dashboards/llm.json — dashboard pulido con todos los paneles RED + USE + coste.
  • experiments/34-load-test/ — script k6 + resultados + screenshot del dashboard.
  • experiments/34-cost-calibration/ — sweep de valores de $_per_hour; linealidad de coste verificada.

El contrato de CostTracker

class CostTracker:
    def __init__(self, rate_usd_per_hour: float):
        self.rate_per_second = rate_usd_per_hour / 3600.0
        self.per_request = {}  # request_id -> dict(stage -> wall_seconds)

    def start_stage(self, request_id: str, stage: str): ...
    def end_stage(self, request_id: str, stage: str): ...

    def finalize(self, request_id: str, output_tokens: int, model_name: str) -> float:
        """Sum stage wall times × rate. Emit two histograms. Return total cost USD."""
        ...

Etapas: retrieve, prefill, decode, other. El tiempo de pared de decode debería ser el tiempo efectivo por petición (teniendo en cuenta el solape del batch — ver archivo de teoría 02, Corrección 1). El batcher debe exponer request.effective_decode_seconds para esto.

Dos nuevos histograms:

COST_BUCKETS_USD = (1e-5, 3e-5, 1e-4, 3e-4, 1e-3, 3e-3, 1e-2, 3e-2, 1e-1, 3e-1, 1.0, float("inf"))
COST_PER_1K_TOKENS_BUCKETS_USD = (1e-4, 3e-4, 1e-3, 3e-3, 1e-2, 3e-2, 1e-1, 3e-1, 1.0, float("inf"))

cost_per_request_usd = Histogram(
    "cost_per_request_usd",
    "Total cost per request in USD",
    ["model_name"],
    buckets=COST_BUCKETS_USD,
)
cost_per_1k_completion_tokens_usd = Histogram(
    "cost_per_1k_completion_tokens_usd",
    "Cost per 1000 completion tokens in USD",
    ["model_name"],
    buckets=COST_PER_1K_TOKENS_BUCKETS_USD,
)

TODOs

Bloque A — src/observability/cost.py

  • Implementa CostTracker según el contrato.
  • Almacena los tiempos de inicio por etapa en un dict indexado por (request_id, stage). Usa time.perf_counter().
  • En finalize:
  • Suma los tiempos de pared de las etapas para obtener segundos de pared totales.
  • Multiplica por rate_per_second para coste en USD.
  • Observa en ambos histograms (el segundo sólo si output_tokens > 0).
  • Establece el atributo del span llm.cost.usd = total en el span OTel actual.
  • Loggea un único evento de log estructurado request.cost.
  • Saca el request_id del dict per-request.
  • Haz la rate configurable vía env var LLM_RATE_USD_PER_HOUR, default 0.17.

Bloque B — cablear en el servidor

  • Instancia un CostTracker por proceso al arrancar.
  • En cada frontera de etapa en app.py / batcher.py, llama a cost.start_stage(req_id, ...) y cost.end_stage(req_id, ...).
  • Al final de la petición, cost.finalize(req_id, output_tokens, model).

Bloque C — pulir el dashboard de Grafana

Paneles requeridos (organiza en 4 filas):

Fila 1 — RED:

  • Peticiones por segundo (sum(rate(request_total[1m])))
  • Tasa de error % (sum(rate(request_total{status=~"5.."}[5m])) / sum(rate(request_total[5m])) * 100)
  • p50/p95/p99 duración de petición (tres series en un panel, histogram_quantile(...))

Fila 2 — USE:

  • Utilización de CPU (100 - (avg(rate(node_cpu_seconds_total{mode="idle"}[1m])) * 100))
  • RAM disponible (node_memory_MemAvailable_bytes / 1024^3 GiB)
  • Profundidad de cola (queue_depth)
  • Slots de KV cache usados (kv_cache_slots_used)

Fila 3 — LLM:

  • Tokens/sec por tipo (sum by (kind) (rate(tokens_total[1m])))
  • TTFT p95 (histogram_quantile(0.95, sum by(le) (rate(time_to_first_token_seconds_bucket[5m]))))

Fila 4 — Coste:

  • p95 coste por 1k tokens de completion (histogram_quantile(0.95, sum by(le) (rate(cost_per_1k_completion_tokens_usd_bucket[5m]))))
  • Gasto total últimas 24h (sum(increase(cost_per_request_usd_sum[24h])))
  • Distribución de coste-por-petición (panel heatmap de cost_per_request_usd_bucket)

Guarda el dashboard, exporta JSON, comitea a infra/grafana/dashboards/llm.json.

Bloque D — prueba de carga

  • Instala k6 (sudo dnf install k6 o usa la imagen docker).
  • Escribe experiments/34-load-test/loadtest.js:
  • Etapas: rampa 0 → 100 VUs en 1 minuto; mantén 100 VUs durante 3 minutos; rampa de bajada en 1 minuto.
  • Cada VU golpea /v1/completions con uno de tres templates de prompt fijos de longitud variable (corto / medio / largo).
  • Valida que la respuesta es 200 + completion no vacío.
  • Ejecuta: k6 run experiments/34-load-test/loadtest.js.
  • Durante la ejecución, toma una captura del dashboard en la carga pico. Guárdala como experiments/34-load-test/dashboard-screenshot.png.
  • Guarda la salida de texto de k6 a experiments/34-load-test/results.txt.
  • manifest.json registra:
{
  "experiment": "34-load-test",
  "date": "YYYY-MM-DD",
  "seed": 42,
  "versions": {"python": "...", "k6": "...", "miniserve_git_sha": "..."},
  "hardware": {...from learners/borja/profile.md...},
  "config": {"peak_vus": 100, "duration_s": 300, "prompts": [...]},
  "results_summary": {
    "rps_peak": null,
    "p50_ms": null,
    "p95_ms": null,
    "p99_ms": null,
    "error_rate_pct": null,
    "mean_cost_per_1k_tokens_usd": null,
    "p95_cost_per_1k_tokens_usd": null
  }
}

Rellena results_summary tras la ejecución.

Bloque E — calibración de coste

  • En experiments/34-cost-calibration/calibrate.py, ejecuta una carga corta fija (10 peticiones) en tres ajustes de rate: LLM_RATE_USD_PER_HOUR=0.085, 0.17, 0.34.
  • Para cada uno, registra la media de cost_per_request_usd.
  • Verifica: las tres medias están en proporción 1:2:4 (dentro de ~5 % de ruido). Eso confirma la linealidad.
  • Comitea un plot experiments/34-cost-calibration/linearity.png: x = rate, y = coste medio.

Restricciones

  • Sin cuerpos de prompt/completion almacenados en ningún lado. Verifica con grep -i prompt experiments/34-load-test/ (sólo debería matchear los fixtures de loadtest.js, no cualquier salida).
  • Prueba de carga single-host. No intentes distribuir el generador de carga aún; Fase 35.
  • No fijes el conteo de VUs a saturar la CPU. 100 VUs en la caja de 4-cores de Borja deberían dejar la CPU en ~70-80 %. Si se clava al 100 % con cola constante, baja a 50 VUs. Documéntalo.

Condiciones de parada

Hecho cuando:

  1. src/observability/cost.py existe, cumple el contrato.
  2. Cada petición emite una observación de histogram en cost_per_request_usd.
  3. infra/grafana/dashboards/llm.json comiteado, abre con los 12+ paneles populados.
  4. experiments/34-load-test/ tiene manifest + script + resultados + screenshot.
  5. experiments/34-cost-calibration/ prueba la linealidad (3 puntos, proporción 1:2:4).
  6. PHASE_34_REPORT.md borrado con: RPS pico, latencia p50/p95/p99, tasa de error, coste medio por 1k tokens de completion, p95 coste por 1k tokens de completion.

Trampas (lee antes de debuggear)

  • Números de coste salvajemente desviados. Si tu coste/1k-tokens es 100× la estimación de orden de magnitud del archivo de teoría 02 ($4.8e-4), estás doble-contando en alguna parte o sumando los tiempos de pared de decode de todas las peticiones batcheadas en lugar del tiempo efectivo por petición. Releer Corrección 1.
  • Panel heatmap vacío. El heatmap requiere la serie temporal _bucket, no el rate. Query sum by (le) (rate(cost_per_request_usd_bucket[1m])) — nota el agrupamiento le.
  • Panel de gasto total muestra 0. increase() sobre el _sum de un histogram está bien; sobre _count te da "peticiones servidas", no gasto.
  • k6 sale con "max VUs reached". Añade options.stages y flag --vus-max.

Cuándo consultar solutions/

Después de que las seis condiciones de parada pasen y exista un borrador de PHASE_34_REPORT.md. Solución en solutions/03-cost-and-loadtest-ref.md recorre los números esperados para el hardware específico de Borja.


Fase hecha. Escribe PHASE_34_REPORT.md, ejecuta /phase-report 34, luego para y espera el proceed de Borja.