La API: env.AI.run, mensajes, parámetros y respuesta
Toda la superficie de Workers AI cabe en una firma: `env.AI.run` recibe el identificador de un modelo y un objeto de entradas, y devuelve una promesa. Lo que cambia de un modelo a otro no es el método sino la forma de esas entradas y la de la salida. El array de mensajes con sus roles frente al prompt suelto, los parámetros que gobiernan la generación, el uso de un esquema para forzar JSON válido, y por qué la respuesta te obliga a leer la documentación de cada modelo antes de escribir el primer `await`.
La API de Workers AI es deliberadamente estrecha: un método, env.AI.run, que toma el identificador de un modelo y un objeto con las entradas, y devuelve una promesa. No hay cliente que instanciar, ni sesión que abrir, ni conexión que mantener. Esa estrechez es una virtud, porque desplaza toda la complejidad a un único sitio donde puedes concentrarte: la forma del objeto de entradas y la forma de la respuesta, que cambian según la modalidad del modelo y son, en la práctica, lo único que hay que aprender de verdad.
- Dominar la firma
env.AI.runcon su identificador de modelo y su objeto de entradas. - Construir conversaciones con el array de mensajes y entender el papel de cada rol.
- Gobernar la generación con los parámetros de muestreo y de longitud.
- Leer y validar la respuesta sabiendo que su forma depende de la modalidad.
La firma: un modelo y unas entradas
El método acepta dos argumentos obligatorios y uno opcional. El primero es la cadena que identifica el modelo. El segundo es un objeto cuyas claves dependen por completo de la modalidad: un modelo de texto espera prompt o messages, uno de embeddings espera text, uno de transcripción espera audio, uno de difusión espera prompt y devuelve bytes. El tercero, opcional, lleva opciones de la propia plataforma, como el encaminamiento a través de AI Gateway.
export default {
async fetch(request, env, ctx): Promise<Response> {
const salida = await env.AI.run(
"@cf/meta/llama-3.1-8b-instruct",
{ prompt: "Resume en dos frases que es un isolate de V8" },
{ gateway: { id: "mi-gateway" } },
);
return Response.json(salida);
},
} satisfies ExportedHandler<Env>;
La forma con prompt es la más simple y sirve para peticiones de un solo turno sin contexto previo. Por debajo, el runtime la envuelve en la plantilla de conversación que ese modelo espera, así que no estás hablando con un modelo base: estás usando un atajo. En cuanto necesites instrucciones de sistema, historial o control fino, el atajo se queda corto y hay que pasar a los mensajes.
Si has regenerado los tipos con wrangler types, el editor te ayudará: las entradas y las salidas están tipadas por modelo, de modo que escribir una clave que ese modelo no acepta se convierte en un error de compilación en lugar de en una respuesta rara en producción.
Mensajes: la conversación como estructura de datos
La forma canónica de hablar con un LLM es un array de mensajes, donde cada elemento tiene un role y un content. Los tres roles hacen cosas distintas y confundirlos es la primera fuente de resultados mediocres.
const messages = [
{
role: "system",
content:
"Eres un asistente tecnico. Respondes en espanol, en dos frases " +
"como maximo, sin adornos ni disculpas.",
},
{ role: "user", content: "Que diferencia hay entre KV y D1?" },
{ role: "assistant", content: "KV es clave-valor con consistencia eventual..." },
{ role: "user", content: "Y para un contador exacto?" },
];
const salida = await env.AI.run("@cf/meta/llama-3.1-8b-instruct", {
messages,
max_tokens: 256,
});
El rol system fija el comportamiento: quién es el modelo, en qué idioma responde, qué formato debe seguir, qué no debe hacer. Es el sitio donde van las reglas estables, no los datos de la petición. El rol user lleva lo que pregunta la persona y también el contexto recuperado que quieras inyectar. El rol assistant guarda lo que el modelo respondió en turnos anteriores, y es lo que convierte una secuencia de llamadas independientes en una conversación con memoria.
Ese último punto tiene una implicación que sorprende a mucha gente: la llamada no guarda estado. Cada invocación es completamente independiente, y si quieres continuidad tienes que reenviar el historial entero cada vez. La memoria vive en tu aplicación —en un Durable Object, en D1, en KV—, no en el modelo. Y como el historial crece, crece también el número de tokens que pagas y el riesgo de rebasar la ventana de contexto, así que tarde o temprano necesitarás una política: truncar por los turnos más antiguos, resumir lo anterior, o ambas.
Si concatenas contenido de tus usuarios dentro del mensaje de sistema, les estás dando permiso para reescribir tus reglas. Las instrucciones van en system, los datos van en user, y el contenido no confiable conviene delimitarlo explícitamente y advertir al modelo de que es material a analizar y no órdenes a cumplir. Aun así, ninguna instrucción es una frontera de seguridad: valida siempre la salida antes de actuar sobre ella, y no dejes que un modelo decida por sí solo una operación con efectos.
Los parámetros que gobiernan la generación
Junto a los mensajes viajan parámetros que modulan cómo se produce el texto. No son muchos y merece la pena conocerlos bien, porque ajustarlos suele mejorar más el resultado que cambiar de modelo.
| Parámetro | Qué controla | Cuándo tocarlo |
|---|---|---|
max_tokens |
Techo de tokens generados en la respuesta | Siempre: acota latencia y coste, y evita respuestas interminables |
temperature |
Aleatoriedad del muestreo, de cero a dos | Bájalo cerca de cero para extraer datos; súbelo para redactar con variedad |
top_p |
Recorte del conjunto de candidatos por probabilidad acumulada | Como alternativa a temperature, rara vez los dos a la vez |
seed |
Semilla del muestreo | Para reproducir una salida en pruebas o depuración |
stream |
Devolver tokens según se generan | Cuando alguien espera mirando la pantalla |
response_format |
Forzar una salida con esquema JSON | Cuando el resultado alimenta código y no a una persona |
De todos ellos, max_tokens es el que más disciplina impone y el que más gente olvida. Sin techo, un modelo puede irse por las ramas durante miles de tokens que pagas íntegros y que multiplican la latencia; con un techo ajustado a lo que tu interfaz puede mostrar, el coste queda acotado por construcción. Y temperature merece una regla mental sencilla: si vas a parsear la salida, quieres determinismo, así que cerca de cero; si vas a mostrarla a una persona, un poco de variedad se agradece.
El parámetro que cambia arquitecturas es response_format. Pedirle a un modelo que responda en JSON dentro del mensaje de sistema funciona la mayoría de las veces, y esa mayoría es justamente el problema. Declarando un esquema, el runtime restringe la generación para que la salida sea válida por construcción.
const salida = await env.AI.run("@cf/meta/llama-3.1-8b-instruct", {
messages: [
{ role: "system", content: "Extraes datos de un texto de soporte." },
{ role: "user", content: texto },
],
response_format: {
type: "json_schema",
json_schema: {
type: "object",
properties: {
sentimiento: { type: "string", enum: ["positivo", "neutro", "negativo"] },
urgencia: { type: "number" },
tema: { type: "string" },
},
required: ["sentimiento", "urgencia", "tema"],
},
},
});
Leer la respuesta sin suposiciones
La salida no tiene una forma única: la tiene por modalidad, y asumir la equivocada es el error más frecuente al saltar de un modelo a otro. Un modelo de texto devuelve un objeto con la propiedad response. Uno de embeddings devuelve un objeto con data, un array de vectores, y shape con sus dimensiones. Whisper devuelve text y, según la variante, segmentos con marcas de tiempo. Un modelo de difusión no devuelve un objeto sino un ReadableStream de bytes de imagen.
// texto: la propiedad response
const { response } = await env.AI.run(modeloTexto, { messages });
// embeddings: un array de vectores en data
const { data, shape } = await env.AI.run("@cf/baai/bge-m3", {
text: ["primer parrafo", "segundo parrafo"],
});
// transcripcion: la propiedad text
const { text } = await env.AI.run("@cf/openai/whisper-large-v3-turbo", {
audio: [...new Uint8Array(await request.arrayBuffer())],
});
// difusion: bytes, no JSON
const imagen = await env.AI.run("@cf/black-forest-labs/flux-1-schnell", {
prompt: "un faro al amanecer, estilo acuarela",
});
return new Response(imagen, { headers: { "content-type": "image/png" } });
Y por encima de la forma está la fiabilidad. Una inferencia puede fallar por límite de peticiones, por sobrecarga puntual o por una entrada inválida, así que la llamada va dentro de un try y con un plan para el fallo: reintentar con espera creciente ante un 429, degradar a un modelo más pequeño, o devolver una respuesta útil sin IA. Si además pediste JSON, valida el resultado con tu propio esquema antes de confiar en él: el modelo cumple el formato, pero el contenido sigue siendo suyo.
Cuesta muy poco aprender env.AI.run, y esa facilidad esconde una lección que solo se paga más tarde. En una API convencional, la firma es el contrato: los tipos de los argumentos delimitan lo aceptable, el tipo de retorno promete lo que recibirás, y el compilador vigila ambos extremos. Aquí la firma apenas dice nada. Toma una cadena y un objeto, devuelve una promesa. Todo el contrato real —qué claves acepta este modelo concreto, qué rango tiene cada parámetro, qué forma tendrá la salida, cuánto tarda, cuánto cuesta, qué pasa cuando la entrada es rara— vive fuera del sistema de tipos, disperso entre la documentación de cada modelo y la experiencia acumulada. Y hay una parte del contrato que ni siquiera está documentada, porque no es determinista: dos ejecuciones con la misma entrada pueden diferir, un cambio de versión del modelo puede alterar el estilo de las respuestas sin romper ni un tipo, y una instrucción que funcionaba impecablemente puede degradarse el día que el catálogo actualiza los pesos. Esto obliga a un cambio de hábitos que no es opcional. Programar contra un modelo se parece menos a llamar a una función y más a integrar un servicio externo, inestable y sin garantías: pones un techo a lo que puede consumir, un tiempo máximo a lo que puede tardar, una validación a lo que devuelve, un camino alternativo para cuando falle, y unas pruebas que comparen salidas reales contra ejemplos que tú consideras correctos, no contra un tipo. La tentación, precisamente porque escribir la llamada es tan fácil, es saltarse todo eso y tratar la inferencia como una función pura más dentro de tu lógica. Los sistemas que envejecen mal con IA no fallan por elegir el modelo equivocado; fallan por haber confiado en un contrato que la firma parecía prometer y que nunca estuvo ahí.
- Llama a un modelo de texto primero con
prompty después conmessages, y compara la salida ante la misma pregunta. - Escribe un mensaje de sistema que fije idioma, longitud y tono, y comprueba cuánto cambia el resultado sin tocar la pregunta.
- Fija
max_tokensy bajatemperaturea cero para una tarea de extracción, y observa la estabilidad entre ejecuciones repetidas. - Declara un
response_formatcon esquema, parsea la salida y valídala con tu propio validador antes de usarla. - Envuelve la llamada en un
trycon reintento ante429y un camino alternativo sin IA, y prueba que tu endpoint sigue respondiendo cuando la inferencia falla.