22 lecturasAgentes de IA · Observabilidad · Medición · Costes

Medir lo que gasta un agente es un problema de contabilidad

Medir lo que gasta un agente es un problema de contabilidad

Llevaba meses trabajando con agentes de IA sin saber lo que costaban. No por falta de datos: por exceso.

Cada herramienta deja su rastro en disco. Claude Code escribe un .jsonl por sesión. Cursor guarda una base SQLite de un gigabyte. Codex escribe rollouts por día. OpenCode reparte un JSON por entidad. Los cuatro registran cosas distintas, con nombres distintos, y ninguno responde la pregunta simple: cuánto.

Así que construí el lector. Y lo interesante no fue la parte de IA —no hay ninguna: son ficheros y SQL— sino que medir consumo resultó ser el mismo problema de siempre. Las cuatro trampas que me comí son las que cualquiera se come al contar dinero.

Trampa 1: el acumulado no se suma

Codex escribe un evento token_count por turno. Dentro va total_token_usage, con sus input_tokens, output_tokens y demás. La lectura natural es recorrer los eventos y sumarlos.

Está mal. Ese total es acumulado de la sesión, no del turno. Sumarlo multiplica el consumo por el número de turnos.

turno 1 → total_token_usage.input = 1.000
turno 2 → total_token_usage.input = 3.000   (no 2.000: es el acumulado)

sumando  → 4.000   ✗
máximo   → 3.000   ✓

No es una hipótesis: es un bug real que le explotó a ccusage, con una inflación reportada de 91×. Un contador que se equivoca por un factor de 91 no se detecta mirando la cifra final —parece grande, y las facturas de IA lo son— sino comparándola contra otra fuente.

El paralelo con un ledger es exacto: un saldo materializado no se suma a los asientos que lo produjeron. Es su resultado, no un sumando más.

Trampa 2: el input reportado ya incluye la caché

El segundo error es más silencioso. Los proveedores reportan input_tokens y, aparte, cached_input_tokens. Es tentador sumarlos para saber "cuántos tokens entraron".

El cacheado ya está dentro del input. Sumarlos cuenta dos veces la parte más barata, y encima la infla justo donde más volumen hay: en mis propios datos, la caché es el 98 % de todos los tokens leídos. Un error del 2 % en el sitio equivocado no importa; un doble conteo en el 98 % te cambia el resultado entero.

// el input reportado ya incluye lo cacheado: se resta o se cuenta dos veces
input: Math.max(0, reportado.input_tokens - reportado.cached_input_tokens),

Trampa 3: la caché no cuesta lo mismo en todos los proveedores

Aquí es donde una fórmula "genérica" se rompe sola.

  • Anthropic cobra la escritura de caché con recargo —1,25× a cinco minutos, 2× a una hora— y la lectura a 0,1×.
  • OpenAI no cobra extra la escritura: va a precio de input normal. Solo la lectura es más barata.

Una única fórmula da mal en uno de los dos, siempre. Y no de forma inocente: si tu carga está dominada por escrituras de caché, el error va en la dirección de subestimar.

Por eso las tarifas viven en un JSON, nunca en el código, y cada proveedor lleva su fecha de verificación y el enlace a la página de la que salió. Un precio en el código es un precio que nadie va a volver a comprobar.

Trampa 4: el filtro tiene que cortar mensajes, no sesiones

Esta la encontré comparando la exportación contra la pantalla que la generaba, y es la más fácil de escribir mal.

Filtras "del 1 al 10 de agosto". La implementación natural es buscar las sesiones de ese rango y sumar su consumo. Pero una sesión que empieza el 5 y termina el 17 toca el rango, así que entra entera, con los quince días de trabajo que no pediste.

En mi base, con el mismo filtro:

mensajes DENTRO del rango                33.154
mensajes de sesiones que TOCAN el rango  47.766     → +65 % de coste imputado

La exportación decía un número y el encabezado de la propia pantalla decía otro. Los dos venían del mismo código, escrito con dos criterios distintos con semanas de diferencia.

El arreglo es cortar por mensaje y recalcular las fechas de cada fila con el mínimo y el máximo que quedan dentro de la ventana. Y como esto es exactamente el tipo de cosa que vuelve a romperse, el test que lo fija no comprueba la implementación: comprueba el invariante.

// una sesión con dos mensajes dentro ($3) y uno fuera ($40)
expect(total.cost).toBeCloseTo(3);                      // no 43
expect(suma(filasExportadas)).toBeCloseTo(total.cost);  // export == pantalla

Un test que dice "la exportación tiene que sumar exactamente lo que muestra la pantalla" sobrevive a que alguien reescriba la consulta. Uno que verifique el WHERE no.

La regla que sostiene todo lo demás: no inventar un número

Es la única regla que no negocio, y de ella salen las decisiones incómodas.

Si una tarifa no está verificada contra la página del proveedor, el modelo se marca UNVERIFIED, su coste cuenta como cero y se avisa en pantalla. Da peor aspecto que estimarlo. También es lo único honesto: un precio inventado se propaga a todos los agregados y nadie lo vuelve a cuestionar.

Cursor no registra tokens por petición —todos vienen en cero—, así que sus cifras se quedan fuera del dinero. Tiene su propia pestaña, donde se mide lo que sí guarda: qué porcentaje del código commiteado escribió la IA, por rama y por commit. Y ningún campo une un identificador de proceso con su sesión, así que la pantalla dice cuántos procesos están vivos y, aparte, qué sesiones escribieron en los últimos diez minutos. No los cruza.

Una celda vacía es información. Una invención plausible es un bug que nadie va a pillar.

Solo lectura, y de verdad

El medidor lee las carpetas de las herramientas. Eso obliga a una segunda regla: no escribir nunca en ellas, y que no sea una promesa del README sino algo que el código impida.

Todo acceso pasa por una función que abre en O_RDONLY; cualquier intento de escritura sobre una raíz ajena lanza excepción, y hay test. Una denylist impide siquiera leer credenciales o ficheros .env.

El caso interesante es Cursor: mantiene sus bases SQLite en modo WAL y las escribe mientras la aplicación está abierta. Abrirlas en modo lectura no basta — SQLite querría crear un fichero -shm junto al original, y eso ya es escribir en carpeta ajena. Se copian antes de leerlas. El test crea una base WAL de juguete y comprueba que el directorio de origen queda byte a byte igual después de leer.

Lo que me llevo

Nada de esto es específico de la IA. Son las mismas cuatro preguntas de cualquier sistema que cuenta dinero: de dónde vino el número, si es un acumulado o un incremento, si estoy contando algo dos veces, y qué pasa cuando el dato no existe.

La diferencia es que en un ledger esas preguntas ya te las hicieron. En un dominio nuevo hay que acordarse de hacérselas uno mismo.

El medidor es local, no tiene dependencias, no envía nada a ningún sitio y está publicado con licencia MIT: github.com/ASanchezT85/agent-engine.

Compartir

Comentarios

Todavía no hay comentarios. Empieza tú.

No se publica. Solo lo uso para responderte.

Los comentarios se revisan antes de publicarse.