Borealis — как обучить audio LLM по цене макбука Borealis — how to train an audio LLM for the price of a MacBook
Открытая 5B audio-language модель для русского и английского. Открытый исходный код, данные и всё для воспроизведения.
Open 5B audio-language model for Russian and English. Open source, open data, full recipe to reproduce.
СодержаниеContents
Привет! Уже как год у меня готовилась модель Borealis — открытая вариация Voxtral/Flamingo-audio. Сегодня хочу поделиться, как мы учили такую модель с нуля, какие эксперименты у нас получились — а какие нет.
В целом ничего особо нового: Whisper3 large, Qwen 4b как LLM-backbone и адаптер посередине.
Hey. Borealis has been quietly cooking for about a year now — our open take on Voxtral / Flamingo-audio. Today I want to share how we trained it from scratch, what worked, and what didn't.
Nothing especially new on the recipe: Whisper3-large, Qwen 4b as the LLM backbone, and an adapter glued in between.
Зачем нужны audio-LLMWhy audio-LLMs
Классические ASR (Whisper, Wav2Vec2) отлично транскрибируют речь, но не понимают контекст. Спросите Whisper «о чём говорится в этом аудио?» — и получите просто транскрипт. Audio-LLM решают именно эту проблему: они не только слышат, но и понимают.
Classical ASR (Whisper, Wav2Vec2) transcribes speech well — but doesn't understand it. Ask Whisper "what is this audio about?" and you get a transcript. Audio-LLMs close that gap: they hear and reason.
Какие задачи мы учили для audio-LLM:
The tasks we trained the audio-LLM for:
- Суммаризировать длинные записи
- Отвечать на вопросы по содержанию
- Анализировать тон и эмоции
- Summarize long recordings
- Answer questions about content
- Reason about tone and emotion
Архитектура BorealisBorealis architecture
Подход проверенный: сильный аудио-энкодер, сильная LLM и адаптер между ними.
The recipe is well-trodden: a strong audio encoder, a strong LLM, and an adapter between them.
Аудио, 16 кГцAudio @ 16 kHz
Whisper Large V3 encoder
Downsampler 4× + MLP-адаптер4× downsampler + MLP adapter
Qwen3-4B
Текстовый ответText response
1Почему именно такWhy this stack
- Whisper Large V3 — лучший открытый энкодер для речи, особенно для мультиязычных задач.
- Заморозка энкодера — сохраняем качество ASR. И вообще так делают +- во всех VLM — а мы тут на самом деле учим VLM, только для звука.
- Downsampling 4× — 1500 токенов → 375. Звук не супер насыщенный информацией канал — лучше сжать.
- Qwen3-4B — взяли что было.
- Whisper Large V3 — best open speech encoder, especially for multilingual.
- Frozen encoder — preserves ASR quality. And basically every VLM does it this way — and we're really just training a VLM, only for audio.
- 4× downsampling — 1500 → 375 tokens. Audio isn't an information-dense channel, so compressing it pays off.
- Qwen3-4B — went with what we had on hand.
Итого ~5B параметров, из которых обучаются ~500M — LoRA на LLM + адаптер.
~5B parameters total, of which ~500M are trained — LoRA on the LLM + the adapter.
Датасеты: что мы использовалиTraining data
Для экспериментов мы собрали несколько пулов данных:
We assembled several data pools for the ablations:
| ПулPool | ДатасетыDatasets | РазмерSize | НазначениеPurpose |
|---|---|---|---|
| RU audio | Speech-Instructions · ToneBooks · AudioBooksInstructGemini2.5 | ~150k | Русские аудиокниги + синтез-инструкции Gemini 2.5Russian audiobooks + Gemini-2.5 synthetic instructions |
| EN audio | Speech-Describe | ~150k | Английские аудио с описаниями (речь и не-речь)English audio with descriptions (speech & non-speech) |
| Text | GrandMaster-PRO-MAX | var | Текстовые инструкции без аудиоText-only instructions |
| IT Fresh | ToneBooksPlus · ToneSpeak · ToneRuLS · ToneRuDevices + Text | ~450k | Финальный микс для instruction tuningFinal mix for instruction tuning |
| Long audioLong audio | AlexWortega/longaudio · Ficbook-Audio-Instruct-10K | ~10k+ | Длинные литературные записи и инструкции по фрагментамLong-form literary audio and chunked instructions |
Датасет AudioBooksInstructGemini2.5 — взяли аудиокниги, нарезали на фрагменты и сгенерировали инструкции через Gemini 2.5 Pro: суммаризация, QA, анализ, structured output и т.д. Если интересно как мы это делали — скрипт генерации лежит рядом.
The AudioBooksInstructGemini2.5 dataset comes from audiobooks chunked into clips with instructions generated via Gemini 2.5 Pro — summarization, QA, analysis, structured output and so on. If you want to see how, the generation script sits next to a sibling dataset.
Сетап экспериментовExperimental setup
Главный вопрос: как правильно обучать audio-LLM? Конкретно нас интересовало:
The core question: what's the right way to train an audio-LLM? Specifically:
- Нужно ли что-то размораживать?
- Как влияет язык данных — RU vs EN?
- Помогает ли добавление текстовых инструкций?
- Какие пропорции языков и текстовых данных оптимальны?
- Do we need to unfreeze anything?
- What's the effect of training-data language — RU vs EN?
- Does adding plain text instructions help?
- What ratios of languages and text data are optimal?
⚙КонфигурацияConfiguration
- Базовый чекпоинт:
AlexWortega/Borealis5b_90k(претренирован на ~90k сэмплов) - Железо: 8× GPU
- Batch: 1 / GPU, grad-accum 16 → effective 128
- LR: 1e-5
- Метрика: WER на 6 русских бенчмарках
- Base checkpoint:
AlexWortega/Borealis5b_90k(pretrained on ~90k samples) - Hardware: 8× GPUs
- Batch: 1 / GPU, grad-accum 16 → effective 128
- LR: 1e-5
- Metric: WER on 6 Russian benchmarks
📐БенчмаркиBenchmarks
| БенчмаркBenchmark | ОписаниеDescription | СложностьDifficulty |
|---|---|---|
| Russian LibriSpeech | Чтение книг, чистая речьBook reading, clean speech | low |
| Common Voice | Краудсорсинг, разные акцентыCrowdsourced, varied accents | medium |
| Tone Books | Аудиокниги (наш датасет)Audiobooks (our dataset) | low |
| Tone Speak | Спонтанная речьSpontaneous speech | medium |
| Tone Webinars | Вебинары, шум, терминыWebinars, noise, jargon | high |
| Sova RuDevices | Речь с устройствOn-device speech | high |
РезультатыResults
Русский vs английскийRussian vs English
Как влияет язык обучающих данных? How much does training-data language matter?| МодельModel | Steps | LibriSpeech | CommonVoice | Books | Avg WER |
|---|---|---|---|---|---|
| ru_only | 1000 | 6.54 | 13.64 | 10.06 | 19.32 |
| en_only | 1172 | 6.89 | 27.92 | 8.56 | 20.88 |
| mixed_large (RU+EN) | 1000 | 6.65 | 26.69 | 11.69 | 22.11 |
EN-only модель показывает WER 20.88% на русских бенчмарках — и это всего на 1.5 п.п. хуже native-RU.
The EN-only run hits 20.88% WER on Russian benchmarks — only 1.5 pp behind the native-RU run.
Это говорит о сильном кросс-языковом трансфере:
That points to strong cross-lingual transfer:
- Whisper уже знает русский (он учился на мультиязычных данных).
- Qwen3 тоже знает русский.
- Адаптеру достаточно научиться на английском — знания переносятся.
- Whisper already knows Russian (it was trained multilingually).
- Qwen3 also knows Russian.
- The adapter only needs alignment in one language; the rest transfers.
Но native данные всё же дают лучшее качество. И — удивительно — добавление EN к RU делает только хуже.
Native data still wins, though. And surprisingly, mixing EN into RU makes things worse.
Кросс-языковой трансфер работает, но native-данные дают лучшее качество. Не разбавляйте RU английским «для разнообразия».
Cross-lingual transfer works, but native data wins. Don't dilute your target-language data with EN "for diversity".
Добавление текстовых инструкцийAdding plain-text instructions
Помогает ли добавить к аудио немного чистого текста? Does mixing in plain text instructions help?| МодельModel | Steps | LibriSpeech | CommonVoice | Avg WER |
|---|---|---|---|---|
| ru_only (0% text) | 1000 | 6.54 | 13.64 | 19.32 |
| ru_text_10pct | 1624 | 6.42 | 15.79 | 19.17 |
| ru_text_25pct | 1000 | 6.35 | 35.02 | 24.02 |
Интересная нелинейная зависимость:
A non-linear shape:
- 10% текста — небольшое улучшение (19.32 → 19.17).
- 25% текста — заметное ухудшение (19.32 → 24.02).
- 10% text — small improvement (19.32 → 19.17).
- 25% text — noticeable degradation (19.32 → 24.02).
При 25% текста модель начинает «забывать» аудио-задачу. LLM переключается в режим text-to-text и хуже обрабатывает аудио-эмбеддинги.
At 25%, the model starts forgetting the audio task. The LLM drifts into text-to-text mode and stops fitting the audio embeddings properly.
10–15% текста помогает, 25% — вредит. У эффекта явно есть оптимум.
10–15% text helps; 25% hurts. There's a clear sweet spot.
Проблема вебинаровThe webinar problem
Один бенчмарк — аномальная просадка. One benchmark stays stubbornly bad.| МодельModel | Webinars WER |
|---|---|
| Whisper-v3 | 7.77 |
| ru_only | 61.32 |
| en_only | 59.76 |
| ru_text_10pct | 60.69 |
Все наши модели показывают ~60% WER на вебинарах, а чистый Whisper — 7.77%. Что не так?
All our runs sit around 60% WER on webinars while plain Whisper is at 7.77%. What's going on?
Вебинары — это шум, эхо, плохой микрофон, узкая терминология, несколько спикеров и перебивания. Whisper-энкодер с этим справляется, но LLM «портит» результат, пытаясь сделать текст более «правильным» — в итоге получаются плохие результаты.
Webinars come with noise, echo, bad mics, niche jargon, multiple speakers and interruptions. The Whisper encoder handles all of that — but the LLM "over-corrects" the transcript toward something more grammatical, and the result is just bad.
Сводные результатыAggregated results
Все эксперименты в одной таблице — отсортированы по среднему WER:
All experiments in one table, sorted by average WER:
| ЭкспериментRun | Steps | LibriSpeech | CommonVoice | Webinars | Books | Speak | RuDevices | Avg |
|---|---|---|---|---|---|---|---|---|
| Whisper-v3 | — | 11.68 | 12.23 | 7.77 | 11.95 | 2.68 | 19.87 | 11.03 |
| IT_fresh (no pretrain) | 2400 | 14.85 | 181.09 | 100.67 | 44.83 | 24.83 | 42.81 | 68.18 |
| ru_only | 1000 | 6.54 | 13.64 | 61.32 | 10.06 | 1.94 | 22.43 | 19.32 |
| en_only | 1172 | 6.89 | 27.92 | 59.76 | 8.56 | 2.09 | 20.03 | 20.88 |
| mixed_large | 1000 | 6.65 | 26.69 | 62.09 | 11.69 | 1.75 | 23.82 | 22.11 |
| ru_text_10pct best | 1624 | 6.42 | 15.79 | 60.69 | 11.36 | 1.99 | 18.75 | 19.17 |
Средний WER (%) — меньше = лучшеAverage WER (%) — lower is better
avg over 6 benchmarks▦Тепловая карта WER по бенчмаркамWER heatmap by benchmark
| LibriSpeech | CommonVoice | Webinars | Books | Speak | RuDevices | |
|---|---|---|---|---|---|---|
| Whisper-v3 | 11.68 | 12.23 | 7.77 | 11.95 | 2.68 | 19.87 |
| ru_only | 6.54 | 13.64 | 61.32 | 10.06 | 1.94 | 22.43 |
| en_only | 6.89 | 27.92 | 59.76 | 8.56 | 2.09 | 20.03 |
| ru_text_10pct | 6.42 | 15.79 | 60.69 | 11.36 | 1.99 | 18.75 |
Как всё это сервить и интегрировать с transformersServing it all & integrating with transformers
Вообще сервинг мультимодальных моделей — тема не новая. У нас есть быстрая часть (энкодер) и медленная (LLM): энкодер нужно сервить асинхронно, копить логиты, потом закидывать в LLM. У нас так и есть — режем аудио на куски для Whisper и сервим как есть. Остальная часть блога — скучная история про патч vLLM.
Serving multimodal models isn't a new topic. You have a fast part (the encoder) and a slow part (the LLM): run the encoder asynchronously, accumulate the logits, then hand them off to the LLM. That's exactly what we do — chunk audio for Whisper and serve it as-is. The rest of this post is the boring story of patching vLLM.
AАдаптер: simple vs deepAdapter: simple vs deep
В borealis/modeling.py живут две реализации адаптера. Прод-чекпоинт использует simple —
двухслойный MLP без bias-векторов:
borealis/modeling.py ships two adapters. The production checkpoint uses the simple one —
a two-layer MLP, no biases:
class AudioLanguageAdapter(nn.Module):
# ~31M params for Whisper-large × Qwen3-4B
def __init__(self, hidden_size: int, dim: int):
super().__init__()
self.w_in = nn.Linear(hidden_size, dim, bias=False)
self.gelu = nn.GELU()
self.w_out = nn.Linear(dim, dim, bias=False)
def forward(self, x):
return self.w_out(self.gelu(self.w_in(x)))
Размерности:
Dimensions:
encoder.d_model = 1280·downsample_factor = 4→hidden_size = 5120llm.config.hidden_size = 2560=dim- Итого:
Linear(5120, 2560) → GELU → Linear(2560, 2560) - Параметров:
5120·2560 + 2560·2560 ≈ 19.7Mпо матрицам; с учётом доп. буферов ~31M
encoder.d_model = 1280·downsample_factor = 4→hidden_size = 5120llm.config.hidden_size = 2560=dim- Net:
Linear(5120, 2560) → GELU → Linear(2560, 2560) - Params:
5120·2560 + 2560·2560 ≈ 19.7Mmatrices; with buffers ~31M
Есть и более жирный вариант AudioLanguageAdapterDeep на ~80M параметров: expansion_factor=1.5,
три transformer-like блока с LayerNorm + GELU + residual + dropout и Xavier-инициализацией.
В прод не пошёл — простой MLP справился; держим как задел на будущие эксперименты.
A fatter AudioLanguageAdapterDeep (~80M params) also lives in the repo: expansion_factor=1.5,
three transformer-like blocks with LayerNorm + GELU + residual + dropout and Xavier init.
Didn't make it to production — the simple MLP was enough — but we keep it for future ablations.
Дальше — downsampling 4×. Это не conv1d и не avg-pool, а простой view: четыре соседних
фрейма склеиваются в один с учетверённой размерностью.
Then the 4× downsample. It isn't a conv1d or avg-pool — it's a plain view:
four neighboring frames are concatenated into one with 4× the channel dimension.
def _downsample(self, seq: torch.Tensor):
k = self.downsample_factor # 4
T, d = seq.shape # 1500 × 1280
target = k * math.ceil(T / k)
if target != T:
seq = F.pad(seq, (0, 0, 0, target - T))
return seq.contiguous().view(target // k, d * k) # 375 × 5120
Поток токенов: 1500 × 1280 (выход Whisper) → 375 × 5120 (downsample) →
375 × 2560 (адаптер). Эти 375 эмбеддингов и попадают в LLM на место <|AUDIO|>-токенов.
Token flow: 1500 × 1280 (Whisper output) → 375 × 5120 (downsample) →
375 × 2560 (adapter). These 375 embeddings fill the slots of <|AUDIO|> placeholder tokens.
Энкодер — заморожен буквально: encoder.eval(), потом for p in encoder.parameters(): p.requires_grad = False.
The encoder is frozen hard: encoder.eval(), then for p in encoder.parameters(): p.requires_grad = False.
BАугментации звукаAudio augmentations
Модуль borealis/augmentations.py — большая курсорная конструкция: AugmentationPipeline с десятком
случайных эффектов и AugmentationScheduler-калбэк, который на разных эпохах включает разные «этапы».
Идея простая: на старте — чистое аудио, потом постепенно усложняем.
borealis/augmentations.py is a big curriculum machine: an AugmentationPipeline with a dozen
randomized effects, plus an AugmentationScheduler callback that activates different "stages" at different epochs.
Start clean, get harsher over time.
Какие операции есть в пайплайне (каждая со своей вероятностью p):
What's in the pipeline (each gated by its own p):
| ЭффектEffect | ДиапазонRange | Что моделируетSimulates |
|---|---|---|
| Подмешивание шумаBackground noise mix | SNR 18–28 dB | кафе, улица, кондиционерcafe, street, A/C hum |
| Свёртка с IRImpulse-response conv | — | реверберация комнат и заловroom & hall reverb |
| EQ | ±6 dB | разные микрофоныdifferent mic curves |
| Случайный gainRandom gain | ±3 dB | тихая/громкая речьquiet vs loud speakers |
| Band-pass | 150–350 / 3200–5200 Hz | дешёвые микрофоныcheap mics |
| Resample | 14–20 kHz | low-bandwidth каналыlow-bandwidth channels |
| TelephonyTelephony | 8–12 kHz · 180–4200 Hz | обычные звонки и колл-центрыphone calls, call-centers |
| Codec | 96–160 kbps | MP3/Opus сжатиеMP3/Opus compression |
| Clipping | 0.82–0.95 | пересжатый сигналoverdriven signal |
| Pitch / Speed | ±4 st · 0.8–1.2× | разные голоса и темпvoice & tempo variation |
| SpecAugment | ≤2 freq (27) · ≤2 time (100) | регуляризация по mel-спектруmel-spec regularization |
AugmentationScheduler — это TrainerCallback, который на on_epoch_begin
выбирает текущий AugmentationStage по start_epoch. Так у нас получается «куррикулум»:
на первых эпохах модель видит чистое аудио, дальше — всё более жёсткие искажения.
AugmentationScheduler is a HF TrainerCallback; on on_epoch_begin it picks the
current AugmentationStage by start_epoch. The result is a curriculum: clean audio first,
progressively harsher distortions later.
Послушать «до» и «после»Listen: before & after
Чистая речь — из ToneBooks, шум — сэмпл из Musan-сплита Vikhrmodels/Audio_Noise_Dataset. Микс — то что модель видит на training-time с включённой шумовой аугментацией.
The clean clip comes from ToneBooks; the noise is sampled from the Musan split of Vikhrmodels/Audio_Noise_Dataset. The mix is what the model sees at training time with noise augmentation enabled.
«Вот только они совсем не радовали, а, напротив, потрясали и ужасали.» "Yet they were not pleasing at all — quite the opposite, they shocked and horrified."Третий сэмпл — то, на чём учится модель в тяжёлых эпохах куррикулума: текст почти на грани разборчивости, но именно это и заставляет адаптер не «прилипать» к чистому Whisper-сигналу. Четвёртый — классическая телефонная полоса (300–3400 Hz через resample 8 kHz туда-обратно).
The third clip is what the model trains on in the heavier curriculum stages — text near the edge of intelligibility, which forces the adapter to stop relying on a clean Whisper signal. The fourth is a classic telephone band (300–3400 Hz via an 8 kHz resample round-trip).
CКак мы патчили vLLMPatching vLLM
Самая интересная часть. У vLLM есть закрытый список мульти-модальных архитектур, которые он умеет из коробки:
Qwen2-Audio, LLaVA, Phi-4-MM и ещё пара. Borealis в этом списке нет — у нас
Whisper-encoder + кастомный адаптер + Qwen3 + два собственных токена в словаре. Чтобы получить ускорение,
пришлось писать vLLM plugin.
The most interesting part. vLLM ships with a closed set of multi-modal architectures it knows out of the box:
Qwen2-Audio, LLaVA, Phi-4-MM and a few others. Borealis isn't there —
we have a Whisper encoder, a custom adapter, Qwen3, and two extra vocab tokens. To get the speedup we had to write
a vLLM plugin.
Плагин — пакет vllm_borealis, лежит прямо в репозитории модели на HF. В нём всего два файла:
The plugin is a package called vllm_borealis, sitting next to the weights in the HF model repo. Two files:
__init__.py— точка входа. Регистрирует модель вvllm.ModelRegistry.borealis.py— собственно имплементация. ~400 строк, четыре класса для vLLM-API.
__init__.py— entry point. Registers the model invllm.ModelRegistry.borealis.py— the actual implementation. ~400 lines, four classes for the vLLM API.
def register():
from vllm import ModelRegistry
if "BorealisForConditionalGeneration" not in ModelRegistry.get_supported_archs():
ModelRegistry.register_model(
"BorealisForConditionalGeneration",
"vllm_borealis.borealis:BorealisForConditionalGeneration",
)
vLLM находит register() через entry_points в pyproject.toml (группа
vllm.general_plugins) и вызывает при старте. С этого момента строка "BorealisForConditionalGeneration"
в config.json модели становится валидным архитектурным именем — как будто это нативная архитектура.
vLLM picks up register() via an entry_points declaration in pyproject.toml (group
vllm.general_plugins) and calls it at startup. From that moment, "BorealisForConditionalGeneration"
in the model's config.json is a first-class architecture name.
Четыре класса, которые ждёт vLLMThe four classes vLLM expects
-
BorealisProcessingInfo— описывает модальность. Главное здесь —get_supported_mm_limits()возвращает{"audio": 1}: одно аудио на промпт, больше vLLM просто не пустит. Также отсюда vLLM забираетWhisperFeatureExtractorдля конвертации waveform → mel-спектрограмма. -
BorealisDummyInputsBuilder— синтезирует «пустое» 30-секундное аудио для warm-up'а и профилирования. vLLM использует это чтобы посчитать максимальный sequence length и зарезервировать KV-cache — иначе он не знает, сколько токенов «весит» одно аудио. -
BorealisMultiModalProcessor— самый магический класс. Когда пользователь пишет промпт с одним токеном<|AUDIO|>, процессор расширяет его в реальную последовательность:<|start_of_audio|>+ 375×<|AUDIO|>+<|start_of_audio|>. А ещё помечает каждый из этих 375 токенов как «эмбеддинг будет подменён извне» — черезPromptUpdateDetails.select_token_id(..., embed_token_id=audio_token_id). -
BorealisForConditionalGeneration— собственно модель. Декорирована@MULTIMODAL_REGISTRY.register_processor(...); внутри хранитWhisperEncoder, нашAudioLanguageAdapterи — самое интересное —init_vllm_registered_model(architectures=["Qwen3ForCausalLM"])вместо переписанной с нуля LLM.
-
BorealisProcessingInfo— declares the modality. The key line isget_supported_mm_limits() == {"audio": 1}: at most one audio per prompt, vLLM enforces it. It also exposes theWhisperFeatureExtractorfor waveform → mel conversion. -
BorealisDummyInputsBuilder— synthesizes an "empty" 30-second audio for warm-up and profiling. vLLM uses this to compute the max sequence length and reserve KV-cache — otherwise it has no idea how many tokens an audio is worth. -
BorealisMultiModalProcessor— the magic class. When the user writes a prompt with a single<|AUDIO|>token, the processor expands it into the real sequence:<|start_of_audio|>+ 375×<|AUDIO|>+<|start_of_audio|>. Each of those 375 tokens is also marked "embedding will be supplied externally" viaPromptUpdateDetails.select_token_id(..., embed_token_id=audio_token_id). -
BorealisForConditionalGeneration— the model itself. Decorated with@MULTIMODAL_REGISTRY.register_processor(...); holds theWhisperEncoder, ourAudioLanguageAdapterand — the cool part — usesinit_vllm_registered_model(architectures=["Qwen3ForCausalLM"])instead of a re-implemented LLM.
Мы не переписываем Qwen3 для vLLM. Мы говорим vLLM «возьми свой собственный оптимизированный Qwen3-блок»
через init_vllm_registered_model — и получаем paged-attention, continuous batching и
fused-kernels бесплатно. Своё у нас только аудио инпут и адаптер: Whisper → downsample → adapter.
We never re-implement Qwen3 for vLLM. We tell vLLM "give us your own optimized Qwen3 block" via
init_vllm_registered_model and get paged-attention, continuous batching and fused kernels for free.
The only thing we own is the audio input and adapter: Whisper → downsample → adapter.
from vllm.model_executor.models.utils import (
init_vllm_registered_model, maybe_prefix,
)
llm_config = AutoConfig.from_pretrained("Qwen/Qwen3-4B")
llm_config.vocab_size = 151671 # base 151669 + 2 audio tokens
self.llm = init_vllm_registered_model(
vllm_config=vllm_config,
hf_config=llm_config,
prefix=maybe_prefix(prefix, "llm"),
architectures=["Qwen3ForCausalLM"], # vLLM's own optimized impl
)
Магия с токенамиToken-level magic
Главная сложность мульти-модального инференса в vLLM — подменить эмбеддинги отдельных токенов на
вычисленные снаружи (вышедшие из адаптера) так, чтобы все остальные оптимизации продолжали работать.
vLLM решает это через PromptReplacement + PromptUpdateDetails.select_token_id.
The hard part of multi-modal inference in vLLM is splicing externally-computed embeddings (the adapter output)
into specific token positions, without losing any of the other optimizations.
vLLM does it via PromptReplacement + PromptUpdateDetails.select_token_id.
def get_replacement_borealis(item_idx):
# 30s audio → 1500 mel frames / 4 = 375 audio tokens
num_features = audio_embeds[item_idx].shape[0] # or 375 default
audio_tokens = [audio_token_id] * num_features
return PromptUpdateDetails.select_token_id(
[audio_marker_id] + audio_tokens + [audio_marker_id],
embed_token_id=audio_token_id, # <-- "these tokens carry external embeddings"
)
return [PromptReplacement(
modality="audio",
target="<|AUDIO|>", # single placeholder in user prompt
replacement=get_replacement_borealis,
)]
То есть пользователь пишет один <|AUDIO|> в промпте, а внутри он раздувается в 377 токенов
(маркер + 375 + маркер). 375 «настоящих» аудио-токенов получают свои эмбеддинги из адаптера через хук
embed_token_id; остальные токены текста идут как обычно через embedding-таблицу LLM.
So the user writes one <|AUDIO|> in the prompt, and internally it inflates to 377 tokens
(marker + 375 + marker). The 375 "real" audio tokens get their embeddings from the adapter via the
embed_token_id hook; everything else flows through the LLM's normal embedding table.
Несколько мелких подкапотных деталейA few small under-the-hood details
-
Vocab resize. Qwen3-4B имеет словарь 151669. Мы добавили два токена:
<|AUDIO|>(id 151669) и<|start_of_audio|>(id 151670). Итогоvocab_size = 151671, и плагин падает на эти id'шники если вconfig.jsonне нашлось явных. -
Лишний batch-dim. vLLM иногда отдаёт мел в форме
[N, 1, 128, 3000]вместо[N, 128, 3000], потому что мульти-модальные поля он пакует в свою «таблицу». Плагин делаетif input_features.dim() == 4 and shape[1] == 1: squeeze(1). Классический футган. -
merge_by_field_config = True. Этот флаг говорит vLLM: «когда батчуешь запросы, склеивай мульти-модальные поля автоматически». Без него пришлось бы вручную писать collator. -
Аудио считается один раз. Whisper-encoder и адаптер запускаются один раз на весь generate-цикл,
результат живёт в KV-cache LLM наравне с текстовыми токенами. Каждый последующий
next-token-stepработает уже только в LLM — поэтому overhead аудио-фронтенда амортизируется на длинной генерации.
-
Vocab resize. Qwen3-4B base vocab is 151669. We add two tokens:
<|AUDIO|>(id 151669) and<|start_of_audio|>(id 151670). Finalvocab_size = 151671; the plugin falls back to those exact ids if not inconfig.json. -
Stray batch dim. vLLM sometimes ships mel as
[N, 1, 128, 3000]instead of[N, 128, 3000]because it packs multimodal fields into its own table. The plugin guards withif input_features.dim() == 4 and shape[1] == 1: squeeze(1). Classic footgun. -
merge_by_field_config = True. Tells vLLM to auto-batch multimodal fields when merging requests. Without it you'd write a collator by hand. -
Audio is computed once. The Whisper encoder + adapter run once per generate call; the resulting 375 embeddings
live in the LLM's KV-cache like normal tokens. Every subsequent
next-token-steponly touches the LLM — so the audio-frontend cost amortizes across long generations.
Откуда 2.1× ускорениеWhere the 2.1× actually comes from
Сравнение нечестное в одну сторону: native-transformers делает eager-attention с динамическим аллокатором,
без continuous batching. vLLM добавляет три вещи поверх:
The comparison is unfair in one direction: native transformers does eager attention with dynamic allocation,
no continuous batching. vLLM adds three things on top:
- PagedAttention — KV-cache живёт в page-table, перестают тратиться gpu-минуты на padding и фрагментацию.
- Continuous batching — запросы разной длины не ждут самого медленного, вливаются «по мере готовности».
- Fused-kernels Qwen3 — оптимизированные CUDA-ядра внимания и MLP, особенно для bf16.
- PagedAttention — KV-cache lives in a page table; you stop wasting GPU time on padding and fragmentation.
- Continuous batching — variable-length requests don't wait for the slowest in the batch.
- Fused Qwen3 kernels — optimized CUDA kernels for attention and MLP, especially in bf16.
Замер: NVIDIA A100, 30s аудио, max_tokens=128, bf16. Native transformers: 44.9 tok/s.
vLLM-плагин: 95.9 tok/s. Это per-request throughput; при батче 4+ относительная победа vLLM ещё растёт.
Measurement: NVIDIA A100, 30s audio, max_tokens=128, bf16. Native transformers:
44.9 tok/s. vLLM plugin: 95.9 tok/s. That's per-request throughput; with batch ≥4 the gap widens further.
Всё что выше — упрощённые версии. Реальный код: vllm_borealis на 🤗 (~400 строк). Если хочется адаптировать тот же подход под свою audio/video-LLM — это хороший reference.
Everything above is simplified. The real source is vllm_borealis on 🤗 (~400 lines). If you want to adapt the same pattern to your own audio/video-LLM, that's a solid reference.
Практические рекомендацииPractical recommendations
1Всегда используйте претрейнAlways start from a pretrain
Без претрейна модель не сойдётся за разумное время. Нет готового чекпоинта — сначала обучите на большом объёме простых ASR-данных (чистая транскрипция), потом переходите к instruction tuning.
Without a pretrain, the model won't converge in reasonable time. No checkpoint? Start with a large ASR-style pretrain (plain transcription) before moving to instruction tuning.
2Начинайте с native-языкаStart native
Кросс-языковой трансфер работает, но native-данные дают лучшее качество. Для русской модели — собирайте русские аудио.
Cross-lingual transfer works, but native data wins. For a Russian model — collect Russian audio.
3Добавляйте текст, но немногоAdd text — but only a little
10–15% текстовых инструкций в миксе улучшают качество. 25% — уже регрессия.
10–15% of plain-text instructions in the mix improves quality. 25% regresses.
4Не смешивайте языки аудиоDon't mix audio languages
Микс RU+EN аудио не дал улучшений по сравнению с чистым RU. Похоже, языки «конкурируют» за capacity.
An RU+EN audio mix didn't beat pure RU. Languages seem to compete for capacity.
5Для шумных аудио нужен отдельный подходPlan a separate path for noisy audio
Если ваш use-case — митинги или колл-центры, либо отдельный fine-tune под этот домен, либо fallback на Whisper. Один общий ckpt — не вытянет.
If your use case is meetings or call-centers, either fine-tune separately for that domain or fall back to Whisper. One general checkpoint won't cover both.
Ограничения BorealisLimitations
Честно о том, что модель пока не умеет:
Being honest about what the model can't do yet:
- Аудио длиннее ~30 секунд — нужно резать на чанки на стороне приложения.
- Сильно зашумленные записи — WER деградирует.
- Streaming — только offline-обработка.
- Несколько аудио в одном промпте — пока limit = 1.
- Audio longer than ~30s — caller must chunk it.
- Heavy noise — WER degrades.
- Streaming — offline only for now.
- Multiple audio per prompt — limit = 1.
Как попробоватьHow to try it
Минимальный инференс через transformers:
Minimal inference via transformers:
from transformers import AutoModel
import torchaudio
model = AutoModel.from_pretrained(
"Vikhrmodels/Borealis-5b-it",
trust_remote_code=True,
device="cuda",
)
audio, sr = torchaudio.load("audio.wav")
if sr != 16000:
audio = torchaudio.functional.resample(audio, sr, 16000)
output = model.generate(
audio=audio.squeeze(),
user_prompt="О чём говорится в этом аудио? <|start_of_audio|><|end_of_audio|>",
system_prompt="Ты полезный голосовой ассистент.",
max_new_tokens=256,
)
print(model.decode(output[0]))
Также есть Colab-ноутбук и Gradio-демо — ссылки в репозитории. Для продакшена рекомендуем vLLM (2× быстрее):
There is also a Colab notebook and a Gradio demo — see the repo. For production we recommend vLLM (2× faster):
# install
pip install vllm>=0.12.0
# serve
vllm serve Vikhrmodels/Borealis-5b-it \
--trust-remote-code \
--dtype bfloat16
Обучение audio-LLM для русского — решаемая задача, но с нюансами. Кратко: претрейн критичен, native-данные лучше, текст помогает в меру, и шум — отдельная проблема. Borealis — не идеальная модель, но solid baseline для русскоязычных audio-LLM-задач. Надеемся, эта статья сэкономит кому-то GPU-часы.
Training an audio-LLM for Russian is doable but nuanced. In one line: pretrain is critical, native data wins, a little text helps, and noise is its own problem. Borealis isn't perfect — but it is a solid open baseline for Russian audio-LLM work, and we hope this post saves someone a few hundred GPU-hours.