Cuotas por usuario en agentes IA: rate limiting real
Tienes un agente en producción con diez clientes activos. Nueve de ellos lo usan de forma razonable. El décimo lleva dos horas haciendo consultas en bucle desde un script que alguien en su equipo dejó corriendo sin supervisión. Cuando llegas al lunes y revisas la factura de Anthropic, ese cliente ha consumido el 73% del gasto mensual que habías estimado para todos.
No hay nada malo en el agente. No hay ningún bug. El modelo funcionó exactamente como debía. El problema es que construiste el agente sin una capa de cuotas.
Por qué el rate limiting del proveedor no te protege
Cuando lees la documentación de Anthropic o OpenAI, ves que tienen límites de uso: tokens por minuto, requests por minuto, tokens por día. Es fácil asumir que esos límites son suficientes. No lo son.
Los límites del proveedor operan en tu cuenta. No en tu usuario. Si un tenant de tu producto consume 800.000 tokens en un día, el proveedor no lo sabe — solo ve un request más de tu API key. Quien absorbe el coste eres tú.
Lo que necesitas es una capa de control en tu código que responda a estas tres preguntas antes de cada llamada al LLM:
- ¿Cuántos tokens ha consumido este tenant en la ventana actual?
- ¿Está cerca del límite? → avisar
- ¿Lo ha superado? → bloquear
Eso es un sistema de cuotas. Y la mayoría de agentes en producción no lo tienen hasta que pasa el primer incidente de factura.
La arquitectura: Redis como contador distribuido
El patrón estándar usa Redis como almacén de contadores. El motivo es simple: Redis tiene operaciones atómicas de incremento que funcionan correctamente en entornos donde tienes múltiples instancias del servidor (Vercel Edge, contenedores, etc.). Un contador en memoria en un proceso único no te protege si tienes escala horizontal.
La estructura del contador es mínima:
// lib/quota.ts
import { Redis } from '@upstash/redis';
const redis = new Redis({
url: process.env.UPSTASH_REDIS_URL!,
token: process.env.UPSTASH_REDIS_TOKEN!,
});
const DAILY_TOKEN_LIMIT = 100_000;
const SOFT_LIMIT_RATIO = 0.8; // aviso al 80%
export class QuotaExceededError extends Error {
constructor(public tenantId: string, public used: number) {
super(`Quota exceeded for ${tenantId}: ${used} / ${DAILY_TOKEN_LIMIT}`);
}
}
export async function checkQuota(tenantId: string): Promise<{ used: number; limit: number }> {
const key = `quota:${tenantId}:${getDayKey()}`;
const used = Number(await redis.get(key) ?? 0);
if (used >= DAILY_TOKEN_LIMIT) {
throw new QuotaExceededError(tenantId, used);
}
if (used > DAILY_TOKEN_LIMIT * SOFT_LIMIT_RATIO) {
// No bloquea — solo registra para notificación asíncrona
await notifyQuotaWarning(tenantId, used, DAILY_TOKEN_LIMIT);
}
return { used, limit: DAILY_TOKEN_LIMIT };
}
export async function recordTokenUsage(tenantId: string, tokens: number): Promise<void> {
const key = `quota:${tenantId}:${getDayKey()}`;
const pipeline = redis.pipeline();
pipeline.incrby(key, tokens);
pipeline.expire(key, 86400 * 2); // TTL generoso para histórico
await pipeline.exec();
}
function getDayKey(): string {
return new Date().toISOString().slice(0, 10); // "2026-09-18"
}
El getDayKey() define la ventana temporal como un día natural UTC. Puedes cambiarlo por ventanas de 24 horas deslizantes si necesitas más precisión, pero para la mayoría de casos el reset diario es suficiente y más fácil de explicar a los usuarios.
El wrapper del agente: cuotas antes y después
El patrón de integración tiene dos puntos de control: uno antes de llamar al LLM (comprueba si puede continuar) y uno después (registra el consumo real):
// lib/agent-with-quota.ts
import Anthropic from '@anthropic-ai/sdk';
import { checkQuota, recordTokenUsage, QuotaExceededError } from './quota';
const anthropic = new Anthropic();
export async function runAgentWithQuota(tenantId: string, userMessage: string) {
// 1. Comprobar quota ANTES de la llamada
await checkQuota(tenantId);
const response = await anthropic.messages.create({
model: 'claude-sonnet-4-6',
max_tokens: 1024,
messages: [{ role: 'user', content: userMessage }],
});
// 2. Registrar uso REAL post-llamada
const tokensUsed = response.usage.input_tokens + response.usage.output_tokens;
await recordTokenUsage(tenantId, tokensUsed);
return response;
}
El check previo usa una estimación implícita: si el tenant ya está en el límite, la llamada no se hace. Si está por debajo, la llamada se ejecuta y se registra el uso real. Esta secuencia es correcta para la gran mayoría de agentes — la alternativa de estimar tokens antes de la llamada añade complejidad sin mucho beneficio en la mayoría de casos.
Manejo del error en el Route Handler
El QuotaExceededError debe traducirse en una respuesta HTTP con el código correcto (429 Too Many Requests) y un mensaje que el cliente pueda mostrar al usuario:
// app/api/agent/route.ts
import { QuotaExceededError } from '@/lib/quota';
export async function POST(req: Request) {
const { message, tenantId } = await req.json();
try {
const result = await runAgentWithQuota(tenantId, message);
return Response.json({ text: result.content[0] });
} catch (err) {
if (err instanceof QuotaExceededError) {
return Response.json(
{ error: 'Has alcanzado tu límite de uso diario. Se resetea a las 00:00 UTC.' },
{ status: 429 }
);
}
throw err;
}
}
El mensaje de error debe ser informativo para el usuario final, no un log técnico. "Has alcanzado tu límite" es mucho mejor que "QuotaExceededError: quota:tenant-abc:2026-09-18".
Soft limit: el aviso que salva relaciones
El hard limit bloquea. El soft limit avisa con tiempo para que el tenant pueda adaptar su uso antes de llegar al muro.
La notifyQuotaWarning puede ser tan simple como un log estructurado o tan complejo como un email o un mensaje en el dashboard del cliente. El mínimo viable:
async function notifyQuotaWarning(tenantId: string, used: number, limit: number): Promise<void> {
const pct = Math.round((used / limit) * 100);
// Evitar spam: solo notificar una vez por ventana
const warningKey = `quota:warned:${tenantId}:${getDayKey()}`;
const alreadyWarned = await redis.get(warningKey);
if (alreadyWarned) return;
await redis.setex(warningKey, 86400, '1');
// Aquí: enviar email, webhook, Slack, lo que uses en tu stack
console.log(JSON.stringify({
event: 'quota_warning',
tenantId,
used,
limit,
percentage: pct,
timestamp: new Date().toISOString(),
}));
}
El warningKey con TTL evita enviar el aviso cien veces. Una notificación por día por tenant es suficiente.
Cuotas por plan: no todos los usuarios son iguales
Si tienes planes de precios distintos, el límite no puede ser el mismo para todos. La forma más limpia es cargar el límite desde tu base de datos en función del plan del tenant:
async function getLimitForTenant(tenantId: string): Promise<number> {
const { plan } = await db.tenants.findUnique({ where: { id: tenantId } });
const limits: Record<string, number> = {
free: 20_000,
pro: 100_000,
enterprise: 500_000,
};
return limits[plan] ?? limits.free;
}
Este límite se puede cachear en Redis también con un TTL corto para no ir a la base de datos en cada request.
El impacto real
En un proyecto de integración de agentes IA con doce tenants activos, la distribución de consumo antes de implementar cuotas era: el 15% de los tenants generaba el 68% del coste total. Tres tenants. Ninguno era consciente de que tenía un proceso mal configurado consumiendo tokens en background.
Después de añadir cuotas con soft limit al 80%:
- Los tres tenants recibieron un aviso antes de llegar al límite
- Dos lo ajustaron solos al revisar sus integraciones
- El tercero necesitó cambiar de plan
- El coste mensual bajó un 31% sin tocar el agente
El patrón no es complejo. Es el tipo de infraestructura que en AI Driven Development añadimos en el primer sprint de cualquier producto con LLMs, porque el coste del olvido es siempre mayor que el coste de implementarlo desde el principio.
Si tienes un agente en producción y no sabes cuánto consume cada uno de tus usuarios, esa es la señal. Escríbeme por WhatsApp — lo primero que hacemos es revisar la arquitectura de costes antes de tocar nada más.