Hace unas semanas mi agent de investigación empezó a costar cuatro veces lo que había costado la semana anterior. Mismo feature, mismo tráfico, mismo modelo. El resultado se veía bien. La factura no.
Pasé una tarde añadiendo sentencias print antes de admitir lo obvio: no tenía idea de lo que el agent estaba haciendo realmente en ninguna ejecución determinada. Hacía un número variable de llamadas a herramientas, reintentaba en fallos que nunca registré, y ocasionalmente caía en un bucle donde releía el mismo documento tres veces antes de darse por vencido. Nada de eso aparecía en ningún lugar excepto en la factura.
El culpable resultó ser un camino de reintento. Una herramienta inestable estaba expirando, el agent la reintentaba silenciosamente con el contexto completo reenviado cada vez, y una ejecución "exitosa" estaba haciendo nueve llamadas al modelo en lugar de dos. Solo lo encontré porque finalmente anoté lo que hacía cada paso. Esa tarde me costó más que un mes de cualquier herramienta de trazado.
La versión corta: un agent es un bucle que no controlas completamente, y no puedes mejorar un bucle que no puedes observar. Necesitas capturar, por ejecución, qué ocurrió en cada paso, cuántos tokens consumió, cuánto costó, cuánto tiempo tomó y si falló. No necesitas una plataforma para empezar. Necesitas un log.
Qué capturar realmente
Olvida el tooling por un momento. La pregunta es: cuando una ejecución sale mal, ¿qué querrías saber? Trabaja hacia atrás desde ahí y obtienes la lista.
Para cada ejecución de agent, quiero un trace — la ejecución completa — hecho de spans, uno por paso. Cada span debería contener:
- Qué tipo de paso fue — una llamada al modelo, una llamada a herramienta, una recuperación, la invocación del agent de nivel superior.
- Uso de tokens — tokens de entrada y tokens de salida, por separado. Cuestan cantidades diferentes y te dicen cosas diferentes.
- Costo — derivado de los tokens y el precio del modelo. Calcúlalo una vez, guárdalo en el span.
- Latencia — tiempo de inicio y fin. Las ejecuciones lentas y las caras suelen ser problemas distintos.
- Llamadas a herramientas — qué herramienta, qué argumentos, qué devolvió (o cómo falló).
- Resultado — si el paso tuvo éxito, falló o reintentó, y por qué.
- Puntuación Eval — si puntúas la ejecución (deberías), adjunta la puntuación para poder correlacionar calidad con costo.
Ese último punto es el que la gente omite, y es el que más rinde. Un trace te dice qué ocurrió; una puntuación eval te dice si importó. Unirlos te permite hacer la única pregunta que cuenta: ¿la versión cara es realmente mejor? A menudo no lo es.
Nota lo que no está en la lista: texto completo de prompt y respuesta en cada span por defecto. Capturar el contenido de los mensajes es útil para depurar y arriesgado para la privacidad y el costo de almacenamiento. Lo capturo detrás de un flag que puedo activar para una ejecución específica, no siempre activo para todo.
Las convenciones GenAI de OpenTelemetry, como línea base
Antes de inventar tus propios nombres de campo, mira lo que ya existe. OpenTelemetry — el mismo estándar neutral de proveedor que usarías para cualquier trazado de backend — tiene un conjunto de convenciones semánticas GenAI: un vocabulario acordado para nombrar las cosas que acabo de listar.
La parte útil son los nombres de atributos. Un span de llamada al modelo obtiene gen_ai.request.model (qué modelo), gen_ai.usage.input_tokens y gen_ai.usage.output_tokens (el desglose de tokens), gen_ai.response.finish_reasons (por qué paró — stop, tool_calls, etc.), y gen_ai.provider.name para marcar el proveedor. Incluso hay atributos para la contabilidad de caché de prompts — gen_ai.usage.cache_read.input_tokens y gen_ai.usage.cache_creation.input_tokens — que importan mucho una vez que el caché está en juego, porque la entrada cacheada puede ser un orden de magnitud más barata. Los nombres de span siguen el formato {gen_ai.operation.name} {gen_ai.request.model}, y las convenciones a nivel de agent definen operaciones como invoke_agent (la ejecución completa), chat (una sola llamada al modelo) y execute_tool (una invocación de herramienta). (OTel GenAI spans, OTel GenAI observability blog)
Dos advertencias honestas. Primera, a mediados de 2026 estas convenciones todavía están marcadas con estado Development — se están estabilizando pero no están congeladas, así que los nombres de atributos aún pueden cambiar. Recientemente se trasladaron a su propio repositorio, lo que te dice que se están tomando en serio y que todavía están en movimiento. Segunda, no tienes que adoptar la maquinaria OTel para beneficiarte. Incluso si escribes un log JSON plano, usa estos nombres de campo. El día que superes tu log casero y te muevas a un backend real, tus datos ya hablan el idioma. Es la compatibilidad hacia adelante más barata que jamás comprarás.
El log JSONL casero que te da el 80%
Aquí está la configuración que realmente uso para proyectos pequeños. Un archivo JSONL de solo adición, un objeto por span, nombres de campo estilo OTel. Sin servicio, sin SDK, sin cuenta.
import json, time, uuid
PRICES = { # USD per token; see your provider's pricing page
"claude-haiku-4.5": {"in": 1.00/1e6, "out": 5.00/1e6},
"claude-sonnet-4.6": {"in": 3.00/1e6, "out": 15.00/1e6},
}
def log_span(trace_id, kind, model, usage, t0, status, **extra):
p = PRICES.get(model, {"in": 0, "out": 0})
cost = usage["in"] * p["in"] + usage["out"] * p["out"]
record = {
"trace_id": trace_id,
"span_id": uuid.uuid4().hex[:12],
"gen_ai.operation.name": kind, # invoke_agent | chat | execute_tool
"gen_ai.request.model": model,
"gen_ai.usage.input_tokens": usage["in"],
"gen_ai.usage.output_tokens": usage["out"],
"cost_usd": round(cost, 6),
"latency_ms": int((time.time() - t0) * 1000),
"status": status, # ok | error | retry
**extra, # feature, tool_name, eval_score...
}
with open("traces.jsonl", "a") as f:
f.write(json.dumps(record) + "\n")
Cada paso en tu bucle de agent llama a log_span con un trace_id compartido. Etiqueta cada ejecución con el feature que sirvió (feature="research", feature="summarize"). Ahora puedes responder preguntas reales con una sola línea sobre el archivo:
# cost per feature, last run-set
jq -s 'group_by(.feature)[] | {feature: .[0].feature,
usd: (map(.cost_usd) | add)}' traces.jsonl
Eso es todo. Costo por ejecución, atribución por feature, latencia, recuentos de reintentos, y — si escribes tu puntuación eval en el span — calidad versus costo, todo desde un archivo que puedes leer con jq, grep, o DuckDB. El bucle de reintentos que cuadruplicó mi factura habría aparecido aquí como filas status: "retry" apiladas bajo un trace_id, tres minutos de mirar en lugar de una tarde de adivinar.
La tabla de precios es lo único que debes mantener honesto. Los tokens de salida cuestan actualmente unas 5 veces más que los de entrada en los niveles actuales de Claude, y las tarifas de cabecera que usé arriba eran $1/$5 por millón para Haiku 4.5 y $3/$15 para Sonnet 4.6 cuando las comprobé — pero las tarifas cambian y los descuentos por caché/lote mueven el número real mucho, así que trata cualquier tabla como una instantánea y consulta la página de tu proveedor. (Claude API pricing breakdown)
Atribución de costos de tokens: la pregunta que realmente paga
Aquí es donde la disciplina muestra su valor. Una vez que cada span lleva un costo y una etiqueta de feature, puedes dejar de adivinar sobre tu economía unitaria.
Ejecuté esto en mi propio proyecto y aprendí que un feature "gratuito" — un auto-resumen que se disparaba en cada carga de documento — era el 60% de mi gasto en modelos, usado por quizás un décimo de mis usuarios. No era un bug. Era una decisión de producto que había tomado a ciegas. Ver el costo por feature lo convirtió en una decisión que podía tomar intencionalmente: lo convertí en opt-in, y la factura bajó a la mitad sin quejas.
Ese es el punto completo de la atribución. El gasto total es un número sobre el que no puedes actuar. "El feature X cuesta $0.40 por ejecución y el feature Y cuesta $0.02" es un número sobre el que sí puedes. Y cuando lo combinas con puntuaciones eval, obtienes la versión más afilada: costo por ejecución buena. Si el camino caro puntúa igual que el barato, el camino caro es solo una fuga con una bonita descripción.
Construir versus comprar: cuándo el log JSONL no es suficiente
El log casero es genuinamente suficiente durante mucho tiempo. Deja de serlo cuando quieres cosas que un archivo no puede dar barato: una UI para navegar por un trace complejo y desordenado, instrumentación automática para no llamar log_span a mano en todas partes, ejecuciones eval conectadas a CI, o compartir traces con alguien que no vive en un terminal.
En ese punto las herramientas alojadas vale la pena mirarlas, y el resumen honesto es que se agrupan según en qué son mejores, no según cuál es "la mejor":
- Langfuse captura el prompt, respuesta, uso de tokens, latencia y costo por trace, y tiene licencia MIT y es auto-alojable — el siguiente paso natural desde un archivo JSONL si quieres UI pero conservar tus datos. (Fue adquirida por ClickHouse a principios de 2026, lo que vale saber si la estabilidad del proveedor te importa.)
- Arize Phoenix es nativo de OpenTelemetry — construido sobre instrumentación OTel y OpenInference — y corre localmente mediante Docker, con una sólida biblioteca open-source de métricas eval. Si aceptaste las convenciones OTel de arriba, este es el backend de menor fricción.
- LangSmith es el más profundo si ya estás en LangChain/LangGraph, y menos si no lo estás.
- Braintrust se inclina hacia eval-primero, con puertas CI que pueden bloquear un merge cuando la calidad regresa — útil cuando la disciplina de envío importa más que los dashboards.
No voy a coronar a ninguno. La regla de decisión que uso: si quiero un dashboard, miro Langfuse o Phoenix; si quiero puertas eval en CI, miro Braintrust; si estoy profundo en LangChain, LangSmith. Y si no quiero ninguno de los dos todavía, me quedo en el archivo JSONL, porque el archivo ya responde las preguntas que tengo hoy. También vale la pena señalar que los principales agents de codificación — Claude Code, Codex, Copilot — ahora exportan telemetría OTel ellos mismos, así que parte de esto lo obtienes sin escribir ningún pegamento.
Cuándo esto es excesivo
Déjame ser yo quien lo diga: para un prototipo nuevo con tres usuarios y un feature, el trazado completo es procrastinación disfrazada de rigor. Si puedes leer cada ejecución a ojo y tu factura mensual es un error de redondeo, registra los recuentos de tokens y el costo, omite el resto y ve a construir la cosa.
El umbral honesto son dos señales: cuando una factura te sorprende, o cuando un fallo se te escapa. La primera vez que no puedas explicar un pico de costo, o la primera vez que un agent falla de una manera que no puedes reproducir, has cruzado la línea — y la solución es una tarde añadiendo log_span, no una semana levantando una plataforma.
Empieza con el archivo. Usa los nombres de campo OTel para que el hack de hoy sea el camino de migración de mañana. Etiqueta cada span con un feature y un costo. Compra una herramienta el día en que el archivo deje de responder tus preguntas — no antes. El objetivo nunca fue la observabilidad por sí misma. Fue poder mirar una ejecución que salió mal y saber, en tres minutos, exactamente adónde fue el dinero.
