Módulo 4: Medir latencia con honestidad
Percentiles: p50 y p95
Descripción
Todo lo que hiciste hasta ahora en este módulo midió un run a la vez. Un negocio real nunca pregunta "¿cuánto tardó ese run específico?" — pregunta "¿qué tan rápido es el sistema, en general, para la gente que lo usa?" Esa pregunta no tiene una sola respuesta correcta con un solo número. Un promedio es una respuesta posible, pero —como ya advirtió el Módulo 1— esconde a los usuarios que peor la pasan. Esta lección introduce una respuesta mejor: los percentiles, calculados sobre un lote real de doce runs de Reservo.
Conexión con el módulo
Esta lección escala total_run_latency_ms (lección 05) de un run a un lote — la primera vez que este módulo trabaja con más de una corrida a la vez. El lote de doce runs que construyes aquí es el mismo que reusan la lección 07 (para identificar qué tool domina) y la lección 08 (el reporte final del mini-proyecto) — constrúyelo con cuidado, porque no se vuelve a rehacer.
Analogía: el cliente número 95 de cada 100
Imagina una cafetería que sirve, en un día ocupado, a cien clientes. Si le preguntas al dueño "¿cuánto espera un cliente en la fila?", y él responde con el promedio de los cien tiempos de espera, esa cifra puede sonar razonable —"en promedio, dos minutos"— y aun así esconder algo importante: noventa y cinco clientes esperaron menos de dos minutos, pero cinco esperaron quince minutos cada uno, porque llegaron justo cuando se atascó la máquina de café. El promedio de esos cien números sigue dando "dos minutos", porque los quince minutos de esos cinco clientes se diluyen entre los noventa y cinco que esperaron poco. Si eres uno de esos cinco, "dos minutos en promedio" no describe tu experiencia en absoluto.
El percentil 95 (p95) responde una pregunta distinta, y más honesta: "si ordeno a los cien clientes de menor a mayor tiempo de espera, ¿cuánto esperó el cliente que está en la posición 95?" Esa cifra —no el promedio— es la que un negocio serio usa para prometer algo real: "el 95% de nuestros clientes espera menos de X minutos" es una promesa que puedes verificar, y que no se rompe solo porque unos pocos casos extremos existan. El percentil 50 (p50, la mediana) responde la pregunta del cliente "típico": el que está justo en el medio de la fila ordenada. Un sistema saludable tiene un p50 bajo (la mayoría de la gente espera poco) y un p95 que no se dispara demasiado por encima del p50 (los casos extremos no son demasiado extremos). Cuando el p95 se dispara muy por encima del p50, ese es, casi siempre, el primer síntoma real de que algo anda mal — mucho antes de que el promedio se mueva lo suficiente como para que alguien lo note.
El lote: doce runs de Reservo, ninguno repetido a propósito
Antes de calcular nada, arma el lote sobre el que trabaja el resto de esta lección — doce tareas distintas, cada una ejercitando una combinación diferente de las cuatro tools:
import reservo_agent as ra
TOOL_LATENCY_MS = {
"list_rooms": 40,
"get_quote": 25,
"book_room": 120,
"cancel_booking": 90,
}
def total_run_latency_ms(history):
latency_ms = 0
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"]
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"):
name = tool_use_name.get(block["tool_use_id"])
latency_ms += TOOL_LATENCY_MS.get(name, 0)
return latency_ms
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.")]),
]
latencies = []
for i, (question, script) in enumerate(batch, start=1):
final, history = ra.run_reservo_agent(question, script)
lat = total_run_latency_ms(history)
latencies.append(lat)
print(f"run {i:2}: {lat:4} ms -- {question}")
Qué esperar:
run 1: 25 ms -- Cuánto cuesta Focus basic 2h
run 2: 40 ms -- Qué salas hay disponibles
run 3: 145 ms -- Reserva Studio basic 1h para Luis
run 4: 90 ms -- Cancela la reserva 1
run 5: 185 ms -- Reserva Boardroom pro 1h para Sofía, con la lista primero
run 6: 25 ms -- Cotiza Focus premium y luego pro 3h
run 7: 185 ms -- Reserva Focus pro 3h para Ana, con corrección
run 8: 210 ms -- Reserva y cancela Studio pro 2h para Diego
run 9: 50 ms -- Compara Focus pro y Boardroom pro 2h
run 10: 120 ms -- Reserva Boardroom basic 1h para Carla, con horas inválidas primero
run 11: 65 ms -- Qué salas hay y cuánto cuesta Studio pro 4h
run 12: 275 ms -- Reserva Focus pro 3h para Marta y luego cancela
Doce runs, doce latencias distintas —bueno, casi: los runs 5 y 7 empatan en 185, y los runs 1 y 6 empatan en 25—. Fíjate en el run 12: 275 ms, el mismo techo que ya calculaste en la lección 04 — es, exactamente, el guion que usa las cuatro tools, cada una una vez.
p50: el valor del medio, con el método nearest-rank
La forma más directa de calcular un percentil —el método nearest-rank— es simple: ordena los valores de menor a mayor, y toma el valor que está en la posición ceil(p/100 * n) (contando desde 1).
import math
def percentile(sorted_values, p):
"""Percentil por el metodo nearest-rank: ordena y toma el valor en la
posicion ceil(p/100 * n) (1-indexado). Siempre devuelve un valor que
ocurrio de verdad -- nunca interpola entre dos runs."""
n = len(sorted_values)
rank = math.ceil(p / 100 * n)
rank = max(1, min(rank, n))
return sorted_values[rank - 1]
sorted_latencies = sorted(latencies)
print("latencias ordenadas:", sorted_latencies)
print("n runs :", len(sorted_latencies))
p50 = percentile(sorted_latencies, 50)
print("p50 (nearest-rank) :", p50, "ms")
Qué esperar:
latencias ordenadas: [25, 25, 40, 50, 65, 90, 120, 145, 185, 185, 210, 275]
n runs : 12
p50 (nearest-rank) : 90 ms
Con n=12, ceil(0.5 * 12) = 6 — la posición 6 (1-indexada) de la lista ordenada. Contando desde el principio (25, 25, 40, 50, 65, 90, ...), el sexto valor es 90 — el run 4, "Cancela la reserva 1", que solo llama a cancel_booking. El p50 de este lote es 90 ms: la mitad de los runs tarda menos que eso, la otra mitad tarda más (o igual).
p95: el mismo método, el punto de corte más alto
p95 = percentile(sorted_latencies, 95)
print("p95 (nearest-rank) :", p95, "ms")
Qué esperar:
p95 (nearest-rank) : 275 ms
ceil(0.95 * 12) = ceil(11.4) = 12 — la posición 12, es decir, el último valor de la lista ordenada: 275, el run 12, el que usa las cuatro tools. Detente en esto, porque es una de las lecciones más honestas de todo este módulo: con solo doce runs, el p95 termina siendo, literalmente, el run más lento del lote completo. Eso no es un error del cálculo — es una consecuencia directa de tener pocos datos: 0.95 * 12 = 11.4 está tan cerca del final de la lista que cualquier redondeo hacia arriba te lleva, casi siempre, hasta la última posición. Con doce muestras, "el percentil 95" y "el peor caso observado" casi coinciden — y esa coincidencia desaparece a medida que el lote crece a cientos o miles de runs, donde el p95 empieza a representar de verdad "el 95% de los casos", no "el único caso peor que todos los demás".
Comparando p50, p95 y el promedio
import statistics
mean = statistics.mean(latencies)
print(f"p50 (mediana, nearest-rank) : {p50} ms")
print(f"promedio (mean) : {mean:.1f} ms")
print(f"p95 (nearest-rank) : {p95} ms")
Qué esperar:
p50 (mediana, nearest-rank) : 90 ms
promedio (mean) : 117.9 ms
p95 (nearest-rank) : 275 ms
Tres cifras, tres lecturas distintas del mismo lote. El p50 (90 ms) dice: "la mitad de los clientes de Reservo tuvo una experiencia de 90 ms o menos". El promedio (117.9 ms) está más cerca del p50 que del p95 — pero ya arrastrado hacia arriba por los pocos runs largos, exactamente como en la analogía de la cafetería. El p95 (275 ms) dice: "el peor caso que observamos en este lote tardó casi el triple que el cliente típico". Si Reservo tuviera que prometerle algo a su equipo de producto, "en promedio respondemos en 118 ms" suena bien pero esconde al cliente que tuvo que esperar 275; "el 95% de nuestros clientes espera menos de 275 ms" es una promesa más honesta, aunque suene menos favorable.
La trampa de statistics.quantiles con pocos datos
Python tiene una función lista para calcular percentiles, statistics.quantiles, y vale la pena conocerla — pero también vale la pena ver, ejecutado, cómo puede sorprenderte con un lote tan chico como este:
q_inclusive = statistics.quantiles(latencies, n=100, method="inclusive")
q_exclusive = statistics.quantiles(latencies, n=100, method="exclusive")
print("p95 con method='inclusive':", q_inclusive[94], "ms")
print("p95 con method='exclusive':", q_exclusive[94], "ms")
print("máximo real del lote :", max(latencies), "ms")
Qué esperar:
p95 con method='inclusive': 239.25 ms
p95 con method='exclusive': 297.75 ms
máximo real del lote : 275 ms
Mira con atención la última línea del method='exclusive': 297.75 ms — un p95 más alto que el run más lento que de verdad ocurrió (275). Esto no es un bug de Python — statistics.quantiles con method='exclusive' (el valor por defecto si no especificas method) usa una fórmula de interpolación pensada para muestras grandes, y con solo doce datos puede extrapolar más allá del máximo observado. method='inclusive' da 239.25 — un número más razonable (entre 210 y 275), pero que tampoco corresponde a ningún run real: es un punto interpolado entre dos observaciones. Ninguno de los dos números de statistics.quantiles es "el" p95 correcto — ambos son válidos, según definiciones distintas de percentil, y ambos difieren del 275 que dio el método nearest-rank de esta lección. Esta es la razón por la que observability/latency_model.py (lección 08) usa la función percentile manual de esta lección, no statistics.quantiles: con un método propio y simple, cada percentil reportado es siempre un valor que un run real produjo — nunca una cifra interpolada que nadie experimentó de verdad.
Errores comunes
-
Reportar solo el promedio, y nunca un percentil. El promedio de este lote (
117.9ms) suena razonable — y esconde por completo que el run más lento tardó más del doble. Un reporte de latencia sin al menos p50 y p95 no le da a nadie suficiente información para saber si el sistema tiene una cola larga de casos malos. -
Confiar en un p95 calculado sobre una muestra chica como si fuera una cifra sólida. Esta lección lo mostró sin adornos: con doce runs, el p95 casi siempre coincide con el peor caso observado — útil para aprender el concepto, insuficiente para una decisión de negocio real. La lección 08 va a insistir en esto: un p95 de producción necesita, como mínimo, cientos de muestras para empezar a ser confiable.
-
Mezclar el método de percentil de dos fuentes distintas sin darte cuenta. Si un dashboard reporta p95 con un método (por ejemplo,
method='inclusive') y tu código de regresión calcula con otro (nearest-rank), vas a comparar dos números que no significan exactamente lo mismo — la diferencia de239.25contra275de esta lección es prueba directa de que el método importa, no solo el dato. -
Pensar que
statistics.quantiles(n=100)siempre devuelve un valor observado. No — con interpolación (el comportamiento por defecto de esta función), puede devolver un número que ningún run real produjo, e incluso, como viste conmethod='exclusive', uno que supera al máximo real del lote. -
Calcular percentiles sobre un lote que mezcla runs de tipos completamente distintos sin pensarlo. El lote de esta lección mezcla consultas simples (
25ms) con reservas complejas (275ms) a propósito, para que el ejemplo sea rico — pero en un sistema real, si "consultar el precio" y "reservar con cuatro tools" son operaciones que un negocio quiere medir por separado, calcular un solo p95 sobre ambas mezcladas puede ocultar más de lo que revela. Esta guía no separa el lote por tipo de tarea —eso queda fuera de su alcance—, pero vale la pena tenerlo presente.
Ejercicios
Ejercicio 1: Calcula p50 y p95 sin el run más lento (Fácil)
Quita el run 12 (275 ms, el más lento) del lote, y recalcula p50 y p95 sobre los once runs restantes con la función percentile de esta lección.
Ver solución
sin_el_mas_lento = sorted(lat for lat in latencies if lat != 275)
print("latencias (11 runs):", sin_el_mas_lento)
print("p50:", percentile(sin_el_mas_lento, 50), "ms")
print("p95:", percentile(sin_el_mas_lento, 95), "ms")
Salida esperada:
latencias (11 runs): [25, 25, 40, 50, 65, 90, 120, 145, 185, 185, 210]
p50: 90 ms
p95: 210 ms
Explicación: el p50 no cambia (90 ms, todavía la posición ceil(0.5*11)=6, que sigue siendo 90) — quitar el caso más extremo no mueve el punto medio. El p95 sí cambia, de 275 a 210 — con un dato menos, ceil(0.95*11)=11 apunta ahora al nuevo último valor. Esto confirma, de nuevo, cuán sensible es el p95 a los extremos cuando la muestra es chica: quitar un solo run cambió el p95 en 65 ms, mientras que el p50 —mucho más estable frente a valores extremos— no se movió ni un milisegundo.
Ejercicio 2: Duplica el lote y confirma que p50 y p95 no cambian (Medio)
Duplica la lista latencies (cada valor aparece dos veces, en el mismo orden), y recalcula p50 y p95 sobre el lote de 24 valores. ¿Cambian las cifras respecto al lote original de 12?
Ver solución
doble = sorted(latencies + latencies)
print("n runs:", len(doble))
print("p50:", percentile(doble, 50), "ms")
print("p95:", percentile(doble, 95), "ms")
Salida esperada:
n runs: 24
p50: 90 ms
p95: 275 ms
Explicación: ninguna de las dos cifras cambia. El p50 sigue en 90 — el mismo tipo de run en el medio de la distribución—. El p95 también sigue en 275, aunque ahora ceil(0.95*24) = ceil(22.8) = 23 apunta a una posición distinta de la lista (la 23, no la 12): como cada valor se duplicó exactamente, la posición 23 de 24 sigue cayendo dentro de las dos copias del run más lento (275), no dentro de las dos copias del anterior valor más alto (210). Duplicar cada dato preserva las proporciones relativas de la distribución completa, y el método nearest-rank es sensible a esas proporciones, no al tamaño absoluto de la muestra — por eso ambos percentiles caen exactamente en el mismo lugar que antes. Esto no significa que "duplicar nunca cambia nada": si hubieras agregado doce runs nuevos y distintos en vez de duplicar los que ya tenías, la forma de la distribución sí cambiaría, y con ella, probablemente, también los percentiles.
Ejercicio 3: Diseña un lote de cuatro runs donde p50 y p95 sean idénticos (Difícil)
¿Es posible construir un lote (de cualquier tamaño, usando latencias de este módulo) donde p50 y p95 den exactamente el mismo número? Razona primero en qué condición tendría que estar el lote para que eso pase, y después constrúyelo y confírmalo con código.
Ver solución
Sí es posible: basta con que todos los runs del lote tengan exactamente la misma latencia. Si no hay ninguna variación en los datos, cualquier percentil —p1, p50, p95, p99— cae sobre el mismo valor, porque no hay ningún "peor caso" que se separe del "caso típico".
lote_uniforme = [185, 185, 185, 185]
print("p50:", percentile(sorted(lote_uniforme), 50), "ms")
print("p95:", percentile(sorted(lote_uniforme), 95), "ms")
Salida esperada:
p50: 185 ms
p95: 185 ms
Explicación: un lote sin ninguna variación —los cuatro runs usan la misma secuencia de tools, sin ningún tropiezo distinto entre uno y otro— produce percentiles idénticos, sin importar qué percentil calcules. En un sistema real, esto casi nunca ocurre —siempre hay algo de variación entre runs—, pero el ejercicio deja clara la relación de fondo: la brecha entre p50 y p95 mide, literalmente, cuánta variación hay en la experiencia de los usuarios. Una brecha grande (como la de 90 a 275 del lote original de esta lección) es la señal de que algunos runs son sustancialmente más lentos que el resto; una brecha de cero es la señal —poco realista, pero instructiva— de un sistema perfectamente consistente.
Resumen y siguiente paso
- Armamos un lote de doce runs reales de Reservo, con latencias que van de
25a275ms, ninguno repetido a propósito. - Calculamos p50 (
90ms) y p95 (275ms) con el método nearest-rank —simple, siempre devuelve un valor que un run real produjo—, y los comparamos contra el promedio (117.9ms): el promedio esconde a los runs que peor la pasan, exactamente como en la analogía de la cafetería. - Confirmamos, ejecutado, que con solo doce muestras el p95 casi siempre coincide con el peor caso observado — una limitación honesta de trabajar con lotes chicos, no un defecto del método.
- Vimos, con números reales, que
statistics.quantilespuede dar resultados distintos según su método de interpolación —incluido un p95 que supera al máximo real del lote— y por qué esta guía prefiere el método nearest-rank, más simple y siempre anclado a un dato real.
Siguiente lección: 07 — La latencia como señal operacional. Con p50 y p95 ya calculados, identificamos qué tool, específicamente, domina la latencia total del lote — y trazamos la frontera entre lo que esta guía mide (los pasos de un agente) y lo que mide sre-and-incident-response-guide (la infraestructura detrás).
Recursos adicionales
- Python —
statistics—statistics.mean,statistics.medianystatistics.quantiles, con sus distintos métodos de interpolación (inclusive/exclusive) confirmados en esta lección. - Python —
math.ceil— La función central del método nearest-rank de percentiles usado en esta lección. - Anthropic — Building effective agents — Sobre por qué la latencia percibida por un usuario real depende de la cola de la distribución, no solo del caso promedio.
- Python 3.14 — What's New — La versión con la que se ejecutó cada percentil, correcto y sorprendente, de esta lección.