토큰이 느린 줄 알았는데, 사실은 컨텍스트를 운용하고 있었다
LLM 서버를 처음 만질 때는 초당 토큰 수부터 보게 된다.
모델 A가 몇 token/s이고 모델 B가 몇 token/s인지 비교한다. 숫자가 높으면 빠른 모델이고, 낮으면 느린 모델이라고 생각한다.
그런데 여러 모델을 실제 gateway 뒤에 붙여서 계속 운용해보니, 토큰 속도 하나로는 설명되지 않는 일이 많았다.
첫 토큰이 늦은 것과 생성이 느린 것은 달랐다. 짧은 요청은 빠른데 긴 대화에서 갑자기 무너지는 경우도 있었다. 한 요청만 볼 때는 괜찮았는데 동시 요청이 들어오면 전체가 느려졌다.
결국 내가 운용한 것은 모델이 아니라 토큰과 컨텍스트가 메모리·계산 자원을 쓰는 방식이었다.
모델을 바꿀 때마다 문제가 달라졌다
처음에는 큰 모델을 NVFP4 양자화로 올렸다. 모델 자체가 메모리를 덜 차지하니 이제는 context를 넉넉하게 줘도 괜찮을 것 같았다.
그 생각이 오래가지는 않았다.
가중치 메모리와 요청마다 필요한 KV cache는 다른 문제였다.
모델 가중치
+ KV cache
+ runtime workspace
+ 동시 요청 수
+ 출력 토큰가중치를 줄였다고 해서 긴 문맥과 동시 요청이 공짜가 되는 것은 아니었다. context를 키우면 한 요청이 사용할 수 있는 범위는 넓어지지만, 실제 운용에서는 cache가 차지하는 공간과 메모리 이동이 같이 커진다.
이때부터 max context를 성능 자랑처럼 보지 않게 됐다.
vLLM은 토큰을 많이 처리해도 기다리게 만들 수 있었다
vLLM 계열의 큰 모델은 여러 요청을 묶어 처리하는 장점이 있었다. 요청이 충분히 들어오면 자원을 잘 쓰고, 긴 작업도 비교적 안정적으로 처리할 수 있었다.
하지만 요청이 항상 이상적인 모양으로 들어오지는 않는다.
- 긴 입력이 먼저 들어옴
- 짧은 요청이 뒤에서 대기함
- 출력 제한을 크게 잡은 요청이 자리를 오래 점유함
- 여러 요청의 KV cache가 동시에 커짐
이런 상황에서는 평균 token/s만 보고 “충분히 빠르다”고 말하기 어렵다.
사용자가 체감하는 것은 보통 다음에 더 가깝다.
요청 수락
→ 첫 토큰까지 기다림
→ 생성 속도
→ 전체 응답 완료특히 gateway에서 timeout을 설정할 때는 생성 속도만 보면 안 된다. 큐에서 기다리는 시간, prompt를 처리하는 시간, 실제 생성 시간, 네트워크를 거치는 시간을 모두 합쳐야 한다.
그래서 큰 모델에는 무작정 긴 timeout을 주는 대신, 작업 성격에 맞는 상한을 두고 실패를 명확하게 끝내는 쪽으로 바꿨다.
llama.cpp에서는 context와 parallel의 관계를 더 직접 보게 됐다
llama.cpp를 붙이고 나서는 --parallel과 전체 context 설정을 따로 볼 수 없게 됐다.
동시 sequence 수를 늘리면 한 번에 더 많은 요청을 받을 수 있다. 대신 전체 context 예산도 그만큼 나눠 써야 한다. 단일 요청에서 충분했던 설정이 여러 요청에서는 전혀 다른 의미가 된다.
전체 context 예산
├─ sequence 1
├─ sequence 2
└─ sequence ...작은 fast 모델을 여러 요청에 쓰는 프로파일에서는 이 관계가 특히 중요했다. context를 크게 잡고 parallel도 늘리면 “동시 요청을 많이 받을 수 있다”는 말처럼 보이지만, 실제로는 KV cache와 메모리 이동이 병목이 될 수 있다.
반대로 parallel을 낮추면 한 요청의 안정성은 좋아질 수 있지만, 짧은 요청이 몰릴 때 대기 시간이 길어진다.
결국 최적의 값은 모델 이름으로 정해지지 않았다.
입력 길이 분포
× 출력 상한
× 동시성
× 허용 가능한 첫 토큰 지연이 네 가지를 같이 봐야 했다.
모델별 역할을 나누니 숫자를 덜 쫓게 됐다
운용 중에는 여러 계열의 모델을 바꿔가며 썼다.
큰 reasoning 작업은 NVFP4로 양자화한 Qwen 계열을 vLLM으로 서빙했다. 별도의 로컬 경로에서는 llama.cpp 기반 Qwen 계열을 사용했고, 빠른 반복 작업에는 더 작은 Qwen 모델을 두었다. Gemma 계열도 한동안 보조 경로로 사용해봤다.
분류는 작은 모델로 분리했고, embedding과 reranking은 생성 모델과 다른 endpoint로 떼었다. 최근에는 작은 classifier와 Jina 계열 embedding/reranker를 조합하는 식으로 역할을 나누고 있다.
이렇게 나누고 나서야 “모든 요청을 제일 큰 모델로 보내는 것”이 좋은 운영 전략이 아니라는 걸 체감했다.
짧은 분류·정리 → 작은 모델
반복적인 빠른 응답 → fast 모델
긴 코드·추론 작업 → 큰 reasoning 모델
검색 보조 → embedding / reranker모델을 역할별로 나누면 각 endpoint의 context와 output 상한을 현실적으로 잡을 수 있다. 작은 모델에 큰 작업을 억지로 맡기거나, 짧은 분류에 큰 모델을 호출하는 낭비도 줄어든다.
대역폭은 숫자가 아니라 병목의 이름이었다
여기서 말하는 대역폭은 네트워크 속도만 뜻하지 않는다.
긴 prompt와 KV cache가 메모리 계층 사이를 오가는 비용, 여러 sequence가 같은 자원을 두고 경쟁하는 비용, gateway와 provider 사이에서 응답이 스트리밍되는 비용이 모두 체감 성능에 영향을 줬다.
그래서 장비 사양이나 특정 측정값을 적어두는 것보다 다음 질문이 더 오래 남았다.
- 입력이 길어질 때 첫 토큰 지연은 어떻게 변하는가
- 출력 상한을 키웠을 때 다른 요청의 대기 시간이 어떻게 변하는가
- 동시성을 늘리면 처리량과 tail latency 중 무엇이 먼저 무너지는가
- provider가 느려졌을 때 gateway가 언제 bounded failure를 반환하는가
- context가 부족할 때 조용히 잘라내는가, 명시적으로 실패하는가
이 질문에 답하지 않은 채 “이 모델은 빠르다”고 말하면, 실제 서비스에서는 금방 반례를 만나게 된다.
/v1/models가 살아 있다고 모델이 준비된 것은 아니었다
운영하면서 가장 반복해서 확인한 실수는 model list와 실제 loaded state를 같은 것으로 본 일이었다.
/v1/models에 모델 ID가 보이고 포트가 열려 있어도, 실제 completion은 timeout이 나거나 provider가 연결을 끊을 수 있다. 특히 모델을 교체하거나 재기동한 직후에는 목록과 실행 상태가 잠깐 어긋날 수 있다.
그래서 지금은 provider를 등록하기 전에 다음 순서를 지킨다.
GET /v1/models
→ 기대하는 ID 확인
→ 짧은 chat completion
→ timeout과 응답 형식 확인
→ LiteLLM 경유 completion
→ Gateway 경유 completion목록은 후보를 확인하는 데 쓰고, readiness의 최종 증거는 실제 기능 요청으로 둔다.
LiteLLM에서 배운 것은 fallback도 자원이라는 점이다
처음에는 provider가 실패하면 다른 모델로 넘기면 된다고 생각했다.
그런데 fallback도 그냥 설정 한 줄이 아니었다. 죽은 endpoint를 fallback pool에 남겨두면 실패 시간이 늘어난다. 서로 다른 장애 도메인이라고 생각했던 경로가 사실 같은 프로세스나 같은 네트워크에 묶여 있으면, fallback은 장애를 해결하지 못한다.
더 나쁜 경우도 있었다.
원래 요청의 출력 상한
→ fallback 모델의 더 작은 한도
→ 재시도는 됐지만 두 번째 실패fallback 모델이 살아 있다는 것만으로는 부족했다. 그 모델이 해당 요청의 context와 output contract를 감당할 수 있어야 했다.
그래서 지금은 readiness가 확인된 provider만 pool에 넣고, fallback이 없을 때는 원인 보존형 오류를 반환하는 쪽을 선호한다. 빈 fallback이나 의도적으로 실패하는 provider를 운영 설정에 남겨두지 않는다.
결국 중요한 것은 평균값이 아니었다
토큰 속도, context 크기, 동시 sequence, 대역폭은 서로 떨어진 설정값이 아니었다.
더 긴 context
→ 더 큰 cache
→ 더 많은 메모리 이동
→ 동시 요청 여유 감소
→ tail latency 증가
→ timeout / fallback 판단 변화이 흐름을 한 번 겪고 나면 benchmark의 평균값만 보고 모델을 고르기 어려워진다.
내가 지금 보는 것은 조금 단순해졌다.
- 이 모델은 어떤 작업을 맡는가.
- 입력 길이와 출력 상한은 어느 정도인가.
- 몇 개의 동시 요청을 감당해야 하는가.
- 첫 토큰과 전체 완료 중 어느 쪽이 중요한가.
- 실패하면 어디까지 기다리고 무엇을 반환할 것인가.
모델 서빙은 결국 모델 파일을 띄우는 일이 아니었다.
요청이 가진 토큰을 어떤 context에 넣고, 그 상태를 어떤 자원으로 유지하고, 느려지거나 끊겼을 때 시스템을 어떻게 끝낼지 정하는 일이었다.
아직도 새 모델이 나오면 올려보고 싶다. 다만 이제는 “몇 token/s가 나오는가”를 보기 전에, 이 모델을 어떤 요청에 어떤 상한으로 맡길 것인가부터 생각한다.