Módulo 4: Medir latencia con honestidad
La latencia como señal operacional
Descripción
Hasta ahora, este módulo respondió "¿cuánto tardó?" — por tool, por run, y por lote, con p50 y p95. Esta lección da el paso final: usar esa cifra para tomar una decisión. La pregunta ya no es solo "¿cuánto tardó el lote?" sino "¿qué, específicamente, es responsable de que tarde lo que tarda?" — y, con esa respuesta en la mano, trazar con precisión hasta dónde llega esta guía y dónde empieza el trabajo de una guía vecina.
Conexión con el módulo
Esta lección reusa el mismo lote de doce runs de la lección 06, y le agrega una dimensión nueva: no solo la latencia total de cada run, sino el desglose de esa latencia por tool, sumado sobre el lote completo. Es la última pieza conceptual antes de que la lección 08 la convierta en un artefacto reusable — observability/latency_model.py completo.
Analogía: la factura de electricidad, desglosada por electrodoméstico
El Módulo 3 ya usó la analogía del medidor de luz para el costo: una fracción insignificante por run, una factura real cuando se multiplica por miles. Esta lección retoma esa misma factura, pero le agrega un desglose que un medidor simple no da: ¿qué electrodoméstico específico es responsable de la mayor parte del consumo? Un hogar que solo ve el total mensual no puede decidir dónde ahorrar; un hogar que ve el desglose —"el refrigerador es el 40% de la factura, aunque solo está encendido el mismo tiempo que las demás cosas"— sabe exactamente dónde enfocar cualquier cambio.
La latencia total de un lote de runs de Reservo es esa factura mensual. El desglose por tool —cuánto de esos milisegundos totales le corresponde a list_rooms, cuánto a get_quote, cuánto a book_room, cuánto a cancel_booking— es el desglose por electrodoméstico. Sin él, sabes que el sistema "tarda lo que tarda"; con él, sabes exactamente qué tool merece la atención si algo necesita mejorar.
Ejemplo trabajado: el desglose del lote, por tool
Retomando el mismo lote de doce runs de la lección 06, esta vez sumando la latencia de cada tool call individual —no solo el total por run— y agrupando por nombre de tool:
from collections import Counter
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)."""
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
# batch: la misma lista de (question, script) de la lección 06.
tool_ms_totals = Counter()
tool_call_counts = Counter()
for question, script in batch:
final, history = ra.run_reservo_agent(question, script)
for name in executed_tool_names(history):
tool_ms_totals[name] += TOOL_LATENCY_MS[name]
tool_call_counts[name] += 1
grand_total_ms = sum(tool_ms_totals.values())
grand_total_calls = sum(tool_call_counts.values())
print(f"{'tool':15} {'llamadas':>9} {'ms totales':>11} {'% del total':>12}")
for name in TOOL_LATENCY_MS:
calls = tool_call_counts[name]
ms = tool_ms_totals[name]
pct = ms / grand_total_ms * 100
print(f"{name:15} {calls:>9} {ms:>11} {pct:>11.1f}%")
print(f"{'TOTAL':15} {grand_total_calls:>9} {grand_total_ms:>11}")
Qué esperar:
tool llamadas ms totales % del total
list_rooms 5 200 14.1%
get_quote 9 225 15.9%
book_room 6 720 50.9%
cancel_booking 3 270 19.1%
TOTAL 23 1415
Lee esta tabla con cuidado, porque el número que más importa no es el más obvio. get_quote se llamó más veces que ninguna otra tool (9 de 23 llamadas, el 39% de todas las tool calls del lote) — y sin embargo, es responsable de apenas el 15.9% de los milisegundos totales. book_room, en cambio, se llamó solo 6 veces (el 26% de las llamadas) y es responsable de más de la mitad de toda la latencia del lote (50.9%). La cantidad de llamadas y la cantidad de tiempo que consumen no son la misma señal — y confundirlas es, con precisión, el error que esta lección existe para prevenir.
Leyendo la señal: qué hacer con "book_room domina"
Confirmar que book_room domina la latencia total del lote no es, todavía, una acción — es una observación, y vale la pena ser precisos sobre qué tipo de decisión habilita y cuál no:
- Sí habilita: priorizar
book_roomcomo el primer candidato si, en el futuro, alguien decide invertir tiempo en optimizar latencia — la misma lógica que la analogía del refrigerador. Sí habilita, también, poner un umbral de latencia en el gate de regresión del Módulo 5 que sea sensible específicamente a cambios enbook_room— un cambio que la duplique de120a240ms movería el total del lote mucho más que un cambio equivalente enget_quote. - No habilita, todavía: decidir qué hacer si
book_roomfalla de forma consistente, no solo si es lenta — esa es una pregunta distinta (fallos, no latencia), y es exactamente el contenido del Módulo 6, con su circuit breaker por tool. Tampoco habilita optimizar nada de verdad — esta guía mide latencia, no la reduce; sibook_roomen un sistema real necesitara ser más rápida (una escritura a base de datos mejor indexada, una cola asíncrona), esa es una decisión de ingeniería fuera del alcance de esta guía.
La distinción importa porque es fácil, al ver una tabla como la de arriba, saltar directo a "hay que arreglar book_room" — y esta guía, deliberadamente, no llega tan lejos. Identifica la señal con precisión; la decisión de qué hacer con ella pertenece a quien opera el sistema real, con contexto que esta guía no tiene.
La frontera: dónde termina esta guía, dónde empieza sre-and-incident-response-guide
Todo lo que mide este módulo es la latencia de los pasos de un agente — cuánto tarda, en el modelo de esta guía, cada tool call y cada run completo. Eso es, con precisión, distinto de la latencia de infraestructura: cuánto tarda un balanceador de carga en repartir una petición, cuál es el SLI/SLO de latencia de un servicio expuesto en producción, cómo se ve un trace distribuido con OpenTelemetry cruzando varios microservicios. Esa capa —infraestructura, no agente— es el territorio de sre-and-incident-response-guide, una guía vecina de otro ecosistema, con Docker, AWS y Prometheus/Grafana, que esta guía nunca reconstruye.
La distinción tiene una prueba simple: si la pregunta es "¿qué tool del agente tardó más, y por qué?", es esta guía. Si la pregunta es "¿el servicio que expone este agente está devolviendo errores 5xx a una tasa aceptable, y quién responde si no?", es sre-and-incident-response-guide. Y hay una nota más, sobre el propio cronómetro: en producción de verdad, la latencia de cada tool call se mide con time.perf_counter() alrededor de cada llamada real —una instrumentación trivial de programar, que esta guía nombra pero nunca ejecuta, por la razón de reproducibilidad que la lección 03 ya explicó a fondo—. Lo que esta guía sí enseña, que un simple cronómetro no enseña por sí solo, es exactamente lo que acabas de practicar en estas siete lecciones: cómo sumar esa medición por run, cómo leerla con percentiles, y cómo identificar qué parte del sistema es responsable de la mayor parte del tiempo.
Errores comunes
-
Confundir "la tool que más se llama" con "la tool que más latencia aporta". El ejemplo trabajado de esta lección lo desmiente con números:
get_quotese llama más veces, perobook_roomaporta más tiempo total. Cualquier decisión operacional basada en "cuál tool aparece más en los logs" en vez de "cuál tool consume más tiempo total" corre el riesgo de priorizar mal. -
Saltar de "book_room domina la latencia" a "hay que optimizar book_room" sin pasar por el resto del análisis. Esta guía mide; no prescribe una optimización. La sección "Leyendo la señal" de esta lección es explícita sobre qué habilita esta observación y qué no.
-
Pensar que este módulo ya resolvió qué hacer cuando una tool falla de forma consistente. No — eso es latencia versus fallos, dos señales relacionadas pero distintas. Este módulo mide cuánto tarda una tool que sí responde; el Módulo 6 —con su circuit breaker— resuelve qué hacer cuando una tool deja de responder de forma confiable, a través de varios runs.
-
Creer que la frontera con
sre-and-incident-response-guidees "esta guía mide poco, esa guía mide mucho". No es una cuestión de cantidad — es una cuestión de nivel: esta guía opera el agente (sus tool calls, sus runs, sus prompts); esa guía opera la infraestructura que expone cualquier servicio (balanceadores, contenedores, el ciclo de vida de un incidente). Un sistema real necesita ambas capas, y ninguna reemplaza a la otra. -
Pensar que, porque la latencia de este módulo está modelada, la señal "book_room domina" no aplicaría en un sistema real. El número exacto (
50.9%) es específico de este lote modelado y no se traslada tal cual a producción — pero el patrón —una tool de escritura que consume desproporcionadamente más tiempo que las de lectura, aunque se llame menos— es una observación que se repite, con mucha frecuencia, en sistemas reales. La lección no es la cifra; es el hábito de calcular el desglose antes de asumir dónde está el problema.
Ejercicios
Ejercicio 1: Calcula el porcentaje de latencia de get_quote sobre el total (Fácil)
Usando la tabla del ejemplo trabajado, confirma con código el porcentaje exacto que representa get_quote sobre el total de milisegundos del lote.
Ver solución
pct_get_quote = tool_ms_totals["get_quote"] / grand_total_ms * 100
print(f"get_quote: {tool_ms_totals['get_quote']} ms de {grand_total_ms} ms totales = {pct_get_quote:.1f}%")
Salida esperada:
get_quote: 225 ms de 1415 ms totales = 15.9%
Explicación: 225 / 1415 = 0.159, exactamente el 15.9% que ya viste en la tabla del ejemplo trabajado — un porcentaje que, a pesar de ser la tool más llamada (9 de 23 llamadas), queda muy por debajo del 50.9% de book_room.
Ejercicio 2: Calcula la latencia promedio por llamada de cada tool (Medio)
En vez de la latencia total por tool, calcula cuánto "pesa" en promedio cada llamada de cada tool (ms totales / llamadas), y confirma que coincide con TOOL_LATENCY_MS.
Ver solución
for name in TOOL_LATENCY_MS:
promedio = tool_ms_totals[name] / tool_call_counts[name]
print(f"{name:15} promedio por llamada: {promedio:.1f} ms (TOOL_LATENCY_MS: {TOOL_LATENCY_MS[name]} ms)")
Salida esperada:
list_rooms promedio por llamada: 40.0 ms (TOOL_LATENCY_MS: 40 ms)
get_quote promedio por llamada: 25.0 ms (TOOL_LATENCY_MS: 25 ms)
book_room promedio por llamada: 120.0 ms (TOOL_LATENCY_MS: 120 ms)
cancel_booking promedio por llamada: 90.0 ms (TOOL_LATENCY_MS: 90 ms)
Explicación: el promedio por llamada de cada tool coincide, exactamente, con su valor en TOOL_LATENCY_MS — una confirmación esperada, porque cada llamada de una misma tool cuesta siempre lo mismo en este modelo (a diferencia de un sistema real, donde el promedio por llamada podría variar de una llamada a otra). Este resultado, sin variación, es otra manifestación de la misma propiedad de reproducibilidad que la lección 03 demostró: el modelo no tiene ruido, así que el promedio de cualquier subconjunto de llamadas a la misma tool es, siempre, exactamente su valor fijo.
Ejercicio 3: Diseña un lote hipotético donde list_rooms domine la latencia total (Difícil)
El lote de esta lección tiene a book_room como la tool dominante. Sin ejecutar código todavía, describe qué tipo de mezcla de tareas —qué combinaciones de tools, en qué proporción— haría que list_rooms (la tool más barata por llamada, después de get_quote) terminara siendo la que más milisegundos totales aporta a un lote. Después, construye un lote breve (de al menos 5 runs) que lo confirme.
Ver solución
Como list_rooms es barata por llamada (40 ms, la segunda más barata de las cuatro), para que domine el total del lote hace falta que se llame muchas más veces que las demás — mucho más desproporcionadamente que en el lote original. Un lote donde casi todas las tareas empiezan con "qué salas hay" (una consulta exploratoria muy común, por ejemplo) y muy pocas terminan reservando algo, haría que list_rooms acumulara más milisegundos totales que book_room, a pesar de costar menos por llamada.
lote_exploratorio = [
("Que salas hay 1", [step(tu("t01", "list_rooms", {})), end("...")]),
("Que salas hay 2", [step(tu("t01", "list_rooms", {})), end("...")]),
("Que salas hay 3", [step(tu("t01", "list_rooms", {})), end("...")]),
("Que salas hay 4", [step(tu("t01", "list_rooms", {})), end("...")]),
("Reserva Focus basic 1h para Uno", [
step(tu("t01", "list_rooms", {})),
step(tu("t02", "get_quote", {"room": "Focus", "tier": "basic", "hours": 1})),
step(tu("t03", "book_room", {"room": "Focus", "tier": "basic", "hours": 1, "member": "Uno"})),
end("...")]),
]
totales = Counter()
for question, script in lote_exploratorio:
final, history = ra.run_reservo_agent(question, script)
for name in executed_tool_names(history):
totales[name] += TOOL_LATENCY_MS[name]
print(dict(totales))
Salida esperada:
{'list_rooms': 200, 'get_quote': 25, 'book_room': 120}
Explicación: con cinco list_rooms (5 * 40 = 200) contra un solo book_room (120), list_rooms termina siendo la tool dominante del lote —200 contra 120—, a pesar de costar un tercio de lo que cuesta book_room por llamada individual. Esto confirma, con un caso construido a propósito, la lectura de fondo de esta lección: el dominio de una tool en la latencia total depende tanto de su costo por llamada como de con qué frecuencia se llama — ninguno de los dos factores por sí solo determina el resultado.
Resumen y siguiente paso
- Desglosamos la latencia total del lote de doce runs por tool, y confirmamos que
book_room—solo el26%de las llamadas— es responsable del50.9%de todos los milisegundos, mientras queget_quote—la tool más llamada,39%de las llamadas— aporta apenas el15.9%. - Distinguimos qué decisiones habilita esta observación (priorizar, poner un umbral en el gate de regresión) y cuáles no (optimizar la tool, decidir qué hacer si empieza a fallar — eso es trabajo de otros módulos).
- Trazamos la frontera final del módulo: la latencia de los pasos de un agente (esta guía) contra la latencia de infraestructura (
sre-and-incident-response-guide) — y nombramos, una vez más y sin ejecutarlo, cómo se mediría esto con el reloj real en producción.
Siguiente lección: 08 — Mini-proyecto: un reporte de latencia. Juntamos las siete lecciones de este módulo en observability/latency_model.py, ejecutado sobre el mismo lote de doce runs, con un reporte integral: por tool, por run, y los percentiles del lote completo.
Recursos adicionales
- Anthropic — Building effective agents — Sobre por qué identificar el cuello de botella real de un sistema agentic requiere desglosar la latencia, no solo sumarla.
- Python —
collections.Counter— La estructura usada para acumular el desglose de latencia por tool en esta lección. sre-and-incident-response-guide— La guía vecina que opera la infraestructura (SLI/SLO, balanceadores, el ciclo de vida de un incidente) detrás del servicio que expone un agente — territorio que esta lección nombra y nunca reconstruye.- Python 3.14 — What's New — La versión con la que se ejecutó cada cálculo de esta lección.