내가 보려고 정리한 Qwen3-ASR /with GPT

Qwen3-ASR 모델 원리 상세 가이드

app.py에서 사용된 Qwen/Qwen3-ASR-1.7B 모델의 동작 원리를 단계별로 설명한다.


목차

  1. 모델 개요
  2. 아키텍처: 오디오 인코더 + LLM 디코더
  3. 오디오 전처리 파이프라인
  4. Mel-spectrogram → 오디오 토큰 변환
  5. LLM 디코더의 Autoregressive 생성
  6. vLLM 백엔드와 KV 캐시
  7. 스트리밍 전사 메커니즘
  8. 슬라이딩 윈도우 원리
  9. 주요 파라미터 해설
  10. 토큰 수 추정 계산

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.pyQwen3ASRModel.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초)

슬라이드 처리 순서:

  1. finish_streaming_transcribe(state) - 현재 세션 버퍼 플러시
  2. 현재 세션 텍스트에서 overlap_text_len 이후 부분을 accumulated_text에 커밋
  3. state.audio_accum[-overlap_samples:] - 마지막 6초 오디오 추출
  4. 새 세션(init_streaming_state) 초기화
  5. 오버랩 오디오를 새 세션에 재투입 → 문맥 연속성 확보
  6. 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 내에서 충분히 동작

참고 자료