LLM 애플리케이션의 관찰성: 프롬프트, 비용, 품질 모니터링
1. 개요
LLM(Large Language Model)을 사용하는 기능을 프로덕션에 배포할 때, 처음 하는 질문들은 보통 이렇습니다. “이번 달에 얼마나 많은 비용이 드는가?”, “어제의 답변이 왜 틀렸는가?”, “새로운 프롬프트가 이전 프롬프트보다 더 나은가?”. 일반적인 HTTP 메트릭으로는 이런 질문에 답할 수 없습니다.
이 글은 LLM 애플리케이션의 관찰성 개념을 설명하고, 이 주제가 왜 주목받고 있는지 설명합니다. 그 다음, Spring Boot 3에서 LLM 클라이언트 주위에 decorator 계층을 만들어 트레이스, 토큰, 비용, 품질 신호를 기록할 것입니다. 마지막 부분에서는 흔한 실수와 과하게 하면 안 되는 경우들을 다룹니다.
2. LLM에 관찰성이 필요한 이유
일반적인 REST API는 결정론적(deterministic)입니다. 같은 입력에는 같은 출력입니다. 지연 시간, 오류율, 처리량만 모니터링하면 됩니다. LLM 호출은 몇 가지 점에서 다릅니다:
- 비결정론적: 같은 프롬프트도 다른 결과를 줄 수 있습니다. 가장 일반적인 오류는 HTTP 500이 아니라 HTTP 200 응답에 틀린 답변이 포함된 경우입니다.
- 토큰 기반 가격 책정: 청구서는 프롬프트 길이와 응답 길이에 따라 달라집니다. 요청 수와는 무관합니다. 프롬프트 템플릿이 팽창하면 비용이 여러 배 증가할 수 있는데 아무도 눈치 채지 못할 수 있습니다.
- 지연 시간 변동이 큼: 응답 시간은 생성된 토큰 수에 비례합니다. 평균값은 거의 무의미합니다. 분포(p95, p99)를 봐야 합니다.
- 프롬프트는 코드입니다: 시스템 프롬프트의 한 문장을 바꾸는 것도 하나의 ’배포’입니다. 어떤 요청이 어떤 버전의 프롬프트로 실행되었는지 알아야 합니다.
따라서 LLM의 observability는 보통 세 가지 기둥을 중심으로 합니다:
- 프롬프트/응답 추적: 어떤 요청, 어떤 모델, 어떤 프롬프트 버전, 소요 시간, 종료 이유.
- 비용: 토큰 입출력, 환산 비용, 기능별 분류.
- 품질: 사용자 피드백, 자동 검사, 오프라인 평가.
OpenTelemetry 커뮤니티도 GenAI를 위한 속성 명명 규칙을 개발 중입니다 (접두사 gen_ai.*). 이 규칙은 여전히 계속 변하고 있습니다. 이 글에서는 나중에 쉽게 통합하기 위해 그 명명 규칙을 따를 것입니다.
3. 프로젝트 설정
Actuator, Prometheus 레지스트리, tracing용 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>
엔드포인트와 trace 전송 대상을 구성합니다:
management:
endpoints:
web:
exposure:
include: health,prometheus
tracing:
sampling:
probability: 0.1
otlp:
tracing:
endpoint: http://localhost:4318/v1/traces
여기서 probability: 0.1은 요청의 10%만 trace가 전송됨을 의미합니다. 메트릭은 여전히 100% 계산됩니다. 이 차이는 섹션 5에서 비용을 계산할 때 중요합니다.
4. LLM 클라이언트를 Observation으로 래핑하기
공급자와 무관한 작은 추상화를 정의합니다:
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 필드를 반환합니다. 따라서 이 레코드로 매핑하기만 하면 됩니다. 공급자가 반환한 숫자를 사용하세요. 토큰을 직접 추정하지 마세요.
다음은 observability를 추가하는 decorator입니다:
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타이머를 생성합니다. tracing bridge가 있으면 추가로 span을 생성합니다.lowCardinalityKeyValue는 메트릭과 span 모두에 태그를 붙입니다. 모델 이름, 프롬프트 버전, 기능 이름처럼 유한한 값 집합에만 사용하세요.highCardinalityKeyValue는 span에만 태그를 붙입니다. 각 요청의 토큰 수는 여기에 적합합니다. 메트릭에 포함하면 각기 다른 값마다 새로운 time series가 생성됩니다.observe(...)는 자동으로 start/stop을 호출합니다. 예외가 발생하면 observation에 오류를 기록합니다.- 두 카운터
llm.tokens와llm.cost.usd는 tracing의 샘플링과 관계없이 모든 요청에 대해 계산됩니다.
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);
}
}
예시 가격 표에 따라 입력 토큰 1,200개와 출력 토큰 300개의 결과는:
(1200 × 0.50 + 300 × 1.50) / 1_000_000 = 0.00105
이 카운터는 feature와 model로 분류된 거의 실시간 비용 추정값을 보여줍니다. 따라서 경고를 설정할 수 있습니다. 예를 들어 “1시간 동안 support-chat의 비용이 임계값 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과 연결됩니다. 정규식은 최소한의 보호 계층일 뿐입니다. 정말 민감한 데이터의 경우 프롬프트를 저장하지 않는 것을 고려하세요. 또는 접근 제어와 삭제 기한이 있는 별도의 리포지토리에 저장하세요.
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는 그 값이 무한하기 때문에 메트릭 태그에 포함될 수 없습니다. DB에만 저장됩니다.
7.3. 오프라인 평가
모델을 변경하거나 시스템 프롬프트를 다시 작성하는 등 큰 변화의 경우, 예상 답변이 포함된 샘플 질문 세트가 있어야 합니다. CI에서 또는 일정에 따라 이를 실행합니다. 점수 매기기는 매칭, 규칙 또는 판사 역할을 하는 다른 LLM을 사용할 수 있습니다. 결과는 비교를 위해 prompt.version별로 기록되어야 합니다. 이 부분은 이 글의 코드 범위를 벗어납니다. 하지만 위의 런타임 메트릭을 보완합니다.
8. observability 부분 테스트하기
실제 API를 호출할 필요 없이 decorator를 테스트할 수 있습니다. SimpleMeterRegistry와 가짜 클라이언트만 있으면 됩니다:
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));
}
}
이 테스트는 세 가지를 확인합니다: 타이머 llm.request가 정확히 한 번 기록됨, 토큰이 type별로 분리됨, 비용이 섹션 5의 계산과 일치함. 나중에 누군가가 메트릭 이름을 바꾸거나 가격 공식을 깨뜨리면 테스트가 실패하고 대시보드가 조용히 데이터를 잃지 않습니다.
9. 흔한 실수와 사용하지 말아야 할 경우
몇 가지 일반적인 실수:
- 메트릭 태그에 무한 값을 넣기:
userId,traceId, 프롬프트 내용 또는 특정 토큰 수. 이들은 시계열 수를 폭발시키고 메트릭 시스템을 마비시킬 수 있습니다. 이러한 값들은 span 또는 log에 속합니다. - 이미 샘플된 trace에서 비용 계산하기: 요청의 10%만 trace가 있으면 trace에서 합산한 총 비용이 잘못됩니다. 섹션 4처럼 counter를 사용하세요.
- 전체 프롬프트를 무기한 저장하기: 개인정보 보호 및 저장 비용 위험. 민감한 데이터를 마스킹하고 샘플링하며 삭제 기한을 설정하세요.
- 프롬프트 버전을 첨부하지 않기: 품질이 떨어질 때, 프롬프트를 바꿨는지, 모델을 바꿨는지, 입력 데이터가 변했는지 알 수 없습니다.
- 평균 지연 시간만 보기: LLM의 경우 p95/p99를 보고 스트리밍을 사용하면 첫 토큰까지의 시간을 따로 추적하세요.
참고: 모든 프로젝트가 위의 모든 부분을 필요로 하는 것은 아닙니다. 내부 프로토타입, 하루에 수십 개의 요청, 작은 예산의 경우, 응답에서 usage를 로깅하고 공급자의 청구서를 보는 것으로 충분합니다. 이 경우 tracing 시스템, 프롬프트 저장소, 평가 파이프라인은 운영 작업만 추가합니다. 토큰/비용 counter와
prompt.version태그로 시작하세요. 나머지는 트래픽, 비용 또는 품질 요구 사항이 실제로 필요할 때 확장하세요.
또한, 조직이 이미 LLM observability 전용 플랫폼을 사용 중이라면, 모든 것을 다시 작성하지 마세요. 이 글의 decorator 계층은 여전히 태그를 정규화하는 데 유용합니다. 하지만 저장 및 표시 부분은 기존 도구에 맡기세요.
10. 결론
LLM 애플리케이션의 observability는 세 가지 새로운 질문에 답하기 위해 친숙한 세 기둥(메트릭, 트레이스, 로그)을 확장합니다: 어떤 프롬프트가 실행 중인지, 얼마나 많은 비용이 드는지, 응답이 좋은지.
이 글에서 Micrometer Observation으로 LLM 클라이언트를 래핑하여 gen_ai.* 태그가 있는 타이머와 스팬을 얻었습니다. 토큰을 계산하고 가격 구성을 통해 비용을 산출했습니다. 민감한 데이터를 마스킹한 프롬프트를 로깅했습니다. 자동 검사와 사용자 피드백 모두에서 품질 신호를 수집했습니다. 합리적인 적용 순서는: 먼저 비용과 프롬프트 버전, 그 다음 추적과 품질, 그 다음 실제 요구 사항에 따라 점진적으로 확장하는 것입니다.