Tutoriales de Laravel • • 5-8 minutos

Coste de la IA en Laravel: medir el gasto por tarea y cachear el prompt que se repite

Diego Cortés
Diego Cortés
Full Stack Developer & SEO Specialist
Compartir:
Coste de la IA en Laravel: medir el gasto por tarea y cachear el prompt que se repite
Imagen generada con IA

La factura de IA casi nunca sube porque el modelo sea caro, sino porque el mismo prompt de 8.000 tokens viaja en cada una de las 20.000 llamadas del mes. Y no se nota porque casi nadie guarda el usage que devuelve la API.

Por qué la factura sube sin que nadie toque el código

Entrada contra salida: quién pesa de verdad

Los tokens de salida cuestan entre tres y cinco veces más que los de entrada por unidad, y la intuición se queda ahí. Pero la entrada se repite: un prompt de sistema de 8.000 tokens enviado 20.000 veces son 160 millones de tokens de entrada al mes, y la factura la hace el volumen, no el precio unitario.

El prompt fijo que se reenvía 20.000 veces

Instrucciones, ejemplos, rúbrica, glosario, política de la empresa. Todo eso es idéntico en cada llamada y se paga como entrada nueva cada vez. La caché de prompt existe justo para eso, y solo funciona si el prefijo es idéntico byte a byte.

Lo que no se mide no se puede arreglar

Cuando la factura llega alta, la pregunta razonable es qué tarea se la comió. Sin registro por llamada esa pregunta no tiene respuesta: queda estimar por caracteres y bajar el termostato a ciegas. El orden de este tutorial es ese: primero el contador, después el precio, después la caché y al final el freno. La otra salida, la de no pagar por token, la cubrimos en IA local con Ollama; aquí hablamos de cuando sí pagas.

Guardar el usage de cada llamada

La tabla ai_calls y la columna que casi nadie pone: task

Sin la columna task el registro sirve para saber cuánto gastaste, no para saber dónde. Es la etiqueta de la habitación en el medidor.

Schema::create('ai_calls', function (Blueprint $table) {
    $table->id();
    $table->string('task', 64);                    // resumen, clasificacion, agente-ventas
    $table->string('provider', 32);                // openai, anthropic, gemini
    $table->string('model', 64);
    $table->unsignedBigInteger('input_tokens')->default(0);
    $table->unsignedBigInteger('cached_tokens')->default(0);
    $table->unsignedBigInteger('cache_write_tokens')->default(0);
    $table->unsignedBigInteger('output_tokens')->default(0);
    $table->decimal('cost_usd', 10, 6)->default(0);
    $table->unsignedInteger('latency_ms')->default(0);
    $table->foreignId('user_id')->nullable()->constrained()->nullOnDelete();
    $table->timestamps();
    $table->index(['task', 'created_at']);
    $table->index('created_at');
});

Los nombres de los campos cambian según el proveedor

Cada proveedor devuelve el desglose con su propio vocabulario. Normalizar en un solo sitio evita repetir el mismo condicional en diez controladores. En OpenAI, usage.prompt_tokens_details.cached_tokens (en la Responses API aparece como input_tokens_details). En Anthropic, cache_read_input_tokens y cache_creation_input_tokens. En Gemini, usage_metadata.cached_content_token_count.

class AiUsage
{
    public static function fromOpenAi(array $usage): array
    {
        return [
            'input_tokens' => $usage['prompt_tokens'] ?? $usage['input_tokens'] ?? 0,
            'cached_tokens' => $usage['prompt_tokens_details']['cached_tokens']
                ?? $usage['input_tokens_details']['cached_tokens'] ?? 0,
            'cache_write_tokens' => 0,
            'output_tokens' => $usage['completion_tokens'] ?? $usage['output_tokens'] ?? 0,
        ];
    }

    public static function fromAnthropic(array $usage): array
    {
        $write = $usage['cache_creation_input_tokens'] ?? 0;
        $read  = $usage['cache_read_input_tokens'] ?? 0;

        return [
            'input_tokens'       => ($usage['input_tokens'] ?? 0) + $write + $read,
            'cached_tokens'      => $read,
            'cache_write_tokens' => $write,
            'output_tokens'      => $usage['output_tokens'] ?? 0,
        ];
    }

    public static function fromGemini(array $meta): array
    {
        return [
            'input_tokens'       => $meta['promptTokenCount'] ?? 0,
            'cached_tokens'      => $meta['cachedContentTokenCount'] ?? 0,
            'cache_write_tokens' => 0,
            'output_tokens'      => $meta['candidatesTokenCount'] ?? 0,
        ];
    }
}

El error de sumar mal los tokens cacheados en Anthropic

En Anthropic, cache_read_input_tokens y cache_creation_input_tokens no reemplazan a input_tokens: se suman. Si los tratas como un desglose sustitutivo, el total de entrada sale corto y el informe miente con toda la confianza del mundo. Por eso input_tokens normalizado es la suma de los tres campos.

Una clase de servicio que registra, no que adivina

El registro va al final de la llamada, con el tiempo medido y el usuario cuando exista. Nada de estimar por caracteres: el usage que devuelve la API es el dato.

class AiRecorder
{
    public function store(string $task, string $provider, string $model, array $usage, int $latencyMs, ?int $userId = null): void
    {
        $cost = AiPricing::cost($provider.':'.$model, $usage);

        DB::table('ai_calls')->insert($usage + [
            'task' => $task,
            'provider' => $provider,
            'model' => $model,
            'latency_ms' => $latencyMs,
            'cost_usd' => $cost,
            'user_id' => $userId,
            'created_at' => now(),
            'updated_at' => now(),
        ]);
    }
}

Convertir tokens en dinero sin mentir

La tabla de precios versionada: añadir, no editar

Los precios cambian varias veces al año. Si están escritos a mano en el controlador, cada cambio convierte el histórico en ficción: los costes de enero se recalculan con el precio de junio. La tabla vive en config/ai_pricing.php con una fecha de vigencia, y cuando el proveedor cambia el precio se añade una fila nueva.

return [
    // Valores de EJEMPLO con fecha. Antes de usarlos en producción,
    // copiar los vigentes de la tabla de precios del proveedor.
    'openai:modelo-ejemplo' => [
        'effective_from' => '2026-06-01',
        'input' => 0.40, 'cached_input' => 0.10, 'cache_write' => 0.0, 'output' => 1.60,
    ],
    'anthropic:modelo-ejemplo' => [
        'effective_from' => '2026-06-01',
        'input' => 3.00, 'cached_input' => 0.30, 'cache_write' => 3.75, 'output' => 15.00,
    ],
];

La fórmula del coste por llamada, paso a paso

La entrada nueva es la que no viene de caché ni se escribió en ella. Con eso, la cuenta queda en una línea por concepto:

class AiPricing
{
    public static function cost(string $key, array $u): float
    {
        $p = config('ai_pricing.'.$key);
        if (! $p) {
            return 0.0; // precio desconocido: se registra 0 y se avisa por log
        }

        $freshIn = max(0, $u['input_tokens'] - $u['cached_tokens'] - $u['cache_write_tokens']);

        $usd = $freshIn            * $p['input']
             + $u['cached_tokens'] * $p['cached_input']
             + $u['cache_write_tokens'] * $p['cache_write']
             + $u['output_tokens'] * $p['output'];

        return round($usd / 1_000_000, 6);
    }
}

Por qué los precios no van en el controlador

Porque el controlador no es el sitio donde se audita una factura. Con la tabla en configuración, cambiar un precio es un cambio revisable y el histórico queda intacto; con los números repartidos por el código, el primer cambio de tarifa obliga a buscar y reemplazar en cinco archivos.

El comando que contesta "qué tarea se lo comió"

Coste total, coste medio y p95 de latencia

protected $signature = 'ai:costes {--desde=} {--hasta=} {--tarea=}';

public function handle(): int
{
    $desde = $this->option('desde') ? Carbon::parse($this->option('desde')) : now()->startOfMonth();
    $hasta = $this->option('hasta') ? Carbon::parse($this->option('hasta'))->endOfDay() : now();

    $filas = DB::table('ai_calls')
        ->selectRaw('task, COUNT(*) as llamadas, SUM(input_tokens) as entrada,
            SUM(cached_tokens) as cacheados, SUM(output_tokens) as salida,
            SUM(cost_usd) as coste, AVG(cost_usd) as coste_medio')
        ->whereBetween('created_at', [$desde, $hasta])
        ->when($this->option('tarea'), fn ($q, $t) => $q->where('task', $t))
        ->groupBy('task')
        ->orderByDesc('coste')
        ->get();

    $this->table(
        ['Tarea', 'Llamadas', 'Entrada', 'Cacheados', 'Salida', 'Coste USD', 'Medio USD', 'Hit rate', 'p95 ms'],
        $filas->map(fn ($f) => [
            $f->task,
            number_format($f->llamadas),
            number_format($f->entrada),
            number_format($f->cacheados),
            number_format($f->salida),
            number_format($f->coste, 2),
            number_format($f->coste_medio, 4),
            $f->entrada > 0 ? number_format($f->cacheados / $f->entrada * 100, 1).'%' : '0%',
            $this->p95($f->task, $desde, $hasta),
        ])->all()
    );

    return self::SUCCESS;
}

private function p95(string $task, Carbon $desde, Carbon $hasta): int
{
    $base = DB::table('ai_calls')->where('task', $task)->whereBetween('created_at', [$desde, $hasta]);
    $total = $base->count();
    if ($total === 0) {
        return 0;
    }

    return (int) $base->orderBy('latency_ms')
        ->skip(min((int) floor($total * 0.95), $total - 1))
        ->value('latency_ms');
}

El denominador que falta: coste por documento, por pedido, por conversación

Un total de 400 dólares no dice si el negocio escala o se hunde. Dividido por 2.000 documentos son 0,20 dólares cada uno; dividido por 40 pedidos son 10 dólares, y eso probablemente no cuadre. Añade a la tabla una columna unit_id o cuenta las unidades por tarea en otra consulta: el coste sin denominador es un número sin significado.

Leer el informe sin engañarse con la media

La media esconde la cola. Una tarea con 200 llamadas de un centavo y 3 de dos dólares tiene una media tranquila y una cola carísima; el p95 y el máximo la delatan. Y ojo con las filas de cost_usd = 0: casi siempre significan un modelo que falta en la tabla de precios, no una llamada gratis.

Cómo cachea cada proveedor

OpenAI: automático, con mínimo de tokens

No hay que configurar nada: el proveedor detecta el prefijo idéntico de la petición y descuenta la lectura cacheada alrededor de una décima parte del precio de entrada, con un mínimo del orden de 1.024 tokens. La escritura no se cobra aparte. Si el prefijo es corto, la caché simplemente no entra en juego.

Anthropic: breakpoints explícitos y dos TTL

Aquí se marca con cache_control hasta cuatro puntos de corte. El TTL por defecto es de 5 minutos, con opción de 1 hora. La lectura ronda 0,1x, y la escritura se cobra por encima del precio normal de entrada: en la ventana de 5 minutos, alrededor de 1,25x con los datos que circulan hoy; para la de 1 hora las fuentes discrepan, así que conviene leer la tabla vigente del proveedor antes de poner un multiplicador en el código.

Gemini: umbral alto y caché explícita

La caché implícita de Gemini exige un prefijo bastante más grande que el de OpenAI (del orden de 4.096 tokens) y además existe la caché explícita con cachedContents, que se crea por adelantado y tiene su propia ventana de vida. El campo de lectura es cached_content_token_count dentro de usage_metadata.

Reordenar el prompt para que la caché acierte

Lo fijo primero, lo variable al final

La regla es una sola y no admite excepciones: el prefijo tiene que ser idéntico byte a byte. Instrucciones, ejemplos y documentos de referencia primero; la pregunta del usuario y los datos del pedido al final. Mismo modelo, mismas herramientas, mismo orden de claves en el JSON.

// Anthropic: dos bloques estables marcados y el variable al final
$payload = [
    'model' => 'modelo-ejemplo',
    'max_tokens' => 900,
    'system' => [
        ['type' => 'text', 'text' => $instrucciones, 'cache_control' => ['type' => 'ephemeral']],
        ['type' => 'text', 'text' => $ejemplos, 'cache_control' => ['type' => 'ephemeral']],
        ['type' => 'text', 'text' => $documento],   // cambia en cada llamada
    ],
    'messages' => [['role' => 'user', 'content' => $pregunta]],
];

Las cinco cosas que rompen el prefijo sin que te des cuenta

Una fecha u hora al principio del prompt. El nombre del usuario o su id dentro del bloque fijo. El id del pedido. Un json_encode con claves en orden distinto cada vez. Y un resultado aleatorio o un timestamp de "última actualización" que se recalcula en cada llamada. Cualquiera de los cinco deja el hit_rate en cero y la factura igual.

Medir el hit rate antes y después con 100 tareas reales

hit_rate = cached_tokens / input_tokens. Si en OpenAI es cero, o el prefijo no llega al mínimo o algo cambia dentro. Si es cero en Anthropic, el breakpoint está mal puesto o el TTL caducó entre llamadas. Mide con cien tareas reales, no con una de prueba: la de prueba suele ser la única con el prompt limpio.

Un límite de gasto que corta antes de la llamada

Presupuesto por tarea y por usuario

El límite se guarda por tarea y por usuario, con la ventana del mes, y se consulta justo antes de enviar. Si parte del gasto va con la suscripción y sus créditos de API incluidos, como contamos en los créditos de API de ChatGPT, deja esos consumos fuera de la suma o la cuenta saldrá doble.

class AiBudget
{
    public function guard(string $task, ?int $userId, float $limiteUsd): void
    {
        $gasto = DB::table('ai_calls')
            ->where('task', $task)
            ->when($userId, fn ($q) => $q->where('user_id', $userId))
            ->whereBetween('created_at', [now()->startOfMonth(), now()])
            ->sum('cost_usd');

        if ($gasto >= $limiteUsd) {
            throw new AiBudgetExceeded($task, (float) $gasto, $limiteUsd);
        }
    }
}

Las tres respuestas cuando se agota: degradar, servir caché o decirlo

Degradar a un modelo más barato (medido antes con una evaluación, no supuesto). Servir una respuesta ya guardada de la aplicación con Cache::remember, que evita la llamada entera. O devolver un mensaje honesto al usuario. Un límite que se comprueba cuando la factura ya llegó no es un límite: es un lamento.

Reintentos, idempotencia y llamadas que se pagan dos veces

Un reintento con backoff sobre una llamada que sí llegó y solo tardó en responder se paga dos veces. La solución es una clave idempotente por tarea (un hash de los parámetros) y un registro de "en curso" antes de enviar: si la clave ya existe y está en curso, no se reenvía. Y las llamadas de IA pesadas van a una cola, como cualquier trabajo lento: lo contamos en colas y jobs en Laravel 13.

Los siete errores que inflan la factura

Contar tokens con una librería de turnos en vez de leer el usage real. Estimar por caracteres. Poner los precios a mano en el controlador. Cachear el prompt de una tarea que cambia en cada llamada. Confundir la caché de prompt del proveedor con el Cache::remember de la aplicación. Borrar los registros antiguos "para que la tabla no crezca", que son la única auditoría de la factura. Y comprobar el presupuesto después de gastar.

Conclusión

Registrar el usage con la tarea a la que pertenece, ponerle precio con una tabla versionada y ordenar el prompt para que la parte fija se cachee: con esas tres piezas la factura pasa de sorpresa a decisión. Si además el límite corta antes de la llamada, el gasto deja de ser un accidente y se vuelve una variable que controlas. En el blog seguimos publicando este tipo de tutoriales con código que corre en producción.

Categorías