커뮤니티 말을 믿고 디코드를 절반으로 깎았다
"투기 디코딩과 멀티모달은 양립 불가"를 믿고 MTP를 껐더니 42.8t/s가 19.4t/s로 떨어졌다. 소스를 읽으니 경고만 하고 진행하는 코드였고, 그 사이 다른 함정이 하나 더 켜져 있었다
DGX Spark에 올린 gemma 서버 앞에 지인들과 같이 쓸 채팅 UI를 붙이기로 했다. 이왕이면 이미지도 받게 하고 싶었고, 모델 자체가 멀티모달이라 서버 설정만 바꾸면 되는 일로 보였다. 그런데 투기 디코딩과 멀티모달은 같이 못 쓴다는 말을 그대로 받아들이면서, 전날 올려 둔 디코드 속도를 반납하는 거래로 시작하게 됐다.
하려던 것
지인들과 같이 쓸 채팅 UI를 붙이기로 했다. Open WebUI를 올리고 Cloudflare 터널 뒤에 두는 구성이다. 이왕이면 이미지도 넣고 싶었다. Gemma 4는 멀티모달 모델이라 서버 설정만 바꾸면 된다.
이미지 가능하게 하면 다시 성능 안 좋아지나? Open WebUI 붙여줘.
어떻게 시켰나
Claude Code의 답은 “vLLM에서 투기 디코딩과 멀티모달 입력은 양립하지 않는다”였다. 커뮤니티 이슈에서 여러 번 본 이야기다. 그래서 전날 채택한 MTP를 빼고 이미지를 켰다.
단일 디코드가 39.5t/s에서 19.4t/s로 떨어졌다. 공시 심사 JSON 하나가 1.9초에서 4.1초로 늘었다. 이미지를 위해 속도 절반을 내주는 거래라고 생각하고 받아들였다.
어디서 막혔나
받아들이자마자 서버가 죽었다. 구조화 출력(json_schema) 첫 요청에서 엔진이 즉사했다.
RuntimeError: sample_tokens() must be called after execute_model() returns None
MTP를 빼면 vLLM이 async scheduling을 자동으로 켠다. 그 상태에서 구조화 출력을 받으면 죽는다. 원인은 소스에 있었다. 지연된 샘플링을 다시 호출하는 코드가 투기 디코딩 전용 플래그 조건문 안에만 있었다. 투기 없이 async와 json_schema를 같이 쓰면 그 호출이 영영 안 일어난다.
당장은 --no-async-scheduling을 강제로 붙여 막았다. “운영에 치명적인 함정이라 메모리에 기록해 둘게”라는 말에 원인을 되물어 저 설명을 받아냈다. A를 고치려다 B가 무장된 상태였고, B는 A를 되돌려야만 풀리는 구조였다.
어떻게 풀었나
밤 10시 반에 처음 전제로 돌아갔다. 양립 불가라는 말의 근거가 뭔지 소스를 읽게 했다.
vLLM에서 멀티모달과 투기 디코딩을 같이 켤 때 실행되는 건 _warn_if_multimodal()이다. 이름 그대로 경고를 찍고 진행한다. 하드 실패는 _raise_if_mrope() 하나뿐인데, 이건 M-RoPE 위치 인코딩을 쓰는 모델에만 해당한다. Gemma 4는 M-RoPE를 쓰지 않는다.
MTP를 복원하고 이미지를 켠 채로 다시 쟀다. 단일 42.8t/s, 동시 16에서 339t/s, 이미지 입력 정상, json_schema 3/3, 툴 콜 9/9. 속도와 이미지 중 하나를 고를 필요가 처음부터 없었다. 절반 손실은 이슈 코멘트 한 줄을 믿은 비용이었다.
배운 점
- “양립 불가”는 소스로 확인한다. 경고와 예외는 다르다. 경고를 찍고 진행하는 코드를 이슈 코멘트가 불가로 옮겨 적은 경우가 있다.
- 설정 하나를 빼면 다른 기본값이 자동으로 바뀔 수 있다. MTP를 빼자 async scheduling이 켜졌다. 뺀 것만 보지 말고 그 결과로 바뀐 것을 본다.
- 구조화 출력은 스모크 테스트에 넣는다. 일반 채팅은 멀쩡한데 json_schema 첫 요청에서 죽는 조합이 있다. 서버를 띄운 직후 한 번 던져 본다.
- 손실을 받아들이기 전에 전제를 한 번 더 의심한다. “절반이면 감수하자”고 결정한 순간이 소스를 읽어야 할 순간이었다.
- 에이전트의 확신은 근거를 되물어 확인한다. 양립 불가도, 엔진 즉사 원인도 “왜?”라고 물은 뒤에야 소스가 나왔다.