Observabilidad para aplicaciones LLM: seguimiento de prompt, costo y calidad

1. Descripción general

Cuando llevas una funcionalidad que usa LLM (Large Language Model) a producción, las primeras preguntas suelen ser: “¿Cuánto nos cuesta este mes?”, “¿Por qué la respuesta de ayer fue incorrecta?”, “¿Es mejor el nuevo prompt que el anterior?”. Las métricas HTTP convencionales no responden estas preguntas.

Este artículo explica el concepto de observabilidad para aplicaciones LLM y por qué este tema está ganando importancia. Después, construirás una capa decoradora alrededor del cliente LLM en Spring Boot 3 para registrar trazas, tokens, costo y señales de calidad. La parte final analiza errores comunes y casos en los que no deberías excederte.

2. Por qué los LLM necesitan observabilidad propia

Una API REST convencional es determinista. El mismo input siempre produce el mismo output. Solo necesitas monitorear latencia, tasa de error y throughput. Las llamadas a LLM son diferentes en varios aspectos:

  • No deterministas: el mismo prompt puede producir resultados diferentes. El error más común no es un HTTP 500, sino un HTTP 200 con una respuesta incorrecta.
  • Costo calculado por token: la factura depende de la longitud del prompt y de la respuesta, no del número de requests. Un template de prompt inflado puede multiplicar el costo varias veces sin que nadie se dé cuenta.
  • Latencia muy variable: el tiempo de respuesta es proporcional al número de tokens generados. El promedio es casi inútil. Necesitas ver la distribución (p95, p99).
  • El prompt es código: cambiar una frase en el system prompt es un “deploy”. Necesitas saber qué request corrió con qué versión del prompt.

Por eso, la observabilidad para LLM generalmente gira alrededor de tres pilares:

  1. Trazas de prompt/respuesta: qué request, qué modelo, qué versión de prompt, cuánto tiempo tardó, por qué terminó.
  2. Costo: tokens entrada/salida, conversión a dinero, desglose por funcionalidad.
  3. Calidad: retroalimentación del usuario, comprobaciones automáticas, evaluación offline.

La comunidad de OpenTelemetry también está construyendo un conjunto de convenciones de nombres de atributos para GenAI (prefijo gen_ai.*). Esta convención aún está evolucionando. En este artículo, usarás esta convención de nombres para facilitar la integración posterior.

3. Configuración del proyecto

Añade Actuator, el registro de Prometheus y el puente de OpenTelemetry para trazas. Las versiones están gestionadas por Spring Boot BOM, así que no necesitas declararlas:

<dependencies>
    <dependency>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter-actuator</artifactId>
    </dependency>
    <dependency>
        <groupId>io.micrometer</groupId>
        <artifactId>micrometer-registry-prometheus</artifactId>
    </dependency>
    <dependency>
        <groupId>io.micrometer</groupId>
        <artifactId>micrometer-tracing-bridge-otel</artifactId>
    </dependency>
    <dependency>
        <groupId>io.opentelemetry</groupId>
        <artifactId>opentelemetry-exporter-otlp</artifactId>
    </dependency>
    <dependency>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter-test</artifactId>
        <scope>test</scope>
    </dependency>
</dependencies>

Configura los endpoints y dónde enviar las trazas:

management:
  endpoints:
    web:
      exposure:
        include: health,prometheus
  tracing:
    sampling:
      probability: 0.1
  otlp:
    tracing:
      endpoint: http://localhost:4318/v1/traces

Aquí probability: 0.1 significa que solo el 10% de los requests se envían como trazas. Las métricas se cuentan completamente al 100%. Esta diferencia es importante cuando calculas el costo en la sección 5.

4. Envuelve el cliente LLM con Observation

Defines una abstracción pequeña, independiente del proveedor:

public record LlmRequest(String model, String promptVersion, String feature, String prompt) {}

public record LlmResponse(String content, int inputTokens, int outputTokens, String finishReason) {}

public interface LlmClient {
    LlmResponse complete(LlmRequest request);
}

LlmResponse incluye el número de tokens. La mayoría de APIs de proveedores devuelven un campo de uso en la respuesta, así que solo necesitas mapearlo a este record. Usa los números que el proveedor devuelve en lugar de estimar tokens por ti mismo.

A continuación va el decorador que añade observabilidad:

public class ObservedLlmClient implements LlmClient {

    private final LlmClient delegate;
    private final ObservationRegistry observations;
    private final MeterRegistry meters;
    private final CostCalculator costs;

    public ObservedLlmClient(LlmClient delegate, ObservationRegistry observations,
                             MeterRegistry meters, CostCalculator costs) {
        this.delegate = delegate;
        this.observations = observations;
        this.meters = meters;
        this.costs = costs;
    }

    @Override
    public LlmResponse complete(LlmRequest req) {
        Observation obs = Observation.createNotStarted("llm.request", observations)
                .lowCardinalityKeyValue("gen_ai.request.model", req.model())
                .lowCardinalityKeyValue("prompt.version", req.promptVersion())
                .lowCardinalityKeyValue("feature", req.feature());

        return obs.observe(() -> {
            LlmResponse res = delegate.complete(req);
            obs.lowCardinalityKeyValue("gen_ai.response.finish_reason", res.finishReason());
            obs.highCardinalityKeyValue("gen_ai.usage.input_tokens", String.valueOf(res.inputTokens()));
            obs.highCardinalityKeyValue("gen_ai.usage.output_tokens", String.valueOf(res.outputTokens()));

            Tags tags = Tags.of("model", req.model(), "feature", req.feature());
            meters.counter("llm.tokens", tags.and("type", "input")).increment(res.inputTokens());
            meters.counter("llm.tokens", tags.and("type", "output")).increment(res.outputTokens());
            meters.counter("llm.cost.usd", tags).increment(costs.cost(req.model(), res).doubleValue());
            return res;
        });
    }
}

Puntos clave:

  • Observation.createNotStarted("llm.request", ...) crea una observation. Cuando hay un DefaultMeterObservationHandler (Spring Boot lo configura automáticamente), esta observation genera un timer llm.request. Cuando hay un puente de tracing, también genera un span.
  • lowCardinalityKeyValue añade etiquetas a tanto la métrica como el span. Solo úsalo para valores con un conjunto finito, como nombre de modelo, versión de prompt, nombre de funcionalidad.
  • highCardinalityKeyValue solo añade etiquetas al span. El número de tokens de cada request encaja aquí. Si lo añades a la métrica, cada valor diferente crearía una nueva serie temporal.
  • observe(...) automáticamente inicia/detiene. Si hay una excepción, la registra en la observation.
  • Los dos contadores llm.tokens y llm.cost.usd se cuentan para todos los requests, independientemente del sampling de trazas.

Registra el bean:

@Configuration
@EnableConfigurationProperties(LlmPricingProperties.class)
class LlmConfig {

    @Bean
    LlmClient llmClient(ObservationRegistry observations, MeterRegistry meters,
                        LlmPricingProperties pricing) {
        LlmClient raw = new VendorLlmClient(); // cliente que llama la API real de tu proveedor
        return new ObservedLlmClient(raw, observations, meters,
                new CostCalculator(pricing.pricing()));
    }
}

En /actuator/prometheus verás series como estas:

llm_request_seconds_count{feature="support-chat",gen_ai_request_model="model-a",prompt_version="v3",...} 42
llm_tokens_total{feature="support-chat",model="model-a",type="input"} 50400
llm_cost_usd_total{feature="support-chat",model="model-a"} 0.0441

Nota: Si usas Spring AI, este framework ya tiene observations integradas para chat models. Entender el mecanismo anterior te ayuda a leer correctamente esas métricas y añadir lo que falte, como la etiqueta feature o prompt.version.

5. Calcula el costo a partir de tokens

Los precios cambian por proveedor y en el tiempo, así que no los hardcodees. Extráelos a la configuración:

@ConfigurationProperties(prefix = "llm")
public record LlmPricingProperties(Map<String, ModelPrice> pricing) {
    public record ModelPrice(BigDecimal inputPerMillion, BigDecimal outputPerMillion) {}
}
llm:
  pricing:
    model-a:
      input-per-million: 0.50   # precio de ejemplo, reemplázalo con los precios reales de tu proveedor
      output-per-million: 1.50

La clase de cálculo:

public class CostCalculator {

    private static final BigDecimal MILLION = BigDecimal.valueOf(1_000_000);
    private final Map<String, LlmPricingProperties.ModelPrice> pricing;

    public CostCalculator(Map<String, LlmPricingProperties.ModelPrice> pricing) {
        this.pricing = pricing;
    }

    public BigDecimal cost(String model, LlmResponse res) {
        var price = pricing.get(model);
        if (price == null) {
            return BigDecimal.ZERO; // deberías registrar una advertencia: modelo sin precio
        }
        return price.inputPerMillion().multiply(BigDecimal.valueOf(res.inputTokens()))
                .add(price.outputPerMillion().multiply(BigDecimal.valueOf(res.outputTokens())))
                .divide(MILLION, 8, RoundingMode.HALF_UP);
    }
}

Con 1.200 tokens de entrada y 300 de salida según los precios de ejemplo, el resultado es:

(1200 × 0.50 + 300 × 1.50) / 1_000_000 = 0.00105

Este contador te dice el costo estimado casi en tiempo real, desglosado por feature y model. Así puedes establecer alertas, por ejemplo “el costo de support-chat en 1 hora excede el umbral X”. La factura oficial sigue viniendo del proveedor. Compara regularmente para detectar desviaciones, como cuando hay tokens en caché o descuentos por lotes que la fórmula simple no considera.

6. Registra prompts de forma segura

Al depurar una respuesta incorrecta, generalmente quieres ver el prompt completo. Pero los prompts a menudo contienen datos del usuario como emails, números de teléfono o contenido de pedidos. Por eso necesitas hacer dos cosas: enmascarar información sensible y tomar muestras.

public class PromptLogger {

    private static final Logger log = LoggerFactory.getLogger(PromptLogger.class);
    private static final Pattern EMAIL = Pattern.compile("[\\w.+-]+@[\\w-]+\\.[\\w.]+");
    private static final Pattern PHONE = Pattern.compile("\\b0\\d{9}\\b");

    private final double sampleRate;

    public PromptLogger(double sampleRate) {
        this.sampleRate = sampleRate;
    }

    public void log(LlmRequest req, LlmResponse res) {
        if (ThreadLocalRandom.current().nextDouble() >= sampleRate) {
            return;
        }
        log.info("llm.exchange feature={} promptVersion={} prompt=\"{}\" response=\"{}\"",
                req.feature(), req.promptVersion(), redact(req.prompt()), redact(res.content()));
    }

    static String redact(String text) {
        String noEmail = EMAIL.matcher(text).replaceAll("[EMAIL]");
        return PHONE.matcher(noEmail).replaceAll("[PHONE]");
    }
}

Con entrada "Contáctanos en an@shop.vn o 0912345678", la función redact devuelve:

Contáctanos en [EMAIL] o [PHONE]

Cuando usas tracing, Micrometer automáticamente pone traceId en el MDC. Gracias a esto, esta línea de log se vincula con el span correspondiente. Las regex son solo una capa de protección mínima. Con datos realmente sensibles, considera no almacenar el prompt en absoluto, o almacenarlo en un repositorio separado con control de acceso y política de retención.

7. Mide la calidad

La calidad es el pilar más difícil porque no hay un “código de estado” para una respuesta correcta. En la práctica, la gente combina tres fuentes de señales.

7.1. Comprobaciones automáticas baratas

Algunos indicadores de problemas se pueden detectar inmediatamente en el código: respuesta truncada (finishReason es length), output vacío, u output que no se puede parsear cuando solicitas JSON.

public class QualityChecks {

    private final MeterRegistry meters;

    public QualityChecks(MeterRegistry meters) {
        this.meters = meters;
    }

    public void inspect(LlmRequest req, LlmResponse res) {
        if ("length".equals(res.finishReason())) {
            count(req, "truncated");
        }
        if (res.content() == null || res.content().isBlank()) {
            count(req, "empty");
        }
    }

    private void count(LlmRequest req, String issue) {
        meters.counter("llm.quality.issue",
                "feature", req.feature(), "prompt.version", req.promptVersion(), "issue", issue)
              .increment();
    }
}

La etiqueta prompt.version te permite comparar la tasa de errores entre v2 y v3 justo en el dashboard después de cambiar el prompt.

7.2. Retroalimentación del usuario

Un botón 👍/👎 es una señal barata y valiosa. Envíalo junto con traceId para rastrear hacia atrás al request exacto:

@RestController
@RequestMapping("/api/feedback")
class FeedbackController {

    private final MeterRegistry meters;

    FeedbackController(MeterRegistry meters) {
        this.meters = meters;
    }

    record Feedback(String traceId, String feature, String promptVersion, boolean helpful) {}

    @PostMapping
    ResponseEntity<Void> submit(@RequestBody Feedback fb) {
        meters.counter("llm.feedback", "feature", fb.feature(),
                "prompt.version", fb.promptVersion(),
                "helpful", String.valueOf(fb.helpful())).increment();
        // guarda fb.traceId() en BD para consultas posteriores
        return ResponseEntity.accepted().build();
    }
}

traceId no se pone en las etiquetas de la métrica porque sus valores son infinitos. Solo se guarda en la BD.

7.3. Evaluación offline

Para cambios grandes como cambiar de modelo o reescribir el system prompt, deberías tener un conjunto de preguntas de referencia con respuestas esperadas. Ejecuta este conjunto en CI o según horario. La puntuación puede usar coincidencia exacta, reglas o un LLM diferente como árbitro. Los resultados deberían guardarse por prompt.version para comparación. Esta parte está fuera del alcance del código del artículo, pero complementa las métricas de runtime anteriores.

8. Escribe tests para la observabilidad

Puedes verificar el decorador sin llamar a la API real. Solo necesitas un SimpleMeterRegistry y un cliente falso:

class ObservedLlmClientTest {

    @Test
    void recordsLatencyTokensAndCost() {
        SimpleMeterRegistry meters = new SimpleMeterRegistry();
        ObservationRegistry observations = ObservationRegistry.create();
        observations.observationConfig()
                .observationHandler(new DefaultMeterObservationHandler(meters));

        var pricing = Map.of("model-a", new LlmPricingProperties.ModelPrice(
                new BigDecimal("0.50"), new BigDecimal("1.50")));
        LlmClient fake = req -> new LlmResponse("Hola", 1200, 300, "stop");

        var client = new ObservedLlmClient(fake, observations, meters, new CostCalculator(pricing));
        client.complete(new LlmRequest("model-a", "v3", "support-chat", "Hola amigo"));

        assertThat(meters.get("llm.request").timer().count()).isEqualTo(1);
        assertThat(meters.get("llm.tokens").tag("type", "input").counter().count())
                .isEqualTo(1200);
        assertThat(meters.get("llm.tokens").tag("type", "output").counter().count())
                .isEqualTo(300);
        assertThat(meters.get("llm.cost.usd").counter().count())
                .isCloseTo(0.00105, within(1e-9));
    }
}

Este test confirma tres cosas: el timer llm.request se registra exactamente una vez, los tokens se separan por type, y el costo coincide con el cálculo de la sección 5. Si más adelante alguien cambia el nombre de la métrica o arruina la fórmula de precio, el test fallará y tu dashboard no se quedará silenciosamente sin datos.

9. Errores comunes y cuándo no deberías hacer demasiado

Algunos errores frecuentes:

  • Poner valores infinitos en las etiquetas de métrica: userId, traceId, contenido del prompt o tokens específicos. Hacen explotar el número de series temporales y pueden derribar el sistema de métricas. Estos valores pertenecen a spans o logs.
  • Calcular costo a partir de traces ya muestreadas: si solo el 10% de requests tiene traza, el costo total sumado desde trazas será incorrecto. Usa contadores como en la sección 4.
  • Guardar el prompt completo indefinidamente: riesgo de privacidad y costo de almacenamiento. Enmascara datos sensibles, toma muestras y establece política de retención.
  • No asociar versión de prompt: cuando la calidad baja, no sabrás si fue por cambiar el prompt, cambiar el modelo o cambios en los datos de entrada.
  • Mirar solo latencia promedio: con LLM, observa p95/p99 y monitorea por separado el tiempo hasta el primer token si usas streaming.

Nota: No todos los proyectos necesitan todas las partes anteriores. Para un prototipo interno con decenas de requests diarios, presupuesto pequeño, leer el uso de la respuesta y revisar la factura del proveedor es suficiente. Trazas, repositorio de prompts y pipeline de evaluación añaden overhead operacional. Comienza con contadores de token/costo y etiqueta prompt.version. Expande el resto cuando el volumen, costo o requisitos de calidad realmente lo justifiquen.

Además, si tu organización ya usa una plataforma especializada en observabilidad para LLM, no reimplements todo. El decorador en este artículo sigue siendo útil para normalizar etiquetas, pero almacenamiento y visualización deberían ser responsabilidad de la herramienta existente.

10. Conclusión

La observabilidad para aplicaciones LLM expande los tres pilares familiares (métrica, traza, log) para responder tres nuevas preguntas: qué prompt está corriendo, cuánto cuesta y qué tan buena es la respuesta.

En este artículo, envolviste el cliente LLM con Micrometer Observation para obtener timer y span con etiquetas gen_ai.*. Contaste tokens y los convertiste a costo mediante configuración de precios, registraste prompts con datos sensibles enmascarados, y recopilaste señales de calidad tanto de comprobaciones automáticas como de retroalimentación del usuario. El orden sensato de implementación es: costo y versión de prompt primero, luego trazas y calidad, expandiendo gradualmente según necesidades reales.

llmobservabilidadmicrometeropentelemetryspring-boot