Migrar miles de candidatos a través de un modelo de lenguaje cuesta lo que cuesta. O la mitad. En Selenios recortamos hasta un 50% de la factura de tokens cada vez que procesamos datos en bulk, con el mismo modelo, los mismos prompts y los mismos resultados. La técnica se llama batch inference, está en la lista de precios de todos los grandes proveedores, y la mayoría de los equipos con los que hablo todavía no la usa para el único tipo de carga donde es plata regalada. Esto es qué es, por qué sale la mitad, qué te cobra a cambio, y el checklist que escribimos después de aprenderlo a los golpes.
Qué es batch inference
En tiempo real, cada request espera su respuesta. En batch no hay conversación. Escribís un archivo con un objeto JSON por línea, miles de prompts, y lo subís a un bucket. Le decís al proveedor "procesá esto cuando puedas". Un rato después aparece otro archivo con todas las respuestas. Sin servidores tuyos esperando en sockets, sin lógica de reintentos, sin una cola que tengas que operar.
En Amazon Bedrock, que es donde lo corremos, el input es un archivo JSONL en S3 donde cada línea lleva un identificador y el mismo body que le mandarías a InvokeModel:
{"recordId":"a1f3c9e2","modelInput":{ …the InvokeModel body for cv 1… }}
{"recordId":"9c02b7d4","modelInput":{ …the InvokeModel body for cv 2… }}
…
{"recordId":"0d41f8a7","modelInput":{ …the InvokeModel body for cv N… }}
Después, una sola llamada a la API crea el job:
import { BedrockClient, CreateModelInvocationJobCommand } from '@aws-sdk/client-bedrock';
const { jobArn } = await bedrock.send(new CreateModelInvocationJobCommand({
jobName: `cv-parse-${runId}-${shard}`,
modelId,
roleArn,
inputDataConfig: { s3InputDataConfig: { s3Uri: `s3://${bucket}/input/${runId}/${shard}.jsonl` } },
outputDataConfig: { s3OutputDataConfig: { s3Uri: `s3://${bucket}/output/${runId}/` } },
}));
A partir de ahí hacés polling del estado del job, y cuando termina leés los archivos de output, matcheás cada línea con su recordId, y seguís con tu pipeline como si las llamadas se hubieran hecho una por una.
Por qué cuesta la mitad
Porque le estás vendiendo al proveedor lo único que le sobra: capacidad ociosa. Las flotas de inferencia están dimensionadas para el pico de tráfico interactivo, y fuera del pico las GPUs quedan medio vacías. Cuando resignás latencia, ellos reciben trabajo que pueden meter en esos huecos, y el precio del token baja un 50%.
No es una promoción ni un descuento negociado. Es el precio de lista del modo batch en Amazon Bedrock, Anthropic, OpenAI y Gemini, los cuatro a la mitad de su tarifa on-demand. Mismo modelo, mismos pesos, mismo output. Lo único por lo que pagás menos es la promesa de una respuesta inmediata.
Un caso real: una migración con años de historia
Hace poco migramos a un cliente que venía de otro sistema de recruiting. Eso significa decenas de miles de CVs distintos, y con ellos las búsquedas, las notas de los recruiters, los movimientos entre etapas del pipeline y las evaluaciones de años de historia. Cada CV pasó por un modelo de lenguaje en Bedrock para extraer su estructura en el formato que espera nuestro producto.
La factura on-demand habría sido un número. La factura en batch fue exactamente la mitad, con el mismo modelo y los mismos resultados. No voy a poner cifras absolutas acá, y no importa: lo que importa es la proporción, y se mantiene a cualquier escala. Llevalo a miles de candidatos con toda su historia y la cuenta da igual. La mitad.
Hay un segundo ahorro que no tiene nada que ver con batch y que deberías hacer igual: antes de mandar nada, hasheamos cada archivo. En un sistema de recruiting el mismo CV aparece adjunto a muchas postulaciones. Un candidato que se postuló a cinco búsquedas tiene cinco copias del mismo PDF. Con un MD5 del archivo como clave, el mismo CV se parsea una sola vez y el resultado se replica a cada postulación que lo referencia. El token más barato es el que nunca mandás.
La ventaja de la que nadie habla: batch no compite con producción
El precio es el titular, pero no es la mejor razón. El tráfico on-demand sale de un pool compartido, y cuando ese pool se llena el proveedor te responde con un 503 y un mensaje de "insufficient capacity". Lo vivimos. Ahora imaginate que, encima de tu tráfico normal, disparás miles de llamadas desde un script de migración por el mismo canal, con el mismo modelo y la misma cuenta. Lo pagan tus usuarios, en latencia y en errores, en las pantallas que realmente usan.
Batch es otra cola con otra cuota. La migración corre de noche y la plataforma ni se entera. La propia guía de Bedrock para errores de capacidad en on-demand dice lo mismo: mové las cargas offline a batch. Este es el argumento que me convenció más que el descuento. Pagar la mitad está bueno. Que el trabajo masivo nunca toque la experiencia de la gente que usa tu producto es lo que realmente gana.
Qué te cobra a cambio
Nada es gratis, así que acá va la cuenta, con los detalles de Bedrock porque es donde lo corremos:
- Latencia. Minutos u horas, con el compromiso de terminar dentro de las 24 horas. No elegís cuándo.
- Menos features. En Bedrock batch no hay tool calling ni structured output. Pedís JSON en el prompt y lo parseás vos, con la misma validación que usarías para cualquier output de un modelo en el que no confiás.
- Sin camino de OCR en varios modelos. Si parte de tu input son imágenes escaneadas, esas necesitan su propia ruta on-demand.
- El orden del output no está garantizado. La línea 1 del input no es la línea 1 del output. El
recordIdes lo único que las une. - Un mínimo por job. Por defecto, 100 registros. Con menos, el job se rechaza.
Si tu caso de uso es interactivo, nada de esto sirve. Si nadie está esperando la respuesta, todo es aceptable.
El checklist que aprendimos a los golpes
Nada de esto está en la guía de getting started. Todo nos costó algo la primera vez.
- Deduplicá antes de mandar. Hasheá el archivo, no la postulación. Un parseo por CV, no uno por postulación.
- Respetá las cuotas. Al menos 100 registros por job, 1 GB por archivo de input, 5 GB por job. Nosotros shardeamos en 50.000 registros.
- Uní el último shard si queda corto. 50.050 registros partidos ingenuamente en shards de 50.000 te dejan un shard final de 50, y ese job se rechaza. El fix son tres líneas, y es el tipo de bug que solo encontrás con datos reales:
El shard combinado termina un poco por encima del tamaño objetivo, así que dejá el objetivo bien por debajo de los límites reales.function shard<T>(items: T[], size = 50_000, min = 100): T[][] { const shards: T[][] = []; for (let i = 0; i < items.length; i += size) shards.push(items.slice(i, i + size)); const last = shards.at(-1); if (shards.length > 1 && last && last.length < min) shards.at(-2)!.push(...shards.pop()!); return shards; } - Persistí el estado de cada job apenas lo aceptan. Un job se factura desde el segundo en que el proveedor lo acepta. Si tu script se cae después de crear el job pero antes de guardar su ARN, y lo relanzás, pagás dos veces el mismo trabajo. Escribí el ARN del job y el shard que cubre en almacenamiento durable antes de hacer cualquier otra cosa, y hacé que el launcher saltee los shards que ya tienen un job.
- Usá identificadores cortos y únicos. El output vuelve desordenado, así que cada línea necesita un
recordIdque mapee a tus propios datos sin ambigüedad. Un hash corto funciona mejor que una clave compuesta larga que después vas a tener que parsear. - Validá con el mismo parser que usás on-demand. El import no debería poder distinguir por qué camino llegó un resultado. Si el output de batch necesita su propio parser, ahora tenés dos fuentes de verdad y una de las dos se va a desalinear.
- Borrá todos los archivos del bucket cuando termines. Inputs y outputs. Son CVs, o sea datos personales, y nada debería quedar en S3 después de verificar el import. Hacé que la limpieza sea parte del job, no una nota en un runbook.
Quién lo soporta
Los cuatro grandes proveedores lo ofrecen con 50% de descuento, con límites distintos. A esta semana:
| Proveedor | Cómo | Límites que conviene conocer |
|---|---|---|
| Amazon Bedrock | JSONL en S3, CreateModelInvocationJob | 1 GB por archivo, 5 GB por job, mínimo 100 registros por job, sin tool calling ni structured output. Disponible para Claude, Nova, Llama, Mistral, DeepSeek, Qwen y otros. |
| Anthropic | Message Batches API | Hasta 100.000 requests o 256 MB por batch. La mayoría de los batches termina en menos de una hora. Resultados disponibles por 29 días. |
| OpenAI | Batch API, archivo JSONL | 50.000 requests y 200 MB por archivo, ventana de 24 horas, rate limits separados de la API sincrónica. |
| Gemini | Batch Mode, inline o archivo | Archivos de hasta 2 GB, objetivo de 24 horas, el context caching funciona dentro del batch. |
Si ya usás el AI SDK, hay un camino más simple para batches chicos. El AI Gateway de Vercel soporta batch con experimental_startBatch, experimental_getBatchStatus y experimental_getBatchResults, sin S3 de por medio: los requests van inline, hasta 1.000 por batch y 4,5 MB, y el mismo código funciona contra modelos de OpenAI y Anthropic.
import {
experimental_startBatch as startBatch,
experimental_getBatchStatus as getBatchStatus,
experimental_getBatchResults as getBatchResults,
} from 'ai';
const batch = await startBatch({
requests: cvs.map((cv) => ({
id: cv.hash,
type: 'text',
model: modelId, // any OpenAI or Anthropic model on the gateway
prompt: buildParsePrompt(cv.text),
})),
});
await saveBatchReference(batch); // { version, id, provider }: persist it before anything else
// later, from a cron or a worker
const { status } = await getBatchStatus({ batch });
if (status === 'completed') {
for await (const item of getBatchResults({ batch })) {
if (item.status === 'succeeded') await importParsedCv(item.id, parseCv(item.text));
}
}
La misma forma que la versión de Bedrock, sin tener que hacerte cargo de un bucket, un rol y la limpieza. Es ideal para batches chicos y medianos. Para decenas de miles de CVs, el archivo en S3 sigue siendo la herramienta correcta.
La regla
Toda la decisión entra en una pregunta: ¿alguien está esperando esta respuesta? Si sí, on-demand. Si no, batch. Migraciones, re-scoring masivo, enriquecimiento de datos, evals, clasificar registros históricos: ninguno tiene a un humano del otro lado mirando un spinner, y todos están pagando el doble si corren on-demand.
Mismo modelo, mitad de precio, y tus usuarios ni se enteran. Y la verdad, la mitad de precio es lo de menos. Lo que importa es que el trabajo masivo nunca toque la experiencia de la gente que usa tu producto.