Observability cho ứng dụng LLM: theo dõi prompt, chi phí và chất lượng

1. Tổng quan

Khi bạn đưa một tính năng dùng LLM (Large Language Model) lên production, các câu hỏi đầu tiên thường là: “Tháng này tốn bao nhiêu tiền?”, “Vì sao câu trả lời hôm qua sai?”, “Prompt mới có tốt hơn prompt cũ không?”. Metric HTTP thông thường không trả lời được những câu hỏi này.

Bài viết giải thích khái niệm observability cho ứng dụng LLM và lý do chủ đề này đang được quan tâm. Sau đó, bạn sẽ dựng một lớp bọc (decorator) quanh LLM client trong Spring Boot 3 để ghi nhận trace, token, chi phí và tín hiệu chất lượng. Phần cuối bàn về các lỗi hay gặp và những trường hợp không nên làm quá tay.

2. Vì sao LLM cần observability riêng

Một REST API bình thường có tính tất định. Cùng input thì cho cùng output. Bạn chỉ cần theo dõi latency, error rate và throughput. Lời gọi LLM khác ở vài điểm:

  • Không tất định: cùng prompt có thể cho kết quả khác nhau. Lỗi phổ biến nhất không phải HTTP 500 mà là một HTTP 200 kèm câu trả lời sai.
  • Chi phí tính theo token: hoá đơn phụ thuộc vào độ dài prompt và độ dài câu trả lời, không phụ thuộc số request. Một prompt template bị phình to có thể nhân chi phí lên nhiều lần mà không ai để ý.
  • Latency dao động lớn: thời gian phản hồi tỉ lệ với số token sinh ra. Giá trị trung bình gần như vô nghĩa. Bạn cần xem phân phối (p95, p99).
  • Prompt là code: thay một câu trong system prompt cũng là một lần “deploy”. Bạn cần biết request nào chạy với phiên bản prompt nào.

Vì vậy observability cho LLM thường xoay quanh ba trụ cột:

  1. Tracing prompt/response: request nào, model nào, phiên bản prompt nào, mất bao lâu, kết thúc vì lý do gì.
  2. Chi phí: token vào/ra, quy ra tiền, phân theo tính năng.
  3. Chất lượng: phản hồi của người dùng, kiểm tra tự động, đánh giá offline.

Cộng đồng OpenTelemetry cũng đang xây dựng bộ quy ước đặt tên thuộc tính cho GenAI (tiền tố gen_ai.*). Bộ quy ước này vẫn đang tiếp tục thay đổi. Trong bài, bạn sẽ mượn cách đặt tên của nó cho dễ tích hợp về sau.

3. Thiết lập dự án

Thêm Actuator, Prometheus registry và cầu nối OpenTelemetry cho tracing. Phiên bản do Spring Boot BOM quản lý nên bạn không cần khai báo:

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

Cấu hình endpoint và nơi gửi trace:

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

Ở đây probability: 0.1 nghĩa là chỉ 10% request được gửi trace. Metric vẫn được đếm đầy đủ 100%. Điểm khác biệt này quan trọng khi bạn tính chi phí ở mục 5.

4. Bọc LLM client bằng Observation

Bạn định nghĩa một abstraction nhỏ, độc lập với nhà cung cấp:

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 mang theo số token. Hầu hết API của các nhà cung cấp đều trả về trường usage trong response, nên bạn chỉ cần map sang record này. Hãy dùng con số nhà cung cấp trả về thay vì tự ước lượng token.

Tiếp theo là decorator thêm observability:

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

Các điểm chính:

  • Observation.createNotStarted("llm.request", ...) tạo một observation. Khi có DefaultMeterObservationHandler (Spring Boot tự cấu hình), observation này sinh ra một timer llm.request. Khi có tracing bridge, nó sinh thêm một span.
  • lowCardinalityKeyValue gắn tag vào cả metric lẫn span. Chỉ dùng cho giá trị có tập hữu hạn như tên model, phiên bản prompt, tên tính năng.
  • highCardinalityKeyValue chỉ gắn vào span. Số token của từng request phù hợp ở đây. Nếu đưa nó vào metric, mỗi giá trị khác nhau sẽ tạo ra một time series mới.
  • observe(...) tự gọi start/stop. Nếu có exception, nó ghi nhận lỗi vào observation.
  • Hai counter llm.tokens và llm.cost.usd được đếm cho mọi request, không phụ thuộc sampling của tracing.

Đăng ký bean:

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

    @Bean
    LlmClient llmClient(ObservationRegistry observations, MeterRegistry meters,
                        LlmPricingProperties pricing) {
        LlmClient raw = new VendorLlmClient(); // client gọi API thật của bạn
        return new ObservedLlmClient(raw, observations, meters,
                new CostCalculator(pricing.pricing()));
    }
}

Trên /actuator/prometheus bạn sẽ thấy các series dạng sau:

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

Lưu ý: Nếu bạn dùng Spring AI, framework này đã có sẵn observation cho chat model. Hiểu cơ chế ở trên giúp bạn đọc đúng các metric đó và bổ sung những gì còn thiếu, ví dụ tag feature hay prompt.version.

5. Tính chi phí từ token

Bảng giá thay đổi theo nhà cung cấp và theo thời gian, nên đừng hard-code. Đưa nó ra cấu hình:

@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   # giá minh hoạ, hãy thay bằng bảng giá thực tế của bạn
      output-per-million: 1.50

Lớp tính toán:

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; // nên log cảnh báo: model chưa có giá
        }
        return price.inputPerMillion().multiply(BigDecimal.valueOf(res.inputTokens()))
                .add(price.outputPerMillion().multiply(BigDecimal.valueOf(res.outputTokens())))
                .divide(MILLION, 8, RoundingMode.HALF_UP);
    }
}

Với 1.200 token vào và 300 token ra theo bảng giá minh hoạ, kết quả là:

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

Counter này cho bạn biết chi phí ước tính gần thời gian thực, phân theo feature và model. Từ đó bạn đặt được alert, ví dụ “chi phí của support-chat trong 1 giờ vượt ngưỡng X”. Hoá đơn chính thức vẫn đến từ nhà cung cấp. Hãy đối chiếu định kỳ để phát hiện sai lệch, chẳng hạn khi có cached token hoặc chiết khấu batch mà công thức đơn giản chưa tính tới.

6. Ghi log prompt an toàn

Khi debug một câu trả lời sai, bạn thường muốn xem prompt đầy đủ. Nhưng prompt hay chứa dữ liệu người dùng như email, số điện thoại hay nội dung đơn hàng. Vì vậy bạn cần làm hai việc: che thông tin nhạy cảm và lấy mẫu.

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

Với input "Liên hệ an@shop.vn hoặc 0912345678", hàm redact trả về:

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

Khi dùng tracing, Micrometer tự đưa traceId vào MDC. Nhờ vậy dòng log này liên kết được với span tương ứng. Regex chỉ là lớp bảo vệ tối thiểu. Với dữ liệu thực sự nhạy cảm, hãy cân nhắc không lưu prompt, hoặc lưu vào kho riêng có kiểm soát truy cập và thời hạn xoá.

7. Đo chất lượng

Chất lượng là trụ cột khó nhất vì không có “status code” cho câu trả lời đúng. Trong thực tế, người ta kết hợp ba nguồn tín hiệu.

7.1. Kiểm tra tự động rẻ tiền

Một số dấu hiệu xấu có thể phát hiện ngay trong code: câu trả lời bị cắt (finishReason là length), output rỗng, hoặc output không parse được khi bạn yêu cầu 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();
    }
}

Tag prompt.version cho phép bạn so sánh tỉ lệ lỗi giữa v2 và v3 ngay trên dashboard sau khi đổi prompt.

7.2. Phản hồi của người dùng

Nút 👍/👎 là tín hiệu rẻ và có giá trị. Hãy gửi kèm traceId để lần ngược về đúng request:

@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();
        // lưu fb.traceId() vào DB để tra cứu sau
        return ResponseEntity.accepted().build();
    }
}

traceId không được đưa vào tag metric vì giá trị của nó là vô hạn. Nó chỉ được lưu vào DB.

7.3. Đánh giá offline

Với thay đổi lớn như đổi model hay viết lại system prompt, bạn nên có một bộ câu hỏi mẫu kèm đáp án mong đợi. Chạy bộ này trong CI hoặc theo lịch. Cách chấm điểm có thể dùng so khớp, rule hoặc một LLM khác đóng vai giám khảo. Kết quả nên được ghi lại theo prompt.version để so sánh. Phần này nằm ngoài phạm vi code của bài, nhưng nó bổ sung cho metric runtime ở trên.

8. Viết test cho phần observability

Bạn có thể kiểm tra decorator mà không cần gọi API thật. Chỉ cần một SimpleMeterRegistry và một client giả:

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

Test này xác nhận ba điều: timer llm.request được ghi đúng một lần, token được tách theo type, và chi phí khớp với phép tính ở mục 5. Nếu sau này ai đó đổi tên metric hoặc làm hỏng công thức giá, test sẽ fail và dashboard không bị “im lặng” mất dữ liệu.

9. Lỗi hay gặp và khi nào không nên dùng

Một số lỗi phổ biến:

  • Đưa giá trị vô hạn vào tag metric: userId, traceId, nội dung prompt hay số token cụ thể. Chúng làm bùng nổ số time series và có thể làm sập hệ thống metric. Những giá trị này thuộc về span hoặc log.
  • Tính chi phí từ trace đã sampling: nếu chỉ 10% request có trace, tổng chi phí cộng từ trace sẽ sai. Hãy dùng counter như ở mục 4.
  • Lưu toàn bộ prompt vô thời hạn: rủi ro về quyền riêng tư và chi phí lưu trữ. Hãy che dữ liệu nhạy cảm, lấy mẫu và đặt thời hạn xoá.
  • Không gắn phiên bản prompt: khi chất lượng giảm, bạn sẽ không biết nguyên nhân là đổi prompt, đổi model hay dữ liệu đầu vào thay đổi.
  • Chỉ nhìn latency trung bình: với LLM, hãy xem p95/p99 và theo dõi riêng thời gian tới token đầu tiên nếu bạn dùng streaming.

Lưu ý: Không phải dự án nào cũng cần đầy đủ các phần trên. Với một prototype nội bộ, vài chục request mỗi ngày, ngân sách nhỏ, thì log usage từ response và xem hoá đơn của nhà cung cấp là đủ. Hệ thống tracing, kho prompt và pipeline đánh giá khi đó chỉ thêm việc vận hành. Hãy bắt đầu với counter token/chi phí và tag prompt.version. Phần còn lại nên mở rộng khi lưu lượng, chi phí hoặc yêu cầu chất lượng thực sự đòi hỏi.

Ngoài ra, nếu tổ chức của bạn đã dùng một nền tảng chuyên cho LLM observability, đừng tự viết lại mọi thứ. Lớp decorator trong bài vẫn hữu ích để chuẩn hoá tag, nhưng phần lưu trữ và hiển thị nên giao cho công cụ sẵn có.

10. Kết luận

Observability cho ứng dụng LLM mở rộng ba trụ cột quen thuộc (metric, trace, log) để trả lời ba câu hỏi mới: prompt nào đang chạy, tốn bao nhiêu tiền, và câu trả lời có tốt không.

Trong bài, bạn đã bọc LLM client bằng Micrometer Observation để có timer và span với các tag gen_ai.*. Bạn đếm token và quy ra chi phí qua cấu hình giá, ghi log prompt có che dữ liệu nhạy cảm, và thu tín hiệu chất lượng từ kiểm tra tự động lẫn phản hồi người dùng. Thứ tự áp dụng hợp lý là: chi phí và phiên bản prompt trước, tracing và chất lượng sau, rồi mở rộng dần theo nhu cầu thực tế.

llmobservabilitymicrometeropentelemetryspring-boot