Módulo 3: Medir costo y tokens por run
Costo por run, en centavos
Descripción
Las dos lecciones anteriores construyeron las piezas por separado: estimate_tokens (lección 03) convierte texto en una cantidad de tokens; el pricing fijo de claude-sonnet-5 (lección 04) convierte tokens en centavos. Esta lección las combina en estimate_cost_cents —la fórmula central de todo el módulo— y construye cost_for_run, la función que recorre el history de un run real de Reservo y calcula, con desglose por cada tool call, cuánto costó ese run completo.
Esta es la lección donde el trace_id del Módulo 2 y el costo de este módulo se encuentran por primera vez: cada CostReport que produce esta lección se identifica por el mismo trace_id determinista que ya viste en RUN_LOG.jsonl — la misma correlación de siempre, aplicada ahora al dinero.
Conexión con el módulo
Esta lección entrega la pieza central de observability/cost_calculator.py: estimate_cost_cents, StepCost, CostReport, y cost_for_run. Es, con precisión, el punto donde este módulo dejó de ser preparación y se convierte en la respuesta real a la pregunta que abrió la lección 01: "¿cuánto costó este run?".
estimate_cost_cents: la fórmula completa, ejecutada
Con las dos constantes de la lección 04 ya fijas, la fórmula combina tokens de entrada y de salida, cada uno con su propio precio, y redondea hacia abajo al final —la misma disciplina de aritmética entera de siempre—:
INPUT_PRICE_CENTS_PER_MILLION_TOKENS = 300 # $3.00 / 1M tokens -- claude-sonnet-5, precio de lista
OUTPUT_PRICE_CENTS_PER_MILLION_TOKENS = 1500 # $15.00 / 1M tokens -- claude-sonnet-5, precio de lista
def estimate_tokens(text):
return len(text) // 4
def estimate_cost_cents(input_tokens, output_tokens):
return (
input_tokens * INPUT_PRICE_CENTS_PER_MILLION_TOKENS
+ output_tokens * OUTPUT_PRICE_CENTS_PER_MILLION_TOKENS
) // 1_000_000
Fíjate en el orden de las operaciones: primero se multiplica cada cantidad de tokens por su precio (dos números potencialmente grandes), después se suman ambos productos, y al final se divide entre 1_000_000. Invertir ese orden —dividir antes de sumar, por ejemplo— produciría resultados distintos e incorrectos por el redondeo hacia abajo de cada división parcial. Esta es la misma fórmula que el DISEÑO de esta guía fijó desde el principio; esta lección es, simplemente, la primera vez que se ejecuta de verdad sobre un run completo.
StepCost y CostReport: el desglose, estructurado
Un solo número de centavos por run es útil, pero no dice dónde se gastó ese costo. StepCost guarda el costo de un tool call individual; CostReport junta todos los StepCost de un run, más sus totales:
from dataclasses import dataclass, field
@dataclass
class StepCost:
"""El costo estimado de UN tool call dentro de un run."""
step: int
tool: str
input_tokens: int
output_tokens: int
cost_cents: int
@dataclass
class CostReport:
"""El costo estimado de un run completo, con su desglose por paso."""
trace_id: str
question: str
steps: list = field(default_factory=list)
input_tokens: int = 0
output_tokens: int = 0
cost_cents: int = 0
trace_id es, deliberadamente, el primer campo de CostReport — el mismo identificador determinista de run_logger.py (Módulo 2), nunca un id nuevo inventado para este módulo. Cualquier CostReport se puede cruzar, por ese campo, contra las líneas de RUN_LOG.jsonl del mismo run.
cost_for_run: recorriendo history, sin tocar run_reservo_agent
cost_for_run recibe el trace_id (de traced_run, Módulo 2), la pregunta, y el history que devuelve run_reservo_agent (agent-fundamentals M8, sin tocar su lógica) — la misma técnica de "envolver, no reconstruir" que ya usaste en el Módulo 1 con run_and_observe.
import itertools
import json
def cost_for_run(trace_id, question, history):
"""Recorre history (de run_reservo_agent, SIN tocarlo) y calcula el
costo estimado del run completo, con desglose por tool call. Input =
texto que el agente LEE (la pregunta + cada tool_result); output =
texto que el agente PRODUCE (el input de cada tool_use + el texto
final)."""
steps = []
total_input_text = question
total_output_text = ""
pending = {}
step_counter = itertools.count(1)
for turn in history:
content = turn["content"]
if isinstance(content, str):
continue # ya se contó como `question`, arriba
for block in content:
if block["type"] == "tool_use":
step = next(step_counter)
args_text = json.dumps(block["input"])
total_output_text += args_text
pending[block["id"]] = {"step": step, "tool": block["name"], "args_text": args_text}
elif block["type"] == "tool_result":
entry = pending[block["tool_use_id"]]
result_text = block["content"]
total_input_text += result_text
step_in = estimate_tokens(result_text)
step_out = estimate_tokens(entry["args_text"])
steps.append(StepCost(
step=entry["step"], tool=entry["tool"],
input_tokens=step_in, output_tokens=step_out,
cost_cents=estimate_cost_cents(step_in, step_out),
))
elif block["type"] == "text":
total_output_text += block["text"]
input_tokens = estimate_tokens(total_input_text)
output_tokens = estimate_tokens(total_output_text)
return CostReport(
trace_id=trace_id, question=question, steps=steps,
input_tokens=input_tokens, output_tokens=output_tokens,
cost_cents=estimate_cost_cents(input_tokens, output_tokens),
)
Lee la clasificación con cuidado, porque es la misma que ya viste en el Módulo 1, lección 08 (run_and_observe), aplicada aquí con más detalle: el texto que el agente lee —la pregunta original y cada tool_result— cuenta como entrada; el texto que el agente produce —los argumentos de cada tool_use (serializados como el modelo los "escribió") y la respuesta final— cuenta como salida. pending, un diccionario indexado por el id del tool_use, es lo que permite emparejar cada tool_use con su tool_result correspondiente para construir un StepCost completo por cada paso, sin asumir que llegan en un orden particular.
Nota una decisión de diseño real: los totales del run (input_tokens, output_tokens de CostReport) se calculan sobre el texto concatenado completo (total_input_text, total_output_text), aplicando estimate_tokens una sola vez al final — no sumando los input_tokens/output_tokens de cada StepCost individual. La lección 03 ya adelantó por qué: sumar estimados ya redondeados pierde precisión frente a concatenar primero y redondear una sola vez. El desglose por paso (steps) sigue siendo útil para ver dónde se concentró el costo, pero el total oficial del run nunca se calcula sumando esas cifras ya redondeadas.
Ejemplo trabajado: el costo del run canónico de Ana
Corre el guion de siempre —list_rooms → get_quote (rechazado) → get_quote (corregido) → book_room—, esta vez envuelto en traced_run del Módulo 2, y calcula su costo inmediatamente después:
import logging
import reservo_agent as ra
import run_logger as rl
rl.logger.setLevel(logging.INFO)
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."}]},
]
with rl.traced_run("Reserva Focus pro 3h para Ana", 1) as trace_id:
final, history = ra.run_reservo_agent("Reserva Focus pro 3h para Ana", script_a)
print()
print("=== costo del run", trace_id, "===")
report = cost_for_run(trace_id, "Reserva Focus pro 3h para Ana", history)
print("input_tokens :", report.input_tokens)
print("output_tokens:", report.output_tokens)
print("cost_cents :", report.cost_cents)
print()
print("--- desglose por tool call ---")
for s in report.steps:
print(f" paso {s.step}: {s.tool:<12} in={s.input_tokens:>3} out={s.output_tokens:>3} cost_cents={s.cost_cents}")
Qué esperar:
{"seq": 1, "trace_id": "run-8487582448eb", "event": "run_started", "question": "Reserva Focus pro 3h para Ana", "tool_errors": 0, "error": ""}
{"seq": 2, "trace_id": "run-8487582448eb", "event": "tool_use", "step": 1, "tool": "list_rooms", "is_error": false, "content": ""}
{"seq": 4, "trace_id": "run-8487582448eb", "event": "tool_result", "step": 1, "tool": "list_rooms", "is_error": false, "content": "[{\"room\": \"Focus\", \"rate_cents\": 2500}, {\"room\": \"Studio\", \"rate_cents\": 4000}, {\"room\": \"Boardroom\", \"rate_cents\": 8000}]"}
{"seq": 6, "trace_id": "run-8487582448eb", "event": "tool_use", "step": 2, "tool": "get_quote", "is_error": false, "content": ""}
{"seq": 8, "trace_id": "run-8487582448eb", "event": "tool_result", "step": 2, "tool": "get_quote", "is_error": true, "content": "'tier'='premium' no está en enum ['basic', 'pro']"}
{"seq": 10, "trace_id": "run-8487582448eb", "event": "tool_use", "step": 3, "tool": "get_quote", "is_error": false, "content": ""}
{"seq": 12, "trace_id": "run-8487582448eb", "event": "tool_result", "step": 3, "tool": "get_quote", "is_error": false, "content": "{\"price_cents\": 6000}"}
{"seq": 14, "trace_id": "run-8487582448eb", "event": "tool_use", "step": 4, "tool": "book_room", "is_error": false, "content": ""}
{"seq": 16, "trace_id": "run-8487582448eb", "event": "tool_result", "step": 4, "tool": "book_room", "is_error": false, "content": "{\"booking_id\": 1, \"confirmed\": true}"}
{"seq": 18, "trace_id": "run-8487582448eb", "event": "run_finished", "question": "Reserva Focus pro 3h para Ana", "tool_errors": 1, "error": ""}
=== costo del run run-8487582448eb ===
input_tokens : 64
output_tokens: 56
cost_cents : 0
--- desglose por tool call ---
paso 1: list_rooms in= 30 out= 0 cost_cents=0
paso 2: get_quote in= 12 out= 12 cost_cents=0
paso 3: get_quote in= 5 out= 11 cost_cents=0
paso 4: book_room in= 9 out= 15 cost_cents=0
Lee el desglose con atención, porque revela algo que no es obvio antes de calcularlo: el paso 2 —el get_quote con el tier inválido, rechazado por check_input_v2— costó tokens igual que cualquier otro paso (12 de entrada, 12 de salida). El error no fue gratis. El modelo (concepto) gastó tokens de salida proponiendo tier="premium", y el sistema gastó tokens de entrada leyendo el mensaje de error que le llegó de vuelta. cost_cents del run completo sigue siendo 0 —la respuesta honesta para un run de este tamaño, la misma que ya viste en el Módulo 1—, pero el desglose por paso ya te dice, con precisión, que un cuarto del costo total de este run se gastó en un intento que terminó rechazado.
Errores comunes
-
Sumar los
cost_centsde cadaStepCostpara obtener el costo total del run. Como ya advirtió la lección 03, sumar valores ya redondeados por separado pierde precisión frente a concatenar el texto primero y aplicarestimate_tokens/estimate_cost_centsuna sola vez.CostReport.cost_centsse calcula sobre los totales del run, nunca sumandosteps. -
Pensar que un
tool_resultconis_error: Trueno cuesta nada. El ejemplo trabajado lo desmiente con números: el paso 2, el intento rechazado, costó exactamente lo mismo en tokens que un paso exitoso de tamaño similar. Un agente que se equivoca y se auto-corrige (M7 deagent-fundamentals) paga el costo de ambos intentos, no solo del que funcionó. -
Olvidar inicializar
total_input_textconquestion. El primer turno dehistoryes{"role": "user", "content": question}—un string, no una lista de bloques—, así que elfor turn in historydecost_for_runlo salta explícitamente (if isinstance(content, str): continue) porque ya se contó al inicializartotal_input_text = question. Olvidar esa inicialización dejaría la pregunta original fuera del costo del run. -
Confundir
entry["args_text"](los argumentos deltool_use, ya serializados) conresult_text(elcontentdeltool_result). El primero cuenta como salida (lo que el modelo generó al pedir la tool); el segundo cuenta como entrada (lo que el sistema le devuelve para que el modelo lo lea). Invertir esta clasificación invertiría, también, qué parte del costo se atribuye a cada dirección. -
Ejecutar
cost_for_runsobre unhistoryde un run que lanzóRuntimeError. Como ya viste en el Módulo 1 conrun_and_observe, sirun_reservo_agentlanza una excepción, nunca llega areturn, y no hay ningúnhistoryque capturar desde afuera de la llamada.cost_for_runasume, como precondición, quehistoryes el resultado de un run que sí terminó —bien o mal, pero terminó—; no está diseñada para un run que ni siquiera llegó a producir unhistorycompleto.
Ejercicios
Ejercicio 1: Calcula el costo del run de Sofía (Fácil)
Corre cost_for_run sobre el guion de Sofía —list_rooms → get_quote (Boardroom, pro, 1h) → book_room, sin ningún error—, envuelto en traced_run con sequence_number=2. Confirma input_tokens, output_tokens y cost_cents.
Ver solución
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."}]},
]
with rl.traced_run("Reserva Boardroom pro 1h para Sofía", 2) as trace_id_b:
final_b, history_b = ra.run_reservo_agent("Reserva Boardroom pro 1h para Sofía", script_b)
report_b = cost_for_run(trace_id_b, "Reserva Boardroom pro 1h para Sofía", history_b)
print("input_tokens :", report_b.input_tokens)
print("output_tokens:", report_b.output_tokens)
print("cost_cents :", report_b.cost_cents)
Salida esperada (además de las cuatro líneas de log de traced_run):
input_tokens : 53
output_tokens: 49
cost_cents : 0
Explicación: un run más corto (tres tool calls, sin ningún error) produce menos tokens que el run de Ana (53+49=102 contra 64+56=120), y el costo sigue siendo 0 centavos — consistente con la escala diminuta de estos runs individuales, el tema exacto que la lección 06 retoma.
Ejercicio 2: Encuentra el paso más costoso del run de Ana (Medio)
Usando el report del ejemplo trabajado (el run de Ana), encuentra el StepCost con el mayor total de tokens (input_tokens + output_tokens), sin asumir de antemano cuál es.
Ver solución
busiest = max(report.steps, key=lambda s: s.input_tokens + s.output_tokens)
print(f"paso más costoso: paso {busiest.step} ({busiest.tool}), "
f"{busiest.input_tokens + busiest.output_tokens} tokens totales")
Salida esperada:
paso más costoso: paso 1 (list_rooms), 30 tokens totales
Explicación: aunque list_rooms es la tool más simple —no recibe ningún argumento—, su tool_result es el más largo de las cuatro (lista las tres salas completas con su tarifa), así que domina el total de tokens del run, a pesar de tener 0 tokens de salida (no generó ningún argumento). Esto confirma, con un caso concreto, que el costo de un paso no depende de cuán "compleja" parezca la tool, sino de cuánto texto entra y sale en ese paso específico.
Ejercicio 3: Cuantifica el costo del error y su corrección (Difícil)
Corre cost_for_run sobre una versión sin ningún error de la tarea de Ana —list_rooms → get_quote (tier="pro" directo, sin el intento rechazado) → book_room—. Compara sus input_tokens + output_tokens contra los del run del ejemplo trabajado (con el error), y confirma que la diferencia coincide, exactamente, con los tokens del paso 2 del run con error.
Ver solución
script_clean = [
{"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": "pro", "hours": 3}}]},
{"stop_reason": "tool_use", "content": [
{"type": "tool_use", "id": "toolu_03", "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."}]},
]
final_clean, history_clean = ra.run_reservo_agent("Reserva Focus pro 3h para Ana", script_clean)
report_clean = cost_for_run("run-clean", "Reserva Focus pro 3h para Ana", history_clean)
total_con_error = report.input_tokens + report.output_tokens # del ejemplo trabajado
total_sin_error = report_clean.input_tokens + report_clean.output_tokens
diferencia = total_con_error - total_sin_error
paso_2 = next(s for s in report.steps if s.step == 2)
tokens_paso_2 = paso_2.input_tokens + paso_2.output_tokens
print("tokens CON error (run original) :", total_con_error)
print("tokens SIN error (run limpio) :", total_sin_error)
print("diferencia :", diferencia)
print("tokens del paso 2 (el rechazado) :", tokens_paso_2)
print("coinciden exactamente :", diferencia == tokens_paso_2)
Salida esperada:
tokens CON error (run original) : 120
tokens SIN error (run limpio) : 96
diferencia : 24
tokens del paso 2 (el rechazado) : 24
coinciden exactamente : True
Explicación: eliminar el intento con tier inválido —y su corrección— del guion reduce el run de 120 a 96 tokens totales, una diferencia de 24 que coincide, exactamente, con lo que costó el paso 2 aislado (12 de entrada más 12 de salida). Esto confirma, con precisión numérica, algo que la teoría de M7 de agent-fundamentals ya explicaba en palabras: la auto-corrección tiene un costo real, medible, no es "gratis" solo porque el agente terminó resolviendo la tarea correctamente.
Resumen y siguiente paso
- Construimos
estimate_cost_cents,StepCost,CostReport, ycost_for_run— el corazón deobservability/cost_calculator.py, combinandoestimate_tokens(L03) con el pricing fijo (L04). - Ejecutamos
cost_for_runsobre el run canónico de Ana, envuelto entraced_rundel Módulo 2:64tokens de entrada,56de salida,0centavos —con un desglose de cuatro pasos que muestra, con precisión, dónde se concentró cada token—. - Confirmamos, ejecutado, que un paso rechazado por validación (
is_error: True) cuesta tokens igual que uno exitoso — el error no es gratis, y el Ejercicio 3 lo cuantificó con exactitud:24tokens, el costo íntegro de un intento fallido y su corrección. - Cada
CostReportse identifica por el mismotrace_iddeterminista derun_logger.py— la correlación del Módulo 2, aplicada ahora al costo.
Siguiente lección: 06 — Escalando el costo a miles de runs. Con el costo de un run individual ya calculado —y frecuentemente 0 centavos, la respuesta honesta para runs de este tamaño—, escalamos esa cifra a lotes de 1.000, 10.000 y 100.000 runs, y confirmamos por qué sumar tokens antes de redondear es la única forma correcta de hacerlo.
Recursos adicionales
- Anthropic — Token counting — El conteo real de tokens de entrada y salida, la referencia contra la que esta lección confronta su propia estimación.
- Python —
dataclasses—StepCostyCostReport, yfield(default_factory=list)para evitar el error clásico de un valor por defecto mutable compartido. - Python —
itertools.count— El contador usado para numerar cada paso dentro decost_for_run, la misma técnica que ya viste enrun_logger.py. - Anthropic — Tool use (function calling) overview — El protocolo
tool_use/tool_resultquecost_for_runrecorre sin alterarlo, exactamente como lo hizorun_and_observeen el Módulo 1. - Python 3.14 — What's New — La versión con la que se ejecutó cada línea de código de esta lección.