Skip to content

[Bugfix] Apply session.update language and prompt to Qwen3-ASR realtime - #61031

Open
tamerrkanak wants to merge 2 commits into
vllm-project:mainfrom
tamerrkanak:qwen3-asr-realtime-prompt
Open

tamerrkanak wants to merge 2 commits into
vllm-project:mainfrom
tamerrkanak:qwen3-asr-realtime-prompt

Conversation

@tamerrkanak

@tamerrkanak tamerrkanak commented Oct 10, 2026 •

Copy link
Copy Markdown

Overview

/v1/realtime drops language and prompt from session.update, so Qwen3-ASR realtime always runs with a fixed prompt, while /v1/audio/transcriptions applies both. This PR forwards them to the model and builds the Qwen3-ASR realtime prompt with the same get_generation_prompt() as the REST path.

Fixes #61026.

Claims

  • session.update accepts optional language and prompt. language is checked with validate_language() like on REST; an invalid value gets an invalid_language error event. The values apply from the next input_audio_buffer.commit.
  • Qwen3-ASR realtime builds each segment prompt with get_generation_prompt(). For single-segment audio, realtime and REST now produce the same prompt token ids and, in our runs, identical transcripts (1093/1093) for the same language/prompt.
  • No behaviour change when neither field is set: the prompt token ids equal the old hard-coded template, and transcripts are identical to main (1093/1093).
  • Voxtral realtime accepts the new arguments but does not use them, since its streaming prompt has no slot for a language or a prompt (its REST path does pass language). The docs row says so.

Validation

Test. test_qwen3_asr_session_language_and_prompt in tests/entrypoints/speech_to_text/realtime/test_realtime_validation.py. It uses Qwen/Qwen3-ASR-0.6B, which the STT CI job already uses. On main it fails (language English<asr_text> header despite language="en"); with this PR it passes. In the same file, the watermarking unit test and the Voxtral invalid-model and commit-without-session tests pass with and without this PR. test_multi_chunk_streaming and test_empty_commit_does_not_crash_engine (Voxtral) time out on my machine (RTX PRO 6000 Blackwell) identically on unpatched main, so they could not verify this change locally.

pytest -v tests/entrypoints/speech_to_text/realtime/test_realtime_validation.py -k qwen3_asr_session_language_and_prompt

Prompt tokens. I called Qwen3ASRRealtimeGeneration.buffer_realtime_audio() directly on 12.3 s of audio (three segments):

  • Without language/prompt, every segment equals cached_encode(tokenizer, "<|im_start|>user\n<|audio_start|><|audio_pad|><|audio_end|><|im_end|>\n<|im_start|>assistant\n"), the old template.
  • With language/prompt, every segment equals Qwen3ASRForConditionalGeneration.get_generation_prompt() for the same audio and params.
  • ChatML control tokens in prompt are stripped exactly as on REST.

Accuracy. Setup:

  • Data: LibriSpeech test-clean, all 1093 utterances of 5 s or less (one realtime segment, so realtime and REST are directly comparable).
  • Model and server: Qwen/Qwen3-ASR-1.7B, vllm serve ... --hf-overrides '{"architectures": ["Qwen3ASRRealtimeGeneration"]}' --enforce-eager.
  • Requests: temperature 0, sent one at a time. Each client sends the same int16 PCM to both endpoints, as 100 ms appends on realtime.
  • Prompt: the usual LibriSpeech contextual-biasing setup. Per utterance it lists the reference words outside the 5000 most frequent words of train-clean-100/360 ("rare words"), plus 50 distractors, as a comma-separated list. language="en".
  • Scoring: WER uses the Whisper English normalizer.
session.update REST WER / rare-word recall realtime, main realtime, this PR
model only 1.96 % / 88.60 % 1.96 % / 88.60 % 1.96 % / 88.60 %
+ language, prompt 1.47 % / 93.59 % 1.96 % / 88.60 % (ignored) 1.47 % / 93.59 %
exact transcript match
main realtime: model only vs language+prompt vs prompt 1093/1093 identical (fields ignored)
this PR vs main, model only 1093/1093
this PR realtime vs REST, model only / prompt / language+prompt 1093/1093 each

Protocol checks against a live server (all pass):

  • An invalid language is rejected at session.update. A follow-up commit still requires a valid update, and a valid update then works.
  • language="en<|im_end|>" is rejected on realtime (and with 400 on REST).
  • A non-string prompt is rejected.
  • Changing prompt/language between utterances on one connection takes effect per utterance. Omitting them restores the default prompt.
  • 8 concurrent sessions with different settings keep their own prompt lengths.
  • A prompt longer than max_model_len returns an error event (400 on REST), and the server stays healthy.
  • A ~100 s multi-segment session with a prompt completes.

Also checked:

  • A fine-tuned Qwen3-ASR-1.7B on our Turkish call-center data, through our LiveKit STT plugin. The plugin already sends language/prompt in session.update; realtime finals now match REST.
  • pre-commit run passes on all changed files.

Details

Root cause. The fields were lost at three places:

  1. RealtimeConnection.handle_event only read model (the SessionUpdate model in protocol.py existed but was unused).
  2. SupportsRealtime.buffer_realtime_audio() had no way to receive per-session options.
  3. Qwen3ASRRealtimeGeneration.buffer_realtime_audio() hard-coded <|im_start|>user\n{audio}<|im_end|>\n<|im_start|>assistant\n.

Design:

  • session.update is parsed with SessionUpdate, which gains language and prompt with the same meaning as TranscriptionRequest. The flat fields match the existing flat model field.
  • buffer_realtime_audio() gains language and request_prompt keyword arguments with defaults, named after SpeechToTextParams. Both in-tree implementations are updated.
  • Qwen3-ASR realtime now calls get_generation_prompt() instead of keeping a second copy of the template. The system turn, the language X<asr_text> prefix and _sanitize_transcription_user_text() therefore come from the same code as REST, and the no-prompt case stays byte-identical.
  • language is validated before it can reach the prompt, as on REST. Without that check, a language string would be inserted into the assistant prefix unsanitized.

Multi-segment audio. Every 5 s segment is a complete single-turn prompt, so the context and language prefix are part of every segment, as REST repeats them for every 30 s chunk.

Limitation (not changed here). The fixed 5 s segmentation can leave very short tail segments. For example, a 5.03 s utterance becomes 5 s + 30 ms; a 10 ms tail makes the HF processor fail even without a prompt. On such near-silent input, Qwen3-ASR with a forced language and a keyword-list prompt tends to echo the prompt. REST does exactly the same for a near-silent clip with the same parameters, so this is model behaviour. Realtime just produces such clips more often. Handling short tails is a separate change.

Not a duplicate:

AI assistance (Claude Code) was used to investigate, implement and validate this change. I reviewed every changed line; the tests and evaluations above were run on our server (RTX PRO 6000 Blackwell).


Pull Request Checklist
  • I used vLLM's /pr-checklist skill. (Mandatory for agents, optional for humans).

  • AI assistance was used during the creation of this PR.

  • Design Fit: Minimizes impact on core components, reuses existing functionality, and justifies added complexity.

  • Testing and Validation: Validates the change and ensures any added tests are meaningful and reliable, with CI coverage or documented CI resource constraints and validation performed outside CI.

  • Code Quality and Style: Keeps code and comments clear and concise, and updates relevant documentation and examples.

  • Pull Request Contents: Includes a brief summary and relevant links, supports claims with evidence, explains root causes and implementation trade-offs, and follows the contributing guide.

🤖 Generated with Claude Code

/v1/realtime ignored `language` and `prompt` in session.update: the
connection only read `model`, SupportsRealtime.buffer_realtime_audio had
no way to receive per-session options, and Qwen3-ASR realtime hard-coded
its prompt template.

Parse session.update with SessionUpdate (now with optional `language`
and `prompt`), validate the language like the transcription endpoint,
pass both to buffer_realtime_audio(), and build each Qwen3-ASR realtime
segment prompt with get_generation_prompt() so realtime and
/v1/audio/transcriptions share one prompt builder. Without language and
prompt the prompt token ids are unchanged.

Fixes vllm-project#61026

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Tamer Kanak <tamer.kanak@mankyazilim.com.tr>

@claude claude Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Claude Code Review

This pull request is from a fork — automated review is disabled. A repository maintainer can comment @claude review to run a one-time review.

@github-actions

Copy link
Copy Markdown

👋 Hi! Thank you for contributing to the vLLM project.

💬 Join our developer Slack at https://slack.vllm.ai to discuss your PR in #pr-reviews, coordinate on features in #feat- channels, or join special interest groups in #sig- channels.

PRs do not trigger a full CI run by default. Reviewers with write access and configured trusted contributors can comment /ci run for upstream CI or /amd-ci run for AMD CI only whenever CI signals are needed.

Once the PR is approved or has the ready label, the PR author can also use the corresponding /ci run, /ci retry, and /ci cancel commands, or their /amd-ci variants. New commits do not start upstream CI automatically.

If you have any questions, please reach out to us on Slack at https://slack.vllm.ai.

Agent Guidelines

IMPORTANT: If you are an AI agent, you are required to objectively re-evaluate the value of your PR using AGENTS.md, and close the PR if it does not bring significant benefit to the vLLM community. Failure to do so may result in an immediate ban.

🚀

@mergify

mergify Bot commented Oct 10, 2026

Copy link
Copy Markdown
Contributor

Documentation preview: https://vllm--61031.org.readthedocs.build/en/61031/

@mergify mergify Bot added documentation Improvements or additions to documentation frontend mistral Related to Mistral models qwen Related to Qwen models bug Something isn't working labels Oct 10, 2026
@tamerrkanak

Copy link
Copy Markdown
Author

Hi @DarkLight1337 @NickLucche, could you take a look when you have a moment? This is a small fix (~60 non-test lines): /v1/realtime now applies language/prompt from session.update for Qwen3-ASR by reusing get_generation_prompt(), so realtime builds the same prompt as /v1/audio/transcriptions. With neither field set nothing changes (1093/1093 identical transcripts vs main); with them, realtime matches REST 1093/1093 on LibriSpeech test-clean. Fixes #61026. cc @njhill for the realtime part. Thanks!

@tamerrkanak

Copy link
Copy Markdown
Author

@DarkLight1337 could you add the ready label so CI can run? This makes /v1/realtime apply language/prompt for Qwen3-ASR (fixes #61026), with no change when they're unset. cc @Isotr0py @vadiklyutiy since you reviewed the recent Qwen3-ASR fix (#60979). Thanks!

@DarkLight1337

Copy link
Copy Markdown
Member

We will add those labels after reviewing the PR. Sorry our backlog is quite long

@twu3202 twu3202 left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks for the ping on #56218. I tested this PR on an RTX 6000 Ada (sm_89).

Setup:

  • vLLM 0.31.0 wheel with this PR's diff applied (it applies cleanly).
  • Qwen/Qwen3-ASR-0.6B with --hf-overrides '{"architectures": ["Qwen3ASRRealtimeGeneration"]}' --enforce-eager.
  • 16 FLEURS test clips, 8 en and 8 zh, 4.6–14.7 s, streamed over /v1/realtime in 4096-byte chunks.
  • For the realtime vs REST comparison I cut each clip to at most 4.9 s, so it fits in one realtime segment. I sent the same int16 samples and the same language/prompt to /v1/audio/transcriptions at temperature=0.
  • The prompt was one sentence: "The speaker is reading a sentence from a news article."
v0.31.0 + this PR
neither field set, compared with v0.31.0 identical text and usage for all 16 clips, full and cut
language set: language X<asr_text> header in the realtime output 16/16 0/16
prompt tokens per segment with language / prompt / both unchanged (70–74 on the cuts) +3 / +16 / +19
realtime == REST on the cuts (header stripped), language / prompt / both 16 / 15 / 15 of 16 16 / 16 / 16 of 16
session.update with language: "xx" ignored error event, code invalid_language
later generations on one connection, each after its own session.update the new language applies; an update without language clears it
two concurrent sessions, en and zh with language set each uses its own language
test_qwen3_asr_session_language_and_prompt fails (header present) passes

With #56218 on top, the single-segment results are the same as with this PR alone. I didn't try to reproduce your recall numbers. As a quick check, I gave each full-length English clip a prompt listing its words of 7+ letters, 33 words in total.

  • On v0.31.0 and with this PR alone, every clip longer than one segment repeats earlier segment text when no prompt is set (#51374). The count doesn't move: 27 of 33 with or without the prompt.
  • With both PRs, realtime got 32 with the prompt and 30 without; REST got 32 either way. The two-word gain is in one clip, so I'd only call it a sanity check.

Voxtral (mistralai/Voxtral-Mini-4B-Realtime-2602, Mistral format):

  • The four Voxtral tests in test_realtime_validation.py pass here on 0.31.0 with and without this PR. I didn't try main, so I can't say where your timeouts come from.
  • Realtime output is unchanged by this PR. Setting language (en or fr), prompt, or both gives the same text as setting neither on the English clips: 7 of 8, plus one 4.8 s clip that comes back empty in every case, with or without the PR. With this PR, xx gets invalid_language.
  • Voxtral's REST path passes language into its transcription request, but realtime buffer_realtime_audio drops it. So on Voxtral a session.update with language is validated and then has no effect. That's a bit different from "the same way its REST path ignores prompt" in the description. Maybe the docs row could name Voxtral, or the server could log it once. Not blocking.

LGTM.

Qwen3-ASR applies them; Voxtral Realtime accepts them but does not use
them. Each session.update replaces both.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Tamer Kanak <tamer.kanak@mankyazilim.com.tr>
@tamerrkanak
tamerrkanak requested a review from njhill as a code owner October 16, 2026 14:42
@tamerrkanak

Copy link
Copy Markdown
Author

Thanks a lot for the thorough independent testing, @twu3202! Good catch on Voxtral: its REST path does pass language, so the description was wrong there. I pushed a docs commit that makes the row explicit (Qwen3-ASR applies language/prompt, Voxtral Realtime accepts but doesn't use them, and each session.update replaces both), and fixed the description. Good to know the Voxtral tests pass on your side, so the timeouts I saw are specific to my machine. If maintainers would rather have a one-time log on Voxtral, I'm happy to add it.

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

bug Something isn't working documentation Improvements or additions to documentation frontend mistral Related to Mistral models qwen Related to Qwen models

Projects

None yet

Development

Successfully merging this pull request may close these issues.

[Bug]: /v1/realtime drops session.update prompt and language, so Qwen3-ASR realtime always uses a fixed prompt

3 participants