LLM应用的可观测性:监控提示词、成本和质量

1. 概述

当你把使用LLM(大语言模型)的功能部署到生产环境时,首先会出现的问题通常是:“这个月花了多少钱?”,“为什么昨天的回答是错的?”,“新提示词是否比旧提示词更好?”。常规的HTTP指标无法回答这些问题。

本文解释了LLM应用的可观测性概念以及为什么这个主题值得关注。接下来,你会在Spring Boot 3中为LLM客户端构建一个装饰器(decorator)层,以记录跟踪、令牌、成本和质量信号。最后一部分讨论常见错误以及何时不应该过度使用。

2. 为什么LLM需要独立的可观测性

常规的REST API具有确定性。相同的输入会产生相同的输出。你只需要监控延迟、错误率和吞吐量。LLM调用在几个方面不同:

  • 非确定性:相同的提示词可能产生不同的结果。最常见的错误不是HTTP 500,而是HTTP 200伴随错误的回答。
  • 成本按令牌计算:账单取决于提示词和回答的长度,而不是请求数量。一个膨胀的提示词模板可能会使成本翻倍,而没人注意到。
  • 延迟波动大:响应时间与生成的令牌数成正比。平均值几乎没有意义。你需要查看分布(p95、p99)。
  • 提示词是代码:改变system prompt中的一句话也是一次“部署”。你需要知道哪个请求运行的是哪个版本的提示词。

因此,LLM的可观测性通常围绕三个支柱:

  1. 追踪提示词/回答:哪个请求、哪个模型、哪个版本的提示词、耗时多长、为什么结束。
  2. 成本:输入/输出令牌、转换为金钱、按特性分类。
  3. 质量:用户反馈、自动检查、离线评估。

OpenTelemetry社区也在为GenAI构建属性命名约定(前缀gen_ai.*)。这个约定仍在不断演变。在本文中,你将借用它的命名方式以便日后轻松集成。

3. 项目设置

添加Actuator、Prometheus registry和OpenTelemetry跟踪桥接。版本由Spring Boot BOM管理,所以你不需要声明:

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

配置端点和跟踪发送位置:

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

这里probability: 0.1意味着只有10%的请求被发送跟踪。指标仍然被100%完整计数。这个区别在你计算第5部分的成本时很重要。

4. 使用Observation包装LLM客户端

你定义一个小的、与提供者无关的抽象:

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携带令牌数。大多数提供者的API在响应中返回usage字段,所以你只需要映射到这个record。使用提供者返回的数字,而不是自己估计令牌。

接下来是添加可观测性的装饰器:

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

主要要点:

  • Observation.createNotStarted("llm.request", ...)创建一个observation。当有DefaultMeterObservationHandler(Spring Boot自动配置)时,这个observation会生成一个llm.request timer。当有tracing bridge时,它还会生成一个span。
  • lowCardinalityKeyValue将标签附加到metric和span。仅用于具有有限集合的值,如模型名称、提示词版本、特性名称。
  • highCardinalityKeyValue仅附加到span。每个请求的令牌数适合这里。如果将其放入指标中,每个不同的值都会创建一个新的时间序列。
  • observe(...)自动调用start/stop。如果有异常,它会将错误记录到observation中。
  • 两个counter llm.tokens和llm.cost.usd对每个请求都被计数,不依赖于tracing的sampling。

注册bean:

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

    @Bean
    LlmClient llmClient(ObservationRegistry observations, MeterRegistry meters,
                        LlmPricingProperties pricing) {
        LlmClient raw = new VendorLlmClient(); // 调用你的实际API的客户端
        return new ObservedLlmClient(raw, observations, meters,
                new CostCalculator(pricing.pricing()));
    }
}

在/actuator/prometheus上你会看到如下系列:

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

注意: 如果你使用Spring AI,该框架已经为chat model提供了observation。理解上述机制可以帮助你正确读取这些指标,并补充缺失的部分,例如标签feature或prompt.version。

5. 从令牌计算成本

价格表随提供者和时间而变化,所以不要硬编码。将其放在配置中:

@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   # 示例价格,请替换为你的实际价格表
      output-per-million: 1.50

计算类:

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; // 应该记录警告:模型还没有价格
        }
        return price.inputPerMillion().multiply(BigDecimal.valueOf(res.inputTokens()))
                .add(price.outputPerMillion().multiply(BigDecimal.valueOf(res.outputTokens())))
                .divide(MILLION, 8, RoundingMode.HALF_UP);
    }
}

以1200个输入令牌和300个输出令牌为例,根据示例价格表,结果是:

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

这个counter为你提供了估计的近实时成本,按feature和model分类。从这里你可以设置告警,例如”support-chat在1小时内的成本超过阈值X”。官方账单仍然来自提供者。定期对比以发现差异,例如缓存令牌或批处理折扣等简单公式未考虑的情况。

6. 安全地记录提示词

当调试错误的回答时,你通常想看完整的提示词。但提示词通常包含用户数据,如电子邮件、电话号码或订单内容。因此你需要做两件事:隐藏敏感信息和采样。

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

以输入"Liên hệ an@shop.vn hoặc 0912345678"为例,redact函数返回:

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

使用tracing时,Micrometer自动将traceId放入MDC。这样这行日志可以链接到相应的span。Regex只是最小的保护层。对于真正敏感的数据,考虑不保存提示词,或保存到有访问控制和删除期限的单独存储库。

7. 测量质量

质量是最难的支柱,因为没有“状态码”表示正确的回答。实际上,人们结合三个信号源。

7.1. 廉价的自动检查

一些不好的迹象可以立即在代码中检测到:回答被截断(finishReason是length)、输出为空、或当你要求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();
    }
}

标签prompt.version允许你在更改提示词后立即在仪表板上比较v2和v3之间的错误率。

7.2. 用户反馈

👍/👎 按钮是便宜且有价值的信号。一起发送traceId以便追踪到正确的请求:

@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();
        // 将fb.traceId()保存到DB以便后续查询
        return ResponseEntity.accepted().build();
    }
}

traceId不应该放入metric标签中,因为它的值是无限的。它只保存在DB中。

7.3. 离线评估

对于诸如更换模型或重写system prompt之类的大更改,你应该有一套示例问题和预期答案。在CI中或按计划运行此套件。评分方法可以使用匹配、规则或另一个LLM充当评审员。结果应该按prompt.version记录以进行比较。这部分超出本文代码的范围,但它补充了上面的运行时指标。

8. 为可观测性部分编写测试

你可以在不调用真实API的情况下测试装饰器。只需要一个SimpleMeterRegistry和一个mock客户端:

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

这个测试确认三件事:timer llm.request被正确记录一次,令牌按type分开,成本与第5部分的计算相匹配。如果以后有人改变metric名称或破坏价格公式,测试将失败,仪表板不会“无声”地丢失数据。

9. 常见错误和何时不应该使用

一些常见错误:

  • 将无限值放入metric标签:userId、traceId、prompt内容或具体的令牌数。它们会导致时间序列数量爆炸,可能导致metric系统崩溃。这些值属于span或log。
  • 从采样的trace计算成本:如果只有10%的请求有trace,从trace累计的总成本将是错误的。使用第4部分的counter。
  • 无限期保存完整提示词:隐私风险和存储成本。隐藏敏感数据、采样并设置删除期限。
  • 不附加提示词版本:当质量下降时,你不会知道原因是更改提示词、更换模型还是输入数据改变。
  • 仅查看平均延迟:对于LLM,查看p95/p99,如果你使用streaming则单独跟踪到第一个令牌的时间。

注意: 并非每个项目都需要上述所有部分。对于内部原型、每天几十个请求、预算小的项目,从响应中记录usage和查看提供者账单就足够了。此时tracing系统、prompt库和评估管道只会增加操作负担。从token/成本counter和prompt.version标签开始。当流量、成本或质量要求确实需要时,再扩展其余部分。

此外,如果你的组织已经使用专门的LLM observability平台,不要自己重写一切。本文中的装饰器层对于标准化标签仍然有用,但存储和展示部分应该委托给现有工具。

10. 结论

LLM应用的可观测性扩展了三个熟悉的支柱(metric、trace、log)来回答三个新问题:正在运行哪个提示词、花了多少钱,以及答案是否好。

在本文中,你用Micrometer Observation包装LLM客户端以获得带有gen_ai.*标签的timer和span。你通过价格配置计数令牌和转换成成本,安全地记录提示词(隐藏敏感数据),并从自动检查和用户反馈中收集质量信号。合理的应用顺序是:先做成本和提示词版本,然后是tracing和质量,然后根据实际需求逐步扩展。

llmobservabilitymicrometeropentelemetryspring-boot