Módulo 1: Por qué operar es distinto de construir
Las señales operacionales que importan
Descripción
La lección 03 confirmó, con código real, que el problema no es "sería lindo saber más" — es que hay preguntas concretas sin respuesta. Esta lección le pone nombre y forma a esas preguntas: cuatro señales, cada una con una definición precisa, que juntas son el equivalente al tablero completo del auto de la analogía. No las cuatro son igual de fáciles de capturar hoy — dos de ellas (tasa de error, tasa de fallo por herramienta) ya se pueden calcular con lo que history te da; las otras dos (costo, latencia) necesitan una capa nueva, que empieza en la lección 05.
Esta lección se queda con las dos primeras, a fondo, y deja las otras dos preparadas para la siguiente. La razón de dividirlo así no es capricho: tasa de error y tasa de fallo por herramienta son señales que se cuentan sobre datos que ya existen; costo y latencia son señales que hay que estimar o modelar, con una capa de ingeniería nueva encima. Vale la pena dominar la primera mitad —la más simple— antes de construir la segunda.
Conexión con el módulo
Esta es la lección donde el módulo deja de nombrar el problema y empieza a construir el vocabulario para resolverlo. Las cuatro señales de esta lección son, literalmente, las cuatro preguntas de negocio que la lección 07 va a plantear como un encargo formal, y las cuatro cosas que el mini-proyecto de la lección 08 va a medir juntas sobre un lote de runs.
Analogía: cuatro agujas, no cien
Un tablero de auto no te muestra todo lo que pasa adentro del motor — eso sería un panel de diagnóstico de taller, abrumador e inútil mientras manejas. Te muestra un puñado de agujas, elegidas con cuidado porque cada una responde a una pregunta concreta que necesitas poder responder en segundos: ¿a qué velocidad voy? ¿cuánta gasolina me queda? ¿el motor se está calentando de más? ¿hay alguna luz de advertencia encendida?
Las señales operacionales de un agente son ese mismo tipo de elección deliberada. No se trata de loguear absolutamente todo lo que history contiene y esperar que alguien lo entienda después — se trata de elegir un puñado de números que respondan, en segundos, las preguntas que de verdad importan. Esta lección elige cuatro:
TASA DE ERROR -> ¿cuántos runs, de cada cien, NO llegaron a
terminar (RuntimeError, tope agotado)?
TASA DE FALLO POR TOOL -> dentro de los runs que SÍ terminaron, ¿qué
fracción de sus tool calls trajo is_error?
COSTO POR RUN -> ¿cuánto costó, en centavos, resolver esta
tarea? (lección 05)
LATENCIA POR RUN -> ¿cuánto tardó, modelado, resolver esta
tarea? (lección 05)
Ejemplo trabajado: dos señales, definidas con precisión y calculadas sobre runs reales
La distinción que hay que tener clara antes de escribir una línea de código
Fíjate en algo que es fácil de mezclar: tasa de error y tasa de fallo por herramienta no son la misma señal, aunque las dos hablan de "algo que salió mal". La tasa de error es a nivel de run completo: ¿el run, en conjunto, llegó a un stop_reason: "end_turn", o reventó el tope de iteraciones con un RuntimeError? La tasa de fallo por herramienta es a nivel de cada intento individual de tool call, dentro de un run que sí terminó bien: de las cuatro veces que se intentó llamar una tool, ¿cuántas trajeron is_error? Un run puede tener una tasa de fallo por herramienta alta —varios tropiezos en el camino— y aun así contar como un éxito a nivel de tasa de error, porque terminó. Eso es, con precisión, lo que ya viste en agent-fundamentals M8: "completar la tarea" no es lo mismo que "nunca equivocarse en el camino".
RunSignals: una estructura para las dos señales de esta lección
from dataclasses import dataclass
import reservo_agent as ra
@dataclass
class RunSignals:
"""Las señales que importan de UN run: cuántos pasos dio, cuántas tool
calls hizo, cuántas de esas tool calls trajeron is_error, y si el run
terminó (end_turn) o reventó el tope de iteraciones."""
steps: int
tool_calls: int
tool_errors: int
completed: bool
@property
def tool_fail_rate(self):
if self.tool_calls == 0:
return 0.0
return self.tool_errors / self.tool_calls
def compute_run_signals(history, completed=True):
tool_calls = 0
tool_errors = 0
for turn in history:
content = turn["content"]
if not isinstance(content, list):
continue
for block in content:
if block["type"] == "tool_use":
tool_calls += 1
elif block["type"] == "tool_result" and block.get("is_error"):
tool_errors += 1
return RunSignals(steps=len(history), tool_calls=tool_calls, tool_errors=tool_errors, completed=completed)
compute_run_signals recorre history una sola vez, contando dos cosas: cuántos bloques tool_use aparecen (cada uno es un intento de llamar una herramienta) y cuántos bloques tool_result traen is_error: True. El parámetro completed es información que no viene de history — viene de si la llamada a run_reservo_agent terminó con un return normal o con un RuntimeError; por eso lo recibe como argumento en vez de calcularlo, algo que la lección 03 ya dejó claro que history no puede decirte por sí solo cuando el run falla del todo.
Corriendo esto sobre tres runs distintos, y sobre el lote completo
# Run A: la demo -- Ana, Focus pro 3h, un tier inválido corregido.
script_a = [
{"stop_reason": "tool_use", "content": [
{"type": "tool_use", "id": "toolu_01", "name": "list_rooms", "input": {}}]},
{"stop_reason": "tool_use", "content": [
{"type": "tool_use", "id": "toolu_02", "name": "get_quote",
"input": {"room": "Focus", "tier": "premium", "hours": 3}}]},
{"stop_reason": "tool_use", "content": [
{"type": "tool_use", "id": "toolu_03", "name": "get_quote",
"input": {"room": "Focus", "tier": "pro", "hours": 3}}]},
{"stop_reason": "tool_use", "content": [
{"type": "tool_use", "id": "toolu_04", "name": "book_room",
"input": {"room": "Focus", "tier": "pro", "hours": 3, "member": "Ana"}}]},
{"stop_reason": "end_turn", "content": [
{"type": "text", "text": "Reservé Focus pro por 3 horas para Ana. Total $60.00. Confirmación #1."}]},
]
# Run B: Sofía, Boardroom pro 1h -- el modelo acierta el tier al primer intento.
script_b = [
{"stop_reason": "tool_use", "content": [
{"type": "tool_use", "id": "toolu_01", "name": "list_rooms", "input": {}}]},
{"stop_reason": "tool_use", "content": [
{"type": "tool_use", "id": "toolu_02", "name": "get_quote",
"input": {"room": "Boardroom", "tier": "pro", "hours": 1}}]},
{"stop_reason": "tool_use", "content": [
{"type": "tool_use", "id": "toolu_03", "name": "book_room",
"input": {"room": "Boardroom", "tier": "pro", "hours": 1, "member": "Sofía"}}]},
{"stop_reason": "end_turn", "content": [
{"type": "text", "text": "Reservé Boardroom pro por 1 hora para Sofía. Total $64.00. Confirmación #2."}]},
]
# Run C: Ana otra vez, pero con DOS errores en el camino (tier inválido, hours inválido).
script_c = [
{"stop_reason": "tool_use", "content": [
{"type": "tool_use", "id": "toolu_01", "name": "list_rooms", "input": {}}]},
{"stop_reason": "tool_use", "content": [
{"type": "tool_use", "id": "toolu_02", "name": "get_quote",
"input": {"room": "Focus", "tier": "premium", "hours": 3}}]},
{"stop_reason": "tool_use", "content": [
{"type": "tool_use", "id": "toolu_03", "name": "get_quote",
"input": {"room": "Focus", "tier": "pro", "hours": 0}}]},
{"stop_reason": "tool_use", "content": [
{"type": "tool_use", "id": "toolu_04", "name": "get_quote",
"input": {"room": "Focus", "tier": "pro", "hours": 3}}]},
{"stop_reason": "tool_use", "content": [
{"type": "tool_use", "id": "toolu_05", "name": "book_room",
"input": {"room": "Focus", "tier": "pro", "hours": 3, "member": "Ana"}}]},
{"stop_reason": "end_turn", "content": [
{"type": "text", "text": "Reservé Focus pro por 3 horas para Ana. Total $60.00. Confirmación #3."}]},
]
batch = [
("Ana - Focus pro 3h", "Reserva Focus pro 3h para Ana", script_a),
("Sofía - Boardroom pro 1h", "Reserva Boardroom pro 1h para Sofía", script_b),
("Ana - Focus pro 3h (2 tropiezos)", "Reserva Focus pro 3h para Ana", script_c),
]
all_signals = []
for label, question, script in batch:
final, history = ra.run_reservo_agent(question, script)
signals = compute_run_signals(history)
all_signals.append(signals)
print(f"{label:35} {signals} tool_fail_rate={signals.tool_fail_rate:.0%}")
print()
total_calls = sum(s.tool_calls for s in all_signals)
total_errors = sum(s.tool_errors for s in all_signals)
total_completed = sum(1 for s in all_signals if s.completed)
print(f"lote de {len(all_signals)} runs")
print(f"tool calls totales : {total_calls}")
print(f"tool errors totales : {total_errors}")
print(f"tool-fail rate del lote : {total_errors / total_calls:.0%}")
print(f"runs completados : {total_completed}/{len(all_signals)} ({total_completed / len(all_signals):.0%})")
Qué esperar:
Ana - Focus pro 3h RunSignals(steps=10, tool_calls=4, tool_errors=1, completed=True) tool_fail_rate=25%
Sofía - Boardroom pro 1h RunSignals(steps=8, tool_calls=3, tool_errors=0, completed=True) tool_fail_rate=0%
Ana - Focus pro 3h (2 tropiezos) RunSignals(steps=12, tool_calls=5, tool_errors=2, completed=True) tool_fail_rate=40%
lote de 3 runs
tool calls totales : 12
tool errors totales : 3
tool-fail rate del lote : 25%
runs completados : 3/3 (100%)
Léelo con cuidado, porque hay dos hallazgos reales en esta salida, no solo números:
- Los tres runs terminaron.
runs completados: 3/3 (100%)— la tasa de error, a nivel de run, es0%. Ningún run reventó el tope de iteraciones, sin importar cuántos tropiezos tuvo en el camino. - La tasa de fallo por herramienta varía enormemente run por run (
25%,0%,40%), pero se estabiliza en25%cuando la miras agregada sobre el lote completo. Un solo run te dice muy poco sobre el comportamiento general del sistema — necesitas un lote para que el número empiece a significar algo. Esta es exactamente la razón por la que ninguna de las dos señales de esta lección tiene sentido medida sobre un run aislado; ambas están pensadas para agregarse sobre docenas, cientos, miles de runs.
Por qué la tasa de error (run) y la tasa de fallo por herramienta (tool call) apuntan a lugares distintos
Estas dos señales, aunque relacionadas, te dicen cosas distintas sobre dónde mirar cuando algo anda mal:
- Una tasa de error alta a nivel de run (muchos
RuntimeError, muchos runs que nunca llegan aend_turn) apunta al diseño del bucle: ¿elmax_iterationses demasiado bajo para la complejidad real de las tareas? ¿el modelo está entrando en un patrón donde repite la misma tool sin converger? Esto es, con precisión, el tipo de pregunta que resuelve ajustar elmax_iterationso revisar por qué el modelo (concepto) no está decidiendo terminar. - Una tasa de fallo por herramienta alta (muchos
is_error, aunque los runs terminen bien) apunta a un lugar distinto: ¿una tool específica está recibiendo argumentos inválidos con frecuencia? ¿elenumde uninput_schemaes demasiado estricto para lo que los usuarios reales piden? ¿una tool en particular concentra la mayoría de los fallos? Esta señal, desglosada por nombre de tool —no solo el número agregado—, es la que el Módulo 6 de esta guía usa para decidir cuándo un circuit breaker debería abrirse para una tool específica.
Confundir las dos lleva a diagnósticos equivocados: si la tasa de fallo por herramienta sube porque get_quote está recibiendo tier="premium" con frecuencia, la solución no es tocar max_iterations — es revisar por qué el modelo (concepto) sigue proponiendo un tier que no existe, quizás porque el prompt del sistema no deja claro cuáles son los valores válidos. Eso es exactamente el tipo de decisión que el Módulo 7 (versionado) formaliza: cambiar el prompt, y comparar la tasa de fallo por herramienta ANTES y DESPUÉS del cambio, con el mismo gate.
Errores comunes
-
Calcular la tasa de fallo por herramienta sobre un solo run y sacar una conclusión. El ejemplo trabajado lo mostró con números reales:
25%,0%,40%— tres runs, tres números completamente distintos. Ninguno de los tres, por sí solo, te dice si el sistema "está bien" o "está mal". Solo el agregado sobre el lote (25%) empieza a ser una señal con la que se puede razonar. -
Tratar
tool_calls == 0como si fuera un error de división.RunSignals.tool_fail_ratechequea explícitamenteif self.tool_calls == 0antes de dividir — un run que nunca llamó a ninguna tool (por ejemplo, una pregunta que el modelo respondió directamente, sin necesitar herramientas) tiene una tasa de fallo de0.0, no un error, y no debería tratarse como "sin datos" al momento de agregar el lote. -
Mezclar "tasa de error" (run) con "tasa de fallo por herramienta" (tool call) en el mismo número. Son señales de capas distintas, con causas distintas y remedios distintos, como explicó la sección anterior. Un dashboard que las mezcla en un solo "% de problemas" oculta exactamente la información que hace falta para saber dónde mirar primero.
-
Pensar que
completed=Truesignifica "sin ningúnis_error". No — significa que el run llegó a unstop_reason: "end_turn"dentro del tope de iteraciones, sin importar cuántosis_errorhaya tenido en el camino. El Run C del ejemplo trabajado es exactamente ese caso:completed=Truecontool_errors=2. -
Esperar que esta lección ya calcule costo y latencia. No las calcula — las nombra, y las deja preparadas.
compute_run_signalsopera exclusivamente sobre lo quehistoryya contiene (pasos, tool calls,is_error); costo y latencia necesitan una capa de estimación que todavía no existe en este punto del módulo. Esa es, precisamente, la lección 05.
Ejercicios
Ejercicio 1: Calcula las señales de un cuarto run (Fácil)
Usando compute_run_signals, calcula las señales del guion de "Diego" — reserva Studio, basic, 1 hora, y después la cancela — con este guion:
script_d = [
{"stop_reason": "tool_use", "content": [
{"type": "tool_use", "id": "toolu_01", "name": "book_room",
"input": {"room": "Studio", "tier": "basic", "hours": 1, "member": "Diego"}}]},
{"stop_reason": "tool_use", "content": [
{"type": "tool_use", "id": "toolu_02", "name": "cancel_booking", "input": {"id": 4}}]},
{"stop_reason": "end_turn", "content": [
{"type": "text", "text": "Reservé y luego cancelé Studio basic 1h para Diego."}]},
]
Antes de ejecutar: ¿cuántos tool_calls esperas? ¿Cuántos tool_errors?
Ver solución
Dos tool_calls (book_room, cancel_booking), cero tool_errors — ninguno de los dos bloques tiene argumentos inválidos ni referencia una reserva inexistente.
final_d, history_d = ra.run_reservo_agent("Reserva y cancela Studio basic 1h para Diego", script_d)
signals_d = compute_run_signals(history_d)
print(signals_d, "tool_fail_rate:", signals_d.tool_fail_rate)
Salida esperada (continuando el mismo proceso del ejemplo trabajado, donde ya se crearon las reservas 1, 2, 3):
RunSignals(steps=6, tool_calls=2, tool_errors=0, completed=True) tool_fail_rate: 0.0
Explicación: steps=6 viene de 1 + 2n + 1 con n=2 tool calls. tool_fail_rate=0.0 confirma la predicción: book_room y cancel_booking con id=4 (la reserva que el propio guion acaba de crear) se ejecutan sin ningún problema de validación.
Ejercicio 2: Extiende RunSignals con una señal nueva (Medio)
Agrega un campo unique_tools a RunSignals — cuántas herramientas distintas (sin contar repeticiones) se llamaron en el run — y ajusta compute_run_signals para calcularlo. Confírmalo sobre el Run C del ejemplo trabajado (que llama get_quote tres veces, pero son la misma herramienta).
Ver solución
from dataclasses import dataclass, field
@dataclass
class RunSignalsV2:
steps: int
tool_calls: int
tool_errors: int
unique_tools: int
completed: bool
@property
def tool_fail_rate(self):
return self.tool_errors / self.tool_calls if self.tool_calls else 0.0
def compute_run_signals_v2(history, completed=True):
tool_calls = 0
tool_errors = 0
names = set()
for turn in history:
content = turn["content"]
if not isinstance(content, list):
continue
for block in content:
if block["type"] == "tool_use":
tool_calls += 1
names.add(block["name"])
elif block["type"] == "tool_result" and block.get("is_error"):
tool_errors += 1
return RunSignalsV2(steps=len(history), tool_calls=tool_calls, tool_errors=tool_errors,
unique_tools=len(names), completed=completed)
final_c, history_c = ra.run_reservo_agent("Reserva Focus pro 3h para Ana", script_c)
print(compute_run_signals_v2(history_c))
Salida esperada:
RunSignalsV2(steps=12, tool_calls=5, tool_errors=2, unique_tools=3, completed=True)
Explicación: tool_calls=5 (tres intentos de get_quote, uno de list_rooms, uno de book_room), pero unique_tools=3 — solo tres nombres distintos de herramienta (list_rooms, get_quote, book_room), sin importar cuántas veces se repitió cada uno. Esta distinción —intentos totales vs. herramientas distintas— es exactamente el tipo de señal adicional que se vuelve valiosa cuando quieres saber si un run está "atascado" repitiendo la misma tool una y otra vez, sin variar su estrategia.
Ejercicio 3: Diseña una señal que combine las dos, y explica por qué NO deberías fusionarlas en una sola (Difícil)
Alguien en tu equipo propone reemplazar tasa de error y tasa de fallo por herramienta con una sola métrica: "% de runs perfectos" — el porcentaje de runs que terminaron (completed=True) y tuvieron tool_errors=0. Calcula esa métrica sobre el lote de tres runs del ejemplo trabajado, y después explica en un párrafo qué información se pierde al fusionar las dos señales en una sola.
Ver solución
perfect_runs = sum(1 for s in all_signals if s.completed and s.tool_errors == 0)
print(f"% de runs perfectos: {perfect_runs}/{len(all_signals)} = {perfect_runs/len(all_signals):.0%}")
Salida esperada:
% de runs perfectos: 1/3 = 33%
Explicación: el 33% es un número real y no está mal calculado — pero esconde exactamente la distinción que la sección "Por qué la tasa de error y la tasa de fallo por herramienta apuntan a lugares distintos" explicó. Con esa sola cifra, no puedes saber si el 67% restante de runs "imperfectos" falló en completarse (un problema del bucle, max_iterations) o si completó la tarea pero con tropiezos en el camino (un problema de validación de argumentos, o del prompt que decide qué tier pedir). Los tres runs de este lote, de hecho, tuvieron el patrón opuesto al que uno asumiría de un "67% imperfecto": los tres se completaron perfectamente bien a nivel de tarea — la "imperfección" fue enteramente de tool calls corregidas en el camino, no de runs fallidos. Fusionar las dos señales en una sola convierte dos diagnósticos con remedios distintos (ajustar max_iterations vs. revisar el prompt o el input_schema) en un solo número que no te dice a cuál de los dos apuntar.
Resumen y siguiente paso
- Definimos con precisión dos de las cuatro señales operacionales de esta guía: tasa de error (a nivel de run completo,
RuntimeErrorvs.end_turn) y tasa de fallo por herramienta (a nivel de cada intento de tool call,is_error). - Construimos
RunSignalsycompute_run_signals, y las corrimos sobre tres runs reales: tasas individuales de25%,0%y40%que se estabilizan en25%al agregarse sobre el lote — la prueba de que una sola corrida dice poco, y un lote empieza a decir algo. - Explicamos por qué mezclar las dos señales en un solo número (como el "% de runs perfectos" del Ejercicio 3) esconde exactamente la distinción que hace falta para saber si el problema está en el diseño del bucle o en la validación de argumentos.
- Costo por run y latencia por run —las otras dos señales— quedaron nombradas, pendientes de la capa de estimación que arma la siguiente lección.
Siguiente lección: 05 — Un primer vistazo a costo, latencia y errores. Con la fórmula de precio fija de claude-sonnet-5 y un modelo de latencia declarado en datos, calculamos por primera vez, de verdad, cuánto costó y cuánto tardó el run canónico de Reservo.
Recursos adicionales
- Python —
dataclasses—@dataclassy@property, la base deRunSignalsy sutool_fail_ratecalculado. - Anthropic — Tool use (function calling) overview — La forma de
tool_use/tool_resultsobre la que se cuenta cada señal de esta lección. - Anthropic — Building effective agents — Sobre por qué un agente confiable se mide con señales específicas, no con una sola noción difusa de "funciona bien".
- Python 3.14 — What's New — La versión con la que se ejecutó cada bloque de código de esta lección.