LLM应用的可观测性:监控提示词、成本和质量
1. 概述
当你把使用LLM(大语言模型)的功能部署到生产环境时,首先会出现的问题通常是:“这个月花了多少钱?”,“为什么昨天的回答是错的?”,“新提示词是否比旧提示词更好?”。常规的HTTP指标无法回答这些问题。
本文解释了LLM应用的可观测性概念以及为什么这个主题值得关注。接下来,你会在Spring Boot 3中为LLM客户端构建一个装饰器(decorator)层,以记录跟踪、令牌、成本和质量信号。最后一部分讨论常见错误以及何时不应该过度使用。
2. 为什么LLM需要独立的可观测性
常规的REST API具有确定性。相同的输入会产生相同的输出。你只需要监控延迟、错误率和吞吐量。LLM调用在几个方面不同:
- 非确定性:相同的提示词可能产生不同的结果。最常见的错误不是HTTP 500,而是HTTP 200伴随错误的回答。
- 成本按令牌计算:账单取决于提示词和回答的长度,而不是请求数量。一个膨胀的提示词模板可能会使成本翻倍,而没人注意到。
- 延迟波动大:响应时间与生成的令牌数成正比。平均值几乎没有意义。你需要查看分布(p95、p99)。
- 提示词是代码:改变system prompt中的一句话也是一次“部署”。你需要知道哪个请求运行的是哪个版本的提示词。
因此,LLM的可观测性通常围绕三个支柱:
- 追踪提示词/回答:哪个请求、哪个模型、哪个版本的提示词、耗时多长、为什么结束。
- 成本:输入/输出令牌、转换为金钱、按特性分类。
- 质量:用户反馈、自动检查、离线评估。
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.requesttimer。当有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和质量,然后根据实际需求逐步扩展。