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 4 —run_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
-
Sumar
latency_msen vez de calcular su percentil.BatchMetricsreportap50/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). -
Sumar
cost_centsde cadaRunMetricspara obtener el costo del lote. Exactamente el error que el comentario deaggregate_metricsseñala — con lotes tan chicos como este (0centavos cada run) da lo mismo, pero a la escala de miles de runs la diferencia es real y ya se midió en M3. -
Intentar calcular
RunMetricspararun 4. Como mostró el ejemplo trabajado, no hay ningúnhistorydisponible para ese run —build_run_metricsfallaría con unNameError(la variablehistory_stucknunca llegó a asignarse fuera deltry) si se intentara. La ausencia de métricas para un run que no completó es información válida, no un bug que arreglar. -
Pensar que
total_cost_cents=0significa 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 ser0sin ninguna razón aparente. -
Calcular
BatchMetricssobre un lote que mezcla runs detraced_runcon runs que nunca pasaron por él.build_run_metricsnecesita untrace_idreal, determinista, detraced_run(M2) — pasarle un identificador inventado a mano rompe la correlación conRUN_LOG.jsonlque 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) ytotal_run_latency_ms(M4) sobre los tres runs completados de la Lección 3, confirmando que ambas funciones leenhistorysin necesitar ningún cambio ni envoltura adicional. - Confirmamos, ejecutado, que
run 4—el que falla conRuntimeError— no tiene ni costo ni latencia calculables, por el mismo límite exacto querun_and_observe(M1) ya mostró: sinhistory, no hay nada que medir. - Construimos
ops/metrics_summary.py:RunMetrics(costo + latencia de un run) yBatchMetrics(el resumen agregado), reusandoestimate_cost_cents,cost_for_run,total_run_latency_msypercentilesin 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
- Python —
dataclasses— La base deRunMetricsyBatchMetrics, el mismo patrón queCostReport(M3) yLatencyReport(M4). - Python —
statistics—statistics.mean, reusado sin cambios enaggregate_metrics. 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.sre-and-incident-response-guide— cuandoflagged(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.