Módulo 8: Project The Reservo Agent In Production

El reporte de costo y latencia

Descripción

Con RUN_LOG.jsonl de la Lección 3 ya escrito, esta lección pone en marcha la segunda disciplina del capstone: medir. cost_for_run (M3) y total_run_latency_ms (M4) leen el mismo history que cada run de la Lección 3 produjo —sin reemplazar nada, sin envolver nada, solo leyendo lo que run_reservo_agent ya devolvió— y responden, con números reales, las dos preguntas que abrieron esta guía: ¿cuánto costó este run?, ¿cuánto tardó? Esta lección va un paso más allá de repetir M3 y M4 por separado: junta las dos respuestas en un solo reporte por run, y agrega el lote completo en un resumen único, con costo total y percentiles de latencia — el segundo artefacto real de este capstone.

Conexión con el módulo

Esta lección entrega ops/metrics_summary.py: RunMetrics (costo + latencia de un run) y BatchMetrics (el resumen del lote). Ninguna de las dos estructuras reconstruye la fórmula de costo, el modelo de latencia, ni la función de percentiles — cada una llama, una sola vez, a la pieza correspondiente de M3 y M4. La Lección 5 va a usar estos mismos números —el costo y la latencia de cada caso— como parte del gate de regresión.


Analogía: el contador y el cronómetro, revisando la misma comanda

El inspector de la Lección 3 —el escáner de cada estación— ya dejó su rastro completo en RUN_LOG.jsonl. Ahora entran dos personas distintas a revisar exactamente esas mismas comandas: el contador, que suma el costo real de los ingredientes que cada plato usó, y el cronómetro de cocina, que ya sabe —de memoria, sin tener que medir de nuevo con un reloj real— cuánto tarda cada estación. Ninguno de los dos vuelve a cocinar nada — ambos leen, después de que el servicio ya pasó, exactamente los mismos tickets que el escáner registró. Y al final de la noche, alguien junta las dos planillas —la del contador, la del cronómetro— en un solo reporte: cuánto costó y cuánto tardó, comanda por comanda, y un resumen de toda la noche.


Ejemplo trabajado: costo y latencia, run por run

Los tres runs completados de la Lección 3, con su history disponible

Retoma los tres runs que sí terminaron en la Lección 3 —Ana, Sofía, y la cancelación de la reserva 999— y calcula, para cada uno, su CostReport (M3) y su latencia total (M4):

import cost_calculator as cc
import latency_model as lm

runs = [
    ("run-8487582448eb", "Reserva Focus pro 3h para Ana", history_ana),
    ("run-c720132bf969", "Reserva Boardroom pro 1h para Sofia", history_sofia),
    ("run-8d26276b0d45", "Cancela la reserva 999", history_cancel),
]

for trace_id, question, history in runs:
    report = cc.cost_for_run(trace_id, question, history)
    latency_ms = lm.total_run_latency_ms(history)
    print(f"{trace_id}  in={report.input_tokens:>3} out={report.output_tokens:>3} "
          f"cost={report.cost_cents}c  latency={latency_ms:>3}ms  -- {question}")

Qué esperar:

run-8487582448eb  in= 64 out= 56 cost=0c  latency=185ms  -- Reserva Focus pro 3h para Ana
run-c720132bf969  in= 53 out= 48 cost=0c  latency=185ms  -- Reserva Boardroom pro 1h para Sofia
run-8d26276b0d45  in= 10 out=  8 cost=0c  latency= 90ms  -- Cancela la reserva 999

Tres runs, tres tamaños distintos, la misma respuesta honesta de siempre: 0 centavos cada uno, la escala real de un run individual de este tamaño. La latencia sí distingue entre ellos con claridad: 185 ms para las dos reservas de tres pasos (list_rooms + get_quote + book_room), 90 ms para la cancelación de un solo paso — exactamente TOOL_LATENCY_MS["cancel_booking"], sin ninguna sorpresa.

run 4 no tiene reporte — y esa ausencia es información

try:
    with rl.traced_run("Reserva algo ambiguo", 4):
        final, history_stuck = ra.run_reservo_agent("Reserva algo ambiguo", stuck_script, max_iterations=2)
except RuntimeError as exc:
    print("run 4 -- sin CostReport, sin latencia:", exc)
    print("razón: RuntimeError se lanza ANTES de que run_reservo_agent retorne -- no hay history que leer.")

Qué esperar:

run 4 -- sin CostReport, sin latencia: max_iterations alcanzado (2)
razón: RuntimeError se lanza ANTES de que run_reservo_agent retorne -- no hay history que leer.

Este no es un error de cost_for_run ni de total_run_latency_ms — es el mismo límite exacto que run_and_observe (M1) ya mostró: cuando run_reservo_agent lanza una excepción, Python descarta su estado local antes de que la función pueda retornar, y no queda ningún history que ninguna función externa pueda leer. RUN_LOG.jsonl (Lección 3) sigue teniendo el rastro completo de run 4run_started y run_failed, con los dos pasos que sí llegaron a ejecutarse—, pero ni el costo ni la latencia de ese run pueden calcularse con las herramientas de M3/M4 sobre un history que nunca llegó a existir fuera de la función. Esta es, precisamente, la clase de fallo que la Lección 6 de este módulo (la capa de resiliencia) existe para reducir, y que el gate de la Lección 5 existe para atrapar antes de que llegue a producción.


RunMetrics y BatchMetrics: un solo reporte, dos disciplinas juntas

ops/metrics_summary.py no reconstruye ninguna fórmula — llama, una vez cada una, a cost_for_run (M3) y total_run_latency_ms (M4), y junta el resultado en una sola estructura por run:

# ops/metrics_summary.py
import statistics
from dataclasses import dataclass, field

import cost_calculator as cc
import latency_model as lm


@dataclass
class RunMetrics:
    """Costo Y latencia de UN run, en un solo lugar -- reusa cost_for_run
    (M3) y total_run_latency_ms (M4), nunca reconstruye ninguna fórmula."""
    trace_id: str
    question: str
    input_tokens: int
    output_tokens: int
    cost_cents: int
    latency_ms: int


def build_run_metrics(trace_id, question, history):
    report = cc.cost_for_run(trace_id, question, history)
    return RunMetrics(
        trace_id=trace_id, question=question,
        input_tokens=report.input_tokens, output_tokens=report.output_tokens,
        cost_cents=report.cost_cents, latency_ms=lm.total_run_latency_ms(history),
    )


@dataclass
class BatchMetrics:
    """El resumen de costo y latencia de un LOTE completo."""
    n_runs: int
    total_cost_cents: int
    mean_latency_ms: float
    p50_latency_ms: int
    p95_latency_ms: int


def aggregate_metrics(runs):
    """Suma TOKENS primero, aplica estimate_cost_cents UNA sola vez sobre
    el total (M3, lección 06) -- nunca suma cost_cents ya redondeados de
    cada run. Percentiles con la MISMA percentile() de M4, lección 06."""
    total_input = sum(r.input_tokens for r in runs)
    total_output = sum(r.output_tokens for r in runs)
    latencies = sorted(r.latency_ms for r in runs)
    return BatchMetrics(
        n_runs=len(runs),
        total_cost_cents=cc.estimate_cost_cents(total_input, total_output),
        mean_latency_ms=round(statistics.mean(latencies), 1),
        p50_latency_ms=lm.percentile(latencies, 50),
        p95_latency_ms=lm.percentile(latencies, 95),
    )

Fíjate en el comentario de aggregate_metrics, porque protege contra el error más fácil de cometer aquí: sumar r.cost_cents de cada RunMetrics en vez de sumar sus tokens y aplicar estimate_cost_cents una sola vez al final. M3 (Lección 6) ya demostró, con una diferencia real de $103.20 sobre cien mil runs, por qué sumar valores ya redondeados hacia abajo pierde precisión — aggregate_metrics reusa esa misma disciplina, no la repite desde cero.


Ejecutado: el resumen del lote de tres runs

from metrics_summary import build_run_metrics, aggregate_metrics

metrics = [
    build_run_metrics("run-8487582448eb", "Reserva Focus pro 3h para Ana", history_ana),
    build_run_metrics("run-c720132bf969", "Reserva Boardroom pro 1h para Sofia", history_sofia),
    build_run_metrics("run-8d26276b0d45", "Cancela la reserva 999", history_cancel),
]

for m in metrics:
    print(f"{m.trace_id}  cost={m.cost_cents}c  latency={m.latency_ms}ms")

batch = aggregate_metrics(metrics)
print()
print("=== BatchMetrics -- lote de", batch.n_runs, "runs ===")
print("costo total       :", batch.total_cost_cents, "centavos")
print("latencia promedio  :", batch.mean_latency_ms, "ms")
print("latencia p50       :", batch.p50_latency_ms, "ms")
print("latencia p95       :", batch.p95_latency_ms, "ms")

Qué esperar:

run-8487582448eb  cost=0c  latency=185ms
run-c720132bf969  cost=0c  latency=185ms
run-8d26276b0d45  cost=0c  latency=90ms

=== BatchMetrics -- lote de 3 runs ===
costo total       : 0 centavos
latencia promedio  : 153.3 ms
latencia p50       : 185 ms
latencia p95       : 185 ms

Detente en el p50 y el p95: los dos coinciden, en 185 ms. No es un error de percentile — es la misma honestidad que M4 (Lección 6) ya advirtió sobre muestras chicas, ahora llevada al extremo: con n=3, ceil(0.5 * 3) = 2 y ceil(0.95 * 3) = ceil(2.85) = 3 caen en posiciones distintas de la lista ordenada ([90, 185, 185]), pero la posición 2 y la posición 3 tienen, por coincidencia, el mismo valor —185—, porque dos de los tres runs (Ana y Sofía) comparten exactamente la misma secuencia de latencia. Con un lote de tres runs, ni el p50 ni el p95 son todavía una señal confiable sobre "la experiencia típica" — son, con precisión, lo que hay: tres números, ordenados, con las posiciones que el método nearest-rank exige.


Errores comunes

  1. Sumar latency_ms en vez de calcular su percentil. BatchMetrics reporta p50/p95/mean — nunca una suma total de latencias, que no tendría ningún significado operacional (a diferencia del costo, donde sumar SÍ tiene sentido: es el gasto total del lote).

  2. Sumar cost_cents de cada RunMetrics para obtener el costo del lote. Exactamente el error que el comentario de aggregate_metrics señala — con lotes tan chicos como este (0 centavos cada run) da lo mismo, pero a la escala de miles de runs la diferencia es real y ya se midió en M3.

  3. Intentar calcular RunMetrics para run 4. Como mostró el ejemplo trabajado, no hay ningún history disponible para ese run — build_run_metrics fallaría con un NameError (la variable history_stuck nunca llegó a asignarse fuera del try) si se intentara. La ausencia de métricas para un run que no completó es información válida, no un bug que arreglar.

  4. Pensar que total_cost_cents=0 significa que el reporte "no sirvió de nada". Sirvió exactamente para lo que M3 (Lección 7) ya enseñó: confirmar, con evidencia, que el costo sigue en el rango esperado para este tamaño de lote — la misma disciplina que un sistema real usa para detectar cuándo el costo deja de ser 0 sin ninguna razón aparente.

  5. Calcular BatchMetrics sobre un lote que mezcla runs de traced_run con runs que nunca pasaron por él. build_run_metrics necesita un trace_id real, determinista, de traced_run (M2) — pasarle un identificador inventado a mano rompe la correlación con RUN_LOG.jsonl que el resto de este capstone depende.


Ejercicios

Ejercicio 1: Confirma el desglose por paso del run de Sofía (Fácil)

Usando cc.cost_for_run sobre history_sofia, imprime el desglose por paso (report.steps) y confirma cuál de los tres pasos —list_rooms, get_quote, book_room— tiene el mayor total de tokens (input_tokens + output_tokens).

Ver solución
report_sofia = cc.cost_for_run("run-c720132bf969", "Reserva Boardroom pro 1h para Sofia", history_sofia)
for s in report_sofia.steps:
    print(f"  paso {s.step}: {s.tool:<12} total={s.input_tokens + s.output_tokens} tokens")

busiest = max(report_sofia.steps, key=lambda s: s.input_tokens + s.output_tokens)
print("paso más costoso:", busiest.tool, "con", busiest.input_tokens + busiest.output_tokens, "tokens")

Salida esperada:

  paso 1: list_rooms   total=30 tokens
  paso 2: get_quote    total=17 tokens
  paso 3: book_room    total=25 tokens
paso más costoso: list_rooms con 30 tokens

Explicación: igual que con el run de Ana (M3, Lección 5), list_rooms domina el total de tokens del paso a pesar de no recibir ningún argumento —su tool_result es el más largo de las cuatro tools, porque lista las tres salas completas—. La misma lección aplica aquí, sobre un run distinto: el costo de un paso depende de cuánto texto entra y sale, no de cuán "compleja" parezca la tool.

Ejercicio 2: Proyecta el costo del lote a mil rondas de tráfico similar (Medio)

Usando la técnica de escalado de M3 (Lección 6) —multiplicar los tokens totales por n antes de aplicar estimate_cost_cents, nunca multiplicar el costo ya redondeado—, proyecta cuánto costarían mil lotes idénticos a este de tres runs.

Ver solución
n = 1000
total_input = sum(m.input_tokens for m in metrics)
total_output = sum(m.output_tokens for m in metrics)

naive = batch.total_cost_cents * n
correct = cc.estimate_cost_cents(total_input * n, total_output * n)
print("método naive  (cost_cents * n):", naive, "centavos")
print("método correcto (tokens * n)  :", correct, "centavos", f"(${correct / 100:.2f})")

Salida esperada:

método naive  (cost_cents * n): 0 centavos
método correcto (tokens * n)  : 206 centavos ($2.06)

Explicación: el método naive predice, otra vez, $0.00 sin importar cuántos lotes se proyecten —porque 0 * n siempre da 0—, exactamente el mismo error de redondeo que M3 (Lección 6) ya demostró con cifras mucho más grandes. El método correcto —escalar los tokens primero, aplicar la fórmula una sola vez— muestra que mil lotes de este tamaño (127 tokens de entrada y 112 de salida por lote) sí cuestan $2.06, una cifra pequeña pero real, que el método naive nunca podría revelar.

Ejercicio 3: Diseña flag_slow_or_expensive_runs, combinando las dos señales (Difícil)

Escribe una función flag_slow_or_expensive_runs(runs, latency_threshold_ms, cost_threshold_cents) que reciba una lista de RunMetrics y devuelva los que exceden cualquiera de los dos umbrales (latencia O costo). Pruébala sobre metrics con latency_threshold_ms=100 y cost_threshold_cents=0 — deberías obtener dos runs marcados por latencia (Ana y Sofía, ambos en 185 ms), ninguno por costo.

Ver solución
def flag_slow_or_expensive_runs(runs, latency_threshold_ms, cost_threshold_cents):
    flagged = []
    for r in runs:
        reasons = []
        if r.latency_ms > latency_threshold_ms:
            reasons.append(f"latency={r.latency_ms}ms > {latency_threshold_ms}ms")
        if r.cost_cents > cost_threshold_cents:
            reasons.append(f"cost={r.cost_cents}c > {cost_threshold_cents}c")
        if reasons:
            flagged.append((r.trace_id, reasons))
    return flagged


flagged = flag_slow_or_expensive_runs(metrics, latency_threshold_ms=100, cost_threshold_cents=0)
for trace_id, reasons in flagged:
    print(trace_id, "--", "; ".join(reasons))
print("total marcados:", len(flagged), "de", len(metrics))

Salida esperada:

run-8487582448eb -- latency=185ms > 100ms
run-c720132bf969 -- latency=185ms > 100ms
total marcados: 2 de 3

Explicación: con un umbral de latencia de 100 ms, los dos runs de tres pasos (Ana, Sofía) lo exceden —cada uno suma list_rooms + get_quote + book_room = 185 ms—, mientras que la cancelación de un solo paso (90 ms) queda por debajo. Ningún run excede el umbral de costo (0 centavos), porque los tres son runs individuales pequeños. Esta función —combinar dos señales con un criterio "O", no "Y"— es, con precisión, el mismo tipo de lógica que el gate de regresión de la Lección 5 aplica, ahora aplicada como un filtro de monitoreo en vez de un gate binario PASS/FAIL.


Resumen y siguiente paso

  • Calculamos CostReport (M3) y total_run_latency_ms (M4) sobre los tres runs completados de la Lección 3, confirmando que ambas funciones leen history sin necesitar ningún cambio ni envoltura adicional.
  • Confirmamos, ejecutado, que run 4 —el que falla con RuntimeError— no tiene ni costo ni latencia calculables, por el mismo límite exacto que run_and_observe (M1) ya mostró: sin history, no hay nada que medir.
  • Construimos ops/metrics_summary.py: RunMetrics (costo + latencia de un run) y BatchMetrics (el resumen agregado), reusando estimate_cost_cents, cost_for_run, total_run_latency_ms y percentile sin reconstruir ninguna fórmula — el segundo artefacto real de este capstone.

Siguiente lección: 05 — El gate de regresión en el capstone. Con costo y latencia ya medidos, corremos el gate de M5 contra el agente tal como está —PASS— y, después, el mismo criterio aplicado a comparar una versión nueva del prompt (M7) — NO-GO, con el rollback ejecutado.


Recursos adicionales

  1. Python — dataclasses — La base de RunMetrics y BatchMetrics, el mismo patrón que CostReport (M3) y LatencyReport (M4).
  2. Python — statisticsstatistics.mean, reusado sin cambios en aggregate_metrics.
  3. cost-optimization-caching-guide — cuando la pregunta deje de ser "cuánto costó" y pase a ser "cómo lo reduzco" — esta lección mide, nunca optimiza; la Lección 7 de este módulo nombra la frontera con precisión.
  4. sre-and-incident-response-guide — cuando flagged (Ejercicio 3) necesite convertirse en una alerta real, con un canal de notificación y un runbook — esta lección se detiene en identificar la señal, no en operar la alerta.