LabHub

한국어

시작하기
블로그

블로그

LLM 서빙을 OpenTelemetry 로 관측하기: vLLM 에서 실제로 나오는 신호, 표준과의 거리, 컬렉터로 메우기

상태: 초안 (2026-10-07). 이 글의 수치는 모두 필자가 직접 잰 값입니다. 환경은 8GB급 GPU 한 장, vLLM 0.31.0, Qwen3-1.7B, OpenTelemetry Collector contrib 0.162.0, 서버 하나입니다. GenAI 시맨틱 컨벤션은 아직 개발(Development) 단계 라서 이름이 바뀌는 중이고(이 글을 쓰는 날에도 지표 이름 하나가 바뀌었습니다), SGLang 과 Dynamo 의 트레이싱, 트레이스 저장소(Tempo, Jaeger 등)와 지표 저장소는 미실측 입니다. 작성 규칙: 실측 데이터가 기본이고, 못 잰 것은 「미실측」으로 표시합니다.

목차

LLM 서버가 느려졌을 때 알고 싶은 것은 대개 셋입니다. 요청이 대기열에서 얼마나 기다렸는가, 계산에 얼마나 걸렸는가, 이 요청이 어느 서비스의 어느 호출에서 시작되었는가. OpenTelemetry(OTel)는 이 셋을 같은 방식으로 남기는 표준이고, 시험(OTCA)이 묻는 컬렉터, 샘플링, 시맨틱 컨벤션, 컨텍스트 전파가 모두 여기서 쓰입니다. 이 글은 vLLM 하나에 실제로 붙여 보고 다섯 가지를 답합니다.

  1. vLLM 은 요청 하나에 어떤 스팬과 속성을 남기고, 지표는 어떤 이름으로 나오는가.
  2. 클라이언트에서 시작한 트레이스가 vLLM 까지 이어지는가.
  3. 켜는 비용은 얼마인가.
  4. 표준(GenAI 시맨틱 컨벤션)과 얼마나 다르고, 컬렉터로 메울 수 있는가.
  5. 느린 요청만 남기는 샘플링은 어떻게 동작하고, 확률 샘플링과 무엇이 다른가.

짧은 답. 요청마다 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 수신기)

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 부족)

세 가지를 확인했습니다.

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

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개

6. 겪은 일

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. 한계와 미실측

참고 자료

로그인하면 좋아요를 누를 수 있습니다

댓글

아직 댓글이 없습니다.

로그인하면 댓글을 쓸 수 있습니다