포스트

내 공식이 13% 틀렸다 — 오염된 데이터로 만든 모델 다시 세우기

2주 전에 만든 prefill 예측 공식이 실제보다 10~15% 비관적이었습니다. 원인을 찾아 다시 측정했고, 버려야 했던 배치 하나가 오히려 새 공식을 검증해줬습니다.

내 공식이 13% 틀렸다 — 오염된 데이터로 만든 모델 다시 세우기

이 글의 독자: 3편에서 이어집니다. 안 읽으셨어도 괜찮게 용어는 다시 설명합니다.

한 줄 요약: 예전에 만든 공식이 틀렸다는 걸 발견하고, 왜 틀렸는지 찾아내고, 다시 만들었습니다. 그리고 규칙에 따라 버린 데이터가 새 공식을 가장 강력하게 검증해줬습니다.


들어가며 — 공식이 안 맞는다

1편에서 저는 이런 공식을 만들었습니다.

\[\text{prefill (ms)} \approx 4.50\,T + 1.96 \times 10^{-4}\,T^{2}\]

prefill모델이 내 질문 전체를 읽는 단계입니다. $T$는 질문의 토큰 수입니다. 사용자가 체감하는 첫 글자까지의 시간(TTFT) 대부분이 여기서 나오기 때문에, 이 공식은 “질문을 몇 토큰까지 허용할 것인가”를 결정하는 도구였습니다.

그런데 3편에서 서버를 세우고 측정하다가, 이상한 걸 봤습니다.

  
공식이 예측한 값 ($T = 195$)885 ms
실제 측정값796 ms

실제가 10% 빠릅니다. 보통 이런 차이는 “내 코드가 느려서”로 설명되는데, 이번엔 그럴 수가 없습니다. 796ms는 Ollama가 스스로 보고한 자기 시간이라 제가 만든 FastAPI 서버와 무관하거든요.

그러면 공식이 틀렸다는 뜻입니다. 그리고 저는 틀렸는지 알 것 같았습니다.


Step 1 — 용의자: 내가 그 공식을 만든 방법

1편의 측정은 이렇게 했습니다. 5분짜리 배치를 쉬지 않고 돌려서 여러 길이의 질문을 순서대로 측정했습니다.

그런데 2편과 3편에서 배운 게 있습니다. 이 맥미니는 부하가 계속되면 느려집니다. 그리고 느려진 값은 측정 대상과 아무 상관이 없습니다.

여기서 중요한 건 순서입니다. 짧은 질문부터 긴 질문 순으로 쟀다면:

  • 짧은 질문 = 배치 초반 = 기계가 시원함 = 정확함
  • 긴 질문 = 배치 후반 = 기계가 뜨거움 = 부풀려짐

긴 질문일수록 더 많이 부풀려진 데이터로 곡선을 맞춘 겁니다. 그러면 공식 전체가 비관적으로 기울어집니다.

가설은 섰습니다. 이제 깨끗한 조건에서 다시 재면 됩니다.


Step 2 — 측정 설계 (결정 1)

다시 재는 건 간단해 보이지만 함정이 있습니다. 긴 질문을 측정하는 행위 자체가 기계를 뜨겁게 만듭니다. 측정하려는 조건을 측정이 파괴합니다.

세 가지로 대응했습니다.

첫째, 생성을 껐습니다. num_predict=1. 답변을 1토큰만 만들게 하면, 측정 시간의 거의 전부가 prefill입니다. 답변 생성에 쓰는 시간이 사라지니 같은 GPU 시간으로 훨씬 많은 점을 찍을 수 있습니다.

둘째, 배치를 쪼갰습니다. 한 번에 3분 넘게 돌리지 않고, 배치 사이에 5분씩 쉽니다.

배치질문 길이반복소요
A200, 500, 1000, 20003회46.8초
B40003회53.8초
C75003회114.6초

셋째, 모든 배치의 시작과 끝에 control point를 넣었습니다. 똑같은 200토큰 질문을 배치 맨 앞과 맨 뒤에 한 번씩 재는 겁니다. 이 둘이 5% 넘게 차이나면, 그 배치는 측정하는 동안 조건이 변한 것이므로 통째로 버립니다.

그리고 x축은 제가 의도한 토큰 수가 아니라 서버가 세어준 실제 토큰 수를 씁니다. 3편에서 단어 수 환산이 35% 빗나갔던 걸 생각하면, 이건 타협할 수 없는 부분입니다.


Step 3 — 배치 C를 버리다

A와 B는 깨끗했습니다. control drift가 각각 −0.1%, −0.5%.

C는 +6.7%였습니다. 규칙에 따라 버렸습니다.

그런데 C의 숫자를 보면 버리기 아깝습니다. 7천 토큰짜리 질문 3번이 이렇게 나왔거든요.

1
2
3
1번째: 35,901 ms
2번째: 37,988 ms
3번째: 38,776 ms

같은 길이의 질문인데 뒤로 갈수록 느려집니다. 115초 동안 7천 토큰짜리 prefill을 연달아 돌렸으니, 이 기계가 할 수 있는 가장 힘든 일을 쉬지 않고 시킨 셈입니다.

아까웠지만 규칙은 규칙입니다. A와 B만 가지고 공식을 다시 맞췄습니다. 그리고 C는 다시 재기로 했습니다 — 이번엔 반복 3회가 아니라 1회로. 38초면 끝나니 뜨거워질 틈이 없습니다.

재측정한 C는 control drift +2.9%. 통과했습니다.


Step 4 — 새 공식

6개 길이(194 ~ 7,120 토큰), 최소제곱법으로 맞춘 결과입니다.

\[\text{prefill (ms)} = 101.2 + 3.870\,T + 1.767 \times 10^{-4}\,T^{2}\]

$R^2 = 0.99999$. 옛 공식과 비교하면:

실제 토큰 수측정값새 공식옛 공식옛 공식 오차
194797 ms859 ms880 ms+10.4%
4741,982 ms1,975 ms2,177 ms+9.8%
9574,029 ms3,967 ms4,486 ms+11.3%
1,9108,167 ms8,137 ms9,310 ms+14.0%
3,79917,305 ms17,353 ms19,924 ms+15.1%
7,12036,622 ms36,612 ms41,976 ms+14.6%

옛 공식은 전 구간에서 10~15% 비관적이고, 질문이 길수록 오차가 커집니다. 이건 제 가설이 예측한 모양 그대로입니다. 긴 질문이 배치 후반에 있었으니까요.

진짜 수확은 맨 앞의 숫자다

새 공식에는 옛 공식에 없던 게 하나 있습니다. 상수항 101.2 ms입니다.

옛 공식은 $4.50T + \cdots$ 로 시작합니다. $T = 0$이면 0ms라는 뜻입니다. 수학적으로는 자연스러워 보입니다. 질문이 없으면 읽을 것도 없으니까요.

하지만 현실에는 고정 비용이 있습니다. HTTP 요청을 받고, 토큰으로 쪼개고, 모델에 넘기는 준비 과정은 질문 길이와 무관하게 발생합니다.

그리고 2편에서 저는 전혀 다른 방법으로 이 값을 이미 만난 적이 있습니다. 그때 노트에 적은 문장은 “요청당 약 100ms의 고정 오버헤드”였습니다.

두 번의 다른 측정, 다른 방법, 같은 숫자. 2편에서는 캐시 적중률 곡선의 잔차로, 이번에는 곡선의 절편으로 나왔습니다. 서로를 검증해준 셈입니다.

옛 공식은 이 100ms를 놓을 자리가 없었습니다. 원점을 지나도록 강제했으니까요. 그래서 그 비용이 1차 항에 녹아들어가 기울기를 부풀렸습니다. 4.50 → 3.870의 차이 중 일부는 여기서 옵니다.


Step 5 — 버린 데이터가 새 공식을 검증하다

이제 재미있는 부분입니다.

버린 배치 C는 7천 토큰짜리였습니다. 그런데 새 공식은 A와 B만으로 맞췄으니, 3,799 토큰까지의 데이터만 본 것입니다. C의 길이는 그 두 배입니다.

공식을 그 두 배 지점까지 외삽(extrapolation)해서, 버린 C의 세 측정값과 비교해봤습니다.

순서실제 토큰측정값공식 예측오차
17,12035,901 ms35,835 ms+0.2%
27,19637,988 ms36,296 ms+4.7%
37,14138,776 ms35,962 ms+7.8%

첫 번째 샘플이 0.2% 오차로 맞습니다. 절반 길이까지의 데이터로 만든 공식이, 두 배 지점을 0.2%로 예측했습니다.

그리고 2번째, 3번째는 점점 벌어집니다. 기계가 뜨거워지는 과정이 그대로 보입니다.

즉 배치 C는 이렇게 읽힙니다.

  • 1번째 샘플: 아직 시원함 → 진짜 성능 → 공식과 일치
  • 2, 3번째: 뜨거워짐 → 느려짐 → 공식에서 벗어남

버려야 했던 데이터가, 버려진 이유를 스스로 증명하면서 동시에 새 공식을 검증했습니다.


Step 6 — 그런데 이게 3편의 그 상황과 뭐가 다른가

3편에서 저는 똑같이 “오염” 판정을 받은 측정을 하나 만났습니다. 그때는 원인이 발열이 아니라 제가 켜둔 브라우저였습니다.

두 상황의 요약 판정은 같습니다. 둘 다 “5% 초과, 버릴 것”. 그런데 모양이 정반대입니다.

3편 (브라우저 경합):

1
22.0 → 17.0 → 21.9 → 14.0      ← 내려갔다 올라왔다 다시 내려감

오늘 (발열):

1
+0.2% → +4.7% → +7.8%          ← 한 방향으로만

열은 쌓입니다. 중간에 쉬지 않습니다. 부하를 받는 중에 칩이 저절로 시원해져서 원래 속도로 돌아올 수는 없습니다. 반면 다른 프로그램과의 경합은 그 프로그램이 뭘 하느냐에 따라 오르락내리락합니다.

요약 숫자만 보면 둘은 구분이 안 됩니다. “6.7% 드리프트”와 “6.3% 드리프트”는 똑같이 생겼습니다. 구분해주는 건 시간 순서대로 늘어놓은 모양 하나뿐입니다.

그래서 이번 프로젝트의 규칙에 한 줄이 추가됐습니다.

측정이 오염 판정을 받으면, 하드웨어를 탓하기 전에 뭐가 돌고 있었는지 먼저 확인할 것.


Step 7 — 이 공식의 한계 (솔직하게)

새 공식이 옛 공식보다 낫지만, 완벽하지 않습니다. 잔차를 보면 이렇습니다.

토큰 수공식이 실제보다
194+7.7% ← 유일하게 크게 빗나감
474−0.4%
957−1.5%
1,910−0.4%
3,799+0.3%
7,120−0.0%

짧은 질문에서만 7.7% 빗나갑니다. 이유는 최소제곱법의 성질입니다. 이 방법은 밀리초의 절대값 오차를 최소화하는데, 7,120 토큰의 오차 1%는 366ms인 반면 194 토큰의 오차 1%는 8ms입니다. 큰 값이 곡선을 지배합니다.

고치는 방법은 있습니다(상대 오차로 맞추거나, 구간을 나누거나). 하지만 지금은 그럴 필요가 없습니다. 450 토큰 미만에서는 측정값 4.11 ms/token을 쓰면 됩니다. 공식을 더 복잡하게 만드는 것보다 “이 구간에선 이걸 쓴다”고 적어두는 게 낫습니다.


그래서 뭐가 달라지나 — 프롬프트 예산

공식을 뒤집으면 “몇 초 안에 답하려면 질문을 몇 토큰까지 허용할 수 있나”가 나옵니다. 제품 결정에 직접 쓰는 표입니다.

TTFT 목표옛 공식새 공식
1초~230 토큰~230 토큰
2초~450 토큰~480 토큰
5초~1,050 토큰~1,200 토큰

1초 예산은 하나도 안 움직였습니다. 230토큰 근처에서는 고정 비용 101ms와 1차 항이 대부분이고, 이번 수정은 그 둘을 크게 건드리지 않았기 때문입니다.

실전 예시도 조금 나아졌습니다. 시스템 프롬프트 + 500토큰짜리 RAG 문서 3개 ≈ 1,600 토큰:

\[101.2 + 3.870 \times 1600 + 1.767 \times 10^{-4} \times 1600^2 = \mathbf{6.7\ \text{초}}\]

옛 공식으로는 8초였습니다. 13% 나아졌지만, 결론은 그대로입니다. RAG 프롬프트를 그냥 밀어넣으면 첫 글자까지 7초 가까이 걸립니다. 이건 캐싱으로 풀어야 할 문제지 하드웨어로 풀 문제가 아닙니다.


정리 — 오늘 배운 것

숫자:

  1. 새 prefill 공식 — $101.2 + 3.870T + 1.767 \times 10^{-4}T^2$, 194~7,120 토큰, $R^2 = 0.99999$
  2. 고정 오버헤드 101 ms — 2편에서 다른 방법으로 얻은 값과 일치
  3. 옛 공식은 10~15% 비관적이었고, 질문이 길수록 오차가 컸음

방법:

  1. 곡선을 맞출 데이터는 한 번에 다 재지 말 것. 배치로 쪼개고, 사이에 식히고, 각 배치를 control point로 검증할 것.
  2. 원점을 지나도록 강제하지 말 것. 고정 비용은 실제로 존재하고, 강제하면 그 비용이 다른 항에 숨어 들어간다.
  3. 오염 판정의 모양을 볼 것. 한 방향이면 발열, 오르락내리락하면 경합.

그리고 가장 마음에 드는 것: 규칙에 따라 버린 데이터가 새 공식을 검증했습니다. 규칙을 지키느라 아까워했던 그 배치가, 결국 제일 좋은 증거였습니다.


다음 글

다음은 드디어 동시성입니다. 지금까지는 전부 사용자 한 명 기준이었습니다. 여러 명이 동시에 요청하면 언제 무너지는지를 잽니다.

그리고 오늘 배운 것 때문에 한 가지가 까다로워졌습니다. 동시성 측정은 기계를 일부러 바쁘게 만드는 일입니다. “조용한 상태에서 측정한다”는 원칙을 정면으로 위반해야 합니다.

의도한 부하와 오염을 어떻게 구분할 것인가 — 그게 다음 글의 첫 번째 결정이 될 것 같습니다.

이 기사는 저작권자의 CC BY 4.0 라이센스를 따릅니다.