상태: 초안 (2026-10-07). 이 글의 수치는 모두 필자가 직접 잰 값입니다. 환경은 8GB급 GPU 한 장, vLLM 0.31.0, Qwen3-1.7B, OpenTelemetry Collector contrib 0.162.0, 서버 하나입니다. GenAI 시맨틱 컨벤션은 아직 개발(Development) 단계 라서 이름이 바뀌는 중이고(이 글을 쓰는 날에도 지표 이름 하나가 바뀌었습니다), SGLang 과 Dynamo 의 트레이싱, 트레이스 저장소(Tempo, Jaeger 등)와 지표 저장소는 미실측 입니다. 작성 규칙: 실측 데이터가 기본이고, 못 잰 것은 「미실측」으로 표시합니다.
목차
- 1. 구성
- 2. vLLM 이 내보내는 신호
- 3. 켜는 비용
- 4. 표준과의 거리, 그리고 컬렉터로 메우기
- 5. 느린 요청을 어떻게 찾는가
- 6. 겪은 일
- 7. OTCA 범위와 이어서 보기
- 8. 한계와 미실측
- 참고 자료
LLM 서버가 느려졌을 때 알고 싶은 것은 대개 셋입니다. 요청이 대기열에서 얼마나 기다렸는가, 계산에 얼마나 걸렸는가, 이 요청이 어느 서비스의 어느 호출에서 시작되었는가. OpenTelemetry(OTel)는 이 셋을 같은 방식으로 남기는 표준이고, 시험(OTCA)이 묻는 컬렉터, 샘플링, 시맨틱 컨벤션, 컨텍스트 전파가 모두 여기서 쓰입니다. 이 글은 vLLM 하나에 실제로 붙여 보고 다섯 가지를 답합니다.
- vLLM 은 요청 하나에 어떤 스팬과 속성을 남기고, 지표는 어떤 이름으로 나오는가.
- 클라이언트에서 시작한 트레이스가 vLLM 까지 이어지는가.
- 켜는 비용은 얼마인가.
- 표준(GenAI 시맨틱 컨벤션)과 얼마나 다르고, 컬렉터로 메울 수 있는가.
- 느린 요청만 남기는 샘플링은 어떻게 동작하고, 확률 샘플링과 무엇이 다른가.
짧은 답. 요청마다 llm_request 스팬 하나가 traceparent 에 이어져 남았고, 그 속성만으로 첫 토큰 시간을 대기와 계산으로 가를 수 있었습니다. 트레이싱을 켜는 비용은 오차 안이었습니다. 속성 이름은 표준과 달랐지만(prompt_tokens 대 input_tokens) 컬렉터의 변환 프로세서로 바꿀 수 있었습니다. 느린 요청만 남기는 꼬리 샘플링은 느린 요청 19개를 전부 남겼고, 확률 샘플링 10% 는 느린 요청 22개 중 1개만 남겼습니다.
1. 구성
클라이언트(계측, traceparent 전송) ──HTTP──▶ vLLM(--otlp-traces-endpoint) ──OTLP/gRPC──▶ OTel Collector ─▶ 파일(JSON)
└─ /metrics(Prometheus) ◀──스크레이프── Collector(prometheus 수신기)
- vLLM 0.31.0
vllm serve Qwen/Qwen3-1.7B --otlp-traces-endpoint http://127.0.0.1:4317. 같은 이미지 안에 OpenTelemetry SDK(1.40.0)와 OTLP 내보내기가 이미 들어 있었습니다. - 컬렉터는 contrib 배포판 0.162.0 의 바이너리를 같은 파드 안에서 돌렸습니다. 수신기는 OTLP(gRPC)와 prometheus, 내보내기는 파일 내보내기(JSON 한 줄이 배치 하나)입니다.
- 저장소(Tempo, Jaeger, Prometheus 등)는 붙이지 않았습니다. 이 글은 「무엇이 나오는가」까지이고 「어디에 쌓고 어떻게 보는가」는 미실측 입니다.
- 스크립트와 원자료는 저장소의
docs/experiments/2026-10-07-otel-llm-serving/에 있습니다.
2. vLLM 이 내보내는 신호
2.1 트레이스: 요청마다 llm_request 하나
클라이언트가 부모 스팬을 만들고 W3C traceparent 헤더를 실어 요청 세 개를 보냈습니다. 컬렉터에 도착한 vLLM 스팬은 세 개 모두 llm_request(종류 SERVER)였고, 부모 스팬 ID 가 클라이언트 스팬의 ID 와 같았으며 트레이스 ID 도 같았습니다. 즉 클라이언트에서 시작한 트레이스가 vLLM 안까지 이어졌습니다.
스팬 하나의 속성은 열두 개였고, 설정을 바꿔도 같은 열두 개였습니다.
| 속성 | 값의 예 | 뜻 |
|---|---|---|
gen_ai.latency.time_to_first_token |
0.0195(초) | 요청이 들어와 첫 토큰이 나오기까지 |
gen_ai.latency.time_in_queue |
0.00002(초) | 대기열에서 기다린 시간 |
gen_ai.latency.time_in_model_prefill |
0.0176 | 프리필(입력 처리) |
gen_ai.latency.time_in_model_decode |
0.4504 | 디코드(토큰 생성) |
gen_ai.latency.time_in_model_inference |
0.4680 | 모델이 일한 전체 |
gen_ai.latency.e2e |
0.4697 | 요청 전체 |
gen_ai.usage.prompt_tokens, gen_ai.usage.completion_tokens |
10, 32 | 입력, 출력 토큰 수 |
gen_ai.request.id, max_tokens, n, top_p |
cmpl-…, 32, 1, 1 |
요청 정보 |
없는 것도 중요합니다. 모델 이름(gen_ai.request.model)과 gen_ai.operation.name 이 스팬에 없었고, 리소스의 service.name 은 unknown_service 였습니다. 서비스 이름을 달지 않으면 여러 서버의 스팬이 한 이름 아래 섞입니다. 환경 변수 OTEL_SERVICE_NAME 으로 달거나, 컬렉터의 resource 프로세서로 service.name 을 채울 수 있습니다(4절에서 후자를 썼습니다).
서버를 띄우는 과정도 트레이스로 남습니다. llm_request 와 별도로 시작 때의 스팬 수십 개가 한 트레이스로 나왔습니다. 모델 파일이 이미 있는 서버 네 대의 평균으로 Overall Loading 이 18.7초, EngineCoreProc init 14.1초, Prepare model 6.7초, Executor init 6.1초, Load model 3.4초, Warmup (GPU) 2.7초, Load weights 1.8초였습니다(스팬이 서로 부모와 자식으로 포함되어 있어 더하면 안 됩니다). 처음 띄운 서버는 가중치 내려받기 하나가 298초였고 Overall Loading 이 341초였습니다. 「서버가 왜 늦게 떴나」를 로그를 뒤지지 않고 스팬 시간으로 읽을 수 있습니다.
2.2 지표: OTLP 가 아니라 Prometheus /metrics
이 버전에서 지표는 /metrics(Prometheus 형식)로만 확인되었습니다. 컬렉터의 prometheus 수신기로 5초마다 가져오자 93개 이름이 나왔고, 쓸 만한 것은 아래입니다(전부 vllm: 접두사).
| 지표 | 종류 | 읽는 법 |
|---|---|---|
time_to_first_token_seconds |
히스토그램 | 첫 토큰까지 |
inter_token_latency_seconds |
히스토그램 | 토큰 사이 간격 |
e2e_request_latency_seconds |
히스토그램 | 요청 전체 |
request_queue_time_seconds, request_prefill_time_seconds, request_decode_time_seconds |
히스토그램 | 단계별 시간 |
num_requests_running, num_requests_waiting |
게이지 | 지금 도는 것과 기다리는 것 |
kv_cache_usage_perc |
게이지 | KV 캐시 사용률 |
prefix_cache_queries_total, prefix_cache_hits_total |
합계 | 접두사 캐시 적중 |
prompt_tokens_total, generation_tokens_total |
합계 | 토큰 처리량 |
num_preemptions_total |
합계 | 선점(KV 부족) |
세 가지를 확인했습니다.
_created시리즈가 지표로 새어 나옵니다. Prometheus 형식의_created줄이 수신기에서 게이지로 변환되어 쓸모없는 이름이 열 개 중 네 개 가까이(36개) 되었습니다. 스크레이프 설정의metric_relabel_configs로.*_created를 버리면 57개로 줄었습니다.- 히스토그램은 누적(cumulative)이고 단위가 비어 있습니다. 수신기가 만든 TTFT 히스토그램의
aggregationTemporality는 누적(2)이고unit은 비었으며(표준은 초s), 레이블은engine과model_name이었습니다. 부하 120요청 뒤의 합계가 51.76초라서 평균은 0.43초이지만 버킷 분포(0.25~0.5초 49건, 0.5~0.75초 19건, 1~2.5초 7건)가 평균보다 많은 것을 보여 줍니다. - 지표 이름의 콜론(
vllm:...)은 그대로 유지됩니다.
3. 켜는 비용
같은 부하(입력 512, 출력 128, 동시 1·8·32)를 세 설정에서 서버를 두 번씩 다시 띄워 쟀습니다. 서버를 띄울 때마다 KV 캐시 자리가 달라질 수 있어(14편) 서버 로그를 확인했고, 여섯 서버 모두 26,560토큰이었습니다.
| 설정 | 동시 1 처리량 / TTFT | 동시 8 | 동시 32 |
|---|---|---|---|
| 끔 | 66 tok/s / 73ms, 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% 안에서 같았습니다. 요청당 스팬 하나를 만드는 비용이 이 모델의 요청 하나(토큰 하나에 약 15ms)에 비해 작았습니다. 더 작은 모델이나 더 빠른 토큰 속도에서는 비율이 커질 수 있고 재지 않았습니다(미실측).
--collect-detailed-traces all은 이 버전에서 스팬도 속성도 늘리지 않았습니다. 네 서버(켬 둘, 상세 둘)에서 모은 500개의 요청 스팬이 모두 같은 열두 속성이었습니다. 옵션 설명은 「성능에 영향을 줄 수 있다」고 하지만 이 시험에서는 차이를 보지 못했습니다.- 저장 부피는 JSON 으로 500스팬이 612KB(스팬 하나 약 1.2KB)였습니다. 파일 내보내기의 JSON 크기이고 OTLP 전송 크기나 저장소의 크기가 아닙니다.
4. 표준과의 거리, 그리고 컬렉터로 메우기
OpenTelemetry 의 GenAI 시맨틱 컨벤션은 별도 저장소(semantic-conventions-genai)로 옮겨졌고 상태는 개발 입니다. 확인한 시점(2026-10-06 의 최신 커밋)의 내용과 vLLM 이 낸 것을 견주면 이렇습니다.
| 항목 | GenAI 컨벤션 | vLLM 0.31.0 |
|---|---|---|
| 추론 스팬 | 이름 gen_ai.client.inference 유형, 스팬 이름 {operation} {model}, 필수 속성 gen_ai.operation.name, gen_ai.provider.name |
llm_request, 서버 쪽 스팬, 위 필수 속성 없음 |
| 토큰 수 | gen_ai.usage.input_tokens, gen_ai.usage.output_tokens |
gen_ai.usage.prompt_tokens, gen_ai.usage.completion_tokens(옛 이름) |
| 모델 이름 | gen_ai.request.model |
스팬에 없음, 지표에는 model_name 레이블 |
| 첫 토큰 시간 | 서버 지표 gen_ai.server.time_to_first_token(히스토그램, 초) |
스팬 속성 gen_ai.latency.time_to_first_token(자체 이름), 지표 vllm:time_to_first_token_seconds |
| 토큰 간격, 요청 시간 | gen_ai.server.time_per_output_token, gen_ai.server.request.duration |
vllm:inter_token_latency_seconds, vllm:e2e_request_latency_seconds |
표준이 아직 개발 단계라서 어느 쪽이 「틀렸다」기보다 vLLM 이 표준이 굳기 전의 이름을 쓰는 상태입니다. 쓰는 쪽에서는 컬렉터가 번역하게 하면 서버를 바꾸지 않아도 됩니다. 아래 설정을 실제로 돌려 확인했습니다.
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")
metrics_transform/genai:
transforms:
- {include: "vllm:time_to_first_token_seconds", action: update, new_name: gen_ai.server.time_to_first_token}
- {include: "vllm:inter_token_latency_seconds", action: update, new_name: gen_ai.server.time_per_output_token}
- {include: "vllm:e2e_request_latency_seconds", action: update, new_name: gen_ai.server.request.duration}
결과로 스팬의 서비스 이름이 vllm 이 되었고, 토큰 수 속성이 input_tokens, output_tokens 로 바뀌었으며(옛 이름은 사라짐), 지표 이름 세 개가 gen_ai.server.* 로 바뀌었습니다. 다만 값의 의미 까지 표준과 같은지는 이름만으로 알 수 없습니다. 예를 들어 표준의 첫 토큰 시간은 「성공한 응답의 첫 토큰까지」이고 vLLM 의 값이 같은 구간인지는 확인하지 않았습니다(미실측). 단위도 앞서 본 것처럼 비어 있어 따로 채워야 합니다.
5. 느린 요청을 어떻게 찾는가
5.1 스팬 속성만으로 첫 토큰 시간을 가른다
서버 네 대(트레이싱 켬 둘, 상세 켬 둘)에서 모은 요청 스팬 500개로 첫 토큰이 1초 이상 걸린 요청 69개와 0.2초 미만인 38개를 견줬습니다.
| 요청 | 첫 토큰 평균 | 대기 | 프리필 |
|---|---|---|---|
| 1초 이상 걸린 69개 | 1,494ms | 886ms | 491ms |
| 0.2초 미만 38개 | 74ms | 0ms | 65ms |
느린 요청의 첫 토큰 시간 중 대기가 차지한 비중의 중앙값은 59% 였습니다. 느린 요청은 계산이 느려서가 아니라 줄을 서서 느렸고, 이는 「GPU 를 더 빠른 것으로」가 아니라 「동시 처리 한계와 복제 수를 늘리거나 요청을 제한」으로 이어집니다. 첫 토큰 시간이 대기와 프리필의 합과 정확히 같지는 않아서 차이의 중앙값이 78ms, 최대 221ms 였습니다(토큰화나 첫 디코드 단계 등으로 짐작하지만 분해하지 않았습니다). 요청 전체 시간은 첫 토큰 시간과 디코드 시간의 합과 1.1ms 안에서 같았습니다.
5.2 꼬리 샘플링 대 확률 샘플링
혼합 부하(입력 길이가 64, 512, 1,500단어, 동시 16)로 요청 120개를 보냈습니다. 클라이언트에서 잰 요청 시간은 중앙값 2.08초, p90 2.73초, 최대 3.02초였고, 서버 쪽 스팬 시간으로 2.5초 이상인 요청이 19개였습니다.
| 정책 | 남은 트레이스 | 느린(2.5초 이상) 요청 |
|---|---|---|
| 모두 남김(변환만) | 120 | 19 |
꼬리 샘플링, latency 정책 2,500ms 이상 |
19(15.8%) | 19(전부) |
| 확률 샘플링 10%(요청 300개 부하) | 39(13%) | 22개 중 1개 |
- 꼬리 샘플링은 트레이스가 끝난 뒤 지연을 보고 정하므로 느린 요청을 전부 남겼습니다. 남은 트레이스의 최소 지연이 2,551ms 였고 전체 트레이스의 16% 만 보관했습니다.
- 확률 샘플링은 느린지 모르고 뽑으므로 느린 요청이 샘플 비율대로만 남았습니다. 10% 를 뽑았는데 실제로 13%(해시 편차)가 남았고 그중 느린 것은 1개였습니다. 평균 지연은 비슷하게 보여도 가장 보고 싶은 꼬리가 사라집니다.
- 꼬리 샘플링의 대가는 트레이스 전체를 같은 컬렉터가 모아 일정 시간(
decision_wait, 여기서는 5초) 메모리에 들고 있어야 한다는 점입니다. 이 시험은 컬렉터 하나, 스팬 하나짜리 트레이스라서 메모리와 CPU 를 재지 않았습니다(미실측). 여러 서비스를 거치는 트레이스에서는 서비스마다 다른 컬렉터로 가면 안 되고 트레이스 ID 로 같은 곳에 모아야 합니다(공식 문서의 설명이고 이 시험에서는 확인하지 않았습니다).
6. 겪은 일
tail_sampling의numeric_attribute정책은 이 속성에서는 아무것도 남기지 않았습니다. 첫 시도는 첫 토큰 시간 속성(실수, 초)의 최솟값으로 걸렀는데 남은 트레이스가 0 이었고, 경고나 오류도 없었습니다. 원인은 확정하지 못했습니다(문서의 예시는 정수 값이고min_value도 정수로 보입니다). 트레이스 길이를 보는latency정책으로 바꿔 동작했습니다. 정책이 아무것도 안 남겼을 때 조용하다는 점이 위험합니다. 샘플링을 바꿀 때는 남은 개수를 꼭 세어 봐야 합니다.metricstransform이름이 더는 권장되지 않습니다. 컬렉터가 시작할 때 「별칭이 폐기되었으니metrics_transform을 쓰라」고 경고했습니다.- 컬렉터가 열어 둔 파일을 잘라 쓰면 파일 앞이 NUL 바이트로 찹니다. 내보내기 파일을
: > file로 비웠더니 컬렉터가 예전 위치에 이어 써서 앞부분이\0으로 채워졌고 JSON 읽기가 첫 줄에서 실패했습니다. 파일을 비우려면 컬렉터를 멈추거나 새 경로를 쓰는 편이 안전합니다. - 프로메테우스 수신기는 서버가 뜨기 전에는 스크레이프 실패 경고를 5초마다 냅니다. 서버가 준비된 뒤에 사라졌고 데이터에는 영향이 없었습니다.
- vLLM 서버의 KV 캐시 자리가 처음 띄운 서버에서 가장 작았던 현상(14편)이 이 시험에서도 있었습니다. 처음 띄운 서버(22,048토큰)와 이후 서버(26,560토큰)가 달랐고, 오버헤드 비교에는 이후 서버만 썼습니다.
7. OTCA 범위와 이어서 보기
이 실험이 시험 범위의 어떤 항목을 직접 보여 주는지 정리합니다.
| 시험 범위 | 이 글에서 확인한 것 |
|---|---|
| 컨텍스트 전파 | traceparent 로 클라이언트 스팬과 vLLM 스팬이 부모와 자식으로 이어짐 |
| 리소스와 시맨틱 컨벤션 | service.name 이 없으면 unknown_service, 표준 이름과 서버 이름의 차이 |
| 컬렉터 파이프라인 | 수신기(otlp, prometheus), 프로세서(resource, transform, metrics_transform, tail_sampling, batch), 내보내기(file) 와 프로세서의 순서 |
| 샘플링 | 확률 샘플링과 꼬리 샘플링이 느린 요청을 남기는 정도의 차이 |
| 지표 | 누적 히스토그램, 단위 누락, 게이지와 합계 |
LabHub 의 OTCA 코스에는 이 내용을 실제 스팬과 히스토그램으로 직접 계산해 보는 모듈(otca-llm-serving, 실습 7단계)을 더했습니다. 배포가 끝난 환경에서 보입니다.
8. 한계와 미실측
- 서버 하나, 모델 하나(1.7B), GPU 한 장입니다. 여러 서버에서 트레이스가 어떻게 이어지는지(게이트웨이, 라우터, 분리 서빙의 워커 사이)는 재지 않았습니다(미실측).
- SGLang 의 트레이싱과 Dynamo 의 OTel 내보내기는 재지 않았습니다(미실측). vLLM 의 값을 다른 엔진에 일반화하지 않습니다.
- 트레이스와 지표 저장소를 붙이지 않았습니다. 시각화와 알림, 저장 비용은 미실측 입니다.
- 프롬프트와 응답 내용을 스팬이나 이벤트로 남기는 설정, 그에 따르는 개인정보 문제는 다루지 않았습니다.
- 컬렉터 자체의 CPU, 메모리, 지연은 재지 않았습니다(미실측).
- 오버헤드는 서버를 두 번 띄운 값이고 표준편차를 구하지 않았습니다. 1% 안이라는 결론의 방향은 확실하지만 소수점 이하는 믿지 않는 편이 좋습니다.
- GenAI 컨벤션은 개발 단계라서 이 글의 표는 2026-10-06 기준이고 곧 달라질 수 있습니다.
참고 자료
- OpenTelemetry GenAI 시맨틱 컨벤션(저장소): https://github.com/open-telemetry/semantic-conventions-genai (추론 스팬
docs/gen-ai/client-inference.md, 서버 지표docs/gen-ai/gen-ai-metrics.md) - OpenTelemetry Collector contrib 0.162.0 와 꼬리 샘플링 프로세서 문서: https://github.com/open-telemetry/opentelemetry-collector-contrib/tree/v0.162.0/processor/tailsamplingprocessor
- vLLM 의 트레이싱:
vllm serve --help=all의--otlp-traces-endpoint,--collect-detailed-traces, 그리고 설치본의vllm/tracing/코드 - 이 시리즈의 14편(vLLM 부하와 KV 캐시), 24편(Dynamo), 20편(실시간 음성 지연의 분해)