AI in Production -- Parte 6

Integrar IA en Sistemas Existentes: Patrones que no rompen lo que funciona

#ai #architecture #integration #dotnet #patterns

El Problema

Los artículos anteriores han construido un servicio de IA desde cero. Interfaces limpias, esquema nuevo, diseño greenfield.

Tu sistema en producción no es así.

Tiene una tabla Documents con 2 millones de filas, ninguna con un resumen. Tiene un sistema de autenticación anterior a los JWTs. Tiene un pipeline de peticiones donde alguien añadió un middleware crítico hace tres años y nadie entiende del todo por qué. Tiene usuarios ahora mismo, haciendo peticiones.

No puedes parar todo y reescribirlo. No puedes añadir un campo de IA obligatorio a una tabla que ya tiene 2 millones de filas. No puedes hacer que cada endpoint de documentos espere 3 segundos a una respuesta de IA sin que los usuarios lo noten.

La pregunta no es “cómo construyo una funcionalidad de IA.” Es “cómo añado una funcionalidad de IA a algo que ya funciona, sin romperlo.”

Eso requiere patrones diferentes.

A developer holds a small AI module and stares at a large complex machine made of pipes, gears, and blinking lights labeled DB, AUTH, and API — clearly old but running fine. They have a thoughtful expression, trying to figure out where to plug it in.

Los Tres Patrones

1. Enriquecimiento asíncrono. No bloquees la petición. Cuando se guarda un documento, responde inmediatamente — luego enriquécelo con datos de IA en segundo plano. Los usuarios reciben una respuesta rápida. El resumen de IA aparece unos segundos después. Este es el patrón que quieres para el 90% de las funcionalidades de IA.

2. Feature flags. No des la nueva funcionalidad de IA a todos el primer día. Despliégala al 10% de los usuarios. Vigila las métricas del artículo 3. Si algo va mal, apágala sin desplegar. Los feature flags también te permiten ejecutar el camino viejo y el nuevo en paralelo — útil cuando quieres comparar outputs antes de comprometerte del todo.

3. Evolución de esquema con nullables. Cuando añades datos generados por IA a una tabla existente, hazlos nullable. Las filas existentes no tienen resumen todavía — no pasa nada. Las filas nuevas lo obtienen. Las antiguas se rellenan con backfill a lo largo del tiempo. Nunca añadas una columna de IA obligatoria a una tabla con datos existentes.

Juntos te dan un camino de integración seguro: no estás reemplazando lo que existe, lo estás enriqueciendo — gradualmente, opcionalmente, sin downtime.

Ejecución

1. Enriquecimiento en segundo plano — no bloquees la petición

El patrón: tu endpoint existente guarda el documento y responde inmediatamente. También encola una tarea para generar el resumen con IA. Un worker en segundo plano la recoge y escribe el resultado de vuelta en la base de datos.

Primero, una cola de tareas en segundo plano sencilla:

public interface IBackgroundTaskQueue
{
    void Enqueue(Func<CancellationToken, Task> task);
    Task<Func<CancellationToken, Task>> DequeueAsync(CancellationToken ct);
}

public class BackgroundTaskQueue : IBackgroundTaskQueue
{
    private readonly Channel<Func<CancellationToken, Task>> _queue =
        Channel.CreateUnbounded<Func<CancellationToken, Task>>();

    public void Enqueue(Func<CancellationToken, Task> task) =>
        _queue.Writer.TryWrite(task);

    public async Task<Func<CancellationToken, Task>> DequeueAsync(CancellationToken ct) =>
        await _queue.Reader.ReadAsync(ct);
}

Tu endpoint existente apenas cambia:

[HttpPost]
public async Task<IActionResult> CreateDocument(
    CreateDocumentRequest request,
    CancellationToken cancellationToken)
{
    // Existing logic — unchanged
    var document = await _documents.InsertAsync(
        new Document { Title = request.Title, Body = request.Body },
        cancellationToken);

    // Enqueue AI enrichment — fire and forget
    _taskQueue.Enqueue(async ct =>
        await _enricher.EnrichAsync(document.Id, ct));

    // Return immediately — no waiting for AI
    return CreatedAtAction(nameof(GetDocument), new { id = document.Id }, document);
}

El enricher se ejecuta en segundo plano:

public class DocumentAiEnricher
{
    private readonly IDocumentRepository _documents;
    private readonly IAiSummaryService _ai;
    private readonly ILogger<DocumentAiEnricher> _logger;

    public async Task EnrichAsync(Guid documentId, CancellationToken ct)
    {
        var document = await _documents.GetByIdAsync(documentId, ct);
        if (document is null) return;

        // Already enriched — skip
        if (document.AiSummary is not null) return;

        var summary = await _ai.SummarizeAsync(document.Body, ct);
        if (summary is null)
        {
            _logger.LogWarning(
                "AI enrichment skipped for document {Id} — AI unavailable", documentId);
            return;
        }

        await _documents.UpdateAiSummaryAsync(documentId, summary, ct);

        _logger.LogInformation(
            "Document {Id} enriched with AI summary", documentId);
    }
}

El hosted service que drena la cola:

public class BackgroundEnrichmentWorker : BackgroundService
{
    private readonly IBackgroundTaskQueue _queue;
    private readonly IServiceScopeFactory _scopeFactory;
    private readonly ILogger<BackgroundEnrichmentWorker> _logger;

    protected override async Task ExecuteAsync(CancellationToken stoppingToken)
    {
        while (!stoppingToken.IsCancellationRequested)
        {
            var task = await _queue.DequeueAsync(stoppingToken);

            // Create a scope for each task — enricher uses scoped services
            await using var scope = _scopeFactory.CreateAsyncScope();
            try
            {
                await task(stoppingToken);
            }
            catch (Exception ex)
            {
                _logger.LogError(ex, "Background enrichment task failed");
            }
        }
    }
}

A split scene: on the left a user clicks Save and gets an instant checkmark. On the right, behind the scenes, a small robot picks up the document from a queue, processes it through an AI brain, and writes SUMMARY READY back. A dotted line connects both sides.

Registra todo:

// Program.cs
builder.Services.AddSingleton<IBackgroundTaskQueue, BackgroundTaskQueue>();
builder.Services.AddScoped<DocumentAiEnricher>();
builder.Services.AddHostedService<BackgroundEnrichmentWorker>();

2. Feature flags — despliega con seguridad

Un feature flag evita que tengas que desplegar para activar o desactivar algo. La interfaz es simple:

public interface IFeatureFlags
{
    Task<bool> IsEnabledAsync(string flag, string userId, CancellationToken ct = default);
}

Para empezar sencillo, impleméntalo con configuración — no hace falta un servicio externo:

public class ConfigFeatureFlags : IFeatureFlags
{
    private readonly IConfiguration _config;

    public ConfigFeatureFlags(IConfiguration config) => _config = config;

    public Task<bool> IsEnabledAsync(string flag, string userId, CancellationToken ct = default)
    {
        var section = _config.GetSection($"FeatureFlags:{flag}");

        // Flag not configured = disabled
        if (!section.Exists()) return Task.FromResult(false);

        // Global on/off
        if (bool.TryParse(section.Value, out var enabled))
            return Task.FromResult(enabled);

        // Percentage rollout: "10" means 10% of users
        if (int.TryParse(section["Percentage"], out var percentage))
        {
            // Stable per user: same user always gets same result
            var hash = Math.Abs(HashCode.Combine(flag, userId));
            return Task.FromResult(hash % 100 < percentage);
        }

        return Task.FromResult(false);
    }
}

A developer stands at a large control panel with toggle switches. Most are OFF. One section labeled "10%" is flipped ON. A screen above shows "AI Summaries: 10% rollout". The developer holds a coffee mug, watching metrics calmly.

En appsettings.json:

{
  "FeatureFlags": {
    "ai-summaries": {
      "Percentage": "10"
    }
  }
}

Cambia "10" a "100" para desplegar a todos. Cambia a "false" para apagarlo. Sin despliegue.

Úsalo en el enricher:

public async Task EnrichAsync(Guid documentId, string userId, CancellationToken ct)
{
    if (!await _flags.IsEnabledAsync("ai-summaries", userId, ct))
        return;

    // ... rest of enrichment
}

3. Evolución de esquema — nullable desde el día uno

Nunca añadas una columna de IA obligatoria a una tabla existente. Siempre nullable, siempre backfill después.

// EF Core migration
public partial class AddAiSummaryToDocuments : Migration
{
    protected override void Up(MigrationBuilder migrationBuilder)
    {
        // Nullable — existing 2 million rows are unaffected
        migrationBuilder.AddColumn<string>(
            name: "AiSummary",
            table: "Documents",
            nullable: true);

        migrationBuilder.AddColumn<DateTimeOffset>(
            name: "AiSummaryGeneratedAt",
            table: "Documents",
            nullable: true);
    }

    protected override void Down(MigrationBuilder migrationBuilder)
    {
        migrationBuilder.DropColumn(name: "AiSummary", table: "Documents");
        migrationBuilder.DropColumn(name: "AiSummaryGeneratedAt", table: "Documents");
    }
}

Tu entidad refleja los campos nullable:

public class Document
{
    public Guid Id { get; set; }
    public string Title { get; set; } = default!;
    public string Body { get; set; } = default!;

    // Nullable — not all documents have been enriched yet
    public string? AiSummary { get; set; }
    public DateTimeOffset? AiSummaryGeneratedAt { get; set; }
}

Tu respuesta de API es honesta al respecto:

public record DocumentResponse(
    Guid Id,
    string Title,
    string Body,
    string? AiSummary,          // null until enrichment runs
    bool AiSummaryAvailable     // client uses this to decide what to show
)
{
    public bool AiSummaryAvailable => AiSummary is not null;
}

El cliente muestra un estado “Generando resumen…” cuando AiSummaryAvailable es false, y el resumen real cuando llega. Sin error. Sin UI rota.

Backfill de datos existentes

Una vez que la funcionalidad está en producción y estable, rellena las filas que no tienen resumen todavía. Ejecútalo como un job en segundo plano, no como una migración — no quieres bloquear la tabla:

public class AiBackfillService
{
    private readonly IDocumentRepository _documents;
    private readonly DocumentAiEnricher _enricher;
    private readonly ILogger<AiBackfillService> _logger;

    // Call this from a scheduled job or a one-off endpoint
    public async Task BackfillAsync(int batchSize, CancellationToken ct)
    {
        var pending = await _documents.GetWithoutAiSummaryAsync(batchSize, ct);

        _logger.LogInformation(
            "Backfilling AI summaries for {Count} documents", pending.Count);

        foreach (var doc in pending)
        {
            await _enricher.EnrichAsync(doc.Id, ct);

            // Small delay between calls — don't hammer the AI API
            await Task.Delay(TimeSpan.FromMilliseconds(200), ct);
        }
    }
}

Ejecútalo en lotes a lo largo del tiempo. Controla el progreso con una métrica de contador del artículo 3.

Cómo se ve de punta a punta

Se crea un documento. El endpoint responde en 50ms. Dos segundos después, el worker en segundo plano lo enriquece. La próxima vez que el cliente hace polling o abre el documento, AiSummaryAvailable es true.

La IA es completamente opcional para el flujo principal. Si el servicio de IA está caído, el documento se guarda igual. El enriquecimiento se reintenta después (o se salta). El usuario nunca se queda bloqueado.

Esa es la mentalidad de integración: la IA enriquece tu sistema, no lo controla.

Checklist

  • Las funcionalidades de IA bloquean la petición principal, o se ejecutan de forma asíncrona?
  • Las columnas generadas por IA son nullable en tu esquema?
  • Hay un feature flag para activar/desactivar funcionalidades de IA sin desplegar?
  • Tu backfill se ejecuta en lotes pequeños con pausas, no en una query gigante?
  • La respuesta de la API le dice al cliente si los datos de IA están disponibles?
  • Si el servicio de IA está completamente caído, los usuarios pueden seguir creando y leyendo datos?

La última pregunta es la más importante. Si la respuesta es no, tienes una dependencia dura — y ya has pasado por los artículos 2 y 3.

Antes del Siguiente Artículo

Has integrado IA en un sistema existente. Está enriqueciendo datos, respetando feature flags, sin bloquear peticiones.

Ahora alguien pregunta: cómo testeamos esto? La funcionalidad usa un modelo de IA que devuelve un output diferente cada vez. No puedes hacer assert de response == "expected summary". Tu suite de tests existente no cubre comportamiento probabilístico.

Eso es el artículo 7. Testear funcionalidades de IA — estrategias deterministas para un componente no determinista.


Si esta serie te resulta útil, considera invitarme a un café.

Este es el artículo 6 de la serie AI in Production. Siguiente: Testing AI Features — cómo escribir tests fiables para algo que es probabilístico por diseño.

Comments

Loading comments...