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 claseCostTracker+ 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
CostTrackersegún el contrato. - Almacena los tiempos de inicio por etapa en un dict indexado por
(request_id, stage). Usatime.perf_counter(). - En
finalize: - Suma los tiempos de pared de las etapas para obtener segundos de pared totales.
- Multiplica por
rate_per_secondpara coste en USD. - Observa en ambos histograms (el segundo sólo si
output_tokens > 0). - Establece el atributo del span
llm.cost.usd = totalen 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, default0.17.
Bloque B — cablear en el servidor¶
- Instancia un
CostTrackerpor proceso al arrancar. - En cada frontera de etapa en
app.py/batcher.py, llama acost.start_stage(req_id, ...)ycost.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^3GiB) - 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 k6o 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/completionscon 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.jsonregistra:
{
"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:
src/observability/cost.pyexiste, cumple el contrato.- Cada petición emite una observación de histogram en
cost_per_request_usd. infra/grafana/dashboards/llm.jsoncomiteado, abre con los 12+ paneles populados.experiments/34-load-test/tiene manifest + script + resultados + screenshot.experiments/34-cost-calibration/prueba la linealidad (3 puntos, proporción 1:2:4).- 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. Querysum by (le) (rate(cost_per_request_usd_bucket[1m]))— nota el agrupamientole. - Panel de gasto total muestra 0.
increase()sobre el_sumde un histogram está bien; sobre_countte da "peticiones servidas", no gasto. - k6 sale con "max VUs reached". Añade
options.stagesy 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.