TTFT가 왜 사용자 경험을 지배하는가
TTFT(Time To First Token)는 요청을 보낸 시점부터 모델이 첫 토큰을 내보내는 순간까지의 지연이다. 스트리밍 UI에서는 전체 응답 시간보다 TTFT가 체감 속도를 결정한다. 첫 글자가 뜨기까지 2초가 걸리면 사용자는 "멈췄다"고 느끼지만, 200ms 만에 흐르기 시작하면 이후 토큰 생성이 다소 느려도 기다린다. 즉 TTFT는 총 처리량(throughput)과 별개로 관리해야 하는 독립 지표다.
TTFT를 늘리는 주요 원인
TTFT는 크게 네 구간에서 발생한다. 첫째, 네트워크·게이트웨이 왕복. 둘째, 큐 대기(서버가 배치를 구성하며 기다리는 시간). 셋째, 프리필(prefill) — 입력 프롬프트 전체를 한 번에 어텐션 연산하는 단계로, 프롬프트가 길수록 선형 이상으로 증가한다. 넷째, 첫 디코드 스텝. 대부분의 병목은 프리필과 큐 대기에 있으며, 프롬프트 길이가 TTFT에 미치는 영향이 가장 크다.
프롬프트 구조와 KV 캐시로 프리필 단축
가장 효과가 큰 최적화는 프리필 연산량 자체를 줄이는 것이다. 시스템 프롬프트·툴 정의·few-shot 예시처럼 매 요청 반복되는 접두부는 prefix caching(자동 KV 캐시 재사용)으로 재계산을 건너뛸 수 있다. 이를 살리려면 가변 부분(사용자 입력)을 반드시 프롬프트 뒤쪽에 두어야 한다. 앞부분이 조금이라도 바뀌면 캐시가 무효화되기 때문이다.
# 나쁨: 사용자 입력이 앞에 있어 접두부 캐시가 매번 깨짐
prompt = f"{user_query}\n\n[규칙]\n{long_system_rules}"
# 좋음: 고정 접두부를 앞으로, 가변 입력을 뒤로 → prefix cache 적중
prompt = f"[규칙]\n{long_system_rules}\n\n[질문]\n{user_query}"
# vLLM 예: 서버 기동 시 automatic prefix caching 활성화
# vllm serve MODEL --enable-prefix-caching
스트리밍 파이프라인에서 버퍼링 제거
모델이 첫 토큰을 냈어도 게이트웨이나 프록시가 응답을 버퍼링하면 TTFT는 그대로 늘어난다. 프록시 버퍼링 해제, Nagle 알고리즘 비활성화, 청크 즉시 flush가 필요하다. 아래는 FastAPI에서 토큰을 즉시 흘려보내는 예시다.
from fastapi import FastAPI
from fastapi.responses import StreamingResponse
app = FastAPI()
@app.post("/chat")
async def chat(req: dict):
async def gen():
async for chunk in llm.astream(req["messages"]):
token = chunk.choices[0].delta.content or ""
# SSE 형식으로 토큰 단위 즉시 전송
yield f"data: {token}\n\n"
return StreamingResponse(
gen(),
media_type="text/event-stream",
headers={
"Cache-Control": "no-cache",
"X-Accel-Buffering": "no", # nginx 버퍼링 비활성화
},
)
서빙 계층 튜닝: 배치와 큐
연속 배칭(continuous batching)은 처리량을 높이지만, 배치 창(window)을 크게 잡으면 큐 대기가 늘어 TTFT가 악화된다. 지연에 민감한 트래픽은 최대 배치 대기 시간을 짧게, 프리필 청크 크기를 조절해 첫 스텝을 앞당긴다.
# vLLM 계열 서빙 설정 예시 (지연 우선 프로파일)
model: MODEL_NAME
enable_prefix_caching: true
enable_chunked_prefill: true # 긴 프롬프트 프리필을 쪼개 첫 토큰 앞당김
max_num_batched_tokens: 2048 # 배치 폭발 방지로 큐 대기 축소
max_num_seqs: 64
gpu_memory_utilization: 0.90
지연 우선 vs 처리량 우선 설정 비교
| 항목 | 지연 우선(TTFT) | 처리량 우선(TPS) |
|---|---|---|
| 배치 대기 창 | 짧게 | 길게 |
| chunked prefill | 켜기 | 선택 |
| max_num_batched_tokens | 작게 | 크게 |
| 적합 워크로드 | 대화형 UI | 배치 요약·오프라인 |
측정: 평균이 아니라 p95를 본다
TTFT는 평균값이 무의미하다. 콜드 스타트, 캐시 미스, 배치 경합 때문에 꼬리 지연이 크므로 p95·p99로 관리한다. 클라이언트에서 요청 전송 시각과 첫 청크 수신 시각의 차이를 직접 기록하는 것이 가장 정확하다.
import time
t0 = time.perf_counter()
ttft = None
async for chunk in stream:
if ttft is None:
ttft = time.perf_counter() - t0 # 첫 청크 도착 = TTFT
metrics.observe("ttft_seconds", ttft) # 히스토그램으로 p95 집계
주의점
첫째, prefix caching은 접두부 토큰이 완전히 동일할 때만 적중한다. 날짜·세션 ID 등을 접두부에 넣으면 매번 무효화되니 뒤로 빼야 한다. 둘째, chunked prefill과 작은 배치 창은 TTFT를 낮추는 대신 전체 처리량을 희생한다 — 트래픽 성격에 따라 라우팅을 분리하는 편이 안전하다. 셋째, TTFT만 좇다 보면 토큰 간 지연(ITL)이 나빠질 수 있으니 두 지표를 함께 봐야 한다. 넷째, 게이트웨이·CDN·로드밸런서 어디든 버퍼링이 하나만 남아도 앞단 최적화가 전부 무력화되므로 엔드투엔드로 첫 청크 도달을 검증해야 한다.