·Code Place

AI 조교 부하 테스트를 다시 - prefix cache가 만든 속도, 그리고 부하 속에서 잰 품질


AI 조교 모델을 Qwen2.5-7B에서 Qwen3.6-35B-A3B(MoE, NVFP4)로 바꾼 뒤 다시 부하를 걸었다. 이번엔 로컬 프로세스가 아니라 실제로 배포돼 있는 prod vLLM에, 실제 힌트 페이로드 그대로 때렸다. 두 가지를 새로 알아냈다. 하나, 지난 테스트가 유리하게 나온 건 문제·코드를 몇 개만 돌려 prefix cache가 과하게 먹은 탓이었고, 캐시를 못 쓰게 하면 첫 토큰이 312ms에서 1,435ms로 4.6배 뛴다. 둘, 속도만 재고 모델을 바꿀 수는 없어서 응답 형식 준수율을 자동으로 쟀고, 부하가 걸려도 구조는 안 깨졌다.

왜 또 부하 테스트인가

Code Place AI 조교는 지난 Ollama에서 vLLM으로 글에서 동접 30명 평균 2.9초까지 끌어올려 놨다. 그 뒤로 모델이 한 번 더 바뀌었다. Qwen2.5-7B-Instruct(dense 7B)에서 Qwen3.6-35B-A3B-NVFP4로. 전체 35B짜리 MoE인데 토큰마다 활성화되는 파라미터는 약 3B고, NVFP4 4bit 양자화로 35B 가중치를 RTX 5090(Blackwell, FP4를 하드웨어로 지원)에 얹었다.

모델을 바꿨으면 다시 재야 한다. 그런데 지난 테스트에는 마음에 걸리는 게 둘 있었다.

하나. 지난번엔 로컬에 vllm serve로 직접 띄운 프로세스를 때렸다. 지금은 K3s prod에 실제로 배포돼 돌아가는 vLLM이 있다. 로컬 프로세스가 아니라 그걸 재야 실서비스에 가깝다.

둘. 지난 부하 스크립트는 다익스트라 코드 하나를 “시간 복잡도 설명해줘” 같은 질문과 함께 계속 던졌다. 그런데 실서비스 힌트는 그렇게 안 생겼다. 시스템 프롬프트가 훨씬 길고, 문제 본문 XML과 이전 단계 힌트, 유저 코드가 다 붙는다. 다익스트라만 주구장창 뽑아내는 건 부하는 재도 실제 서비스는 아니다.

목표선은 그대로 뒀다. 공식 요구는 동시 50명·평균 10초지만, 사용자는 3초를 넘기면 불편함을 느낀다. “동시 N명을 3초 이내”가 내 실측 기준이다.

배포본을 어떻게 때렸나

vLLM Service는 ClusterIP라 클러스터 밖으론 안 열려 있다. 그런데 부하를 거는 서버 자체가 K3s 노드라, 노드에선 ClusterIP로 바로 닿는다.

VLLM_IP=$(kubectl get svc -n code-place-prod vllm -o jsonpath='{.spec.clusterIP}')
curl -s http://$VLLM_IP:8000/v1/models | head -c 300

여기서 하나 발견했다. 응답에 max_model_len이 32768로 찍혔다. 저장소 매니페스트에는 4096으로 적혀 있는데 배포본은 32768로 돌고 있었다. 테스트엔 지장 없지만 저장소와 배포 상태가 어긋난 거라 따로 적어뒀다.

Locust는 노드에서 돌리고, 웹 UI만 내 PC에서 보고 싶었다. --web-host 0.0.0.0으로 바인딩한 뒤 SSH 포트 포워딩으로 터널을 뚫었다.

# 노드에서
locust -f vllm_loadtest.py --host http://$VLLM_IP:8000 --web-host 0.0.0.0 --web-port 8089
# 내 PC에서 (이 창을 열어두면 터널)
ssh -L 8089:localhost:8089 ojserver@<>

그리고 http://localhost:8089. 방화벽은 SSH 포트만 열려 있으면 되니 이 방식이 깔끔하다.

1단계 - 이전과 똑같은 조건으로 모델만 바꿔보기

먼저 비교가 되게, 지난번과 완전히 같은 스크립트(다익스트라 코드, stream=False, max_tokens=256)에 모델 이름만 Qwen3.6으로 바꿔 돌렸다. Qwen3.6은 thinking 모델이라 chat_template_kwargsenable_thinking: false만 추가했다. 안 끄면 256토큰을 생각으로 다 태워버린다.

30명·50명·100명, 각 5분.

동접 이전 (Qwen2.5-7B) 이번 (Qwen3.6) 차이
12명 2.7초 약 2.4초 동급
30명 2.891초 3.413초 +18%
50명 3.400초 4.980초 +46%
100명 8.098초 7.134초 -12%

전 구간 실패는 0이었다. 그런데 격차가 한 방향이 아니었다. 30–50명 구간은 새 모델이 느린데, 100명에서는 오히려 빠르다.

이유를 나눠보면 이렇다. 30–50명 구간의 지연은 모델 크기 비용이다. MoE라 활성 파라미터는 3B여도 KV 캐시나 어텐션 계산은 모델 전체 크기를 따라가서, 배치가 커질수록 토큰당 비용이 7B dense보다 빨리 올라간다. 반면 100명에서 역전한 건 세팅 차이다. 이전은 --max-num-seqs 40이라 100명이면 40 슬롯 뒤로 큐가 길게 쌓였고, 이번은 60에 chunked prefill까지 붙어서 큐를 더 잘 버틴다.

Grafana의 AI Inference 대시보드가 이걸 그대로 보여줬다. 100명 구간에서 Running Requests가 60에 딱 붙고 Waiting이 20–30 생겼다. 토큰 처리량은 30명 약 1K에서 100명 약 3K tok/s까지 계속 올랐고, GPU framebuffer 메모리는 85–90%(약 29GB/32GB)에서 고정, 온도는 최대 40도, XID 에러 0. GPU 연산 자체는 여유가 있고, 지연이 오른 건 배치 확대와 큐 대기 때문이라는 뜻이다.

전환 - 이건 실제 서비스가 아니다

수치는 나왔지만 이게 실서비스를 대변하진 않는다. 다익스트라 코드 하나를 계속 던지고 있으니까. 그래서 페이로드를 실운영과 똑같이 갈아엎었다.

시스템 프롬프트는 재타이핑하지 않고 prod 백엔드에서 그대로 뽑았다. 이래야 진짜 운영 프롬프트라는 게 보장된다.

echo 'from problem.llm_hint import SYSTEM_PROMPT; open("/tmp/sp.txt","w").write(SYSTEM_PROMPT)' \
  | kubectl exec -i -n code-place-prod deploy/backend -- python manage.py shell
kubectl exec -n code-place-prod deploy/backend -- cat /tmp/sp.txt > system_prompt.txt

페이로드는 llm_hint.py가 실제로 만드는 것과 같게 구성했다. 시스템 프롬프트 전문에 문제 XML, 이전 단계 힌트(0–2개 랜덤), 유저 코드를 붙이고, stream=True, max_tokens=512, temperature=0.2. 컨텍스트가 대략 3천 토큰이다.

스트리밍이라 지표도 둘로 쪼갰다. 첫 토큰까지 걸린 시간(TTFT)과 답변 완성까지 걸린 시간. 사용자 체감은 사실 TTFT가 지배한다. 스트리밍은 첫 글자만 빨리 뜨면 기다린다는 느낌이 덜하니까.

응답은 전부 jsonl로 저장하게 해뒀다. 부하 테스트를 돌리면 품질 채점용 샘플이 같이 쌓인다.

2단계 - 실운영 페이로드가 오히려 더 빨랐다

항목 워밍업(1명) 30명 50명 100명
TTFT 평균 130.6ms 311.9ms 358.8ms 7,536ms
완성 평균 730ms 2,361ms 3,330ms 17,165ms
RPS 1.2 19.5 29.7 19.7
성공/실패 21/0 2,058/0 2,773/0 1,515/0

30명에서 완성 2.361초. 같은 30명인데 다익스트라 판(3.413초)보다 빠르고, 이전 모델 기록(2.891초)보다도 빠르다. 처음엔 이게 당황스러웠다. 모델을 5배 키웠는데 왜 더 빨라지나.

답은 prefix cache다. 시스템 프롬프트가 약 2천 토큰인데 매 요청 똑같으니 vLLM이 그 구간을 캐시해서 프리필이 거의 공짜가 된다. 게다가 힌트 답변은 짧다(평균 283자). TTFT가 312ms라 체감은 사실상 즉답이다.

50명까지는 거의 선형이다. TTFT는 359ms로 그대로고 완성도 3.3초, RPS는 29.7로 최고치를 찍는다. 그런데 100명에서 무너진다. max-num-seqs 60을 넘으면서 큐 대기가 TTFT에 그대로 얹혀 312ms가 7.5초로 튀고, 완성은 17초. RPS도 29.7에서 19.7로 오히려 떨어졌다. 실패는 0이지만 17초짜리 힌트는 서비스가 아니다.

의심 - 캐시가 과하게 먹은 거 아닌가

여기서 다시 걸렸다. 문제 2종, 코드 3종만 돌리고 있으니 prefix cache가 실제보다 잘 먹는 거 아닌가. 시스템 프롬프트가 캐시되는 건 실서비스도 똑같지만, 문제 본문이랑 유저 코드까지 캐시되는 건 실제와 다르다. 실서비스는 문제가 632개고 코드는 유저마다 제각각이다.

그래서 매 요청마다 문제 본문과 유저 코드를 랜덤으로 생성하는 판을 하나 더 만들었다. 시스템 프롬프트만 캐시에 걸리고 그 뒤는 전부 캐시 미스가 나는 조건이다. 랜덤이 실제로 먹었는지부터 확인했다.

샘플 1320 | 서로 다른 문제 수: 1306

1,320건 중 1,306개가 서로 다른 문제. 캐시는 사실상 못 탄다.

3단계 - 캐시를 껐더니 드러난 것

항목 30명 고정 30명 랜덤 50명 랜덤 100명 랜덤
TTFT 평균 311.9ms 1,434.7ms 2,149.7ms 9,538ms
TTFT p95 500ms 2,800ms 4,700ms 13,000ms
완성 평균 2,361ms 4,783.7ms 7,464.5ms 19,898ms
완성 max 4,074ms 6,720ms 14,410ms 33,701ms
처리 건수(5분) 2,058 1,320 1,551 1,300
RPS 19.5 16.2 17.4 10

같은 30명인데 첫 토큰이 312ms에서 1,435ms로 4.6배 뛰었다. 완성은 2.36초에서 4.78초로 2배. 이 비대칭이 핵심이다. prefix cache가 죽여주던 건 대부분 프리필, 즉 TTFT다. 캐시가 없으면 매 요청 3천 토큰을 새로 처리해야 하니 첫 토큰이 1.4초로 늘어난다. 반면 완성 시간은 토큰 생성(decode)이 지배하는데 decode는 캐시와 무관해서 절반만 오른다.

처리량도 같은 이야기를 한다. 캐시가 있으면 30명 5분에 2,058건, 없으면 1,320건. prefix cache가 처리량의 약 36%를 만들어주고 있었다.

동접을 올리면 캐시 없는 조건은 더 빨리 무너진다. 랜덤 100명이면 첫 토큰 9.5초, 완성 20초, 최악 33.7초. RPS는 10까지 떨어진다. 여전히 실패는 0이지만, 34초를 기다리는 힌트는 존재하지 않는 것과 같다.

실서비스는 이 두 판 사이 어딘가다. 시스템 프롬프트는 항상 캐시되고, 대회처럼 다 같은 문제를 풀면 문제·코드까지 캐시가 잘 먹어 고정 판에 가깝다. 반대로 평소 각자 다른 문제를 풀면 랜덤 판 쪽이다. 그래서 안전선은 동접 50명 근처로 보는 게 맞고, 그 이상은 max-num-seqs를 올리거나 인스턴스를 늘려야 한다.

속도만 재고 모델을 바꿀 순 없다 - 품질도 쟀다

여기까지가 속도다. 그런데 모델을 통째로 바꿨으면 답변이 규칙을 지키는지도 봐야 한다. 힌트 시스템은 다행히 정량화하기 좋다. 시스템 프롬프트가 형식을 아주 빡세게 정의해놨기 때문이다. 저장해둔 응답 jsonl을 규칙별로 자동 채점하는 스크립트를 짰다. 단계 라벨([N단계])로 시작하는지, 코드 진단·힌트·점검 포인트 세 섹션이 순서대로 있는지, 마크다운을 안 썼는지, 소스코드를 흘리지 않았는지, 존댓말을 유지했는지, 잘리지 않고 끝맺었는지.

규칙 30명 고정 (2,058건) 30명 랜덤 (1,320건) 100명 랜덤 (1,300건)
라벨 / 3섹션 / 순서 / 완결 100% 100% 99.9%
마크다운 없음(백틱) 88.9% 97.2% 97.3%
코드 유출 없음 99.95% 100% 100%
해요체 없음 95.1% 94.8% 95.6%
전 규칙 통과 84.3% 92.1% 93.0%

부하가 걸려도, 처음 보는 랜덤 문제를 던져도 라벨과 섹션 구조는 안 깨진다. 6천 건 넘게 뽑는 동안 힌트 시스템에서 제일 중요한 정답 유출은 실질 0건이었다. 고정 판에서 코드 유출로 잡힌 1건은 열어보니 정답 코드가 아니라 “print(n) 등으로 직접 출력해보고”라는 디버깅 안내였다. 채점기의 print( 패턴에 걸린 경계 사례지 진짜 유출이 아니다.

걸리는 건 두 가지다. 하나는 백틱. Qwen3.6이 set, list, half 같은 식별자에 백틱을 붙이는 습관이 있다. 시스템 프롬프트에 백틱 금지가 명시돼 있고 프론트는 힌트를 플레인 텍스트로 렌더링하니 이건 실제 위반이다. 흥미로운 건 고정 판에서 11%였다가 랜덤 판에선 2.8%로 떨어진 점이다. 고정 판은 정책투표·구간합 코드에 같은 식별자가 계속 나와서 백틱 습관이 자주 발동하고, 랜덤 판은 문제가 매번 달라 트리거가 덜하다. 백틱 위반은 문제 유형에 의존적이다.

다른 하나는 해요체 5%. 존댓말 규칙이 있는데 일부가 새어나온다. 그리고 100명 극한에서 딱 1건 형식이 깨졌는데, 부하 때문이 아니라 답변이 길어져 max_tokens 512에 걸려 잘린 케이스였다.

남는 것

모델을 5배 키웠는데도 실패는 전 구간 0이었고, 캐시가 먹는 조건에선 오히려 이전보다 빨랐다. 안전선은 실서비스 조건 기준 동접 50명 근처, 60이 벽이다. 그 이상을 원하면 max-num-seqs를 올리거나 인스턴스를 늘려야 하고, 어느 쪽이든 이번처럼 캐시 유무를 갈라서 재야 한다. 안 그러면 캐시가 만든 착시를 실력으로 착각한다.

품질은 부하와 랜덤에 오히려 강했지만, 백틱과 해요체는 남은 숙제다. 시스템 프롬프트에 한 줄 더 박든지, 백엔드에서 후처리로 백틱을 걷어내든지. 둘 다 제품 결정이라 다음에 정리한다.

실서비스에 이미 배포된 걸 그대로 때리고, 진짜 페이로드로 재고, 캐시를 껐다 켜서 착시를 걷어낸 게 이번 테스트의 전부다. 속도만 보고 넘어갔으면 “모델 바꿨는데 더 빨라졌다”는 잘못된 결론을 낼 뻔했다.