The problem
We run voice agents over the phone in many languages — English, Mandarin, and Cantonese among them. Bad transcription on this kind of traffic is a known problem, not a new one.
Multilingual speech — callers mixing English into Mandarin mid-sentence, and above all Cantonese — plus the mess of real phone calls: pauses, interruptions, side conversations, background noise. That is where speech recognition is most likely to go wrong. For a while, ours ran on a hosted realtime API, the industry default. The transcripts of real Cantonese calls looked like this:
The same audio, decoded two ways
Switch the decoder — only what comes out changes.
Cantonese heard as English — the words are gone.
Transcribed as Thai — a script the agent can’t even read.
Sounds nearly the same, means “all around” — valid Cantonese, nonsense in context.
Hosted realtime API: all 3 transcripts wrong.
These are real production transcripts — single detached utterances from different calls, with anything identifying removed. They happen to come from one booking-line deployment; every domain we serve has its own versions. When we replayed the same audio through a model we control — telling it, turn by turn, what the call was about — all three came back correct. More on that in a minute.
In a cascading voice-AI flow, when your transcription is wrong, everything downstream is wrong. The LLM reasons about the wrong words, the agent acts on them, and the caller repeats themselves until they hang up. And with a hosted API there is nothing you can do about it.
Open weights changed that math. Qwen3-ASR is an Apache-2.0 speech recognition model with strong English, Mandarin, and Cantonese; the 1.7B-parameter version fits comfortably on a single mid-range GPU — we serve it on an NVIDIA L4. We already orchestrate many STT engines and pick the best one per deployment — we’re not trying to win the model race. But for this slice of traffic, none of the engines we route to fit. So we hosted one ourselves — and today, every call that used to go to the hosted API runs on it.
A production speech stack has four jobs. Be accurate — hear the caller right, in whichever language they’re speaking. Be fast — the transcript has to land before the pause turns awkward. Be concurrent — both of the above, for every simultaneous call, at a cost that doesn’t meter the minutes. And handle turn-taking — know when the caller is actually done speaking. This post is how we went after the first three; the fourth is where we’re headed, at the end.
We didn’t move production traffic on vibes. Before the first call switched over, the self-hosted model had to beat or tie the hosted API on a controlled benchmark: a public multilingual test set (FLEURS), 400 clips per language, every clip pushed through a simulated telephone channel to match real call audio — and scored so that a win has to survive statistical resampling before we’re allowed to call it a win. The result:
What the controlled benchmark actually said
| Language | Metric | Self-hosted | Hosted API | Verdict |
|---|---|---|---|---|
| English | WER | 4.79% | 6.85% | self-hosted better |
| Mandarin | CER | 6.32% | 6.48% | statistical tie |
| Cantonese | CER | 3.84% | 4.15% | parity, slight edge |
One detail we’re proud of: every time we found a flaw in our own benchmark harness, fixing it improved the hosted API’s numbers. One language flipped from “we win” to “tie.” We published the tie. A benchmark you want to win needs safeguards that stop you from accidentally winning.
Parity on benchmarks, better on our real calls. But parity was never the point. The point was the three things we could suddenly do.
01Continuous context — the conversation knows things the model doesn’t
Remember 四位 coming back as 四圍? The recognizer heard the sounds correctly and picked the wrong words. A human wouldn’t make that mistake, because a human knows the caller is booking a table. The model doesn’t know that. Nobody told it. And the domain is incidental — swap in a clinic, a courier desk, or a bank, and the shape is identical: the conversation always knows things the model doesn’t.
Hosted realtime APIs technically accept a “context” or vocabulary hint — but almost universally as a connection-time setting. Deepgram’s keyword boosting rides in as query parameters when the WebSocket opens; Google’s speech adaptation is part of the streaming config sent at stream start; AssemblyAI’s word boost is a connection parameter. Same with qwen3-asr-flash-realtime, the API we were running: relatively good on English- and Mandarin-dominant traffic, but it takes its context corpus once, before the audio starts — send a context update after that and the server closes the WebSocket on you. (All as of this writing — these APIs evolve.)
A phone call is one connection. The conversation moves — the caller switches topics, switches languages, brings up a name, states a time — and the context you set at second zero is frozen. To update it, you’d have to tear down the socket mid-call.
Nobody would accept that from a chat model. When you work with an LLM, you feed it fresh context every turn — the conversation so far, whatever matters right now — and that continuous context is half of why the answers are good. Modern speech recognition is the same kind of model underneath. It deserves the same treatment.
Self-hosting, we made context a per-turn input: each turn, the agent can hand the recognizer a fresh line of context — what the conversation is about right now: the expected vocabulary, the caller’s language so far, the entities on the table. Under the hood the context rides as a leading prefix of the decode, and because the server caches decoded prefixes, the repeated part is nearly free — each step pays mainly for the new audio.
Concretely: the agent runs the call off a state machine, so it always knows which question is on the table — and each turn it compiles a short context line from that state, in layers. The deployment’s proper nouns (the business name, nearby place names). The vocabulary of the answer it expects next — date words when it just asked for a date, digit names when it’s collecting a phone number (that one turned a mis-heard “To.” back into “two”). The facts the caller has already confirmed, but only the ones a caller would say out loud — a caller says “Thursday,” never “2026-08-06.” And whatever the agent itself just put on the table, like the alternative times it just offered. Everything else is deliberately left out. We measured our way to that discipline: common words buy nothing, marketing prose from the knowledge base is a mild negative, and a long list of candidate names makes the recognizer emit names nobody said — so name hints are capped at two.
Same recognizer every turn. New context line every turn.
Pick the question the agent just asked, then take its context away.
Party-size words in context; the recognizer stops picking the homophone.
Does it actually help, or does the model just parrot whatever you feed it? We ran it distractor-controlled: the same hard clips with the right context, with an unrelated context, and with none.
Only the right context moves the number
| Context | Error rate | Change |
|---|---|---|
| No context | 6.60% | — |
| Unrelated terms (distractor) | 6.73% | no effect |
| The right terms | 5.33% | ≈19% fewer errors |
The context has to be right to help; a wrong one buys you nothing.
Then we replayed real production calls. The party-size error fixed itself every time the right vocabulary was in context — and the unrelated context did not fix it. That’s the difference between “the model got lucky” and “the context caused the fix.”
02Tunable high concurrency, flat cost
A production recognizer doesn’t serve one call. Our first server did the safe thing: a global lock, one decode at a time. Correct — and it fell off a cliff. At one concurrent stream, the end-of-turn tail was ~95 ms. At eight, 2,123 ms. Each stream waited its turn on the GPU while a hosted API’s managed batching stayed flat. Serial is fair, and fairness is slow.
The inference engine assumes it has exactly one owner — its scheduler and its bookkeeping for in-flight work aren’t built to be called from a crowd of threads. So we stopped fighting that and leaned into it: one worker thread owns the engine, and everyone else’s work rides in its batches. Each incoming decode drops onto a shared queue; the worker collects whatever arrives within an ~8 ms window — up to a batch cap — and submits it as one batched call. The GPU decodes eight conversations’ audio chunks in one step instead of eight.
One thread owns the engine
Switch servers. Same calls, same queue, same GPU — the only difference is who gets to talk to it.
Micro-batched. Three ~1 second audio chunks from two mid-turn calls each become a decode request; a pool thread enqueues the request and parks on its future. The batch worker — the engine's sole owner — takes the first request, coalesces for about 8 milliseconds up to the batch cap, and submits one batched call. The GPU decodes them in a single step, every future resolves at once and each transcript goes back over its own call's socket. Illustrative tail: all about 100 milliseconds.
… N open calls: cheap, mostly idle — sessions are NOT the bottleneck
each ~1 s audio chunk = one decode request
3 of 4 threads in flight — the pool serves the calls concurrently, one thread each.
enqueue (request, future) … then block on the future
single consumer
- 1.take the first request
- 2.coalesce ~8 ms, up to the batch cap
- 3.ONE batched call
- 4.resolve each request’s future
decodes A+B in one step — 1 GPU step for 3 requests
each future resolves → its parked thread wakes
transcript goes back over each call’s own socket
three streams behind one server — the mechanism, not a benchmark
- A95 ms
- B190 ms
- C285 ms
- A≈100 ms
- B≈100 ms
- C≈100 ms
Why does such a simple design work? Because of an asymmetry in the workload. Continuous batching — requests joining and leaving mid-flight — earns its complexity when requests have wildly different lengths, like a chatbot writing one 50-token reply and one 4,000-token essay. ASR turns are short and similar-sized: a batch finishes together, so a fixed batch wastes almost nothing. We got nearly all of the win with a fraction of the machinery, and the streaming state — per-turn context included — never had to change.
So how does the finished streaming endpoint stack up against the hosted realtime API — qwen3-asr-flash-realtime, the hosted sibling of the same model family — measured from the same test client in the same run? First, latency: the finalization tail, end of speech → final transcript, with the audio paced in real time. Ours runs on a single NVIDIA L4.
The tail doesn’t grow with the turn
| Turn length | Qwen3-ASR-1.7B (self-hosted) | qwen3-asr-flash-realtime (hosted) |
|---|---|---|
| Turn length: 1 s | Qwen3-ASR-1.7B (self-hosted)2 ms | qwen3-asr-flash-realtime (hosted)84 ms |
| Turn length: 2 s | Qwen3-ASR-1.7B (self-hosted)83 ms | qwen3-asr-flash-realtime (hosted)158 ms |
| Turn length: 4 s | Qwen3-ASR-1.7B (self-hosted)161 ms | qwen3-asr-flash-realtime (hosted)285 ms |
| Turn length: 8 s | Qwen3-ASR-1.7B (self-hosted)116 ms | qwen3-asr-flash-realtime (hosted)199 ms |
| Turn length: 16 s | Qwen3-ASR-1.7B (self-hosted)24 ms | qwen3-asr-flash-realtime (hosted)128 ms |
1 s turn: 2 ms. 16 s turn: 24 ms. Never above 161 ms in between.
Low, and roughly flat with turn length — by the time the caller stops talking, the growing window has already decoded nearly everything, so the final flush is close to free.
Part of that gap is simple geography. The self-hosted recognizer sits next to the workload it serves — same cluster as the agents — so a turn never pays a network hop out to a vendor and back. Co-location isn’t cheating; it’s the point: a hosted API is always on the other side of the internet, and yours doesn’t have to be. The gap widens with geography — not every STT provider serves every region, and when the nearest endpoint is an ocean away, every turn pays an intercontinental round trip. We once benchmarked a sub-second API from the wrong continent and watched it look like a three-second one. A model you host deploys wherever your callers are.
Then concurrency — per-stream tail as simultaneous streams pile onto that one GPU:
One cliff, two flat columns
| Concurrent streams | Qwen3-ASR-1.7B — global lock (before) | Qwen3-ASR-1.7B — micro-batched (after) | qwen3-asr-flash-realtime (hosted) |
|---|---|---|---|
| Concurrent streams: 1 | Qwen3-ASR-1.7B — global lock (before)95 ms | Qwen3-ASR-1.7B — micro-batched (after)162 ms | qwen3-asr-flash-realtime (hosted)471 ms |
| Concurrent streams: 4 | Qwen3-ASR-1.7B — global lock (before)502 ms | Qwen3-ASR-1.7B — micro-batched (after)304 ms | qwen3-asr-flash-realtime (hosted)380 ms |
| Concurrent streams: 8 | Qwen3-ASR-1.7B — global lock (before)2,123 ms | Qwen3-ASR-1.7B — micro-batched (after)373 ms | qwen3-asr-flash-realtime (hosted)380 ms |
| Concurrent streams: 16 | Qwen3-ASR-1.7B — global lock (before)not measured | Qwen3-ASR-1.7B — micro-batched (after)404 ms | qwen3-asr-flash-realtime (hosted)335 ms |
| Concurrent streams: 32 | Qwen3-ASR-1.7B — global lock (before)not measured | Qwen3-ASR-1.7B — micro-batched (after)433 ms · 32/32 streams | qwen3-asr-flash-realtime (hosted)357 ms · 29/32 streams |
The before column is the cliff; the after column is flat. One L4 behind a queue, a window, and a worker thread holds its own against managed continuous batching, stream for stream. We then pushed one pod far past reasonable: 512 simultaneous streams. The pod didn’t fall over at any load we tested; it just gets slower. And how much slower is a tuning knob, not a ceiling.
One pod, 512 simultaneous streams
Turn the knob to see how much of the slowdown was tuning.
512 of 512 succeeded, no out-of-memory, GPU memory flat — the engine bounds memory by batch size, not by how many calls are connected.
Retuning — a bigger batch, more workers — brings the 512-stream tail to 2,031 ms.
Capacity planning becomes a latency budget, not a fear of falling over. Beyond one GPU’s budget, you add pods.
The economics land the same way. An NVIDIA L4 runs roughly $500–700 a month on-demand from a major cloud — less with commitments — and one of them just held 512 simultaneous streams. A hosted API meters every minute of every call; a GPU you own costs the same whether it’s carrying ten calls or two hundred. At voice-agent volumes, that line crosses fast.
03When the decoder drifts, you can just say no
Remember the Cantonese turn that came back in Thai? That drift came from the hosted API — qwen3-asr-flash-realtime — not from our stack: when we replayed the same audio through the self-hosted model, it produced correct Cantonese. Still, the failure mode is structural to multilingual ASR: the model identifies the language and transcribes jointly, so if the language decision ever goes wrong, the entire turn is garbage in a script your agent can’t even read. Two defenses already stand in the way — the model itself, and the per-turn context from section 1: a recognizer that knows what the conversation is about has little reason to wander. This is the third: make the wrong language not merely unlikely, but unpickable.
With a hosted API you watch this happen. With your own decoder, you can reach into the exact moment the mistake is made.
It turns out the model announces its language decision. The output stream literally begins with it — and the language is one or two tokens, decided exactly once, so the guardrail can be surgical.
One decision, masked once
Take a language off the request line and the next allowed name wins. Empty it and nothing stops the drift.
Caller said 七點 — “seven o’clock” in Cantonese.
Tag: Cantonese — the highest-scoring name still on the list. Past ⟨asr_text⟩ the mask is off and the transcript is untouched.
Allowed names keep the score the model gave them; everything else drops to −∞.
- Thaith−∞
- Cantoneseyue0.31
- Mandarinzh0.18
- Vietnamesevi−∞
- Englishen0.05
- Cape Verdeankea−∞
At those positions, and only those, we mask the logits: the scores the model assigns every candidate next token. The mechanics matter. The mask doesn’t force a language; it narrows the menu. Allowed names keep their original scores and everything else drops to negative infinity, so the model still picks the language it genuinely thinks it heard — from choices that are all sane. Some names are one token and Cantonese is two, so the mask walks each allowed name token by token; the moment the boundary token appears, it switches off entirely and the transcript decodes untouched.
And the allowed list is not baked into the server — it’s a new parameter on the transcription request itself. The client code running the conversation sends it the same way it sends the per-turn context line: allowed_languages=yue,zh,en for this deployment. The server compiles those into token masks for that one request; requests without the parameter decode completely unconstrained. To a caller of the API, the whole guardrail is one more field. That field mostly has no equivalent elsewhere: common STT APIs offer a binary choice — pin one language, or auto-detect across everything the model knows. Where candidate-language lists exist at all, they’re connection-time settings again — not a per-turn, decode-level guarantee.
Getting this right taught us a lesson about constrained decoding. Our first attempt was a deny-list: push down the score of every unwanted language. It leaked. Suppress everything except Thai as a stress test, and the model escaped to “Cape Verdean” — a language name that wasn’t on our list at all. A deny-list can’t enumerate what it hasn’t imagined. The allow-list version — mask everything except the permitted names — is airtight by construction: in the same stress test it yields exactly what it’s told.
Two properties made this work. First, the language tag and the transcript are genuinely separable: force the language ID to English on a Cantonese clip and the transcript comes back character-for-character unchanged — you steer the decision without touching the words. Second, it’s free: measured with constrained and unconstrained requests interleaved in the same GPU batches, the overhead is within noise — a few percent either way, tails essentially identical.
We built this belt-and-suspenders: a recovery pass that catches a drifted turn after the fact and re-decodes it with the language forced (~0.3 s, on a path that rarely fires), and the logit mask that stops the drift before it happens — built, validated, and measured on the same serving stack. Neither is possible when the decoder is on someone else’s server.
On the benchWhat’s next
Owning the loop doesn’t stop at serving. Two things are next on the bench.
In closingThe bottom line
It isn’t on someone else’s server anymore. We have since migrated all of the traffic that used to run on qwen3-asr-flash-realtime onto the ASR we host ourselves.
None of these three ideas is exotic. Per-turn context is prompt engineering for a recognizer. The batching engine is a queue, a window, and a worker thread. The guardrail is a mask over a handful of logits. What they have in common is where they live: inside the decode loop. And between them they cover the first three jobs — context and the guardrail buy accuracy; the batching engine buys speed and concurrency. The fourth is on the bench.
A hosted speech API gives you audio in, text out. Owning inference gives you a programmable transcriber.
- You own the promptso the recognizer can know what the conversation knows.
- You own the serving loopso concurrency is a knob you turn, not a quota you rent.
- You own the logitsso the decoder can’t wander off into Thai.
Speech recognition stops being a vendor’s black box and becomes part of your system.
At the end of July 2026 the Qwen team released qwen-audio-3.0-asr-flash-streaming, a hosted streaming recognizer that adds context input and hotwords of its own. We haven’t benchmarked it, and everything above describes the API we were running at the time — but the gap in section 1 is exactly the kind that closes, and this one is closing. It doesn’t change the argument. A capability on someone else’s server arrives when they ship it; the same capability inside your own decode loop arrives when you build it, and it composes with everything else you own in there.
And none of it exists without the open-source work underneath. The Qwen team released a genuinely excellent multilingual ASR model — with real Cantonese support — under Apache 2.0, along with the tooling to serve it; vLLM provides the inference engine it runs on. We spent a good part of this post benchmarking against a hosted API from the same family — that’s a compliment to the model, not a knock. Open weights opened the door. Everything in this post is what we built on the other side.