This entry is part 4 of 25 in the series Introducción a Microsoft Semantic Kernel

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#

Share this content:

Introducción a Microsoft Semantic Kernel

. Clasificación de Intenciones con LLMs en .NET . Vector Embeddings y Búsqueda Semántica con .NET

por David Cantón Nadales

David Cantón Nadales, ingeniero de software de Sevilla, España, es autor del bestseller Build Your own Metaverse with Unity. Reconocido como Microsoft MVP y Top Voices en Aplicaciones Móviles de LinkedIn. Con más de 20 años de experiencia, David ha liderado cientos proyectos a lo largo de su carrera, incluyendo videojuegos y aplicaciones de realidad virtual y aumentada con Oculus, Hololens, HTC Vive, DayDream y LeapMotion. Ha trabajado como Tech Lead en importantes multinacionales como Grupo Viajes El Corte Inglés y actualmente en SCRM Lidl del Grupo Schwarz. Fue embajador de la comunidad Samsung Dev Spain y organizador del Google Developers Group Sevilla. Durante el confinamiento por COVID-19, destacó como emprendedor social con la creación de Grita, una red social que facilitaba el apoyo psicológico entre personas. En 2022, ganó los Samsung Top Developers Awards.