LabHub
시작하기
배우기 러닝패스 코스

OTCA — 오픈텔레메트리 인증 어소시에이트

LLM 서빙의 스팬, 샘플링, 히스토그램을 OTLP 로 직접 읽고 계산하기

LabHub 에서 이어서 보기

목표

vLLM 이 내보낸 실제 OTLP 스팬과 히스토그램을 읽고 계산해 본다. 요청 스팬만 골라 읽고, 첫 토큰 시간을 대기와 프리필로 나누고, 컬렉터의 변환(속성 이름 바꾸기, 서비스 이름 채우기)을 파이썬으로 옮기고, 지연 정책과 확률 정책이 느린 요청을 얼마나 남기는지 세고, traceparent 헤더를 W3C 규칙대로 검증하고, 누적 히스토그램에서 분위수와 최근 구간의 분위수를 구한다. GPU 도 컬렉터도 쓰지 않고 파이썬만 쓴다.

왜 중요한가

2026-10-07 의 측정에서 첫 토큰이 1초 이상 걸린 요청 69개는 평균 1,494ms 중 대기가 886ms 였고, 줄을 서서 느렸다는 사실은 평균 지연 하나로는 보이지 않았다. 느린 요청만 남기는 꼬리 샘플링은 느린 요청 19개를 전부 남겼지만 확률 샘플링 10% 는 22개 중 1개만 남겼다. 이 실습은 그 계산을 컬렉터를 띄우지 않고 재료의 JSON 만으로 되풀이한다. 재료는 /opt/fixtures/otel-llm/ 아래에 있고, 컬렉터가 남긴 그대로이며 고치지 않는다. 이 실습의 확률 정책은 트레이스 ID 끝 8자리를 쓰는 단순한 모형이라 실제 확률 샘플링 프로세서의 해시와 같지 않고, 실습 재료의 기준(5초)은 읽기 자료의 샘플링 표(2.5초)와 다른 데이터라서 숫자가 다르다.

단계

  1. /root/otelllm/spans.py 에 load_requests(path) 를 만든다. OTLP JSON 줄 파일에서 이름이 llm_request 인 스팬만 열두 키의 dict 목록으로 읽고, 재료를 읽은 결과를 /root/otelllm/requests.json 에 저장한다.
  2. /root/otelllm/breakdown.py 에 breakdown(reqs, min_ttft_ms=0, max_ttft_ms=None) 를 만든다. 첫 토큰 시간 범위 안의 요청의 평균과 대기 비중(요청별 비율의 중앙값)을 구하고, 느린 쪽(1초 이상)과 빠른 쪽(0.2초 미만)을 /root/otelllm/breakdown.json 에 저장한다.
  3. /root/otelllm/rename.py 에 rename_attrs(attrs) 와 upsert_service(resource_attrs, name) 를 만든다. 옛 토큰 속성 이름을 표준으로 바꾸고 서비스 이름을 덮어쓰며, 재료의 첫 요청 스팬의 속성 키 전후를 /root/otelllm/renamed.json 에 저장한다.
  4. /root/otelllm/sampling.py 에 latency_policy(reqs, threshold_ms) 와 probabilistic_policy(reqs, percent) 를 만든다. 둘 다 남길 트레이스 ID 의 집합을 돌려준다. 저장할 파일은 없다.
  5. sampling.py 를 명령줄로도 쓰게 한다. --input requests.json --threshold-ms N --percent P 로 두 정책이 남긴 트레이스 수와 느린 트레이스 수를 JSON 한 줄로 찍고, 재료에 --threshold-ms 5000 --percent 10 으로 돌린 출력을 /root/otelllm/sampling.json 에 저장한다.
  6. /root/otelllm/propagation.py 에 parse_traceparent(value) 와 child_traceparent(value, new_span_id) 를 만든다. 아래 헤더 여섯을 순서대로 푼 결과의 목록을 /root/otelllm/propagation.json 에 저장한다.
  7. /root/otelllm/hist.py 에 quantile(bounds, bucket_counts, q) 와 window(prev, cur) 를 만든다. 재료의 마지막 시점의 누적 분위수와, 4번째 시점(count 80)에서 마지막 시점까지의 구간 분위수를 /root/otelllm/hist.json 에 저장한다.

6단계의 헤더 여섯 개는 다음과 같다.

  1. 00-4bf92f3577b34da6a3ce929d0e0e4736-00f067aa0ba902b7-01
  2. 00-4bf92f3577b34da6a3ce929d0e0e4736-00f067aa0ba902b7-00
  3. 01-0af7651916cd43dd8448eb211c80319c-b7ad6b7169203331-03-extra-field
  4. 00-4BF92F3577B34DA6A3CE929D0E0E4736-00f067aa0ba902b7-01
  5. 00-00000000000000000000000000000000-00f067aa0ba902b7-01
  6. ff-4bf92f3577b34da6a3ce929d0e0e4736-00f067aa0ba902b7-01

참고

스팬 파일에서 요청만 골라 읽기

/root/otelllm/spans.py 에 load_requests(path) 를 만드세요. path 는 컬렉터의 파일 내보내기가 쓴 OTLP JSON 줄 파일입니다(한 줄이 배치 하나이고 한 줄에 resourceSpans 가 여러 개일 수 있으며, 빈 줄은 건너뜁니다). 스팬 이름이 정확히 llm_request 인 것만 나온 순서대로 모아, 키 열두 개의 dict 목록으로 돌려줍니다. trace_id, span_id, parent_span_id(parentSpanId 가 없거나 비어 있으면 None), service(리소스의 service.name, 없으면 None), start_ns(정수), duration_ms(끝 시각에서 시작 시각을 뺀 값을 밀리초로, 실수), ttft_ms, queue_ms, prefill_ms, decode_ms(속성 gen_ai.latency.time_to_first_token, time_in_queue, time_in_model_prefill, time_in_model_decode 의 초 값에 1000 을 곱한 값이고 반올림하지 않습니다), input_tokens, output_tokens(정수)입니다. 속성 값은 stringValue, intValue, doubleValue 중 하나로 오고, intValue 는 문자열일 수도 숫자일 수도 있습니다. 토큰 수는 옛 이름(gen_ai.usage.prompt_tokens, gen_ai.usage.completion_tokens)과 표준 이름(gen_ai.usage.input_tokens, gen_ai.usage.output_tokens) 어느 쪽이든 받고 둘 다 있으면 표준 이름의 값을 씁니다. 없는 속성은 None 입니다. 재료 /opt/fixtures/otel-llm/spans.jsonl 을 읽은 결과 목록을 /root/otelllm/requests.json 에 JSON 으로 저장하세요.

스팬 하나의 attributes 는 [{key, value: {...}}] 목록이므로 먼저 {키: 값} dict 로 풉니다. intValue 는 문자열로 오기 때문에 int() 로 바꾸지 않으면 토큰 수가 "34" 로 남습니다. 값이 0 인 속성을 if value: 로 걸러 None 으로 만들지 마세요(대기 시간 0 은 흔합니다). 시각은 19자리 정수라서 실수로 바꾼 뒤 빼면 정밀도를 잃으니 정수로 뺀 다음 나눕니다. 재료에는 서버를 띄울 때의 스팬(Overall Loading 등)도 섞여 있어서 이름으로 거르지 않으면 요청이 아닌 것까지 세게 됩니다. 채점기는 여러 resource 가 든 줄, 스팬이 없는 줄, 빈 줄, 속성이 일부 없는 스팬으로도 코드를 다시 돌립니다.

첫 토큰 시간을 대기와 프리필로 나누기

/root/otelllm/breakdown.py 에 breakdown(reqs, min_ttft_ms=0, max_ttft_ms=None) 를 만드세요. reqs 는 1단계의 요청 dict 목록이고, ttft_ms 가 min_ttft_ms 이상이고 max_ttft_ms 미만인 요청만 고릅니다(max_ttft_ms 가 None 이면 위쪽 한계가 없고, ttft_ms 가 None 인 요청은 뺍니다). 돌려줄 값은 {"n", "ttft_ms", "queue_ms", "prefill_ms", "queue_share"} 입니다. n 은 고른 요청 수이고 ttft_ms, queue_ms, prefill_ms 는 그 평균(소수 첫째 자리)입니다(값이 None 인 요청은 그 평균에서만 뺍니다). queue_share 는 요청마다 queue_ms / ttft_ms 를 구해 그 중앙값을 소수 둘째 자리로 한 비율입니다(퍼센트가 아닙니다. queue_ms 가 None 이거나 ttft_ms 가 0 인 요청은 뺍니다). 고른 요청이 없으면 n 은 0 이고 나머지는 None 입니다. 입력 목록은 바꾸지 마세요. 1단계의 load_requests 로 읽은 재료에서 {"slow": breakdown(reqs, 1000), "fast": breakdown(reqs, 0, 200)} 를 /root/otelllm/breakdown.json 에 저장하세요.

평균은 합을 개수로 나눈 값이고 중앙값은 statistics.median 입니다. 요청마다 비율을 먼저 구해 중앙값을 내는 것과 대기 평균을 첫 토큰 평균으로 나누는 것은 다른 값입니다. 첫 토큰이 아주 긴 요청 하나가 평균을 끌고 가므로, 평균을 쓸 곳과 중앙값을 쓸 곳을 지시문대로 구분하세요. 결과를 읽을 때는 느린 쪽(1초 이상)의 첫 토큰 시간 중 대기가 차지하는 몫을 보세요. 느린 요청이 계산이 느려서인지 줄을 서서인지가 여기서 갈립니다. 채점기는 경계값(200 과 1000 정확히), 값이 빠진 요청, 평균과 가운데 값이 크게 다른 입력으로도 다시 돌립니다.

속성 이름을 표준으로 옮기고 서비스 이름 채우기

/root/otelllm/rename.py 에 두 함수를 만드세요. (1) rename_attrs(attrs) 는 속성 dict 를 받아 gen_ai.usage.prompt_tokens 를 gen_ai.usage.input_tokens 로, gen_ai.usage.completion_tokens 를 gen_ai.usage.output_tokens 로 바꾼 새 dict 를 돌려줍니다. 원본은 바꾸지 않고, 옛 이름은 사라지며, 새 이름이 이미 있으면 새 이름의 값을 지키고 옛 이름만 지웁니다. 그 밖의 키는 그대로입니다. (2) upsert_service(resource_attrs, name) 은 service.name 을 name 으로 덮어쓴(없으면 더한) 새 dict 를 돌려줍니다. 컬렉터의 resource 프로세서가 action: upsert 로 하는 일과 같습니다. 그다음 재료의 첫 llm_request 스팬의 속성을 {키: 값} dict 로 만들어 {"before": 그 키들을 정렬한 목록, "after": rename_attrs 를 거친 뒤의 키들을 정렬한 목록} 을 /root/otelllm/renamed.json 에 저장하세요.

컬렉터의 transform 프로세서가 하는 일(새 이름에 값을 복사하고 옛 이름을 지움)을 파이썬으로 옮긴 것입니다. 새 이름이 이미 있는데 옛 값으로 덮어쓰면 표준을 따르는 쪽의 값을 잃습니다. if attrs.get(old): 처럼 값이 참인지로 판단하면 토큰 수 0 의 이름을 바꾸지 못합니다. 원본 dict 를 직접 고치면 호출한 쪽의 데이터가 조용히 달라지니 복사본을 돌려주세요. str.replace 로 키 전체를 바꾸면 다른 이름공간의 비슷한 키까지 건드립니다. 1단계의 load_requests 는 속성을 이미 가공해 버리므로, 이 단계의 첫 스팬 속성은 원본 JSON 에서 따로 풉니다.

느린 트레이스를 남기는 정책과 확률 정책

/root/otelllm/sampling.py 에 두 함수를 만드세요. 입력 reqs 는 1단계의 요청 dict 목록입니다. (1) latency_policy(reqs, threshold_ms) 는 duration_ms 가 threshold_ms 이상인 요청이 있는 트레이스의 trace_id 집합(set)을 돌려줍니다. 컬렉터 tail_sampling 의 latency 정책처럼 트레이스가 끝난 뒤 지연을 보고 정하는 쪽입니다. (2) probabilistic_policy(reqs, percent) 는 int(trace_id[-8:], 16) % 100 < percent 인 트레이스의 trace_id 집합을 돌려줍니다. 지연을 보지 않고 트레이스 ID 만으로 정하므로 같은 입력이면 늘 같은 결과입니다(실제 확률 샘플링 프로세서의 해시와 같지는 않고, 이 실습이 쓰는 단순한 모형입니다). 같은 트레이스가 여러 요청에 있어도 한 번만 들어가야 하고, 입력 목록은 바꾸지 마세요. 이 단계는 저장할 파일이 없고 채점기가 함수를 직접 시험합니다.

집합을 돌려주는 까닭은 트레이스 하나를 요청 몇 개로 세지 않기 위해서입니다. 확률 정책이 느린지 모르고 뽑는 것과 지연 정책이 느린 것을 골라 남기는 것의 차이가 다음 단계에서 숫자로 드러납니다. 지연이 임계값과 정확히 같은 요청은 남깁니다(이상). 확률 정책의 percent 가 0 이면 아무것도 남지 않고 100 이면 전부 남습니다. 파이썬 내장 hash() 나 난수는 실행마다 달라질 수 있어 맞지 않습니다. 채점기는 경계값, 빈 목록, 같은 트레이스 ID 가 여러 요청에 있는 입력으로 시험합니다.

두 정책이 느린 요청을 얼마나 남기는지 세기

sampling.py 를 명령줄로도 쓰게 하세요. python3 sampling.py --input requests.json --threshold-ms N --percent P 는 JSON 한 줄 {"total", "slow_total", "latency": {"kept", "slow_kept"}, "probabilistic": {"kept", "slow_kept"}} 를 표준 출력에 찍습니다. --input 은 1단계가 만든 요청 목록 JSON, --threshold-ms 와 --percent 는 정수입니다. 모두 트레이스 수입니다. total 은 트레이스 전체, slow_total 은 duration_ms 가 N 이상인 요청이 하나라도 있는 트레이스, kept 는 그 정책이 남긴 트레이스, slow_kept 는 남긴 것 가운데 느린 트레이스입니다. 재료의 요청 목록(/root/otelllm/requests.json)에 --threshold-ms 5000 --percent 10 으로 돌린 출력을 /root/otelllm/sampling.json 에 저장하세요.

느린 트레이스의 집합을 한 번 만들어 두고, 두 정책이 남긴 집합과 교집합을 세면 됩니다. 요청 수가 아니라 트레이스 수를 세는 것이 핵심입니다. 이 재료의 요청은 트레이스마다 하나뿐이지만, 채점기는 한 트레이스에 요청이 여럿인 입력으로도 돌립니다. 결과를 읽을 때는 latency 쪽의 kept 와 slow_kept 가 slow_total 과 어떤 관계인지, probabilistic 쪽은 slow_total 의 몇 퍼센트쯤을 남겼는지 보세요. 지연을 보고 정하는 쪽과 보지 않고 정하는 쪽의 차이가 한 줄로 나옵니다. 꼬리 샘플링의 대가(트레이스를 모아 두는 메모리)는 이 숫자에 들어 있지 않습니다.

traceparent 헤더 검증하고 자식 헤더 만들기

/root/otelllm/propagation.py 에 두 함수를 만드세요. (1) parse_traceparent(value) 는 W3C Trace Context 의 traceparent 헤더를 {"version", "trace_id", "span_id", "sampled"}(sampled 는 bool)로 풀고, 규칙에 어긋나면 None 을 돌려줍니다. 규칙은 이렇습니다. version-trace_id-parent_id-trace_flags 네 필드가 각각 소문자 16진수 2, 32, 16, 2자이고, trace_id 와 parent_id 는 전부 0 이면 무효, version ff 는 무효입니다. version 00 은 필드가 정확히 네 개여야 하고(뒤에 더 붙으면 무효), 00 보다 큰 version 은 네 필드 뒤에 - 로 시작하는 추가 필드를 허용합니다. 대문자와 앞뒤 공백은 무효이고, sampled 는 flags 의 최하위 비트입니다. (2) child_traceparent(value, new_span_id) 는 같은 trace_id 와 flags 에 새 span id 를 넣은 헤더 00-<trace_id>-<new_span_id>-<flags> 를 돌려줍니다(부모가 무효이거나 new_span_id 가 소문자 16진 16자가 아니거나 전부 0 이면 None). 그다음 아래 헤더 여섯을 이 순서대로 parse_traceparent 로 푼 결과의 목록(무효는 null)을 /root/otelllm/propagation.json 에 저장하세요.

  1. 00-4bf92f3577b34da6a3ce929d0e0e4736-00f067aa0ba902b7-01
  2. 00-4bf92f3577b34da6a3ce929d0e0e4736-00f067aa0ba902b7-00
  3. 01-0af7651916cd43dd8448eb211c80319c-b7ad6b7169203331-03-extra-field
  4. 00-4BF92F3577B34DA6A3CE929D0E0E4736-00f067aa0ba902b7-01
  5. 00-00000000000000000000000000000000-00f067aa0ba902b7-01
  6. ff-4bf92f3577b34da6a3ce929d0e0e4736-00f067aa0ba902b7-01

컨텍스트 전파는 서비스 사이에서 트레이스를 잇는 약속이라, 헤더를 너그럽게 받아들이면 잘못된 값이 조용히 트레이스를 이어 붙입니다. int(text, 16) 은 대문자, 0x 접두사, 밑줄, 부호까지 받으므로 문자 검사에는 맞지 않습니다. 정규식을 쓴다면 $ 는 끝의 줄바꿈 앞에서도 맞으니 fullmatch 를 쓰세요. sampled 는 flags == "01" 이 아니라 최하위 비트입니다(03 도 sampled). 자식 헤더는 부모의 flags 를 그대로 이어받습니다. 채점기는 위 여섯 개 말고도 길이가 하나 모자란 것, 앞뒤 공백, 대문자 flags, 알려진 형식보다 큰 version 의 추가 필드 등으로 시험합니다.

누적 히스토그램에서 분위수와 구간 분위수 구하기

/root/otelllm/hist.py 에 두 함수를 만드세요. (1) quantile(bounds, bucket_counts, q) 는 OTLP 히스토그램(bounds 는 버킷 경계 n개, bucket_counts 는 버킷별 개수 n+1개이고 마지막이 +Inf 버킷)의 q 분위수(0 < q ≤ 1)를 프로메테우스 histogram_quantile 처럼 추정합니다. 전체 개수에 q 를 곱한 순위가 처음 넘는 버킷을 찾아 그 버킷 안을 선형으로 보간하고(첫 버킷의 하한은 0), 마지막(+Inf) 버킷에 걸리면 마지막 경계를 돌려주며, 전체 개수가 0 이면 None 입니다. (2) window(prev, cur) 는 누적 히스토그램의 두 시점(count, sum, bucket_counts 를 가진 dict)의 차로 그 구간만의 히스토그램 {"count", "sum", "bucket_counts"} 을 만듭니다(cur 에 bounds 가 있으면 그것도 넣습니다). cur["count"] 가 prev["count"] 보다 작으면 카운터가 초기화된 것이므로 cur 를 그대로 구간으로 씁니다. 두 시점 dict 는 바꾸지 마세요. 재료 /opt/fixtures/otel-llm/ttft-hist.jsonl(줄마다 time_ns, count, sum, bounds, bucket_counts 를 가진 누적 히스토그램, 9개 시점)로 {"cumulative": {"p50", "p90", "p99"}, "window": {"count", "p50", "p90", "p99"}} 를 /root/otelllm/hist.json 에 저장하세요. cumulative 는 마지막 시점의 분위수이고, window 는 4번째 시점(count 가 80 인 줄)에서 마지막 시점까지의 구간입니다. 분위수는 초 단위로 소수 셋째 자리입니다.

bucket_counts 는 버킷별 값이라 앞에서부터 쌓아 가며 순위를 넘는 버킷을 찾습니다. 순위가 누적 경계와 정확히 같을 때는 그 버킷에 속합니다(이상). 누적 히스토그램은 서버가 뜬 뒤 전부를 담으므로 마지막 시점의 분위수는 오래전 요청의 영향을 계속 받습니다. 최근 몇 초의 분포를 보려면 두 시점을 빼야 하고, 그 차이가 두 분위수 사이에 어떻게 나오는지 읽어 보세요. window 에서 카운터 초기화를 놓치면 뺄셈이 음수가 됩니다. 채점기는 경계, 빈 히스토그램, +Inf 버킷, 초기화 입력으로도 코드를 다시 돌립니다.