Qwen3-ASR 모델 원리 상세 가이드
app.py에서 사용된 Qwen/Qwen3-ASR-1.7B 모델의 동작 원리를 단계별로 설명한다.
목차
- 모델 개요
- 아키텍처: 오디오 인코더 + LLM 디코더
- 오디오 전처리 파이프라인
- Mel-spectrogram → 오디오 토큰 변환
- LLM 디코더의 Autoregressive 생성
- vLLM 백엔드와 KV 캐시
- 스트리밍 전사 메커니즘
- 슬라이딩 윈도우 원리
- 주요 파라미터 해설
- 토큰 수 추정 계산
1. 모델 개요
Qwen3-ASR은 Alibaba Qwen 팀이 공개한 자동 음성 인식(Automatic Speech Recognition) 모델 패밀리다.
| 항목 | 내용 |
|---|---|
| 모델 ID | Qwen/Qwen3-ASR-1.7B |
| 파라미터 수 | 약 17억 (1.7B) |
| 가중치 정밀도 | BF16 (bfloat16) |
| 지원 언어 | 30개 언어 + 22개 중국어 방언 (한국어 포함) |
| 기반 모델 | Qwen3-Omni (멀티모달 기반 모델) |
| 라이선스 | Apache 2.0 |
주요 특징
- All-in-one: 언어 자동 감지 + 음성 인식을 단일 모델로 처리
- 스트리밍/오프라인 통합: 하나의 가중치로 실시간 스트리밍과 일괄 처리 모두 지원
- 강인성: 복잡한 음향 환경, 방언, 노이즈에 강함
- 오픈소스 SOTA: LibriSpeech clean WER 1.63%, 상용 API에 필적하는 성능
2. 아키텍처: 오디오 인코더 + LLM 디코더
Qwen3-ASR는 Encoder-Decoder 하이브리드 구조가 아닌, 오디오 인코더 + LLM 자동회귀 디코더의 조합이다.
원시 오디오 파형 (PCM)
│
▼
┌─────────────────────────┐
│ 오디오 인코더 │
│ (Whisper 계열 구조) │
│ │
│ • 16kHz PCM 입력 │
│ • STFT → mel-spec 변환 │
│ • CNN + Transformer │
│ • → 고차원 오디오 특성 │
└─────────────┬───────────┘
│ 오디오 특성 벡터
▼
┌─────────────────────────┐
│ 어댑터 레이어 │
│ (Projector / Adapter) │
│ 오디오 특성 차원을 │
│ LLM 임베딩 차원으로 정렬│
└─────────────┬───────────┘
│ 오디오 토큰 시퀀스
▼
┌─────────────────────────┐
│ LLM 디코더 │
│ (Qwen3-Omni 기반) │
│ │
│ • Transformer 아키텍처 │
│ • Multi-Head Attention │
│ • RoPE 위치 인코딩 │
│ • Autoregressive 생성 │
│ → 텍스트 토큰 순차 생성│
└─────────────────────────┘Whisper 계열 인코더를 사용하는 이유
OpenAI Whisper는 대규모 음성 데이터(68만 시간)로 훈련된 오디오 인코더를 갖추고 있으며,
mel-spectrogram을 고품질 의미 표현으로 변환하는 데 검증된 구조다.
Qwen3-ASR는 이 인코더 구조를 계승하여 오디오의 음향·의미 표현을 추출하고,
강력한 LLM 디코더가 이를 텍스트로 변환한다.
3. 오디오 전처리 파이프라인
모델에 오디오를 입력하기 전에 app.py에서 수행하는 전처리 단계다.
3-1. 포맷 변환 (_bytes_to_wav16k)
브라우저 MediaRecorder (WebM/Opus)
│
├─── soundfile로 직접 파싱 시도
│ (WAV, FLAC, OGG 등 libsndfile 지원 포맷)
│
└─── 실패 시 ffmpeg 폴백
-ar 16000 -ac 1 -f wav
(WebM/Opus → WAV 변환)브라우저의 MediaRecorder는 WebM/Opus 컨테이너를 기본으로 생성하는데, 이는 libsndfile이 지원하지 않는다. ffmpeg를 통해 WAV로 변환하는 2단계 처리가 필수다.
3-2. 모노 변환
스테레오 이상의 다채널 오디오는 채널 평균(mean)으로 모노로 변환한다.
$
x_{\text{mono}}[n] = \frac{1}{C} \sum_{c=1}^{C} x_c[n]
$
단순 평균이 L/R 채널 위상차로 인한 상쇄를 최소화하면서 모노 다운믹스에 적합하다.
3-3. 리샘플링 (_resample_to_16k)
Qwen3-ASR는 16kHz 입력만 허용한다. 임의 샘플레이트의 오디오를 선형 보간으로 변환한다.
$
x_{\text{new}}[i] = \text{interp}\left(t_{\text{new}}[i],\ t_{\text{old}},\ x_{\text{old}}\right)
$
여기서 $t = \text{linspace}(0, \text{dur}, N)$ 는 등간격 시간 좌표다.
librosa/scipy 대신 numpy 선형 보간을 선택한 이유: 음성인식 전처리 수준에서는 선형 보간의 음질이 충분하며, 의존성 없이 구현 가능하다.
4. Mel-spectrogram → 오디오 토큰 변환
오디오 인코더 내부에서 수행되는 핵심 처리 과정이다.
4-1. STFT와 Mel-filterbank
PCM 파형 (16kHz, float32)
│
▼ Short-Time Fourier Transform (STFT)
│ • FFT 크기: 400 (25ms 윈도우)
│ • hop 길이: 160 샘플 (10ms)
│ → 주파수 × 시간 스펙트로그램
│
▼ Mel-filterbank 적용
│ • 80개 mel 필터 (log scale)
│ • 인간 청각의 주파수 인식을 모방
│ → 80차원 × 프레임 수 행렬
│
▼ log 스케일 변환
log(max(spectrogram, 1e-10))hop 크기 160샘플(10ms)의 의미: 오디오 1초 = 16,000 샘플 → 16,000 / 160 = 100 mel 프레임/초
4-2. CNN + Transformer 인코딩
mel-spectrogram은 1D/2D CNN 레이어를 통해 시간 방향으로 다운샘플링된 후 Transformer로 인코딩된다.
mel-spectrogram (100 frames/sec)
│
▼ CNN 다운샘플링 (stride=2)
│ → ~50 frames/sec
│
▼ 어댑터 프로젝션
→ 약 13 오디오 토큰/초app.py 주석의 토큰 수 추정 근거:
vLLM qwen3_asr.py _get_feat_extract_output_lengths 공식:
hop_size = 160 샘플 (10ms)
CNN stride 등 내부 다운샘플링 적용
→ 초당 약 13 오디오 토큰5. LLM 디코더의 Autoregressive 생성
오디오 토큰이 생성된 후 LLM이 텍스트를 자동회귀 방식으로 생성한다.
생성 프롬프트 구조
[시스템 프롬프트]
"You are an ASR system. Transcribe the following audio..."
[언어 힌트]
"Language: Korean"
[오디오 토큰 시퀀스]
<|audio_bos|> [토큰1] [토큰2] ... [토큰N] <|audio_eos|>
[생성 시작]
<|im_start|>assistant
→ 텍스트를 한 토큰씩 autoregressive하게 생성스트리밍과 일반 추론의 차이
| 방식 | 원리 |
|---|---|
일반 전사 (transcribe) |
전체 오디오 → 전체 텍스트 1회 생성 |
스트리밍 (streaming_transcribe) |
2초 청크마다 누적 오디오 전체를 재투입 |
스트리밍 방식은 매번 처음부터 재처리하므로 prefix 재사용 메커니즘이 핵심이다:
unfixed_token_num=5: 이전 청크 결과의 마지막 5 토큰은 확정하지 않고 다음 청크에서 재생성 → 청크 경계 오인식 방지
6. vLLM 백엔드와 KV 캐시
app.py는 Qwen3ASRModel.LLM(...) 즉 vLLM 백엔드를 사용한다.
vLLM 동작 원리
메인 프로세스 vLLM EngineCore (자식 프로세스)
│ │
│ ─── zmq IPC ──────────────────────►│
│ 요청 전송 (오디오+파라미터) │ GPU에서 추론 실행
│ │ • PagedAttention
│◄─── zmq IPC ───────────────────────│ • CUDA 그래프
│ 결과 수신 (텍스트 토큰) │ • KV 캐시 관리PagedAttention과 KV 캐시
vLLM의 핵심 최적화는 PagedAttention이다:
- KV 캐시를 OS의 가상 메모리처럼 페이지(블록) 단위로 관리
- 시퀀스 길이가 달라도 메모리 단편화 없이 효율적 할당
max_model_len=4096: KV 캐시가 지원하는 최대 시퀀스 길이
VRAM 배분 (16GB GPU 기준)
전체 VRAM: 16.00 GB
├─ 모델 가중치: 3.87 GB (로그 실측)
├─ CUDA 그래프: 0.49 GB (로그 실측)
├─ CUDA 컨텍스트+활성화: 3.21 GB (로그 실측)
├─ KV 캐시 (4096): 0.44 GB (vLLM 실측)
└─ 여유: 7.99 GB
─────────
gpu_memory_utilization=0.55 → 8.80 GB 예약
KV 캐시 가용량: 1.23 GB (8.80 - 7.57)Windows spawn 문제와 지연 초기화
Windows는 멀티프로세싱에 spawn 방식을 사용한다. 모듈 최상위에서 vLLM을 초기화하면:
python app.py
└─ vLLM이 EngineCore 자식 프로세스 생성
└─ 자식 프로세스가 app.py를 처음부터 다시 실행
└─ 다시 vLLM 초기화 시도 → ZMQError (Address in use) 발생!_get_asr() 함수를 통한 지연 초기화(lazy initialization) 패턴으로 해결:
startup이벤트 또는 첫 요청 시점에만 초기화if __name__ == '__main__':가드와 동일한 효과를 함수 레벨에서 달성
7. 스트리밍 전사 메커니즘
init_streaming_state 파라미터
| 파라미터 | 값 | 역할 |
|---|---|---|
unfixed_chunk_num=2 |
2 | 세션 처음 2청크는 prefix 재활용 없이 자유 생성 (모델 안정화 구간) |
unfixed_token_num=5 |
5 | 매 청크 경계에서 이전 결과의 끝 5토큰은 롤백하여 재생성 |
chunk_size_sec=2.0 |
2.0초 | 내부 청크 분할 단위 |
unfixed_token_num 동작 설명
청크 #1 인식 결과: "안녕하세요 저는 김"
^^^^^^^^
끝 5토큰 (확정 보류)
청크 #2 입력 후 재생성:
prefix: "안녕하세요 저는" + 새 오디오 → "안녕하세요 저는 김민준입니다"
(청크 경계에서 "김"이 단어 일부였음을 올바르게 처리)이 메커니즘이 없으면 "김" → "김" + "민준입니다"로 분리되어 보이거나 잘못 인식될 수 있다.
8. 슬라이딩 윈도우 원리
왜 슬라이딩 윈도우가 필요한가?
streaming_transcribe()는 매 청크 호출마다 세션 시작부터 현재까지의 전체 누적 오디오를 모델에 재투입한다(state.audio_accum).
시간 경과에 따른 오디오 토큰 수 변화:
10초 → 130 오디오 토큰
30초 → 390 오디오 토큰
60초 → 780 오디오 토큰 ← max_model_len=4096의 19% 사용
120초 → 1560 오디오 토큰 ← 38% 사용
300초 → 3900 오디오 토큰 ← 95% → KV 캐시 위험!무제한 누적 시 KV 캐시 포화 → 추론 불가 → 먹통 발생.
슬라이딩 윈도우 동작 (WINDOW_SEC=30, OVERLAP_SEC=6)
시간축: 0 6 24 30 36
│─────────┼─────────┼─────────│
│ 세션 #1 (30초) │
│ │─────────────────────────────│
│ │ 오버랩 구간 (6초) │세션 #2 │
│ │ │ │ │
└─── 세션 #1 확정 텍스트 커밋 ──┘
↑
슬라이드 시점 (audio_accum ≥ 30초)슬라이드 처리 순서:
finish_streaming_transcribe(state)- 현재 세션 버퍼 플러시- 현재 세션 텍스트에서
overlap_text_len이후 부분을accumulated_text에 커밋 state.audio_accum[-overlap_samples:]- 마지막 6초 오디오 추출- 새 세션(
init_streaming_state) 초기화 - 오버랩 오디오를 새 세션에 재투입 → 문맥 연속성 확보
overlap_text_len = len(state.text)- 오버랩으로 생성된 텍스트 길이 기록
overlap_text_len을 사용하는 이유
새 세션에 오버랩 오디오를 재투입하면 이 6초 부분의 텍스트가 다시 생성된다.
이를 그대로 표시하면 이전 세션에서 이미 표시된 텍스트와 중복된다.overlap_text_len으로 중복 구간을 제거하여 최종 표시 텍스트를 구성한다:
display_text = accumulated_text + current_session_text[overlap_text_len:]9. 주요 파라미터 해설
gpu_memory_utilization=0.55
vLLM이 GPU VRAM의 55%를 사전 예약한다. 너무 낮으면 KV 캐시가 부족해 max_model_len을 줄여야 하고, 너무 높으면 다른 CUDA 연산(NeMo 등)과 충돌한다.
$
\text{KV 캐시} = \underbrace{0.55 \times 16,\text{GB}}{\text{예약}} - \underbrace{7.57,\text{GB}}{\text{고정 오버헤드}} = 1.23,\text{GB}
$
max_new_tokens=32
한 청크(2초) 당 생성할 최대 텍스트 토큰 수. 2초 오디오에서 생성될 수 있는 한국어 텍스트는 통상 10~20 토큰이므로 32는 충분한 상한선이다. 값이 크면 생성 시간이 길어지므로 스트리밍 지연(latency)이 증가한다.
max_model_len=4096
KV 캐시가 지원하는 최대 시퀀스 길이. 슬라이딩 윈도우로 오디오 누적이 30초로 제한되므로:
$
\text{peak 토큰} = \underbrace{390}{\text{오디오 토큰}} + \underbrace{500}{\text{텍스트 토큰 추정}} \approx 890 \ll 4096
$
안전 마진: $4096 / 890 \approx 4.6$배
WINDOW_SEC=30, OVERLAP_SEC=6
| 파라미터 | 값 | 근거 |
|---|---|---|
WINDOW_SEC |
30초 | 오디오 토큰 390개로 max_model_len 여유 유지 |
OVERLAP_SEC |
6초 | 평균 문장 길이(3~5초)를 초과하여 문장 경계를 포함 |
OVERLAP이 너무 짧으면 단어가 잘리고, 너무 길면 중복 텍스트가 많아진다.
10. 토큰 수 추정 계산
[오디오 → mel 프레임]
hop_size = 160 샘플 = 10ms
1초 = 16,000 샘플 / 160 = 100 mel 프레임
[mel 프레임 → 오디오 토큰]
vLLM qwen3_asr.py _get_feat_extract_output_lengths 공식:
→ 내부 CNN stride + 어댑터 다운샘플링 적용
→ 약 13 오디오 토큰/초
[30초 오디오]
30 × 13 = 390 오디오 토큰
[텍스트 토큰 추정]
한국어 평균 발화 속도: ~3 음절/초
BPE 토큰화: ~1.5 토큰/음절
30초 × 3 × 1.5 ≈ 135 토큰 (조용한 구간 제외 시 실제는 더 적음)
→ app.py 주석의 "약 500토큰"은 빠른 발화 / 긴 텍스트 최악의 경우 추정
[KV 캐시 피크]
오디오 390 + 텍스트 ~500 = ~890 토큰 / 4096 = 21.7%
→ max_model_len 내에서 충분히 동작