LLM이 느리다는 말을 쪼개기: TTFT·토큰당 시간·큐 대기 따로 재는 법

LLM이 느리다는 말을

운영 중인 챗봇에 “응답이 느려요”라는 문의가 쌓이면, 어디부터 손대야 할지 막막합니다. 이럴 때는 LLM이 느리다는 말을 쪼개서 첫 토큰이 나오기까지 걸리는 시간, 토큰 하나를 생성하는 데 걸리는 시간, 요청이 처리되기 전 대기열에 머무는 시간을 따로 측정해야 원인이 보입니다. 세 구간을 나누지 않고 “전체 응답 시간”만 재면, 프롬프트가 길어서 느린 건지 GPU가 포화된 건지 요청이 몰려서 밀린 건지 구별할 방법이 없습니다.

응답이 느리다는 말이 뭉뚱그리는 세 구간

사용자가 “느리다”고 느끼는 순간은 사실 세 개의 서로 다른 구간이 합쳐진 결과입니다. 요청을 서버에 보낸 뒤 화면에 글자가 처음 찍히기까지의 시간(TTFT, Time To First Token), 그 이후 토큰이 한 개씩 이어지는 속도(TPOT, Time Per Output Token), 그리고 요청이 서버에 도착했지만 GPU가 바빠서 처리를 시작하지 못하고 기다리는 큐 대기 시간(Queue Wait Time)입니다.

이 세 구간은 원인도, 해결책도 완전히 다릅니다. TTFT가 길면 프롬프트가 너무 길거나 prefill 연산이 무거운 경우이고, TPOT가 길면 GPU 연산 자체가 느리거나 배치가 비효율적인 경우이며, 큐 대기가 길면 동시 요청 수가 서버 처리 능력을 넘어선 경우입니다. 하나로 뭉쳐서 “느림”이라고만 부르면 세 가지 중 무엇을 고쳐야 할지 알 수 없습니다.

TTFT 측정법: 스트리밍 응답에서 첫 토큰까지 걸린 시간 재기

TTFT는 반드시 스트리밍(streaming) 응답으로만 잴 수 있습니다. 스트리밍을 켜지 않고 전체 응답을 한 번에 받는 방식으로 호출하면, 서버가 응답을 완성해서 통째로 돌려주기 때문에 “첫 토큰이 언제 나왔는지”는 클라이언트에서 원천적으로 알 수 없습니다. 그래서 OpenAI 호환 API나 vLLM, TGI(Text Generation Inference) 같은 추론 서버를 쓸 때는 stream=True 옵션이 TTFT 측정의 전제 조건이 됩니다.

측정 방식은 요청을 보낸 시각을 찍고, 스트리밍 청크가 처음 도착한 시각을 찍어서 그 차이를 구하는 것입니다. Python에서 OpenAI 클라이언트 라이브러리를 쓰면 다음과 같이 재현할 수 있습니다.

import time
from openai import OpenAI

client = OpenAI(base_url="http://localhost:8000/v1", api_key="EMPTY")

def measure(prompt: str, model: str):
    t0 = time.perf_counter()
    stream = client.chat.completions.create(
        model=model,
        messages=[{"role": "user", "content": prompt}],
        stream=True,
        max_tokens=128,
    )
    ttft = None
    token_times = []
    for chunk in stream:
        now = time.perf_counter()
        delta = chunk.choices[0].delta.content
        if delta:
            if ttft is None:
                ttft = now - t0
            token_times.append(now)

    tpot = None
    if len(token_times) >= 2:
        tpot = (token_times[-1] - token_times[0]) / (len(token_times) - 1)
    return ttft, tpot, len(token_times)

ttft, tpot, n = measure("한국의 수도는 어디인가요?", "meta-llama/Llama-3.1-8B-Instruct")
print(f"TTFT: {ttft*1000:.1f} ms")
print(f"TPOT: {tpot*1000:.1f} ms/token")
print(f"생성 토큰 수: {n}")

서버 성능 모니터링 대시보드에 응답 시간 그래프가 표시된 화면

예시 출력은 아래와 같은 형태로 나옵니다. 실제 값은 모델 크기, GPU, 동시 요청 수에 따라 크게 달라지므로 절대적인 기준으로 삼지 말고 형식만 참고하시면 됩니다.

TTFT: 312.4 ms
TPOT: 41.7 ms/token
생성 토큰 수: 47

토큰당 시간(TPOT)은 왜 따로 봐야 할까요

TTFT만 좋고 TPOT가 나쁜 경우가 실제로 자주 발생합니다. 예를 들어 짧은 프롬프트라 첫 토큰은 300ms 만에 나왔는데, 이후 토큰이 하나씩 나오는 간격이 100ms씩 벌어진다면 답변이 길어질수록 체감 속도는 급격히 나빠집니다. 128토큰짜리 답변이면 TTFT 300ms보다 TPOT가 누적되는 13초 가까운 시간이 사용자 체감에 훨씬 더 크게 작용합니다.

TPOT가 늘어나는 원인은 보통 GPU 연산 자체보다 배치 처리 방식에 있습니다. 여러 요청을 동시에 처리하는 연속 배치(continuous batching) 환경에서는 내 요청과 함께 묶인 다른 요청들의 시퀀스 길이, KV 캐시 메모리 여유 공간이 토큰 생성 속도에 직접 영향을 줍니다. 그래서 TPOT가 특정 시간대에만 나빠진다면 동시 접속자 수 그래프와 겹쳐 보는 것이 원인 파악에 도움이 될 수 있습니다.

큐 대기 시간은 클라이언트가 아니라 서버 메트릭에서 확인해야 하는 이유

TTFT와 TPOT는 클라이언트에서 스트리밍 응답만 받아도 계산할 수 있지만, 큐 대기 시간은 그렇지 않습니다. 클라이언트가 보는 TTFT 안에는 “대기열에서 기다린 시간”과 “실제로 GPU가 prefill 연산을 하는 시간”이 섞여 있기 때문에, 겉에서는 둘을 구분할 수 없습니다. 이 부분을 나누려면 요청이 서버에 도착한 시각과 스케줄러가 실제로 처리를 시작한 시각을 서버 쪽에서 직접 로깅해야 합니다.

자체 호스팅 추론 서버를 쓴다면 이 값을 이미 제공하는 경우가 있습니다. 오픈소스 추론 엔진인 vLLM은 Prometheus 형식으로 요청 단위 지표를 노출하는데, 요청이 대기열에 머문 시간과 실제 처리(prefill·decode)에 걸린 시간을 구분해서 기록합니다. 이런 지표는 동시 요청이 몰릴 때 서버 처리 능력이 부족한 건지 아니면 순수 연산이 느린 건지 구분하는 근거가 되며, 이 구분은 대기행렬 이론으로 설명되는 현상이기도 합니다. 반면 OpenAI, Anthropic 같은 상용 API를 그대로 쓰는 경우에는 이런 서버 내부 지표에 접근할 수 없어서, 큐 대기 시간은 추정만 가능하고 정확히 분리해서 잴 수는 없습니다.

개발자가 터미널 화면에서 응답 지연 시간을 측정하는 모습

세 지표를 한 번에 기록하는 코드와 원인 좁히기 표

세 지표를 동시에 로깅해두면, 이상 발생 시 어느 구간이 늘어났는지 비교만으로 원인을 좁힐 수 있습니다. 자체 서버라면 요청 수신 시각을 헤더나 로그에 남기고, 클라이언트 TTFT와의 차이로 대략적인 큐 대기를 근사할 수 있습니다.

지표 측정 시점 어디서 재나요 값이 커지면 의심할 곳
TTFT 요청 전송 ~ 첫 토큰 도착 클라이언트(스트리밍 필수) 프롬프트 길이, prefill 연산량, 큐 대기 혼입
TPOT 첫 토큰 ~ 마지막 토큰, 토큰 간 평균 클라이언트(스트리밍 필수) 동시 배치 크기, KV 캐시 부족, GPU 포화
큐 대기 시간 요청 도착 ~ 스케줄러 처리 시작 서버 내부 메트릭(Prometheus 등) 동시 요청 수 초과, max_num_seqs 설정값

세 값을 같은 타임스탬프 기준으로 모아 대시보드에 쌓아두면, “느려졌다”는 문의가 들어왔을 때 로그를 뒤지지 않고 그래프만 봐도 어느 구간 문제인지 바로 나옵니다. vLLM처럼 Prometheus 지표를 노출하는 엔진을 쓴다면 Grafana로 세 값을 같은 패널에 겹쳐 그리는 것만으로 충분합니다.

같은 프롬프트인데 왜 TTFT가 매번 다르게 나올까요

동일한 프롬프트를 반복 호출해도 TTFT가 200ms에서 800ms까지 흔들리는 경우가 있습니다. 대부분은 측정 문제가 아니라 서버 상태가 매번 다르기 때문입니다. 연속 배치 서버는 내 요청 하나만 처리하는 게 아니라 그 순간 들어온 다른 요청들과 함께 스케줄링하므로, 같은 프롬프트라도 동시에 몰린 요청 수에 따라 TTFT가 달라집니다.

이 변동성을 줄이려면 평균값 하나가 아니라 p50, p95, p99 같은 퍼센타일로 봐야 합니다. 평균 TTFT가 400ms라도 p99가 3초라면, 일부 사용자는 실제로 심각하게 느린 경험을 하고 있다는 뜻입니다. vLLM의 벤치마크 스크립트(benchmark_serving.py)도 TTFT와 TPOT를 퍼센타일 단위로 집계하는 방식을 기본으로 채택하고 있는데, 이 코드는 vLLM 저장소에서 직접 확인할 수 있습니다.

이 측정 방식이 항상 통하는 것은 아닙니다. 상용 API처럼 서버 내부에 접근할 수 없는 환경에서는 큐 대기 시간을 정확히 분리할 수 없고, TTFT 안에 섞인 채로만 관찰됩니다. 또한 짧은 프롬프트·짧은 응답 테스트로 얻은 TPOT 값을 실제 서비스의 긴 대화 맥락에 그대로 적용하면 오차가 커질 수 있으므로, 실제 트래픽 패턴과 비슷한 조건에서 재측정하는 과정이 필요합니다.

LLM이 느리다는 말을 TTFT, TPOT, 큐 대기 세 지표로 쪼개서 보면, 다음번에 “느려요”라는 문의가 들어왔을 때 로그 하나만 확인해도 원인을 좁힐 수 있습니다. 자체 호스팅 환경이라면 오늘 바로 스트리밍 응답에 타임스탬프를 찍는 것부터 시작해 보시고, 추론 엔진이 Prometheus 지표를 제공한다면 세 값을 같은 대시보드에 올려두는 작업을 다음 단계로 진행해 보시기 바랍니다.

프롬프트 A/B 테스트, 운영 중 트래픽 분리와 품질 지표부터 정하세요

참고: LLM이 느리다는 말을 — 위키백과

Leave a Comment