Observabilidade para aplicações LLM: monitorar prompt, custo e qualidade

1. Visão geral

Quando você coloca uma funcionalidade que usa LLM (Large Language Model) em produção, as primeiras perguntas geralmente são: “Quanto custou este mês?”, “Por que a resposta de ontem estava errada?”, “O novo prompt é melhor que o anterior?”. As métricas HTTP convencionais não conseguem responder essas perguntas.

Este artigo explica o conceito de observabilidade para aplicações LLM e por que o tema está recebendo atenção. Em seguida, você construirá um decorador em torno do cliente LLM no Spring Boot 3 para registrar traces, tokens, custos e sinais de qualidade. A parte final discute erros comuns e casos em que não deve exagerar.

2. Por que LLM precisa de observabilidade própria

Uma API REST comum é determinística. O mesmo input sempre produz a mesma saída. Você apenas precisa monitorar latência, taxa de erro e throughput. Chamadas para LLM são diferentes em alguns pontos:

  • Não determinístico: o mesmo prompt pode produzir resultados diferentes. O erro mais comum não é um HTTP 500, mas um HTTP 200 com uma resposta errada.
  • Custo calculado por tokens: a fatura depende da duração do prompt e da duração da resposta, não do número de requisições. Um template de prompt inchado pode multiplicar o custo várias vezes sem que ninguém perceba.
  • Latência altamente variável: o tempo de resposta é proporcional ao número de tokens gerados. O valor médio é praticamente irrelevante. Você precisa examinar a distribuição (p95, p99).
  • Prompt é código: mudar uma frase no system prompt é uma “implantação”. Você precisa saber qual versão de prompt está executando cada requisição.

Portanto, a observabilidade para LLM geralmente gira em torno de três pilares:

  1. Rastreamento de prompt/resposta: qual requisição, qual modelo, qual versão de prompt, quanto tempo levou, por que terminou.
  2. Custo: tokens de entrada/saída, convertidos em dinheiro, discriminados por funcionalidade.
  3. Qualidade: feedback do usuário, testes automatizados, avaliação offline.

A comunidade OpenTelemetry também está construindo um conjunto de convenções de nomenclatura para atributos GenAI (prefixo gen_ai.*). Este conjunto de convenções ainda está evoluindo. Neste artigo, você usará essa nomenclatura para facilitar futuras integrações.

3. Configuração do projeto

Adicione Actuator, registro Prometheus e ponte OpenTelemetry para rastreamento. As versões são gerenciadas pelo BOM do Spring Boot, então você não precisa declarar:

<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>

Configure o endpoint e para onde enviar traces:

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

Aqui probability: 0.1 significa que apenas 10% das requisições são enviadas como traces. As métricas ainda são contadas completamente 100%. Esta distinção é importante quando você calcula custos na seção 5.

4. Envolvendo o cliente LLM com Observation

Você define uma abstração pequena, independente de fornecedores:

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 carrega o número de tokens. A maioria das APIs dos fornecedores retorna um campo usage na resposta, então você apenas precisa mapeá-lo para este record. Use os números retornados pelo fornecedor em vez de estimar tokens por conta própria.

A seguir está o decorador que adiciona observabilidade:

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;
        });
    }
}

Pontos principais:

  • Observation.createNotStarted("llm.request", ...) cria uma observação. Quando há DefaultMeterObservationHandler (configurado automaticamente pelo Spring Boot), essa observação gera um timer llm.request. Quando há uma ponte de rastreamento, ela também gera um span.
  • lowCardinalityKeyValue anexa tags tanto à métrica quanto ao span. Use apenas para valores com conjunto finito, como nome do modelo, versão do prompt, nome da funcionalidade.
  • highCardinalityKeyValue anexa apenas ao span. O número de tokens de cada requisição se encaixa aqui. Se o incluir na métrica, cada valor diferente criaria uma nova série temporal.
  • observe(...) chama start/stop automaticamente. Se houver exceção, ele registra o erro na observação.
  • Os dois contadores llm.tokens e llm.cost.usd são contados para todas as requisições, independentemente da amostragem do rastreamento.

Registre o bean:

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

    @Bean
    LlmClient llmClient(ObservationRegistry observations, MeterRegistry meters,
                        LlmPricingProperties pricing) {
        LlmClient raw = new VendorLlmClient(); // cliente que chama a API real do seu fornecedor
        return new ObservedLlmClient(raw, observations, meters,
                new CostCalculator(pricing.pricing()));
    }
}

Em /actuator/prometheus você verá séries como:

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: Se você usar Spring AI, esse framework já possui observações prontas para modelos de chat. Compreender o mecanismo acima o ajuda a ler as métricas corretamente e complementar o que está faltando, como as tags feature ou prompt.version.

5. Calculando custo a partir de tokens

A tabela de preços muda de acordo com o fornecedor e o tempo, então não a codifique. Coloque-a na configuração:

@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   # preço ilustrativo, substitua pela tabela de preços real da sua
      output-per-million: 1.50

Classe 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; // você deve registrar um aviso: modelo sem preço definido
        }
        return price.inputPerMillion().multiply(BigDecimal.valueOf(res.inputTokens()))
                .add(price.outputPerMillion().multiply(BigDecimal.valueOf(res.outputTokens())))
                .divide(MILLION, 8, RoundingMode.HALF_UP);
    }
}

Com 1.200 tokens de entrada e 300 tokens de saída de acordo com a tabela de preços ilustrativa, o resultado é:

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

Este contador fornece o custo estimado em tempo quase real, discriminado por feature e model. Com isso você pode definir alertas, por exemplo “o custo de support-chat em 1 hora ultrapassou o limite X”. A fatura oficial ainda vem do fornecedor. Compare periodicamente para detectar discrepâncias, como tokens em cache ou descontos em lote que a fórmula simples não leva em conta.

6. Registrando prompts com segurança

Ao depurar uma resposta incorreta, você geralmente deseja ver o prompt completo. Mas o prompt frequentemente contém dados de usuários como email, números de telefone ou conteúdo de pedidos. Portanto, você precisa fazer duas coisas: mascarar informações sensíveis e fazer amostragem.

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]");
    }
}

Com entrada "Liên hệ an@shop.vn hoặc 0912345678", a função redact retorna:

Liên hệ [EMAIL] hoặc [PHONE]

Ao usar rastreamento, Micrometer coloca automaticamente traceId no MDC. Graças a isso, essa linha de log se vincula ao span correspondente. Regex é apenas uma camada de proteção mínima. Para dados realmente sensíveis, considere não armazenar o prompt, ou armazene em um repositório separado com controle de acesso e prazo de exclusão.

7. Medindo qualidade

Qualidade é o pilar mais difícil porque não há “status code” para respostas corretas. Na prática, combina-se três fontes de sinais.

7.1. Verificação automatizada barata

Alguns sinais ruins podem ser detectados imediatamente no código: resposta truncada (finishReason é length), saída vazia, ou saída que não pode ser processada quando você solicita 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();
    }
}

A tag prompt.version permite comparar a taxa de erros entre v2 e v3 diretamente no dashboard após mudar o prompt.

7.2. Feedback do usuário

Os botões 👍/👎 são sinais baratos e valiosos. Envie junto com traceId para rastrear de volta até a requisição correta:

@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();
        // armazene fb.traceId() no banco de dados para consulta posterior
        return ResponseEntity.accepted().build();
    }
}

traceId não deve ser incluído em tags de métrica porque seu valor é infinito. Deve ser armazenado apenas no banco de dados.

7.3. Avaliação offline

Para mudanças grandes, como trocar modelo ou reescrever o system prompt, você deve ter um conjunto de perguntas de exemplo com respostas esperadas. Execute esse conjunto em CI ou conforme agendado. A pontuação pode usar correspondência, regras ou outro LLM atuando como árbitro. Os resultados devem ser registrados por prompt.version para comparação. Esta parte está fora do escopo do código deste artigo, mas complementa as métricas de tempo de execução acima.

8. Escrevendo testes para observabilidade

Você pode testar o decorador sem chamar a API real. Apenas use um SimpleMeterRegistry e um cliente fake:

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("Xin chào", 1200, 300, "stop");

        var client = new ObservedLlmClient(fake, observations, meters, new CostCalculator(pricing));
        client.complete(new LlmRequest("model-a", "v3", "support-chat", "Chào bạn"));

        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 teste confirma três coisas: o timer llm.request é registrado exatamente uma vez, os tokens são separados por type, e o custo corresponde ao cálculo da seção 5. Se mais tarde alguém mudar o nome da métrica ou quebrar a fórmula de preço, o teste falhará e o dashboard não será silenciosamente privado de dados.

9. Erros comuns e quando não usar

Alguns erros comuns:

  • Colocar valores infinitos em tags de métricas: userId, traceId, conteúdo de prompt ou número específico de tokens. Eles causam explosão de séries temporais e podem derrubar o sistema de métricas. Esses valores pertencem a spans ou logs.
  • Calcular custo a partir de traces já amostrados: se apenas 10% das requisições têm traces, o custo total somado a partir de traces será incorreto. Use um counter como na seção 4.
  • Armazenar prompts completos indefinidamente: risco de privacidade e custo de armazenamento. Mascare dados sensíveis, faça amostragem e defina prazos de exclusão.
  • Não anexar versão do prompt: quando a qualidade cai, você não saberá se foi por causa da mudança de prompt, mudança de modelo ou mudança nos dados de entrada.
  • Olhar apenas para latência média: com LLM, examine p95/p99 e rastreie separadamente o tempo até o primeiro token se você usar streaming.

Nota: Nem todo projeto precisa de todas as partes acima. Para um protótipo interno com algumas dezenas de requisições por dia e orçamento pequeno, registrar o uso da resposta e verificar a fatura do fornecedor é suficiente. Sistemas de rastreamento, armazenamento de prompts e pipeline de avaliação apenas adicionam sobrecarga operacional. Comece com counters de token/custo e a tag prompt.version. Expanda o resto quando o volume, custo ou requisitos de qualidade realmente exigir.

Além disso, se sua organização já usa uma plataforma especializada em observabilidade de LLM, não reescreva tudo do zero. O decorador neste artigo ainda é útil para padronizar tags, mas a parte de armazenamento e exibição deve ser deixada com ferramentas existentes.

10. Conclusão

Observabilidade para aplicações LLM estende os três pilares familiares (métrica, trace, log) para responder três novas perguntas: qual prompt está em execução, quanto custou, e a resposta é boa.

Neste artigo, você envolveu o cliente LLM com Micrometer Observation para obter timers e spans com tags gen_ai.*. Você contou tokens e os converteu em custos por meio de configuração de preço, registrou prompts com dados sensíveis mascarados e coletou sinais de qualidade de testes automatizados e feedback do usuário. A ordem sensata de implementação é: custo e versão de prompt primeiro, rastreamento e qualidade depois, depois expanda gradualmente de acordo com necessidades reais.

llmobservabilidademicrometeropentelemetryspring-boot