Orquestador de agentes IA: cómo se construye en TypeScript
Le pides a tu agente de IA que audite un repositorio: dependencias, seguridad, tests y rendimiento. En el fichero quince ya no recuerda lo que vio en el tercero. En el treinta, te devuelve un informe seguro de sí mismo con la mitad de los hallazgos inventados.
Así que haces lo lógico: lo partes en cuatro agentes, uno por área, sin un orquestador de agentes IA que los coordine. Y aparece un problema nuevo.
A cada uno le pasas el historial entero "por si acaso". La factura se multiplica. El agente de seguridad falla por un timeout y nadie se entera. El resumen final rellena su hueco con frases genéricas que suenan a auditoría y no lo son.
El problema no era tener uno o cuatro agentes. Faltaba alguien que repartiera el trabajo, vigilara el gasto y supiera qué hacer cuando algo cae.
En corto: un orquestador de agentes IA es un modelo que divide la tarea en subtareas, entrega a cada worker solo el contexto que necesita y une los resultados al final. Lo que lo hace funcionar en producción no es el prompt, sino el código que lo rodea: un plan tipado, paralelismo con techo, presupuesto de tokens y una regla clara para cuando un worker falla. Según Anthropic, un sistema multiagente gasta unas 15 veces más tokens que un chat, así que esos límites no son opcionales.
Si aún no sabes si necesitas varios agentes, empieza por cómo decidir entre multiagente y agente único. Este post da por hecho que ya decidiste que sí.
¿Qué es un orquestador de agentes IA?
Un orquestador de agentes IA es el componente que recibe una tarea, decide en tiempo de ejecución en qué subtareas se divide, delega cada una a un worker con un contexto acotado y sintetiza sus resultados en una única respuesta.
Anthropic lo describe como el patrón orchestrator-workers en Building effective agents. Lo distingue de la paralelización simple por una razón: ahí las subtareas están fijadas de antemano, y aquí el orquestador las decide según la entrada. Ese es todo el valor del patrón. Si siempre divides la tarea de la misma forma, no necesitas un orquestador: te basta un Promise.allSettled sobre llamadas fijas.
El reparto de responsabilidades es este:
| Pieza | Qué hace | Qué NO debe hacer | Modelo razonable | Riesgo si falla |
|---|---|---|---|---|
| Orquestador (planner) | Divide la tarea, decide qué es crítico, fija el formato de salida | Resolver subtareas él mismo | Grande (claude-opus-5-5) |
Un plan malo lo heredan todos los workers |
| Worker | Resuelve una subtarea con su contexto | Ver el historial completo o lanzar otros workers | Pequeño (claude-haiku-5-5), con fallback |
Inventa datos si le falta contexto |
| Sintetizador | Une resultados, marca huecos y contradicciones | Inventar lo que falta | Grande | Rellena huecos con texto que suena bien |
| Código (runtime) | Concurrencia, presupuesto, timeouts, reintentos | Delegar estas decisiones al LLM | No aplica | Coste y tiempo sin techo |
La fila del código es la que más importa. En un orquestador, el control de flujo vive en tu código, no en el modelo. El modelo propone el plan; tu código decide cuánto se gasta, cuántos workers corren a la vez y qué pasa si uno cae.
Cómo descompone la tarea el orquestador: plan con structured output
El orquestador no devuelve texto, devuelve un plan tipado que tu código puede validar. Si el plan llega como prosa, tendrás que parsearlo, y parsear prosa de un LLM es una apuesta.
Primero, los tipos. Cada subtarea lleva cuatro cosas que Anthropic considera imprescindibles en su sistema de research multiagente (13 de junio de 2025): "Each subagent needs an objective, an output format, guidance on the tools and sources to use, and clear task boundaries." Yo añado una quinta: si es crítica o no.
// types.ts
import { z } from 39;zod39;;
export const SubtaskSchema = z.object({
id: z.string(),
objective: z.string().describe(39;Qué debe conseguir el worker, en una frase39;),
context: z.string().describe(39;Solo los datos que este worker necesita39;),
outputFormat: z.string().describe(39;Forma exacta de la respuesta39;),
boundaries: z.string().describe(39;Qué queda fuera de esta subtarea39;),
critical: z.boolean().describe(39;true si sin esta parte la respuesta final no sirve39;),
});
export const PlanSchema = z.object({
subtasks: z.array(SubtaskSchema),
});
export type Subtask = z.infer<typeof SubtaskSchema>;
export type Plan = z.infer<typeof PlanSchema>;
export type WorkerResult =
| { status: 39;ok39;; subtaskId: string; output: string; model: string; attempts: number }
| { status: 39;failed39;; subtaskId: string; error: string; attempts: number };
export type OrchestratorResult =
| { status: 39;complete39; | 39;partial39;; answer: string; results: WorkerResult[]; tokensUsed: number }
| { status: 39;aborted39;; reason: string; results: WorkerResult[]; tokensUsed: number };
Fíjate en boundaries. El mismo post de Anthropic lo explica sin rodeos: "Without detailed task descriptions, agents duplicate work, leave gaps, or fail to find necessary information." Un worker sin límites hace el trabajo del de al lado.
El planner usa Vercel AI SDK v7 con generateText y Output.object:
// planner.ts
import { generateText, Output } from 39;ai39;;
import { anthropic } from 39;@ai-sdk/anthropic39;;
import { PlanSchema, type Plan } from 39;./types.js39;;
import { Budget, estimateTokens } from 39;./budget.js39;;
const PLANNER_MAX_OUTPUT = 2_000;
const MAX_SUBTASKS = 6; // tope de anchura: más workers = más tokens y más síntesis
export async function planTask(task: string, budget: Budget, signal: AbortSignal): Promise<Plan> {
const estimate = estimateTokens(task) + PLANNER_MAX_OUTPUT;
budget.reserve(estimate);
const { output, usage } = await generateText({
model: anthropic(39;claude-opus-5-539;),
instructions: [
39;Eres el orquestador. No resuelves la tarea: la divides.39;,
39;Crea entre 1 y 6 subtareas independientes entre sí, que se puedan hacer en paralelo.39;,
39;En "context" copia solo los datos que esa subtarea necesita, nunca la tarea entera.39;,
39;Si la tarea es pequeña, devuelve una sola subtarea.39;,
].join(39;\n39;),
prompt: task,
output: Output.object({ schema: PlanSchema }),
maxOutputTokens: PLANNER_MAX_OUTPUT,
abortSignal: signal,
}).catch((error: unknown) => {
budget.settle(estimate, 0); // si la llamada falla, la reserva se libera igual
throw error;
});
budget.settle(estimate, usage.totalTokens ?? estimate);
if (output.subtasks.length === 0) throw new Error(39;El plan llegó vacío39;);
return { subtasks: output.subtasks.slice(0, MAX_SUBTASKS) };
}
El tope de seis subtareas está en el código, no solo en el prompt. El prompt pide; el slice garantiza. Lo hago así porque no todos los proveedores respetan minItems/maxItems en el schema, y un plan con veinte subtareas es una factura con veinte llamadas.
Escribir el plan antes de ejecutar es la misma idea que defiendo en el libro de Spec-Driven Development: primero el contrato, después el trabajo. Aquí el contrato lo escribe un modelo y lo valida Zod.
Cómo pasa contexto un orquestador de agentes IA a cada worker
En un orquestador de agentes IA, cada worker recibe su objetivo, su formato y su contexto. Nada más. Ni el historial del orquestador, ni la tarea completa, ni lo que hacen los demás.
Esto ahorra tokens, pero sobre todo protege la calidad. Un worker con el historial entero se distrae con decisiones que no son suyas. Es el mismo motivo por el que el mega-prompt falla frente a los subagentes: cuanto más contexto irrelevante, peor razona el modelo sobre lo relevante.
// worker.ts
import { generateText } from 39;ai39;;
import { anthropic } from 39;@ai-sdk/anthropic39;;
import type { Subtask, WorkerResult } from 39;./types.js39;;
import { Budget, BudgetExceededError, estimateTokens } from 39;./budget.js39;;
const WORKER_MAX_OUTPUT = 1_500;
// Primero el modelo barato; si falla, uno más capaz. Nunca al revés.
const MODEL_CHAIN = [39;claude-haiku-5-539;, 39;claude-sonnet-5-539;] as const;
export async function runWorker(
subtask: Subtask,
budget: Budget,
signal: AbortSignal,
): Promise<WorkerResult> {
let lastError = 39;sin intentos39;;
for (const [index, modelId] of MODEL_CHAIN.entries()) {
const estimate = estimateTokens(subtask.context) + WORKER_MAX_OUTPUT;
budget.reserve(estimate); // sin presupuesto, lanza: no tiene sentido reintentar
try {
const { text, usage } = await generateText({
model: anthropic(modelId),
instructions: [
`Objetivo: ${subtask.objective}`,
`Formato de salida: ${subtask.outputFormat}`,
`Fuera de alcance: ${subtask.boundaries}`,
39;Si te faltan datos, dilo. No los inventes.39;,
].join(39;\n39;),
prompt: subtask.context, // contexto mínimo, no el historial del orquestador
maxOutputTokens: WORKER_MAX_OUTPUT,
maxRetries: 2, // errores de red y 429: el SDK reintenta con backoff
abortSignal: signal,
});
budget.settle(estimate, usage.totalTokens ?? estimate);
return { status: 39;ok39;, subtaskId: subtask.id, output: text, model: modelId, attempts: index + 1 };
} catch (error) {
budget.settle(estimate, 0);
if (error instanceof BudgetExceededError || signal.aborted) throw error;
lastError = error instanceof Error ? error.message : String(error);
}
}
return { status: 39;failed39;, subtaskId: subtask.id, error: lastError, attempts: MODEL_CHAIN.length };
}
Hay tres decisiones en ese bloque.
La primera: el worker no tiene herramientas para lanzar otros workers. Así la profundidad máxima es uno y no hace falta un contador: es estructural. Si un worker necesita delegar, esa subtarea estaba mal planificada.
La segunda: dos niveles de reintento. maxRetries cubre los fallos transitorios (red, rate limit). La cadena de modelos cubre los fallos de capacidad: si Haiku no puede, sube a Sonnet.
La tercera: un worker que falla devuelve un resultado failed, no lanza. Solo lanza si se acabó el presupuesto o el tiempo, porque ahí sí hay que parar a todos.
Cómo ejecutar workers en paralelo: Promise.allSettled y un limitador
Promise.all es el error clásico. Si un worker rechaza, pierdes el resultado de los otros cinco, que ya pagaste. Promise.allSettled espera a todos y te dice quién cayó.
Pero sin techo, seis workers salen a la vez y te comes el rate limit del proveedor. Este limitador cabe en veinte líneas:
// limiter.ts
export function createLimiter(max: number) {
let active = 0;
const queue: Array<() => void> = [];
// Si alguien espera, le pasamos el hueco sin liberarlo: así nunca hay max + 1
const release = () => {
const resume = queue.shift();
if (resume) resume();
else active--;
};
return async <T>(fn: () => Promise<T>): Promise<T> => {
if (active < max) active++;
else await new Promise<void>((resolve) => queue.push(resolve));
try {
return await fn();
} finally {
release();
}
};
}
El detalle está en release. Si decrementas active y después despiertas al siguiente, otra llamada puede colarse entre medias y tendrás un worker de más. Pasar el hueco directamente lo evita. Con ocho tareas y un límite de tres, el pico de concurrencia medido es tres.
Cómo limitar el presupuesto de tokens de un orquestador
El presupuesto de un orquestador se controla antes de cada llamada, no al final. Con workers en paralelo, si solo sumas lo gastado cuando cada uno termina, seis llamadas pueden arrancar a la vez con el presupuesto "disponible" y pasarse todas juntas.
// budget.ts
export class BudgetExceededError extends Error {}
export class Budget {
private used = 0;
private reserved = 0;
constructor(private readonly maxTokens: number) {}
// Reserva antes de llamar: con workers en paralelo, comprobar después llega tarde
reserve(estimate: number): void {
if (this.used + this.reserved + estimate > this.maxTokens) {
throw new BudgetExceededError(
`Presupuesto agotado: ${this.used} usados, ${this.reserved} reservados, máximo ${this.maxTokens}`,
);
}
this.reserved += estimate;
}
// Al terminar la llamada: se libera la reserva y se apunta el coste real
settle(estimate: number, actual: number): void {
this.reserved -= estimate;
this.used += actual;
}
get tokensUsed(): number {
return this.used;
}
}
// Aproximación barata: ~4 caracteres por token. Sirve para reservar, no para facturar.
export const estimateTokens = (text: string): number => Math.ceil(text.length / 4);
Un orquestador sin límites explícitos no tiene un coste: tiene una distribución de costes con una cola larga. Estos son los cinco límites que fijo siempre:
| Límite | Dónde vive | Valor de partida | Qué pasa si no lo pones |
|---|---|---|---|
| Tokens totales | Budget |
150.000 por tarea | Una tarea rara se come el presupuesto de cien normales |
| Anchura (subtareas) | MAX_SUBTASKS |
6 | El planner descompone de más y la síntesis no cabe |
| Profundidad | Workers sin herramienta de delegar | 1 | Recursión: workers que lanzan workers |
| Concurrencia | createLimiter |
3 | Errores 429 en cadena |
| Tiempo | AbortSignal.timeout |
120 s | Un worker colgado bloquea la respuesta |
Los valores son míos, no un estándar. Ajústalos midiendo tokensUsed en tus tareas reales.
Qué hace el orquestador cuando un worker falla
Esta es la decisión que más se olvida. Hay cuatro respuestas posibles y cada una tiene su sitio:
| Estrategia | Cuándo usarla | Coste extra | Riesgo |
|---|---|---|---|
| Reintento (mismo modelo) | Timeout, 429, error de red | Bajo | Inútil si el fallo es de capacidad |
| Fallback (modelo mayor) | El modelo pequeño no resuelve la subtarea | Medio-alto | Oculta subtareas mal planificadas |
| Resultado parcial | La subtarea no es crítica | Ninguno | El sintetizador puede rellenar el hueco |
| Abortar | Falla una subtarea crítica | Ninguno (ahorra la síntesis) | Pierdes lo que ya pagaste |
El campo critical del plan es lo que permite elegir sin preguntarle al modelo otra vez. Aquí está el orquestador completo:
// orchestrator.ts
import { generateText } from 39;ai39;;
import { anthropic } from 39;@ai-sdk/anthropic39;;
import type { OrchestratorResult, Plan, WorkerResult } from 39;./types.js39;;
import { Budget, estimateTokens } from 39;./budget.js39;;
import { createLimiter } from 39;./limiter.js39;;
import { planTask } from 39;./planner.js39;;
import { runWorker } from 39;./worker.js39;;
interface OrchestratorOptions {
maxTokens: number;
concurrency: number;
timeoutMs: number;
}
const DEFAULTS: OrchestratorOptions = { maxTokens: 150_000, concurrency: 3, timeoutMs: 120_000 };
export async function orchestrate(
task: string,
options: Partial<OrchestratorOptions> = {},
): Promise<OrchestratorResult> {
const { maxTokens, concurrency, timeoutMs } = { ...DEFAULTS, ...options };
const budget = new Budget(maxTokens);
const signal = AbortSignal.timeout(timeoutMs);
const limit = createLimiter(concurrency);
// 1. Descomponer
const plan = await planTask(task, budget, signal);
// 2. Ejecutar en paralelo, con techo de concurrencia
const settled = await Promise.allSettled(
plan.subtasks.map((subtask) => limit(() => runWorker(subtask, budget, signal))),
);
const results: WorkerResult[] = settled.map((outcome, i) =>
outcome.status === 39;fulfilled39;
? outcome.value
: { status: 39;failed39;, subtaskId: plan.subtasks[i].id, error: String(outcome.reason), attempts: 0 },
);
// 3. Decidir: abortar, sintetizar en parcial o sintetizar completo
const brokenCritical = plan.subtasks.filter(
(subtask, i) => subtask.critical && results[i].status === 39;failed39;,
);
if (brokenCritical.length > 0) {
return {
status: 39;aborted39;,
reason: `Fallaron subtareas críticas: ${brokenCritical.map((s) => s.id).join(', ')}`,
results,
tokensUsed: budget.tokensUsed,
};
}
// Si el tiempo se agotó durante los workers, no pagues una síntesis de huecos
if (signal.aborted) {
return { status: 39;aborted39;, reason: 39;Timeout global39;, results, tokensUsed: budget.tokensUsed };
}
const answer = await synthesize(task, plan, results, budget, signal);
const complete = results.every((r) => r.status === 39;ok39;);
return { status: complete ? 39;complete39; : 39;partial39;, answer, results, tokensUsed: budget.tokensUsed };
}
async function synthesize(
task: string,
plan: Plan,
results: WorkerResult[],
budget: Budget,
signal: AbortSignal,
) {
const report = results
.map((result, i) => {
const { objective } = plan.subtasks[i];
return result.status === 39;ok39;
? `## ${result.subtaskId}: ${objective}\n${result.output}`
: `## ${result.subtaskId}: ${objective}\nNO DISPONIBLE (${result.error})`;
})
.join(39;\n\n39;);
const estimate = estimateTokens(task + report) + 3_000;
budget.reserve(estimate);
const { text, usage } = await generateText({
model: anthropic(39;claude-opus-5-539;),
instructions: [
39;Une los resultados de los workers en una sola respuesta a la tarea original.39;,
39;Si una parte figura como NO DISPONIBLE, dilo de forma explícita. No la rellenes tú.39;,
39;Si dos resultados se contradicen, señala la contradicción en vez de elegir uno en silencio.39;,
].join(39;\n39;),
prompt: `Tarea original:\n${task}\n\nResultados:\n${report}`,
maxOutputTokens: 3_000,
abortSignal: signal,
}).catch((error: unknown) => {
budget.settle(estimate, 0);
throw error;
});
budget.settle(estimate, usage.totalTokens ?? estimate);
return text;
}
Todo el código de este post compila (comprobado el 20 de octubre de 2026) con tsc en modo strict contra ai 7.0.146, @ai-sdk/anthropic 4.0.84 y zod 4.6.7.
La síntesis tiene dos instrucciones que no son decoración. "NO DISPONIBLE" evita el resumen inventado de la historia del principio. Y señalar contradicciones importa porque los workers no se ven entre sí. Cognition lo resume en uno de sus principios en Don't Build Multi-Agents: "Actions carry implicit decisions, and conflicting decisions carry bad results".
El estado partial también es un dato: devuélvelo a quien llama para que la interfaz lo enseñe. Si quieres profundizar en cómo tipar y propagar errores así, tienes manejo de errores en agentes IA con TypeScript.
Cuánto cuesta un orquestador de agentes IA en tokens
Mucho más que un agente. Anthropic publicó sus cifras en el post de su sistema de research: "agents typically use about 4× more tokens than chat interactions, and multi-agent systems use about 15× more tokens than chats". En su evaluación interna, Opus 4 como orquestador con workers Sonnet 4 superó al Opus 4 en solitario en un 90,2 %. Ojo: esa cifra se midió con Sonnet en los workers, no con Haiku como en este post. Y el paralelismo recortó el tiempo de investigación hasta un 90 % en las consultas complejas.
Lee bien la cifra que viene después. Según el mismo post, el uso de tokens explica por sí solo el 80 % de la varianza del rendimiento en su evaluación con BrowseComp. Es decir: buena parte de la mejora viene de gastar más, no de una arquitectura mágica. Por eso el propio Anthropic dice que el patrón solo compensa en tareas cuyo valor paga ese coste.
Por eso uso Opus solo para planificar y sintetizar, y Haiku en los workers. Es donde está el volumen de llamadas. Si quieres ver cuánto cambia la factura al mover el modelo de los subagentes, lo desgloso en el coste de los subagentes según el modelo.
Cuándo NO usar un orquestador
Un orquestador de agentes IA multiplica el coste, la latencia y los puntos de fallo. Estas son las situaciones en las que lo quito:
Cuando las subtareas no son independientes. Si el resultado de una cambia la definición de otra, los workers en paralelo divergen. En el hilo de Hacker News sobre el post de Cognition (123 puntos, 89 comentarios, septiembre de 2025), un usuario lo explicaba así: lo difícil es tener entradas bien definidas y estables, porque al implementar descubres cosas que cambian otras tareas, y un sistema paralelo que no lo gestione acaba con resultados que divergen. En código pasa mucho: Anthropic reconoce en el mismo post que la mayoría de tareas de programación tienen menos partes paralelizables que la investigación.
Cuando la tarea es pequeña. Planificar y sintetizar son dos llamadas al modelo grande que pagas aunque haya un solo worker. Para tareas que caben en un contexto, un agente con buenas herramientas es más barato y más rápido.
Cuando la latencia importa más que la cobertura. La respuesta nunca llega antes que el worker más lento, más la síntesis. En un chat interactivo eso se nota.
Cuando los errores se componen. Un plan malo contamina a todos los workers, y el sintetizador hereda todo. Más etapas son más sitios donde algo sale mal sin que lo veas, si no tienes trazas por worker.
Cuando todos necesitan el mismo contexto. Si cada worker necesita casi todo el historial, el contexto mínimo deja de ser posible y pagas lo mismo varias veces. Ahí vale más la pena mirar multi-agente sin orquestador, o volver a un solo agente.
Cómo empezar tu primer orquestador de agentes IA
Coge la tarea de tu agente que más se degrada con el contexto largo. Escribe a mano el plan que haría el orquestador: tres subtareas, cada una con objetivo, contexto, formato, límites y si es crítica. Si no puedes escribirlo sin que las subtareas se pisen, no necesitas un orquestador: necesitas otra arquitectura.
Si puedes, el código de arriba es tu punto de partida. Primero mide tokensUsed en diez tareas reales; después ajusta los límites.
Para el resto del sistema (herramientas, memoria, RAG, MCP, evaluación) tienes el ebook gratuito El Developer Agéntico, 194 páginas que incluyen un capítulo de multiagente. Si vienes de cero, el hub para construir un agente de IA desde cero ordena todo el recorrido. Y si quieres llevarlo a un producto real con Claude Code, eso es justo lo que hacemos en Construye con IA.
Preguntas frecuentes
¿Qué diferencia hay entre un orquestador y la paralelización simple?
En la paralelización, las subtareas están definidas de antemano en el código y siempre son las mismas. En el patrón orchestrator-worker, un modelo decide las subtareas según la entrada concreta. Si tu división de la tarea no cambia nunca, no necesitas un orquestador: un Promise.allSettled sobre llamadas fijas hace el mismo trabajo con una llamada menos al modelo grande.
¿Qué modelo uso para el orquestador y cuál para los workers?
Un modelo grande para planificar y sintetizar, porque ahí los errores se propagan a todo lo demás, y uno pequeño para los workers, que es donde está el volumen de llamadas. En el código de este post son claude-opus-5-5 y claude-haiku-5-5, con claude-sonnet-5-5 como fallback del worker. Anthropic usó la misma idea en su sistema de research: Opus 4 como orquestador y Sonnet 4 en los workers.
¿Cuánto contexto le paso a cada worker?
El mínimo para cumplir su objetivo: los datos de su subtarea, el formato de salida y los límites. Nunca el historial del orquestador ni la tarea completa. Si un worker necesita casi todo el contexto para hacer su parte, la descomposición está mal o la tarea no se presta a orquestar.
¿Por qué Promise.allSettled y no Promise.all?
Porque Promise.all rechaza en cuanto un worker falla, y pierdes los resultados que los demás ya terminaron y ya pagaste. Promise.allSettled espera a todos y te dice cuál cayó, así puedes decidir si sintetizas en parcial o abortas. Combínalo con un limitador de concurrencia para no disparar los rate limits del proveedor.
¿Cómo evito que el orquestador entre en un bucle o gaste sin control?
Con límites que viven en el código y no en el prompt: presupuesto de tokens con reserva antes de cada llamada, número máximo de subtareas, profundidad uno (los workers no pueden delegar), concurrencia y timeout global con AbortSignal. El prompt puede pedir moderación, pero solo el código la garantiza.
Por Bezael Pérez — Developer senior con más de 15 años de experiencia y fundador de Dominicode.
¿Te resultó útil este artículo?
Compártelo con tu comunidad y ayuda a otros desarrolladores.
