Inicio / Artículos

Ingeniería de prompts y salida estructurada en .NET: OpenAI, Azure, Anthropic, Gemini

OpenAI, Azure OpenAI, Anthropic o Gemini para .NET: residencia, SDKs, precios, salida estructurada con JSON Schema, ingeniería de prompts y un IChatClient ligero.

Editor: REACTAPP.TOP11 min de lectura2544 palabras

Fuentes: OpenAI vs Azure OpenAI vs Anthropic vs Gemini for .NET: Provider Comparison Guide

Un bot de soporte que llama directamente a GPT-4o desde un controlador ASP.NET Core puede permitir obtener una demostración. Luego, el cliente empresarial solicita la residencia de datos en una región específica, el departamento de seguridad prefiere Claude para documentos largos, y el de finanzas quiere saber por qué cada pregunta cuesta diez veces más de lo estimado. Cada uno de esos pedidos te lleva hacia un proveedor diferente, o hacia una capa de abstracción que te permite cambiarlos.

OpenAI, Azure OpenAI, Anthropic y Gemini venden lo mismo en esencia: chat gestionado, posibilidad de llamar a herramientas y uso de embeddings sin necesidad de alojar GPUs. Se diferencian en su presentación, regiones disponibles, requisitos de cumplimiento, calidad de sus SDK y forma de facturación. Para un servicio .NET que funcionará durante años, esas diferencias son más importantes que el modelo que estuviera en la cima de las listas de rendimiento la semana pasada.

Qué proveedor para qué tarea

  • Plataforma OpenAI: la vía más rápida para acceder a los modelos más recientes. Adecuado para productos que ya funcionan bien dentro de los límites de datos de OpenAI.
  • Azure OpenAI: cuando la carga de trabajo debe ejecutarse dentro de un tenant de Azure con identidad empresarial, redes y controles regionales.
  • Anthropic: excelente capacidad para trabajar con contextos largos y seguimiento preciso de instrucciones. Útil para agentes que manejan grandes cantidades de documentos.
  • Gemini: soluciones alineadas con Google Cloud, fortalezas multimodales y productos integrados estrechamente con Workspace.

No opte por usar múltiples proveedores por defecto. Con un único equipo, una sola región y sin necesidades específicas de cumplimiento, un solo proveedor implica menos elementos que gestionar. Cada abstracción conlleva un costo adicional.

Madurez del SDK y paquetes a instalar

Lo que está evaluando aquí son las bibliotecas oficiales para clientes, los patrones asíncronos idiomáticos y la frecuencia con la que se introducen cambios que rompen la funcionalidad existente. Un SDK que interfiera con el ciclo de vida de HttpClient y con la inyección de dependencias le costará más que la factura por tokens. Espere encontrar paquetes de primera parte o bien mantenidos por la comunidad para los cuatro casos, pero verifique las versiones actuales de NuGet y los flujos de autenticación (clave API versus Entra ID) antes de estandarizar cualquier solución.

// Official OpenAI SDK
dotnet add package OpenAI

// Azure AI Foundry (Azure OpenAI)
dotnet add package Azure.AI.OpenAI

// Anthropic Claude (official, beta as of 2026)
dotnet add package Anthropic

// Gemini via Google Cloud Vertex AI (IChatClient-compatible, January 2026)
dotnet add package Google.Cloud.VertexAI.Extensions --prerelease

// Abstraction layer (ships with all provider adapters)
dotnet add package Microsoft.Extensions.AI

Microsoft.Extensions.AI es el elemento que los une: cada SDK expone un IChatClient, de modo que el resto de la aplicación puede mantenerse neutral con respecto al proveedor.

Mecánicas de precios y enrutamiento de modelos

Cada proveedor cobra por tokens, pero los detalles influyen en el monto total: los tokens de entrada y salida tienen precios diferentes, los tokens en caché son más económicos, las herramientas pueden tener un costo adicional, y algunos planes establecen montos mínimos. Los contextos largos y los agentes con muchas herramientas elevan rápidamente la factura. Azure factura a través de su sistema de medición, mientras que los otros tres lo hacen a través de la cuenta del proveedor.

El departamento financiero creará un modelo de economía unitaria, por lo que su arquitectura debe generar cifras de uso en las que confíen. Coloque cada llamada de finalización detrás de un medidor que registre el modelo, los tokens y las banderas de funcionalidad. Los presupuestos deben estar definidos en el código y en las alertas de facturación en la nube. Enrutar según la complejidad de la tarea es la solución más económica:

// Partial example — model routing based on task complexity
public class ModelRouter
{
    // Route cheap, simple tasks to the nano model
    // Route complex multi-step reasoning to the full model
    public string SelectModel(TaskComplexity complexity) => complexity switch
    {
        TaskComplexity.Classification  => "gpt-5.4-nano",   // ~20x cheaper than full
        TaskComplexity.Conversational  => "gpt-5.4-mini",   // ~5x cheaper
        TaskComplexity.Reasoning       => "gpt-5.4",         // full capability
        _                              => "gpt-5.4-mini"
    };
}

Adecuación empresarial: identidad, red y residencia

Los cuestionarios de seguridad arruinan los proyectos que solo se optimizan para la latencia de las demostraciones. Las preguntas se refieren a la identidad, el aislamiento de redes, la retención de datos y la disponibilidad regional.

Azure OpenAI hereda el sistema RBAC de Azure, Private Link y la posibilidad de elegir región. OpenAI y Anthropic ofrecen controles organizacionales y opciones de cero retención, con matices que es necesario leer. Gemini sigue los patrones IAM y VPC de Google Cloud.

En cuanto a la autenticación dentro de ASP.NET Core, prefiera la identidad gestionada para Azure OpenAI. Para los proveedores de claves API, cargue los secretos desde Key Vault al iniciar y cámbielos sin necesidad de volver a desplegar los scripts. Nunca vincule las claves del proveedor a los JWT de los usuarios.

La revisión legal debe abarcar la opción de excluir el uso para entrenamiento, los subprocesadores y el texto del DPA para cada proveedor que active, no solo el principal. Tener varios proveedores implica tener varios contratos.

Salida estructurada con JSON Schema

El soporte para JSON Schema y la decodificación restringida varía entre los proveedores. Es preferible elegir aquellos que puedan aplicar un esquema a los argumentos de la herramienta y las respuestas en las que ya confían sus deserializadores en C#, y recurrir a métodos de validación y reintentos cuando la aplicación del esquema sea deficiente. Con Azure OpenAI se puede hacer que el esquema sea estricto:

// Full example — Azure OpenAI structured output with strict schema enforcement
using Azure.AI.OpenAI;
using OpenAI.Chat;
using System.ComponentModel;
using System.Text.Json;

// Define the schema type — only JSON Schema primitives are supported
public class SupportTicketAnalysis
{
    public string Category { get; set; } = string.Empty;        // e.g. "billing", "technical"
    public string Priority { get; set; } = string.Empty;        // "low", "medium", "high", "critical"

    [Description("One to two sentence summary of the issue")]
    public string Summary { get; set; } = string.Empty;

    public string[] RequiredTeams { get; set; } = [];           // which teams must be looped in

    [Description("Estimated resolution time as a human-readable string, e.g. '2 business days'")]
    public string EstimatedResolution { get; set; } = string.Empty;
}

// Build the client — structured outputs require API version 2024-08-01-preview or later
var azureClient = new AzureOpenAIClient(
    new Uri(Environment.GetEnvironmentVariable("AZURE_OPENAI_ENDPOINT")!),
    new AzureKeyCredential(Environment.GetEnvironmentVariable("AZURE_OPENAI_KEY")!),
    new AzureOpenAIClientOptions(AzureOpenAIClientOptions.ServiceVersion.V2024_08_01_Preview));

ChatClient chatClient = azureClient.GetChatClient("gpt-5.4-mini-deployment");

// Configure the request to enforce the schema strictly
var options = new ChatCompletionOptions
{
    ResponseFormat = ChatResponseFormat.CreateJsonSchemaFormat<SupportTicketAnalysis>(
        "support_ticket_analysis",  // snake_case — spaces and hyphens cause 400 errors
        strict: true)               // without this, schema compliance is best-effort only
};

List<ChatMessage> messages =
[
    new SystemChatMessage("You are a support ticket classifier. Analyze the ticket and return a structured assessment."),
    new UserChatMessage("User reports: 'My invoice shows $999 but the contract says $299. Need this fixed immediately.'")
];

// If structured outputs fail with a 400, the schema has unsupported types — check for DateTime, Uri, TimeSpan
ClientResult<ChatCompletion> result = await chatClient.CompleteChatAsync(messages, options);

// JsonSerializer.Deserialize will always succeed if the API call succeeded
SupportTicketAnalysis analysis = JsonSerializer.Deserialize<SupportTicketAnalysis>(
    result.Value.Content.Text)!;

Console.WriteLine(quot;Category: {analysis.Category}");
Console.WriteLine(quot;Priority: {analysis.Priority}");
Console.WriteLine(quot;Summary: {analysis.Summary}");

Dos detalles en ese ejemplo causan la mayoría de los errores 400: los nombres de esquema deben estar en formato snake_case, y tipos como DateTime, Uri o TimeSpan no son primitivas de JSON Schema. Sin strict: true, el cumplimiento es solo una aproximación.

Llamadas a herramientas, embeddings y RAG

Todos los cuatro métodos admiten llamadas a herramientas o funciones, cada uno con una estructura de carga diferente. Normalice la información a un DTO interno ToolCall en el punto de conexión para que el código de orquestación no dependa de cada proveedor de forma separada.

Elegir un proveedor de chat no implica elegir sus embeddings. Defina de forma fija el modelo y la dimensión de los embeddings en la configuración, y no los cambie silenciosamente al cambiar de proveedor de chat. Almacene en caché los embeddings y los resultados deterministas de las herramientas de forma intensiva, pero no almacene en caché las respuestas de chat en bruto que contengan información personal del usuario a menos que exista una política de retención que lo permita.

Una abstracción ligera en .NET con IChatClient

Defina una interfaz de proveedor pequeña con métodos de transmisión en flujo y sin flujo, asocie cada SDK detrás de un adaptador e inyéctelo mediante DI. Mantenga los mensajes de confirmación y las herramientas fuera de los adaptadores. Evite los frameworks “todo en uno” que ocultan las operaciones de reintentos y los tiempos de espera que deberá ajustar. Con IChatClient, el registro lee el proveedor desde la configuración y envuelve todo en OpenTelemetry:

// Partial example — Program.cs provider registration with IChatClient
using Microsoft.Extensions.AI;
using Azure.AI.OpenAI;
using OpenAI;
using Anthropic;
using Google.Cloud.VertexAI.Extensions;

var builder = WebApplication.CreateBuilder(args);
var config = builder.Configuration;

// Read which provider to use from configuration — no code change to switch
string provider = config["AI:Provider"]!; // "azure", "openai", "anthropic", "gemini"

builder.Services.AddSingleton<IChatClient>(sp =>
{
    // Return the appropriate IChatClient implementation based on config
    return provider switch
    {
        "azure" => new AzureOpenAIClient(
                        new Uri(config["AI:Azure:Endpoint"]!),
                        new Azure.AzureKeyCredential(config["AI:Azure:Key"]!))
                    .GetChatClient(config["AI:Azure:Deployment"]!)
                    .AsIChatClient(), // extension method from Azure.AI.OpenAI

        "openai" => new OpenAIClient(config["AI:OpenAI:Key"]!)
                    .GetChatClient(config["AI:OpenAI:Model"]!)
                    .AsIChatClient(),

        "anthropic" => new AnthropicClient(config["AI:Anthropic:Key"]!)
                    .AsIChatClient(), // Anthropic SDK v10+ implements IChatClient

        "gemini" => new VertexAIChatClient(
                        projectId: config["AI:Gemini:ProjectId"]!,
                        location: config["AI:Gemini:Location"]!,
                        modelId: config["AI:Gemini:Model"]!),

        _ => throw new InvalidOperationException(quot;Unknown AI provider: {provider}")
    };
});

// Add OpenTelemetry instrumentation via pipeline decorator — applies to all providers
builder.Services.AddSingleton(sp =>
{
    var inner = sp.GetRequiredService<IChatClient>();
    return new ChatClientBuilder(inner)
        .UseOpenTelemetry(loggerFactory: sp.GetRequiredService<ILoggerFactory>(),
                          sourceName: "YourApp.AI")
        .Build();
});

Las banderas de funcionalidad deben seleccionar al proveedor por inquilino o por habilidad. Un cliente estático único, codificado directamente en Program.cs, es la causa por la cual las excepciones de residencia se convierten en incidentes.

La transmisión en tiempo real también requiere un contrato explícito en la abstracción: tokens de cancelación, mecanismos de contrapresión y la forma en que las llamadas parciales a las herramientas llegan a quienes las realizan. Los proveedores difieren lo suficiente en este aspecto como para que “simplemente esperar la cadena de texto” introduzca semánticas propias del proveedor en sus controladores.

Una prueba de concepto funcional

Toda la idea cabe en una pequeña Web API. Inicie el proyecto y agregue los paquetes:

dotnet new webapi -n AiProviderPoc --no-openapi
cd AiProviderPoc

dotnet add package Microsoft.Extensions.AI
dotnet add package OpenAI
dotnet add package Azure.AI.OpenAI
dotnet add package Anthropic
dotnet add package Google.Cloud.VertexAI.Extensions --prerelease

El contrato de solicitud es compartido por todos los proveedores. El tipo de salida se ajusta a los primitivos de JSON Schema, y las descripciones sirven además como guía para el modelo:

// PromptContracts.cs — shared across all providers
using System.ComponentModel;

namespace AiProviderPoc;

// The input to every analysis request
public record AnalysisRequest(string TicketText, string? Context = null);

// The structured output — must use only JSON Schema primitives (string, int, bool, arrays)
public class TicketAnalysis
{
    [Description("Category of issue: 'billing', 'technical', 'account', 'feature-request'")]
    public string Category { get; set; } = string.Empty;

    [Description("Severity: 'low', 'medium', 'high', 'critical'")]
    public string Severity { get; set; } = string.Empty;

    [Description("One to two sentence summary of the customer's issue")]
    public string Summary { get; set; } = string.Empty;

    [Description("Suggested action for the support agent, one sentence")]
    public string SuggestedAction { get; set; } = string.Empty;

    public bool RequiresEscalation { get; set; }

    [Description("Estimated time to resolve as human-readable string, e.g. '30 minutes' or '2 business days'")]
    public string EstimatedResolution { get; set; } = string.Empty;
}

El servicio posee el prompt del sistema como un contrato estable y versionado, y solo depende de IChatClient. Utiliza el modo JSON neutral con respecto a los proveedores; para una aplicación estricta del esquema en Azure u OpenAI, use el enfoque directo con SDK mostrado anteriormente.

// TicketAnalysisService.cs
using Microsoft.Extensions.AI;
using System.Text.Json;

namespace AiProviderPoc;

public class TicketAnalysisService(IChatClient chatClient)
{
    // The system prompt is the stable contract — versioned and centralized here
    private const string SystemPrompt =
        """
        You are a support ticket classifier for an enterprise software product.
        Analyze the provided support ticket and return a structured assessment.
        Be concise and accurate. Do not invent information not present in the ticket.
        """;

    public async Task<TicketAnalysis> AnalyzeAsync(AnalysisRequest request, CancellationToken ct = default)
    {
        // Build the message list — system prompt is fixed, user content is the ticket
        var messages = new List<ChatMessage>
        {
            new(ChatRole.System, SystemPrompt),
            new(ChatRole.User, request.TicketText)
        };

        // Use JSON mode via ChatOptions — provider-agnostic structured output
        // For strict schema enforcement with Azure/OpenAI, use the SDK-direct approach instead
        var options = new ChatOptions
        {
            ResponseFormat = ChatResponseFormat.Json
        };

        ChatResponse response = await chatClient.GetResponseAsync(messages, options, ct);

        // Deserialize — IChatClient normalizes the response shape across all providers
        return JsonSerializer.Deserialize<TicketAnalysis>(response.Text,
            new JsonSerializerOptions { PropertyNameCaseInsensitive = true })
            ?? throw new InvalidOperationException("Model returned unparseable JSON");
    }
}

Program.cs registra un cliente por cada ramificación del proveedor y expone un único endpoint que nunca determina qué proveedor respondió:

using AiProviderPoc.Api;
using Anthropic;
using Azure.AI.OpenAI;
using Google.GenAI;
using Microsoft.Extensions.AI;
using OpenAI;
using System.ClientModel;          // ApiKeyCredential

var builder = WebApplication.CreateBuilder(args);
var config = builder.Configuration;

// Read provider selection from config — change "AI:Provider" to switch providers
string provider = config["AI:Provider"]
    ?? throw new InvalidOperationException("AI:Provider must be set in appsettings");

// Register IChatClient — each branch returns a different SDK's implementation
builder.Services.AddSingleton<IChatClient>(_ =>
{
    return provider.ToLowerInvariant() switch
    {
        "azure" =>
            // Azure AI Foundry: needs endpoint + key + deployment name
            new AzureOpenAIClient(
                new Uri(config["AI:Azure:Endpoint"]!),
                new ApiKeyCredential(config["AI:Azure:Key"]!))
            .GetChatClient(config["AI:Azure:Deployment"]!)
            .AsIChatClient(),

        "openai" =>
            // OpenAI direct: needs API key + model name
            new OpenAIClient(config["AI:OpenAI:Key"]!)
            .GetChatClient(config["AI:OpenAI:Model"]!)
            .AsIChatClient(),

        "anthropic" =>
            // AnthropicClient expects a ClientOptions instance (not a string API key).
            // Create a ClientOptions and pass it to the constructor.
            new AnthropicClient(
                new Anthropic.Core.ClientOptions
                {
                    ApiKey = config["AI:Anthropic:Key"]!
                })
            .AsIChatClient(),

        "gemini" =>
            // Google.GenAI: named params on Client constructor, no ClientConfig class needed.
            // Uses GOOGLE_APPLICATION_CREDENTIALS / ADC for auth; no API key required for Vertex AI.
            new Client(
                project: config["AI:Gemini:ProjectId"]!,
                location: config["AI:Gemini:Location"]!,
                vertexAI: true)
            .AsIChatClient(config["AI:Gemini:Model"]!),

        _ => throw new InvalidOperationException(quot;Unknown provider: '{provider}'. Valid: azure, openai, anthropic, gemini")
    };
});

// Register the service — depends only on IChatClient
builder.Services.AddScoped<TicketAnalysisService>();

var app = builder.Build();

// Single endpoint — provider is invisible here
app.MapPost("/analyze", async (AnalysisRequest request, TicketAnalysisService service, CancellationToken ct) =>
{
    var analysis = await service.AnalyzeAsync(request, ct);
    return Results.Ok(analysis);
});

app.Run();

Cambiar de proveedor implica realizar un cambio de configuración en appsettings.json:

{
  "AI": {
    "Provider": "azure",
    "Azure": {
      "Endpoint": "https://your-resource.openai.azure.com/",
      "Key": "your-azure-key",
      "Deployment": "gpt-5.4-mini-deployment"
    },
    "OpenAI": {
      "Key": "sk-...",
      "Model": "gpt-4o-mini"
    },
    "Anthropic": {
      "Key": "sk-ant-..."
    },
    "Gemini": {
      "ProjectId": "your-gcp-project",
      "Location": "us-central1",
      "Model": "gemini-2.0-flash"
    }
  }
}

Ejecútelo y publica un ticket:

dotnet run

curl -X POST http://localhost:5000/analyze \
  -H "Content-Type: application/json" \
  -d '{"ticketText": "My subscription renews tomorrow but I was charged twice this month. Account #A-1234."}'

La respuesta vuelve en la misma estructura, independientemente del proveedor que esté configurado:

{
  "category": "billing",
  "severity": "high",
  "summary": "Customer was charged twice for their subscription and is seeking a refund before their next renewal date.",
  "suggestedAction": "Review billing history for account A-1234 and initiate a refund for the duplicate charge.",
  "requiresEscalation": false,
  "estimatedResolution": "1 business day"
}

Ingeniería de prompts, evaluaciones y comparaciones justas

Al comparar la calidad, mantenga fijos los prompts y la temperatura, y varíe únicamente el proveedor y el modelo. De lo contrario, está comparando la suerte en la ingeniería de prompts y no a los proveedores. Mantenga las plantillas de prompts versionadas y realice pruebas A/B de forma independiente del adaptador del proveedor, ya que cada cambio de modelo invalida sus experimentos con prompts.

Genere spans de OpenTelemetry alrededor de las llamadas al proveedor, utilizando como atributos el proveedor, el modelo, el conteo de tokens y la razón de finalización. Combine esas métricas en línea con conjuntos de evaluación fuera de línea para que el cambio a un modelo más económico sea una decisión respaldada por evidencias y no por esperanzas.

También difieren los presupuestos de latencia. Algunos proveedores transmiten el primer token más rápido; otros destacan en tareas de completación larga. Mida tanto el tiempo hasta el primer token como el tiempo de punta a punta para su carga de trabajo antes de elegir uno por defecto.

Ejecutar más de un proveedor en producción

  • Canary: mantenga un prompt dorado por entorno que asegure que el JSON sea válido según el esquema y cumpla con un SLO de latencia, y rechace los despliegues que lo violen en el proveedor principal.
  • Filtros de contenido: los filtros de contenido de Azure y otras capas de seguridad devuelven formatos de error diferentes. Asócielos una vez a las excepciones de su dominio.
  • Conmutación por falla: decida qué errores pueden intentarse nuevamente en un proveedor secundario y cuáles deben ser rechazados inmediatamente, como los errores de autorización y las negaciones de políticas. Una conmutación por falla automática puede incumplir las promesas de residencia.
  • Personal de soporte: capacite al personal en el panel de facturación de cada proveedor habilitado. Es más fácil detener un aumento repentino en las solicitudes a las 2 a.m. cuando el manual indica exactamente qué sección del portal usar.
  • Desprovisionamiento: desactive las claves, elimine los registros de aplicaciones, borre los conjuntos de datos de evaluación que contengan texto de clientes y archive los informes de costos.

Lista de verificación rápida para tomar decisiones

¿Se requiere residencia? Comience con Azure OpenAI. ¿Documentos largos? Pruebe Anthropic. ¿Ya está en GCP? Evalúe Gemini. ¿Necesita los modelos más recientes de OpenAI el mismo día que se lanzan? Utilice la plataforma de OpenAI. ¿Necesita dos de las opciones anteriores? Construya primero un adaptador sencillo y un medidor de uso.

La elección del proveedor es una decisión de arquitectura empresarial con consecuencias en cuanto a SDK y costos. Compare en términos de residencia, identidad, transparencia de precios, salida estructurada y adecuación operativa.

Leer más