Nadie estaba mirando
Miras el desglose de coste del mes y el pico no está donde esperas. No es el chat del producto, que es lo que ve el cliente. Es el trabajo de fondo: reclasificar el catálogo entero, regenerar resúmenes de tickets antiguos, extraer entidades de los documentos que entraron ayer. Procesos nocturnos que se ejecutan mientras duermes y que pagas a precio de urgencia.
Ese es el detalle incómodo. Estás pagando latencia interactiva por peticiones que nadie va a leer hasta dentro de ocho horas. OpenAI, Anthropic y Google llevan tiempo ofreciendo el mismo trato: manda ese trabajo por una cola diferida y te cobran aproximadamente la mitad. La contrapartida es que la respuesta llega cuando llegue, normalmente dentro de una ventana de 24 horas.
Es de las pocas optimizaciones de coste en las que no cambias de modelo, no recortas contexto y no tocas la calidad. Solo renuncias a la prisa.
Lo que compras cuando renuncias a la prisa
El descuento no es una promoción. Es el precio de un recurso distinto.
La inferencia interactiva obliga al proveedor a reservar capacidad para responderte ahora mismo, con un objetivo de latencia por token. Eso implica mantener capacidad de GPU disponible incluso en horas valle. Un trabajo por lotes no tiene esa restricción: el planificador lo encaja donde hay hueco, agrupa peticiones para llenar la GPU y prioriza el rendimiento agregado sobre el tiempo de respuesta individual. Ese hueco es más barato de producir y parte del ahorro te llega a ti.
Conviene distinguirlo del batching continuo del servidor de inferencia, que ya ocurre por debajo en cualquier endpoint moderno. Aquí hablamos de otra capa: una cola de trabajos con su propio ciclo de vida, sus propios límites y su propio formato de entrada.
La pregunta que decide: ¿quién espera al otro lado?
No es una decisión técnica, es una decisión de producto. La única pregunta útil es si hay una persona con la vista fija en un cursor parpadeando.
| Trabajo | ¿Alguien espera? | Dónde va |
|---|---|---|
| Chat del producto | Sí, en tiempo real | Síncrono, con streaming |
| Autocompletado, sugerencias | Sí, en milisegundos | Síncrono, modelo pequeño |
| Clasificar tickets del día | No, se revisa mañana | Lotes |
| Reindexar el catálogo | No, es un pipeline | Lotes |
| Evaluaciones sobre un dataset | No, miras el informe | Lotes |
| Enriquecer registros al importarlos | Normalmente no | Lotes |
| Resumen tras subir un fichero | Sí, hay una barra de progreso | Síncrono |
Hay una zona gris interesante: tareas que hoy son síncronas porque nadie se ha planteado lo contrario. Un resumen que se genera al abrir la ficha del cliente puede precalcularse por la noche para las fichas que se abren a diario. Cambias una llamada cara en el momento crítico por una llamada barata anticipada y, de paso, la vista carga al instante.
Cómo se escribe un lote, sin sorpresas
El patrón es el mismo en los tres proveedores: preparas un fichero JSONL con una petición por línea, cada una con un identificador tuyo, lo subes, obtienes un identificador de trabajo y consultas su estado hasta que termina.
La parte que decide si esto te va a doler o no es el identificador. Ese campo es lo único que conecta la respuesta con la fila de tu base de datos, y la tentación de usar un contador o un UUID aleatorio sale cara al primer reintento.
import hashlib, json
def custom_id(doc_id: str, prompt_version: str) -> str:
# Determinista: el mismo documento con el mismo prompt genera
# siempre la misma clave. Reenviar un lote no duplica trabajo
# y las respuestas se pueden reconciliar sin tabla auxiliar.
semilla = f"{doc_id}:{prompt_version}"
return hashlib.sha256(semilla.encode()).hexdigest()[:32]
def linea(doc: dict, prompt_version: str = "v3") -> str:
return json.dumps({
"custom_id": custom_id(doc["id"], prompt_version),
"method": "POST",
"url": "/v1/chat/completions",
"body": {
"model": "gpt-5.6-mini",
"max_tokens": 512,
"messages": [
{"role": "system", "content": SISTEMA},
{"role": "user", "content": doc["texto"][:20_000]},
],
},
}, ensure_ascii=False)
with open("lote.jsonl", "w", encoding="utf-8") as f:
for doc in documentos:
f.write(linea(doc) + "\n")
Dos detalles que se agradecen más tarde. prompt_version dentro de la clave hace
que cambiar el prompt invalide los resultados anteriores de forma natural, sin
borrar nada. Y truncar el texto de entrada por adelantado evita que una fila
anómala reviente el trabajo entero cuando ya lleva seis horas en cola.
Lo que se rompe
La cola de lotes es fiable, pero falla de formas distintas a las que tienes domesticadas en un endpoint síncrono.
Los fallos son parciales. Un lote de 50.000 peticiones puede terminar con 49.980 correctas y 20 errores. El fichero de salida trae el detalle por línea, y tu código tiene que leerlo. Si tratas el trabajo como “completado” y pasas página, esos 20 registros se quedan sin procesar en silencio.
El orden no se conserva. Las respuestas llegan como salgan. Sin el identificador propio no hay forma de reconstruir la correspondencia.
La ventana puede expirar. Si el trabajo no termina en 24 horas, lo que hay se devuelve y el resto se cancela. Con lotes muy grandes es un riesgo real; partirlos en trozos de unos pocos miles de peticiones da además granularidad para reprocesar solo lo que falló.
Hay límites de cola por organización. Se cuentan en tokens encolados, no en número de trabajos. Un pipeline que empuja lotes sin control acaba rechazado a mitad de noche.
El coste no está garantizado. El descuento aplica a los tokens del trabajo, pero si tu proceso reenvía el lote entero cada vez que algo va mal, el ahorro se evapora. Por eso importa el identificador determinista.
Un plan de migración honesto
No hace falta rediseñar nada para empezar. El camino corto es este:
- Ordena el gasto de inferencia del último mes por proceso, no por modelo.
- Marca los procesos donde ninguna persona espera la respuesta.
- Coge el más caro de esos y muévelo a lotes con claves deterministas.
- Deja el camino síncrono como reserva para el reproceso de los fallos parciales.
- Mide la factura del mes siguiente antes de mover el segundo.
Ese paso cuatro es el que suele olvidarse y el que hace que el sistema sea mantenible. La ruta de lotes cubre el volumen, la ruta síncrona cubre la cola de excepciones. Son el mismo prompt y el mismo modelo con dos perfiles de servicio distintos, no dos implementaciones.
La conclusión es poco épica y bastante rentable: antes de buscar un modelo más barato o de pelearte con el prompt para ahorrar tokens, comprueba cuánto de tu factura está pagando una urgencia que nadie pidió.