Módulo 4: Medir latencia con honestidad
Mini-proyecto: un reporte de latencia
Descripción
Siete lecciones construyeron, por separado, cada pieza de este módulo: qué latencia se mide (lección 02), por qué se modela en vez de medirse con el reloj real (lección 03), TOOL_LATENCY_MS como dato fijo (lección 04), la suma total de un run con su matiz sobre los tool_use rechazados (lección 05), percentiles p50/p95 sobre un lote (lección 06), y la latencia como señal operacional con su desglose por tool (lección 07). Este mini-proyecto las junta todas en observability/latency_model.py: dos dataclasses —LatencyReport por run, BatchLatencyReport para el lote completo— y las funciones que las construyen, ejecutadas sobre el mismo lote de doce runs que acompañó a las lecciones 06 y 07.
Con esto, observability/latency_model.py queda al lado de observability/run_logger.py (Módulo 2) y observability/cost_calculator.py (Módulo 3) — las tres piezas de la capa de operación que el Módulo 5 va a usar, sin volver a construir nada, para el gate de regresión.
Conexión con el módulo
Esta es la síntesis de las ocho lecciones del módulo. observability/latency_model.py no inventa ninguna técnica nueva: reusa TOOL_LATENCY_MS de la lección 04, la lógica de total_run_latency_ms de la lección 05, la función percentile de la lección 06, y el desglose por tool de la lección 07 — todo junto, en dos estructuras de datos reusables, ejecutado sobre un lote real.
Ejemplo trabajado: observability/latency_model.py, completo
Las piezas ya conocidas, en un solo archivo
import math
import statistics
from collections import Counter
from dataclasses import dataclass
import reservo_agent as ra
TOOL_LATENCY_MS = {
"list_rooms": 40,
"get_quote": 25,
"book_room": 120,
"cancel_booking": 90,
}
def executed_tool_names(history):
"""Devuelve, en orden, los nombres de las tools que de verdad se
ejecutaron en un run (tool_result sin is_error) -- lección 05/07."""
tool_use_name = {}
for turn in history:
if turn["role"] != "assistant" or not isinstance(turn["content"], list):
continue
for block in turn["content"]:
if block["type"] == "tool_use":
tool_use_name[block["id"]] = block["name"]
names = []
for turn in history:
if turn["role"] != "user" or not isinstance(turn["content"], list):
continue
for block in turn["content"]:
if block["type"] == "tool_result" and not block.get("is_error"):
names.append(tool_use_name.get(block["tool_use_id"]))
return names
def total_run_latency_ms(tool_calls):
"""Suma la latencia modelada de las tools que de verdad se ejecutaron
-- lección 05."""
return sum(TOOL_LATENCY_MS.get(name, 0) for name in tool_calls)
def percentile(sorted_values, p):
"""Percentil nearest-rank -- lección 06. Siempre devuelve un valor
que un run real produjo."""
n = len(sorted_values)
rank = math.ceil(p / 100 * n)
rank = max(1, min(rank, n))
return sorted_values[rank - 1]
Nada de esto es nuevo — son, literalmente, las mismas cuatro funciones de las lecciones 05, 06 y 07, copiadas sin ningún cambio a este archivo único.
Las dos dataclasses: por run, y por lote
@dataclass
class LatencyReport:
"""Todo lo que este modulo puede decir de la latencia de UN run."""
run_id: int
question: str
tool_calls: list
total_latency_ms: int
@dataclass
class BatchLatencyReport:
"""El resumen de latencia de un LOTE completo de runs."""
n_runs: int
p50_ms: int
p95_ms: int
mean_ms: float
tool_ms_totals: dict
dominant_tool: str
def build_latency_report(run_id, question, history):
tool_calls = executed_tool_names(history)
return LatencyReport(
run_id=run_id, question=question, tool_calls=tool_calls,
total_latency_ms=total_run_latency_ms(tool_calls),
)
def build_batch_report(reports):
latencies = sorted(r.total_latency_ms for r in reports)
tool_ms_totals = Counter()
for r in reports:
for name in r.tool_calls:
tool_ms_totals[name] += TOOL_LATENCY_MS[name]
dominant_tool = max(tool_ms_totals, key=lambda name: tool_ms_totals[name])
return BatchLatencyReport(
n_runs=len(reports),
p50_ms=percentile(latencies, 50),
p95_ms=percentile(latencies, 95),
mean_ms=round(statistics.mean(latencies), 1),
tool_ms_totals=dict(tool_ms_totals),
dominant_tool=dominant_tool,
)
LatencyReport es la unidad mínima de este módulo: un run, su pregunta, qué tools ejecutó de verdad, y su latencia total. BatchLatencyReport es la síntesis de un lote completo: cuántos runs, p50, p95, el promedio, el desglose de milisegundos por tool, y cuál tool domina — literalmente, las lecciones 06 y 07 convertidas en una sola estructura.
El lote completo, de punta a punta
Reusa, sin cambios, el mismo lote de doce runs de las lecciones 06 y 07:
def tu(id_, name, input_):
return {"type": "tool_use", "id": id_, "name": name, "input": input_}
def step(*blocks):
return {"stop_reason": "tool_use", "content": list(blocks)}
def end(text):
return {"stop_reason": "end_turn", "content": [{"type": "text", "text": text}]}
batch = [
("Cuánto cuesta Focus basic 2h", [
step(tu("t01", "get_quote", {"room": "Focus", "tier": "basic", "hours": 2})),
end("Focus basic 2h cuesta $50.00.")]),
("Qué salas hay disponibles", [
step(tu("t01", "list_rooms", {})),
end("Tenemos Focus, Studio y Boardroom.")]),
("Reserva Studio basic 1h para Luis", [
step(tu("t01", "get_quote", {"room": "Studio", "tier": "basic", "hours": 1})),
step(tu("t02", "book_room", {"room": "Studio", "tier": "basic", "hours": 1, "member": "Luis"})),
end("Reservé Studio basic 1h para Luis. Confirmación #1.")]),
("Cancela la reserva 1", [
step(tu("t01", "cancel_booking", {"id": 1})),
end("Cancelé la reserva #1.")]),
("Reserva Boardroom pro 1h para Sofía, con la lista primero", [
step(tu("t01", "list_rooms", {})),
step(tu("t02", "get_quote", {"room": "Boardroom", "tier": "pro", "hours": 1})),
step(tu("t03", "book_room", {"room": "Boardroom", "tier": "pro", "hours": 1, "member": "Sofía"})),
end("Reservé Boardroom pro 1h para Sofía. Confirmación #2.")]),
("Cotiza Focus premium y luego pro 3h", [
step(tu("t01", "get_quote", {"room": "Focus", "tier": "premium", "hours": 3})),
step(tu("t02", "get_quote", {"room": "Focus", "tier": "pro", "hours": 3})),
end("Focus pro 3h cuesta $60.00.")]),
("Reserva Focus pro 3h para Ana, con corrección", [
step(tu("t01", "list_rooms", {})),
step(tu("t02", "get_quote", {"room": "Focus", "tier": "premium", "hours": 3})),
step(tu("t03", "get_quote", {"room": "Focus", "tier": "pro", "hours": 3})),
step(tu("t04", "book_room", {"room": "Focus", "tier": "pro", "hours": 3, "member": "Ana"})),
end("Reservé Focus pro 3h para Ana. Confirmación #3.")]),
("Reserva y cancela Studio pro 2h para Diego", [
step(tu("t01", "book_room", {"room": "Studio", "tier": "pro", "hours": 2, "member": "Diego"})),
step(tu("t02", "cancel_booking", {"id": 4})),
end("Reservé y cancelé Studio pro 2h para Diego.")]),
("Compara Focus pro y Boardroom pro 2h", [
step(tu("t01", "get_quote", {"room": "Focus", "tier": "pro", "hours": 2})),
step(tu("t02", "get_quote", {"room": "Boardroom", "tier": "pro", "hours": 2})),
end("Focus pro 2h cuesta $40.00 y Boardroom pro 2h cuesta $128.00.")]),
("Reserva Boardroom basic 1h para Carla, con horas inválidas primero", [
step(tu("t01", "book_room", {"room": "Boardroom", "tier": "basic", "hours": 0, "member": "Carla"})),
step(tu("t02", "book_room", {"room": "Boardroom", "tier": "basic", "hours": 1, "member": "Carla"})),
end("Reservé Boardroom basic 1h para Carla. Confirmación #5.")]),
("Qué salas hay y cuánto cuesta Studio pro 4h", [
step(tu("t01", "list_rooms", {})),
step(tu("t02", "get_quote", {"room": "Studio", "tier": "pro", "hours": 4})),
end("Studio pro 4h cuesta $128.00.")]),
("Reserva Focus pro 3h para Marta y luego cancela", [
step(tu("t01", "list_rooms", {})),
step(tu("t02", "get_quote", {"room": "Focus", "tier": "pro", "hours": 3})),
step(tu("t03", "book_room", {"room": "Focus", "tier": "pro", "hours": 3, "member": "Marta"})),
step(tu("t04", "cancel_booking", {"id": 6})),
end("Reservé y cancelé Focus pro 3h para Marta.")]),
]
reports = []
for i, (question, script) in enumerate(batch, start=1):
final, history = ra.run_reservo_agent(question, script)
reports.append(build_latency_report(i, question, history))
print("=== LatencyReport por run ===")
for r in reports:
print(f"run {r.run_id:2}: {r.total_latency_ms:4} ms tools={r.tool_calls}")
batch_report = build_batch_report(reports)
print()
print("=== BatchLatencyReport ===")
print("n_runs :", batch_report.n_runs)
print("p50_ms :", batch_report.p50_ms)
print("p95_ms :", batch_report.p95_ms)
print("mean_ms :", batch_report.mean_ms)
print("tool_ms_totals:", batch_report.tool_ms_totals)
print("dominant_tool :", batch_report.dominant_tool)
Qué esperar:
=== LatencyReport por run ===
run 1: 25 ms tools=['get_quote']
run 2: 40 ms tools=['list_rooms']
run 3: 145 ms tools=['get_quote', 'book_room']
run 4: 90 ms tools=['cancel_booking']
run 5: 185 ms tools=['list_rooms', 'get_quote', 'book_room']
run 6: 25 ms tools=['get_quote']
run 7: 185 ms tools=['list_rooms', 'get_quote', 'book_room']
run 8: 210 ms tools=['book_room', 'cancel_booking']
run 9: 50 ms tools=['get_quote', 'get_quote']
run 10: 120 ms tools=['book_room']
run 11: 65 ms tools=['list_rooms', 'get_quote']
run 12: 275 ms tools=['list_rooms', 'get_quote', 'book_room', 'cancel_booking']
=== BatchLatencyReport ===
n_runs : 12
p50_ms : 90
p95_ms : 275
mean_ms : 117.9
tool_ms_totals: {'get_quote': 225, 'list_rooms': 200, 'book_room': 720, 'cancel_booking': 270}
dominant_tool : book_room
Cada cifra de este reporte ya la viste, por separado, en una lección anterior — la diferencia es que ahora vive en dos objetos reusables (LatencyReport, BatchLatencyReport), construidos por dos funciones (build_latency_report, build_batch_report) que cualquier módulo posterior de esta guía puede importar y llamar, sin volver a escribir ninguna de estas cuatro funciones de base.
Lo que este reporte todavía no hace
Vale la pena nombrar, con precisión, los límites de este mini-proyecto, porque cada uno de ellos es, con exactitud, lo que otro módulo de esta guía agrega después:
- No persiste a ningún archivo.
BatchLatencyReportvive en memoria, en esta sesión de Python — a diferencia deRUN_LOG.jsonldel Módulo 2, que sí escribe a disco. Correlacionar unLatencyReportcon eltrace_idderun_logger.pyes posible —ambos identifican el mismo run—, pero esta lección no lo hace explícitamente; queda como una integración natural para el capstone del Módulo 8. - No decide nada. Reporta
p95_ms=275ydominant_tool="book_room", pero no compara esa cifra contra ningún umbral, ni falla ni pasa ningún gate. Eso es, con precisión, el trabajo del Módulo 5: tomar exactamente estas cifras y convertirlas en un criterio de pass/fail. - No reacciona a una tool que falla de forma consistente. Si
book_roomempezara a fallar en el90%de sus llamadas a través de varios runs, este reporte seguiría calculando su latencia con normalidad para las llamadas que sí tuvieron éxito — no tiene ningún mecanismo para "cortar" el tráfico hacia una tool que dejó de responder. Eso es el circuit breaker del Módulo 6.
Ninguno de estos tres límites es un descuido — es, con precisión, el alcance correcto de un módulo que mide, no que decide ni endurece. Los Módulos 5 y 6 existen porque medir es una disciplina distinta de actuar sobre lo medido.
Errores comunes
-
Pensar que
observability/latency_model.pyreemplaza aobservability/cost_calculator.pyo aobservability/run_logger.py. No — las tres piezas coexisten, cada una respondiendo una pregunta distinta (qué pasó, cuánto costó, cuánto tardó), y el Módulo 5 las va a usar juntas, no una en lugar de otra. -
Calcular
tool_ms_totalssumandoLatencyReport.total_latency_msen vez de recorrertool_callsde cada reporte.total_latency_mses la suma por run; para el desglose por tool, hay que volver a recorrertool_callsde cadaLatencyReport— sumar lostotal_latency_msde todos los runs solo te da, de nuevo, el gran total, sin ningún desglose. -
Construir
BatchLatencyReportantes de tener todos losLatencyReportdel lote.percentilenecesita la lista completa, ordenada, de latencias — llamarlo con un lote parcial (por ejemplo, dentro del mismoforque todavía está generando los reportes) daría un p50/p95 calculado sobre menos datos de los que el lote realmente tiene. -
Olvidar que
dominant_toolse calcula sobre milisegundos totales, no sobre cantidad de llamadas. La lección 07 ya lo demostró:get_quotese llama más veces que cualquier otra tool, perobook_roomes ladominant_toolde este reporte, precisamente porquemax(tool_ms_totals, key=...)compara milisegundos, no conteos. -
Pensar que este mini-proyecto necesita un
trace_ido un logger para ser útil. No lo necesita para el objetivo de este módulo —medir la latencia—, aunque integrarlo con eltrace_iddel Módulo 2 sería una mejora natural en un sistema real. Este mini-proyecto es deliberadamente autosuficiente, para que quede claro qué aporta la capa de latencia por sí sola, antes de combinarla con las demás.
Ejercicios
Ejercicio 1: Agrega un decimotercer run y recalcula el BatchLatencyReport (Fácil)
En un proceso nuevo —para que BOOKINGS arranque vacío y los id hardcodeados en batch (cancel_booking con id=1, id=4, id=6) sigan apuntando a las reservas correctas, sin arrastrar ninguna reserva de una corrida anterior de este mismo módulo—: agrega al batch un run nuevo que solo llame a list_rooms (40 ms), reconstruye la lista de reports corriendo batch_extendido de punta a punta, y vuelve a calcular build_batch_report. ¿Cambia el dominant_tool?
Ver solución
batch_extendido = batch + [
("Qué salas hay, otra vez", [
step(tu("t01", "list_rooms", {})),
end("Tenemos Focus, Studio y Boardroom.")]),
]
reports_extendidos = []
for i, (question, script) in enumerate(batch_extendido, start=1):
final, history = ra.run_reservo_agent(question, script)
reports_extendidos.append(build_latency_report(i, question, history))
batch_report_extendido = build_batch_report(reports_extendidos)
print("n_runs :", batch_report_extendido.n_runs)
print("tool_ms_totals:", batch_report_extendido.tool_ms_totals)
print("dominant_tool :", batch_report_extendido.dominant_tool)
Salida esperada:
n_runs : 13
tool_ms_totals: {'get_quote': 225, 'list_rooms': 240, 'book_room': 720, 'cancel_booking': 270}
dominant_tool : book_room
Explicación: list_rooms sube de 200 a 240 ms (5 * 40 + 1 * 40), pero sigue muy por debajo de book_room (720 ms) — el dominant_tool no cambia. Un solo run adicional, con la tool más barata de las dos de lectura, no tiene peso suficiente para desplazar a una tool que ya domina por un margen tan amplio.
Ejercicio 2: Encuentra el run con la latencia más cercana al promedio del lote (Medio)
Usando batch_report.mean_ms y la lista de reports, encuentra cuál LatencyReport tiene la latencia total más cercana al promedio del lote (117.9 ms).
Ver solución
mas_cercano = min(reports, key=lambda r: abs(r.total_latency_ms - batch_report.mean_ms))
print(f"run {mas_cercano.run_id}: {mas_cercano.total_latency_ms} ms -- {mas_cercano.question}")
Salida esperada:
run 10: 120 ms -- Reserva Boardroom basic 1h para Carla, con horas inválidas primero
Explicación: 120 ms está a apenas 2.1 ms del promedio (|120 - 117.9| = 2.1), la distancia más chica de los doce runs del lote — más cerca, incluso, que el run 3 (145 ms, a 27.1 de distancia) o el run 4 (90 ms, a 27.9). Vale la pena notar que el run más cercano al promedio (run 10, 120 ms) no es el mismo que el run del p50 (run 4, 90 ms, calculado en la lección 06) — el promedio y la mediana son dos medidas de "centro" distintas, sensibles a cosas distintas, y no hay ninguna garantía de que coincidan en el mismo run.
Ejercicio 3: Diseña una función runs_above_p95 que filtre el lote (Difícil)
Escribe una función runs_above_p95(reports, batch_report) que devuelva la lista de LatencyReport cuya total_latency_ms sea mayor o igual al p95_ms del lote. Ejecútala sobre reports y batch_report, y explica qué significa, operacionalmente, que la lista tenga más de un elemento en un lote de doce runs.
Ver solución
def runs_above_p95(reports, batch_report):
return [r for r in reports if r.total_latency_ms >= batch_report.p95_ms]
sospechosos = runs_above_p95(reports, batch_report)
for r in sospechosos:
print(f"run {r.run_id}: {r.total_latency_ms} ms -- {r.question}")
Salida esperada:
run 12: 275 ms -- Reserva Focus pro 3h para Marta y luego cancela
Explicación: con p95_ms=275 y un solo run (el 12) alcanzando exactamente ese valor, la lista tiene un único elemento — coherente con lo que ya explicó la lección 06: con doce runs, el p95 casi siempre coincide con el run más lento del lote, así que "por encima o igual al p95" casi nunca describe a más de un run. En un lote de producción real, con cientos o miles de runs, esta misma función devolvería, por definición, aproximadamente el 5% de todos los runs — el grupo exacto de "los peores casos" que un equipo de operación querría revisar primero si algo empieza a sentirse lento para algunos clientes.
Resumen y siguiente paso
- Construimos
observability/latency_model.pycompleto:TOOL_LATENCY_MS,total_run_latency_ms,percentile, y dosdataclasses—LatencyReportpor run,BatchLatencyReportpor lote— que juntan las siete lecciones anteriores en un solo artefacto reusable. - Lo ejecutamos sobre el mismo lote de doce runs de las lecciones 06 y 07, y confirmamos, de nuevo, cada cifra:
p50=90,p95=275,mean=117.9,dominant_tool="book_room"(720de1415ms totales). - Nombramos, con precisión, los tres límites de este mini-proyecto —no persiste, no decide, no reacciona a fallos consistentes— y a qué módulo le corresponde cada uno.
- Con esto se cierra el Módulo 4. Junto a
observability/run_logger.py(Módulo 2) yobservability/cost_calculator.py(Módulo 3), esta guía tiene ahora las tres piezas de "medir" completas: qué pasó, cuánto costó, cuánto tardó.
Siguiente módulo: Módulo 5 — Evals de regresión como gate de producción. Con costo y latencia ya medidos, y observabilidad ya construida, este módulo convierte esas señales en un criterio automático: un harness que corre un set fijo de casos y falla el build si el agente eligió la tool equivocada, si el schema del output no valida, o si el costo o la latencia se dispararon por encima de un umbral — nunca un juicio semántico, siempre una comparación literal contra un valor esperado.
Recursos adicionales
- Python —
dataclasses—LatencyReportyBatchLatencyReport, las dos estructuras centrales de este mini-proyecto. - Python —
statistics—statistics.mean, usado enbuild_batch_reportpara el promedio del lote. - Python —
collections.Counter— La estructura detrás detool_ms_totals, acumulada sobre cadaLatencyReportdel lote. - Anthropic — Building effective agents — Sobre por qué medir un sistema agentic —costo, latencia, tasa de fallo— es una disciplina que precede, siempre, a cualquier decisión de optimizarlo o endurecerlo.
- Python 3.14 — What's New — La versión con la que se ejecutó cada línea de código de este módulo, desde la lección 01 hasta el cierre de este mini-proyecto.