LLM 서빙을 OpenTelemetry 로 관측하기: 요청 스팬, 컬렉터 변환, 꼬리 샘플링
한 줄 요약
vLLM 에 OpenTelemetry 트레이싱을 켜면 요청마다 llm_request 스팬 하나가 남고, 그 속성만으로 첫 토큰이 느린 이유를 대기와 프리필로 가를 수 있다. 이 측정에서 첫 토큰이 1초 이상 걸린 요청 69개는 평균 1,494ms 가운데 대기가 886ms, 프리필이 491ms 였다. 트레이싱을 켜는 비용은 처리량과 첫 토큰 시간이 1% 안에서 같을 만큼 작았다. 느린 요청만 남기는 꼬리 샘플링은 느린 요청 19개를 전부 남겼지만, 확률 샘플링 10% 는 느린 요청 22개 중 1개만 남겼다.
왜 이게 필요했나
LLM 서버가 느려졌을 때 알고 싶은 것은 대개 셋이다. 요청이 대기열에서 얼마나 기다렸는가, 계산에 얼마나 걸렸는가, 이 요청이 어느 서비스의 어느 호출에서 시작되었는가. 평균 지연 하나는 이 셋을 뭉개 버린다. OpenTelemetry 는 이 셋을 같은 방식으로 남기는 표준이고, OTCA 가 묻는 컬렉터 파이프라인, 샘플링, 시맨틱 컨벤션, 컨텍스트 전파가 모두 여기서 쓰인다.
그런데 현실의 엔진은 표준과 조금 다르게 내보낸다. 서비스 이름이 비어 있고, 토큰 수 속성은 표준이 되기 전의 이름을 쓰고, 지표의 히스토그램은 누적이다. 이름이 다른 신호를 그대로 쌓으면 서버가 여러 대일 때 한 이름 아래 섞이고 대시보드의 쿼리도 서버마다 달라진다. 이 모듈은 실제 vLLM 이 낸 스팬과 히스토그램을 직접 읽고 계산하면서, 컬렉터가 어디까지 메워 주는지와 우리가 직접 판단해야 하는 곳이 어디인지를 확인한다.
어떻게 동작하나
구성은 단순하다. 클라이언트가 traceparent 헤더를 실어 vLLM 에 요청을 보내고, vLLM(--otlp-traces-endpoint)이 OTLP/gRPC 로 컬렉터에 스팬을 내보내고, 컬렉터가 파일(JSON 한 줄이 배치 하나)로 쓴다. 지표는 OTLP 가 아니라 Prometheus /metrics 로 나오고 컬렉터의 prometheus 수신기가 가져간다.
요청 스팬. 클라이언트가 부모 스팬을 만들어 보낸 요청 세 개 모두 llm_request(종류 SERVER) 스팬이 만들어졌고, 부모 스팬 ID 와 트레이스 ID 가 클라이언트 스팬의 것과 같았다. 컨텍스트 전파가 서버 안까지 이어졌다는 뜻이다. 스팬 하나의 속성은 열두 개였다.
| 속성 | 뜻 |
|---|---|
gen_ai.latency.time_to_first_token |
요청이 들어와 첫 토큰이 나오기까지(초) |
gen_ai.latency.time_in_queue |
대기열에서 기다린 시간 |
gen_ai.latency.time_in_model_prefill, time_in_model_decode |
프리필(입력 처리)과 디코드(토큰 생성) |
gen_ai.latency.time_in_model_inference, e2e |
모델이 일한 전체, 요청 전체 |
gen_ai.usage.prompt_tokens, completion_tokens |
입력, 출력 토큰 수 |
gen_ai.request.id, max_tokens, n, top_p |
요청 정보 |
없는 것도 중요하다. 모델 이름 속성이 스팬에 없었고 리소스의 service.name 은 unknown_service 였다. 서버를 띄우는 과정도 트레이스로 남는다. 모델 파일이 이미 있는 서버 네 대의 평균으로 Overall Loading 이 18.7초, EngineCoreProc init 이 14.1초였다. 이 스팬들은 서로 부모와 자식으로 포함되어 있어 더하면 안 된다. 실습 재료에는 이 시작 스팬이 요청 스팬과 섞여 있으므로 이름으로 거르는 것이 첫 단계의 일이다.
지표. prometheus 수신기로 5초마다 가져오자 93개 이름이 나왔고, 그중 _created 시리즈가 게이지로 변환된 쓸모없는 이름이 36개였다. 스크레이프 설정의 metric_relabel_configs 로 .*_created 를 버리면 57개가 되었다. 히스토그램은 누적(aggregationTemporality 가 2)이고 단위가 비어 있었다(표준은 초 s). 누적이라는 것은 서버가 뜬 뒤의 모든 관측이 들어 있다는 뜻이라서, 최근 몇 초의 분포는 두 시점의 버킷 개수를 뺀 구간 히스토그램에서 구해야 한다.
표준과의 거리. OpenTelemetry 의 GenAI 시맨틱 컨벤션은 아직 개발 단계다. vLLM 은 토큰 수에 옛 이름(prompt_tokens, completion_tokens)과 자체 gen_ai.latency.* 를 쓰고, 표준은 gen_ai.usage.input_tokens, output_tokens 와 서버 지표 gen_ai.server.time_to_first_token 등이다. 서버를 바꾸지 않고 컬렉터의 resource, transform, metrics_transform 프로세서로 번역할 수 있고, 아래 설정을 실제로 돌려 확인했다.
processors:
resource/service:
attributes:
- {key: service.name, value: vllm, action: upsert}
transform/genai:
trace_statements:
- context: span
statements:
- set(attributes["gen_ai.usage.input_tokens"], attributes["gen_ai.usage.prompt_tokens"]) where attributes["gen_ai.usage.prompt_tokens"] != nil
- set(attributes["gen_ai.usage.output_tokens"], attributes["gen_ai.usage.completion_tokens"]) where attributes["gen_ai.usage.completion_tokens"] != nil
- delete_key(attributes, "gen_ai.usage.prompt_tokens")
- delete_key(attributes, "gen_ai.usage.completion_tokens")
프로세서는 파이프라인의 processors 배열에 적은 순서대로 실행되고, 위쪽 정의 블록의 순서는 상관이 없다. 어떤 프로세서가 만든 속성을 뒤의 프로세서가 읽는다면 앞쪽에 둬야 한다.
샘플링. 꼬리 기반 샘플링(tail_sampling)은 트레이스가 끝난 뒤 지연 같은 결과를 보고 남길지 정한다. 확률 샘플링은 느린지 모르는 채로 뽑는다. 꼬리 샘플링의 대가는 트레이스 전체를 같은 컬렉터가 모아 일정 시간 메모리에 들고 있어야 한다는 점이고, 여러 서비스를 거치는 트레이스는 같은 곳으로 모아야 한다. 이것은 공식 문서의 설명이고 이 시험에서는 재지 않았다.
실측 결과
환경은 8GB급 GPU 한 장, vLLM 0.31.0, 모델 Qwen3-1.7B, 컬렉터는 contrib 0.162.0 이고 서버는 하나다. 원자료와 스크립트는 docs/experiments/2026-10-07-otel-llm-serving/ 에 있다.
켜는 비용. 같은 부하(입력 512, 출력 128, 동시 1, 8, 32)를 세 설정에서 서버를 두 번씩 다시 띄워 쟀다. 여섯 서버 모두 KV 캐시가 26,560토큰이었다.
| 설정 | 동시 1 처리량 / TTFT | 동시 8 | 동시 32 |
|---|---|---|---|
| 끔 | 66, 66 tok/s / 73, 73ms | 384, 384 tok/s / 349, 353ms | 833, 831 tok/s / 670, 669ms |
--otlp-traces-endpoint |
66, 66 / 73, 73ms | 385, 384 / 350, 348ms | 832, 832 / 668, 668ms |
위 + --collect-detailed-traces all |
66, 66 / 74, 73ms | 385, 384 / 349, 351ms | 832, 834 / 667, 667ms |
트레이싱을 켜도 처리량과 첫 토큰 시간이 1% 안에서 같았다. --collect-detailed-traces all 은 이 버전에서 스팬도 속성도 늘리지 않았고, 모은 요청 스팬 500개가 모두 같은 열두 속성이었다.
느린 요청 분해. 요청 스팬 500개 가운데 첫 토큰이 1초 이상 걸린 요청 69개와 0.2초 미만인 38개를 견줬다.
| 요청 | 첫 토큰 평균 | 대기 | 프리필 |
|---|---|---|---|
| 1초 이상 걸린 69개 | 1,494ms | 886ms | 491ms |
| 0.2초 미만 38개 | 74ms | 0ms | 65ms |
느린 요청의 첫 토큰 시간 중 대기가 차지한 비중의 중앙값은 59% 였다. 느린 요청은 계산이 느려서가 아니라 줄을 서서 느렸고, 그래서 대응은 더 빠른 GPU 보다 동시 처리 한계와 복제 수를 늘리거나 요청을 제한하는 쪽이다. 첫 토큰 시간이 대기와 프리필의 합과 정확히 같지는 않아서 차이의 중앙값이 78ms, 최대 221ms 였다. 토큰화나 첫 디코드 단계 등으로 짐작하지만 분해하지 않았다.
샘플링. 혼합 부하로 요청 120개를 보냈고, 서버 쪽 스팬 시간이 2.5초 이상인 요청은 19개였다.
| 정책 | 남은 트레이스 | 느린(2.5초 이상) 요청 |
|---|---|---|
| 모두 남김(변환만) | 120 | 19 |
꼬리 샘플링, latency 정책 2,500ms 이상 |
19(15.8%) | 19(전부) |
| 확률 샘플링 10%(요청 300개 부하) | 39(13%) | 22개 중 1개 |
꼬리 샘플링은 느린 요청을 전부 남겼고 전체 트레이스의 16% 만 보관했다. 확률 샘플링은 10% 를 뽑았는데 실제로 13% 가 남았고(해시 편차) 그중 느린 것은 1개였다. 평균 지연은 비슷하게 보여도 가장 보고 싶은 꼬리가 사라진다.
조용히 아무것도 남기지 않는 정책. tail_sampling 의 numeric_attribute 정책으로 첫 토큰 시간 속성(실수, 초)의 최솟값을 걸었더니 남은 트레이스가 0 이었고 경고도 오류도 없었다. 원인은 확정하지 못했다(문서의 예시는 정수 값으로 보인다). 트레이스 길이를 보는 latency 정책으로 바꿔 동작했다. 정책이 아무것도 남기지 않을 때 조용하다는 점이 위험하므로, 샘플링을 바꿀 때는 남은 개수를 꼭 세어 본다.
히스토그램 재료. 실습의 히스토그램은 120요청 부하의 첫 토큰 시간을 5초 간격 9개 시점으로 남긴 누적값이고, 마지막 시점의 개수는 120, 합계는 51.76초라서 평균은 0.43초다. 버킷 분포(0.25–0.5초 49건, 0.5–0.75초 19건, 1–2.5초 7건)가 평균보다 많은 것을 보여 준다.
이 숫자를 어디까지 믿을 것인가
- 서버 하나, 모델 하나(1.7B), GPU 한 장이다. 여러 서버에서 트레이스가 어떻게 이어지는지(게이트웨이, 라우터, 워커 사이)는 재지 않았다.
- SGLang 과 Dynamo 의 트레이싱은 재지 않았다. vLLM 의 값을 다른 엔진에 일반화하지 않는다.
- 트레이스와 지표 저장소를 붙이지 않았다. 시각화, 알림, 저장 비용은 미실측이다.
- 컬렉터 자체의 CPU, 메모리, 지연은 재지 않았다. 꼬리 샘플링의 메모리 대가는 문서의 설명일 뿐이다.
- 켜는 비용은 서버를 두 번씩 띄운 값이고 표준편차를 구하지 않았다. 1% 안이라는 방향은 확실해도 소수점 이하는 믿지 않는 편이 좋다. 더 작은 모델이나 더 빠른 토큰 속도에서 비율이 커지는지는 재지 않았다.
- 프롬프트와 응답 내용을 스팬으로 남기는 설정과 그에 따르는 개인정보 문제는 다루지 않았다.
- 표준 이름과 값의 의미까지 같은지는 확인하지 않았다. 이름만 바꾸는 번역이 의미까지 맞추는 것은 아니다.
- GenAI 시맨틱 컨벤션은 개발 단계라서 이름이 곧 달라질 수 있다.
- 실습의 확률 정책은 트레이스 ID 끝 8자리를 쓰는 단순한 모형이다. 실제 확률 샘플링 프로세서의 해시와 같지 않으므로, 남기는 개수의 경향만 배우고 개별 트레이스의 운명은 배우지 않는다. 실습 재료의 5초 기준 비교는 위 샘플링 표(2.5초 기준, 다른 부하)와 데이터가 달라 숫자가 다르다.
현장에서 만나는 모습
첫째, 첫 토큰이 느리다는 알림이 오면 GPU 를 의심하기 전에 스팬 속성으로 대기와 프리필을 가른다. 대기가 대부분이면 처리 능력이 아니라 동시성과 복제 수의 문제다. 둘째, 서비스 이름을 달지 않은 서버는 unknown_service 로 섞이므로 환경 변수 OTEL_SERVICE_NAME 으로 달거나 컬렉터의 resource 프로세서로 채운다. 셋째, 서버마다 속성 이름이 다르면 쿼리도 서버마다 달라지므로 컬렉터에서 표준 이름으로 맞춘다. 넷째, 샘플링 정책을 바꾼 뒤에는 남은 트레이스 수를 센다. 정책이 아무것도 남기지 않아도 컬렉터는 조용하다. 다섯째, 누적 히스토그램의 분위수는 서버가 뜬 뒤 전부의 값이라서 방금 시작된 장애를 늦게 보여 준다. 최근 구간의 분위수가 필요하면 두 시점의 차로 구한다.
다음 실습에서 할 것
GPU 와 컬렉터 없이 순수 파이썬으로 이 흐름을 만든다. 컬렉터가 남긴 OTLP JSON 에서 요청 스팬만 골라 읽고, 첫 토큰 시간을 대기와 프리필로 나눠 느린 요청이 줄을 서서 느린지 확인한다. 옛 속성 이름을 표준으로 옮기고 서비스 이름을 채우는 변환을 만들고, 지연 정책과 확률 정책이 느린 요청을 얼마나 남기는지 센다. 그다음 traceparent 헤더를 W3C 규칙대로 검증해 자식 헤더를 만들고, 마지막으로 누적 히스토그램에서 분위수와 최근 구간의 분위수를 직접 계산한다.