4. Servicios de Chat Completion con Azure OpenAI y Semantic Kernel

Aprende a crear servicios de chat en .NET con Semantic Kernel y Azure OpenAI: ChatHistory, estado, streaming, function calling, persistencia, rate limiting y retries.

Introducción

Los servicios de Chat Completion permiten crear conversaciones naturales con modelos de IA. En este tutorial aprenderás a implementar servicios de chat robustos y escalables usando Azure OpenAI y Semantic Kernel.

Conceptos Fundamentales

Chat History

El historial de chat mantiene el contexto de la conversación:

using Microsoft.SemanticKernel.ChatCompletion;

var chatHistory = new ChatHistory();

// Mensaje del sistema: define el comportamiento del asistente
chatHistory.AddSystemMessage("Eres un experto en programación .NET.");

// Mensajes del usuario
chatHistory.AddUserMessage("¿Qué es async/await?");

// Respuestas del asistente
chatHistory.AddAssistantMessage("async/await es un patrón para programación asíncrona...");

Servicio de Chat Completion

using Microsoft.SemanticKernel;
using Microsoft.SemanticKernel.ChatCompletion;

var kernel = Kernel.CreateBuilder()
    .AddAzureOpenAIChatCompletion(
        deploymentName: "gpt-4",
        endpoint: "https://tu-recurso.openai.azure.com/",
        apiKey: "tu-api-key")
    .Build();

var chatService = kernel.GetRequiredService<IChatCompletionService>();

var response = await chatService.GetChatMessageContentAsync(
    chatHistory,
    kernel: kernel);

Console.WriteLine(response.Content);

Implementación de un Servicio de Chat Completo

Modelo de Conversación

public class ConversationMessage
{
    public required string Role { get; init; }  // "user", "assistant", "system"
    public required string Content { get; init; }
    public DateTime Timestamp { get; init; } = DateTime.UtcNow;
    public Dictionary<string, object>? Metadata { get; init; }
}

public class Conversation
{
    public string Id { get; init; } = Guid.NewGuid().ToString();
    public List<ConversationMessage> Messages { get; init; } = new();
    public DateTime CreatedAt { get; init; } = DateTime.UtcNow;
    public DateTime LastUpdated { get; set; } = DateTime.UtcNow;
    public Dictionary<string, object>? Context { get; set; }
}

Servicio de Chat con Estado

using Microsoft.SemanticKernel;
using Microsoft.SemanticKernel.ChatCompletion;
using Microsoft.SemanticKernel.Connectors.OpenAI;
using Microsoft.Extensions.Logging;

public class ChatService
{
    private readonly Kernel _kernel;
    private readonly ILogger<ChatService> _logger;
    private readonly Dictionary<string, Conversation> _conversations;
    private readonly string _systemPrompt;
    
    public ChatService(
        Kernel kernel,
        ILogger<ChatService> logger,
        string systemPrompt = "Eres un asistente útil y amigable.")
    {
        _kernel = kernel;
        _logger = logger;
        _conversations = new Dictionary<string, Conversation>();
        _systemPrompt = systemPrompt;
    }
    
    public string CreateConversation(Dictionary<string, object>? context = null)
    {
        var conversation = new Conversation
        {
            Context = context
        };
        
        _conversations[conversation.Id] = conversation;
        
        _logger.LogInformation("Conversación creada: {ConversationId}", conversation.Id);
        
        return conversation.Id;
    }
    
    public async Task<string> SendMessageAsync(
        string conversationId,
        string message,
        CancellationToken cancellationToken = default)
    {
        if (!_conversations.TryGetValue(conversationId, out var conversation))
        {
            throw new InvalidOperationException($"Conversación {conversationId} no encontrada");
        }
        
        try
        {
            // Agregar mensaje del usuario
            conversation.Messages.Add(new ConversationMessage
            {
                Role = "user",
                Content = message
            });
            
            // Construir historial de chat
            var chatHistory = BuildChatHistory(conversation);
            
            // Obtener respuesta del modelo
            var chatService = _kernel.GetRequiredService<IChatCompletionService>();
            
            var settings = new OpenAIPromptExecutionSettings
            {
                Temperature = 0.7,
                MaxTokens = 800,
                TopP = 0.9
            };
            
            var response = await chatService.GetChatMessageContentAsync(
                chatHistory,
                settings,
                _kernel,
                cancellationToken);
            
            var assistantMessage = response.Content ?? string.Empty;
            
            // Guardar respuesta del asistente
            conversation.Messages.Add(new ConversationMessage
            {
                Role = "assistant",
                Content = assistantMessage,
                Metadata = new Dictionary<string, object>
                {
                    ["model"] = response.ModelId ?? "unknown",
                    ["tokens"] = response.Metadata?.ContainsKey("Usage") == true 
                        ? response.Metadata["Usage"] 
                        : null
                }
            });
            
            conversation.LastUpdated = DateTime.UtcNow;
            
            _logger.LogInformation(
                "Mensaje procesado en conversación {ConversationId}. Tokens: {Tokens}",
                conversationId,
                response.Metadata?.ContainsKey("Usage") == true 
                    ? response.Metadata["Usage"] 
                    : "N/A");
            
            return assistantMessage;
        }
        catch (Exception ex)
        {
            _logger.LogError(ex, "Error procesando mensaje en conversación {ConversationId}", 
                conversationId);
            throw;
        }
    }
    
    public Conversation? GetConversation(string conversationId)
    {
        return _conversations.TryGetValue(conversationId, out var conversation) 
            ? conversation 
            : null;
    }
    
    public void ClearConversation(string conversationId)
    {
        if (_conversations.TryGetValue(conversationId, out var conversation))
        {
            conversation.Messages.Clear();
            conversation.LastUpdated = DateTime.UtcNow;
            
            _logger.LogInformation("Conversación {ConversationId} limpiada", conversationId);
        }
    }
    
    public void DeleteConversation(string conversationId)
    {
        _conversations.Remove(conversationId);
        _logger.LogInformation("Conversación {ConversationId} eliminada", conversationId);
    }
    
    private ChatHistory BuildChatHistory(Conversation conversation)
    {
        var chatHistory = new ChatHistory();
        
        // Agregar mensaje del sistema
        chatHistory.AddSystemMessage(_systemPrompt);
        
        // Agregar contexto si existe
        if (conversation.Context != null && conversation.Context.Count > 0)
        {
            var contextInfo = string.Join(", ", 
                conversation.Context.Select(kvp => $"{kvp.Key}: {kvp.Value}"));
            chatHistory.AddSystemMessage($"Contexto: {contextInfo}");
        }
        
        // Agregar mensajes de la conversación (últimos N mensajes para no exceder límite)
        var recentMessages = conversation.Messages.TakeLast(10);
        
        foreach (var msg in recentMessages)
        {
            switch (msg.Role.ToLower())
            {
                case "user":
                    chatHistory.AddUserMessage(msg.Content);
                    break;
                case "assistant":
                    chatHistory.AddAssistantMessage(msg.Content);
                    break;
                case "system":
                    chatHistory.AddSystemMessage(msg.Content);
                    break;
            }
        }
        
        return chatHistory;
    }
}

Streaming de Respuestas

Para respuestas en tiempo real:

public class StreamingChatService
{
    private readonly Kernel _kernel;
    
    public async IAsyncEnumerable<string> StreamMessageAsync(
        string conversationId,
        string message,
        [EnumeratorCancellation] CancellationToken cancellationToken = default)
    {
        var chatService = _kernel.GetRequiredService<IChatCompletionService>();
        var chatHistory = BuildChatHistory(conversationId);
        
        chatHistory.AddUserMessage(message);
        
        var settings = new OpenAIPromptExecutionSettings
        {
            Temperature = 0.7,
            MaxTokens = 800
        };
        
        var fullResponse = new StringBuilder();
        
        await foreach (var chunk in chatService.GetStreamingChatMessageContentsAsync(
            chatHistory,
            settings,
            _kernel,
            cancellationToken))
        {
            var content = chunk.Content ?? string.Empty;
            fullResponse.Append(content);
            yield return content;
        }
        
        // Guardar mensaje completo después del streaming
        SaveAssistantMessage(conversationId, fullResponse.ToString());
    }
    
    private void SaveAssistantMessage(string conversationId, string message)
    {
        // Implementación para guardar el mensaje
    }
}

Chat con Funciones (Function Calling)

public class FunctionCallingChatService
{
    private readonly Kernel _kernel;
    
    public FunctionCallingChatService(Kernel kernel)
    {
        _kernel = kernel;
        
        // Registrar funciones disponibles
        _kernel.Plugins.AddFromObject(new WeatherPlugin());
        _kernel.Plugins.AddFromObject(new CalculatorPlugin());
    }
    
    public async Task<string> ChatWithFunctionsAsync(
        string message,
        CancellationToken cancellationToken = default)
    {
        var chatService = _kernel.GetRequiredService<IChatCompletionService>();
        var chatHistory = new ChatHistory();
        
        chatHistory.AddSystemMessage("""
            Eres un asistente que puede usar funciones para responder preguntas.
            Tienes acceso a:
            - WeatherPlugin: para obtener información del clima
            - CalculatorPlugin: para realizar cálculos
            
            Usa las funciones cuando sea necesario.
            """);
        
        chatHistory.AddUserMessage(message);
        
        var settings = new OpenAIPromptExecutionSettings
        {
            Temperature = 0.7,
            ToolCallBehavior = ToolCallBehavior.AutoInvokeKernelFunctions
        };
        
        var response = await chatService.GetChatMessageContentAsync(
            chatHistory,
            settings,
            _kernel,
            cancellationToken);
        
        return response.Content ?? "No pude generar una respuesta";
    }
}

// Plugins de ejemplo
public class WeatherPlugin
{
    [KernelFunction("get_weather")]
    [Description("Obtiene el clima actual de una ciudad")]
    public string GetWeather([Description("Nombre de la ciudad")] string city)
    {
        // Simular llamada a API de clima
        return $"En {city}: 22°C, soleado";
    }
}

public class CalculatorPlugin
{
    [KernelFunction("calculate")]
    [Description("Realiza un cálculo matemático")]
    public double Calculate(
        [Description("Primera operando")] double a,
        [Description("Operador: +, -, *, /")] string op,
        [Description("Segundo operando")] double b)
    {
        return op switch
        {
            "+" => a + b,
            "-" => a - b,
            "*" => a * b,
            "/" => a / b,
            _ => 0
        };
    }
}

Chat Multimodal (Texto e Imágenes)

using Microsoft.SemanticKernel.ChatCompletion;

public class MultimodalChatService
{
    private readonly Kernel _kernel;
    
    public async Task<string> AnalyzeImageAsync(
        string imageUrl,
        string question,
        CancellationToken cancellationToken = default)
    {
        var chatService = _kernel.GetRequiredService<IChatCompletionService>();
        var chatHistory = new ChatHistory();
        
        chatHistory.AddSystemMessage("Eres un experto en análisis de imágenes.");
        
        // Agregar imagen y pregunta
        var message = new ChatMessageContent(
            AuthorRole.User,
            new ChatMessageContentItemCollection
            {
                new TextContent(question),
                new ImageContent(new Uri(imageUrl))
            });
        
        chatHistory.Add(message);
        
        var response = await chatService.GetChatMessageContentAsync(
            chatHistory,
            kernel: _kernel,
            cancellationToken: cancellationToken);
        
        return response.Content ?? "No pude analizar la imagen";
    }
}

Chat con Memoria Persistente

using System.Text.Json;

public class PersistentChatService
{
    private readonly ChatService _chatService;
    private readonly string _storageDirectory;
    
    public PersistentChatService(ChatService chatService, string storageDirectory)
    {
        _chatService = chatService;
        _storageDirectory = storageDirectory;
        
        Directory.CreateDirectory(storageDirectory);
    }
    
    public async Task SaveConversationAsync(string conversationId)
    {
        var conversation = _chatService.GetConversation(conversationId);
        if (conversation == null)
            throw new InvalidOperationException("Conversación no encontrada");
        
        var filePath = Path.Combine(_storageDirectory, $"{conversationId}.json");
        var json = JsonSerializer.Serialize(conversation, new JsonSerializerOptions
        {
            WriteIndented = true
        });
        
        await File.WriteAllTextAsync(filePath, json);
    }
    
    public async Task<string> LoadConversationAsync(string conversationId)
    {
        var filePath = Path.Combine(_storageDirectory, $"{conversationId}.json");
        
        if (!File.Exists(filePath))
            throw new FileNotFoundException("Conversación no encontrada");
        
        var json = await File.ReadAllTextAsync(filePath);
        var conversation = JsonSerializer.Deserialize<Conversation>(json);
        
        if (conversation == null)
            throw new InvalidOperationException("Error deserializando conversación");
        
        // Cargar en el servicio de chat
        // Implementación específica según tu arquitectura
        
        return conversationId;
    }
    
    public async Task<List<string>> ListConversationsAsync()
    {
        var files = Directory.GetFiles(_storageDirectory, "*.json");
        return files.Select(Path.GetFileNameWithoutExtension).ToList()!;
    }
}

Manejo de Rate Limits

public class RateLimitedChatService
{
    private readonly ChatService _chatService;
    private readonly SemaphoreSlim _semaphore;
    private readonly int _maxConcurrent;
    
    public RateLimitedChatService(ChatService chatService, int maxConcurrent = 5)
    {
        _chatService = chatService;
        _maxConcurrent = maxConcurrent;
        _semaphore = new SemaphoreSlim(maxConcurrent, maxConcurrent);
    }
    
    public async Task<string> SendMessageAsync(
        string conversationId,
        string message,
        CancellationToken cancellationToken = default)
    {
        await _semaphore.WaitAsync(cancellationToken);
        
        try
        {
            return await _chatService.SendMessageAsync(
                conversationId, 
                message, 
                cancellationToken);
        }
        finally
        {
            _semaphore.Release();
        }
    }
}

Retry con Exponential Backoff

using Polly;
using Polly.Retry;

public class ResilientChatService
{
    private readonly ChatService _chatService;
    private readonly AsyncRetryPolicy _retryPolicy;
    
    public ResilientChatService(ChatService chatService)
    {
        _chatService = chatService;
        
        _retryPolicy = Policy
            .Handle<HttpRequestException>()
            .Or<TimeoutException>()
            .WaitAndRetryAsync(
                retryCount: 3,
                sleepDurationProvider: attempt => TimeSpan.FromSeconds(Math.Pow(2, attempt)),
                onRetry: (exception, timeSpan, retryCount, context) =>
                {
                    Console.WriteLine($"Intento {retryCount} después de {timeSpan.TotalSeconds}s");
                });
    }
    
    public async Task<string> SendMessageWithRetryAsync(
        string conversationId,
        string message,
        CancellationToken cancellationToken = default)
    {
        return await _retryPolicy.ExecuteAsync(async () =>
            await _chatService.SendMessageAsync(conversationId, message, cancellationToken));
    }
}

Testing de Servicios de Chat

using Xunit;
using Moq;

public class ChatServiceTests
{
    [Fact]
    public async Task SendMessageAsync_ReturnsResponse()
    {
        // Arrange
        var kernel = CreateTestKernel();
        var logger = Mock.Of<ILogger<ChatService>>();
        var chatService = new ChatService(kernel, logger);
        
        var conversationId = chatService.CreateConversation();
        
        // Act
        var response = await chatService.SendMessageAsync(
            conversationId, 
            "Hola, ¿cómo estás?");
        
        // Assert
        Assert.NotNull(response);
        Assert.NotEmpty(response);
    }
    
    [Fact]
    public void CreateConversation_GeneratesUniqueId()
    {
        // Arrange
        var kernel = CreateTestKernel();
        var logger = Mock.Of<ILogger<ChatService>>();
        var chatService = new ChatService(kernel, logger);
        
        // Act
        var id1 = chatService.CreateConversation();
        var id2 = chatService.CreateConversation();
        
        // Assert
        Assert.NotEqual(id1, id2);
    }
    
    private Kernel CreateTestKernel()
    {
        var builder = Kernel.CreateBuilder();
        // Configurar kernel de prueba
        return builder.Build();
    }
}

Mejores Prácticas

1. Limitar Historial de Mensajes

var recentMessages = conversation.Messages
    .TakeLast(10)  // Solo últimos 10 mensajes
    .ToList();

2. Validar Longitud de Mensajes

public async Task<string> SendMessageAsync(string conversationId, string message)
{
    const int maxLength = 4000;
    
    if (message.Length > maxLength)
    {
        throw new ArgumentException($"Mensaje excede {maxLength} caracteres");
    }
    
    // Procesar mensaje
}

3. Sanitizar Entrada del Usuario

private string SanitizeInput(string input)
{
    // Eliminar caracteres peligrosos o no deseados
    return input
        .Replace("<script>", "")
        .Replace("</script>", "")
        .Trim();
}

4. Monitorear Uso de Tokens

private void LogTokenUsage(ChatMessageContent response)
{
    if (response.Metadata?.ContainsKey("Usage") == true)
    {
        var usage = response.Metadata["Usage"];
        _logger.LogInformation("Tokens usados: {Usage}", usage);
    }
}

Conclusión

Los servicios de chat completion son la base de aplicaciones conversacionales modernas. Con Semantic Kernel puedes crear servicios robustos que manejan estado, funciones, streaming y más. Las prácticas de manejo de errores, rate limiting y persistencia aseguran un sistema confiable en producción.


Palabras clave: chat completion, Azure OpenAI, Semantic Kernel, conversational AI, chatbot, streaming, function calling, C#