A voice agent is a latency machine wearing an LLM costume — you win by overlapping STT, generation, and TTS inside an 800ms budget while still answering the phone when the user interrupts.
A real-time voice assistant is not "speech-to-text, then ChatGPT, then text-to-speech." It is a pipeline of streaming stages racing a clock: the moment the user stops talking, you have roughly 800 milliseconds before the silence feels broken, and humans start to perceive lag at 300–500ms. The entire art is overlap — your speech-to-text (STT) emits partial transcripts before the user finishes, your LLM streams tokens before the sentence is done, and your text-to-speech (TTS) starts synthesizing audio off the first clause instead of waiting for the full response. Layered on top of that race are three things that make voice uniquely hard: barge-in (the user can interrupt mid-response and you must instantly stop talking), turn detection (telling "pause while thinking" apart from "I'm done"), and tool calls (where a 600ms backend round-trip blows your whole budget unless you hide it). Get the latency choreography right and the rest is plumbing; get it wrong and no model quality saves you.
The words first.
Step by step.
Remember this: the whole game is overlapping stages so the user hears a reply before any single stage has finished.
Before any boxes-and-arrows, write the budget. A realistic 2026 TTFA target is ~800ms, and humans perceive anything above 300–500ms of silence after their turn as unnatural. Here's where it goes:
| Stage | Budget | What's happening |
|---|---|---|
| VAD + audio capture | 50ms | Detect end-of-speech, buffer the last frames |
| STT transcription | 150ms | Finalize the transcript past the speech tail |
| LLM time-to-first-token | 400ms | The dominant term — model + prompt + network |
| TTS first chunk | 150ms | Synthesize the first clause to audio |
| Network overhead | 50ms | Round trips between services |
The LLM's time-to-first-token (TTFT) is the elephant — it's half the budget. This single fact drives most of your design decisions: it's why you stream tokens (so TTS can start before generation finishes), why you reach for a smaller/faster model, and why prompt caching matters (a cached system prompt cuts prefill latency 80–90% on the reused portion). Notice TTS gets only 150ms for the first chunk — you are explicitly not waiting for the full audio, you're synthesizing the first sentence and streaming the rest behind it.
Symbols in plain words: TTFA = time from user-stops to first-audio-out. Each stage has a full duration (how long it takes end-to-end) and a first-output time (when it produces its first usable piece).
Say a 3-second user utterance. Stage full durations: STT 600ms, LLM 1200ms total generation, TTS 900ms total audio.
Cascading (wait for each stage to finish):
TTFA = STT_full + LLM_full + TTS_first_chunk = 600 + 1200 + 150 = 1950ms. The user hears nothing for ~2 seconds. Feels broken.
Streaming (overlap):
150 + 400 + 150 + 100 = 800ms.What it did to the data: same models, same total work — but by overlapping the stages and only waiting for first outputs instead of full outputs, TTFA dropped from 1950ms to 800ms, a ~1150ms win that is the difference between "robotic" and "natural."
A cascading design — STT finishes → hand full transcript to LLM → LLM finishes → hand full text to TTS — is the trap. It's easy to build and it's 300–600ms slower than it needs to be. The streaming version has every stage feeding the next as it produces output:
The cost of streaming is complexity: now you have three concurrent streams whose lifecycles you must manage, and you have to handle the fact that partials are unstable.
Partial transcripts change as more audio arrives — "I want to..." might become "I want two tickets." If you fire the LLM on every partial, you generate against a half-formed query, waste tokens, and produce wrong answers. Two production fixes:
The deeper problem is turn detection: distinguishing "I paused to think" from "I'm finished." Tune this wrong toward eager and you talk over the user ("...the address is — let me think — " and you've already barged in with an answer). Tune it toward patient and you feel sluggish. Modern systems use semantic end-of-turn models (is this utterance grammatically/semantically complete?) layered on top of acoustic silence detection, because raw silence alone can't tell a thinking pause from a finished thought.
Barge-in is the single design decision that shapes your entire audio layer. The requirement: while the assistant is speaking, the user can interrupt, and the assistant must stop immediately — not finish its sentence. Concretely, when VAD detects user speech during playback you must:
This requires tight coupling between the audio playback layer and VAD. It's also why full-duplex (simultaneously playing and listening) is non-negotiable for a good experience — a half-duplex walkie-talkie design physically cannot barge-in. The subtle failure mode is echo: your own TTS output leaks into the mic and VAD thinks the user is talking, causing the assistant to interrupt itself. You need acoustic echo cancellation (AEC) so the system doesn't hear its own voice as a barge-in.
A 10-minute call is dozens of turns. You can't replay the full transcript into the LLM every turn — latency and cost grow with context length. Three strategies, often combined:
The pragmatic production pattern is sliding window + rolling summary: last ~6 turns verbatim, everything older folded into a running summary updated off the critical path. Keeping context tight also directly helps TTFT — a shorter prompt prefills faster. See /context-engineering for the compression tradeoffs in depth.
This is where voice agents get genuinely hard. The LLM decides it needs to call a tool — "look up order #4412" — and that tool takes 200–600ms. That round-trip lands inside your 800ms budget and blows it. Three patterns, none free:
Tool use also forces a sizing decision: a smaller LLM with good prompts often beats a large one on TTFA, and for tool-routing specifically you can use a cheap model to decide which tool, then a stronger one (or none) to phrase the answer. See /agents for tool-call orchestration patterns.
Every quality dial trades against latency or cost:
This is a shape-accurate (not toy) orchestrator for the streaming loop: it shows the overlap, the barge-in coupling, the partial-transcript gating, and clause-level TTS handoff. The transport/provider clients are abstracted, but the control flow is the part that matters in an interview and in production.
import asyncio
import re
# --- Abstracted streaming clients (provider-agnostic) ---
# stt.stream() -> async iterator of (text, is_final, confidence)
# llm.stream(messages) -> async iterator of token strings
# tts.synthesize(text) -> async iterator of audio chunks (bytes)
# player.play(chunk) / player.flush() control the speaker
# vad.speech_detected() -> bool, on raw mic frames
CLAUSE_BOUNDARY = re.compile(r"[.!?,;]\s")
class VoiceSession:
def __init__(self, stt, llm, tts, player, vad):
self.stt, self.llm, self.tts = stt, llm, tts
self.player, self.vad = player, vad
self.history = [] # rolling context (window + summary)
self.assistant_speaking = False
self.cancel = asyncio.Event() # barge-in signal
async def run(self):
# Two concurrent loops: one transcribes + responds,
# one watches for barge-in while we speak.
await asyncio.gather(self._dialog_loop(), self._barge_in_watch())
async def _dialog_loop(self):
async for text, is_final, conf in self.stt.stream():
# Gate on turn-end: only respond on a finalized, confident turn.
# This is what prevents firing on unstable partials.
if not is_final or conf < 0.6:
continue
self.history.append({"role": "user", "content": text})
await self._respond(self._build_context())
async def _respond(self, messages):
self.assistant_speaking = True
self.cancel.clear()
buffer, spoken = "", ""
try:
# Stream LLM tokens; hand each completed clause to TTS immediately.
async for token in self.llm.stream(messages):
if self.cancel.is_set():
return # barged-in: abandon generation
buffer += token
if CLAUSE_BOUNDARY.search(buffer):
clause, buffer = self._split_clause(buffer)
spoken += clause
await self._speak(clause) # overlaps with next-token gen
if buffer.strip() and not self.cancel.is_set():
spoken += buffer
await self._speak(buffer)
finally:
self.assistant_speaking = False
# Record only what we actually said (barge-in may have cut us off).
self.history.append({"role": "assistant", "content": spoken})
async def _speak(self, text):
async for audio_chunk in self.tts.synthesize(text):
if self.cancel.is_set():
self.player.flush() # drop queued audio instantly
return
self.player.play(audio_chunk)
async def _barge_in_watch(self):
# Tight coupling: VAD + playback. If the user talks while we speak,
# cancel TTS and flush buffers so we stop within ~one frame.
while True:
await asyncio.sleep(0.02) # 20ms audio frame cadence
if self.assistant_speaking and self.vad.speech_detected():
self.cancel.set()
self.player.flush()
def _build_context(self):
# Sliding window of recent turns; older turns would be folded
# into a rolling summary off the critical path (omitted here).
window = self.history[-6:]
return [{"role": "system", "content": "You are a concise voice assistant."}] + window
def _split_clause(self, buf):
m = CLAUSE_BOUNDARY.search(buf)
idx = m.end()
return buf[:idx], buf[idx:]The load-bearing details: _respond hands each clause to TTS the instant it's complete instead of waiting for the full response (3.2); the cancel event is checked in both the LLM loop and the TTS loop so barge-in stops us within one audio frame (3.4); _barge_in_watch is the tight VAD-playback coupling running concurrently; and we record only what was actually spoken so the context reflects reality after an interruption (3.5). The turn-end gate (is_final and conf >= 0.6) is the transcript-instability fix from 3.3.
| Decision | Cheaper / Faster | Slower / Pricier | What changes at scale |
|---|---|---|---|
| LLM size | Small model, ~400ms TTFT, lower $/turn | Large model, smarter, +300–800ms TTFT | At 1000s of concurrent calls, small-model TTFT win compounds; route trivial turns to a tiny model |
| STT model | Smaller, faster, noisier transcript | Larger, accurate, +100ms | LLM tolerates STT noise; accuracy matters most for names/numbers/tool args |
| TTS | Cached/pre-gen for fixed phrases, instant | Neural per-turn, fresh, +150ms first chunk | Cache greetings + fillers fleet-wide; reserve neural for dynamic content |
| Context | Sliding window, cheap, lossy | Summary + retrieval, faithful, +latency | Long calls need summary; unbounded context kills TTFT and $ |
| Tool calls | Cache + parallel + filler speech | Serial, blocking, visible lag | Pre-fetch likely context; cache results across calls |
| Turn detection | Eager (silence-only) | Patient (semantic end-of-turn) | Eager talks over users; tune per use case |
Cost reality. LLM API per-token prices dropped ~80% from 2025→2026, but a voice agent makes many calls per conversation (every turn, plus tool-routing, plus summarization), so the per-call cheapness turns into meaningful per-conversation cost at fleet scale. The dominant cost levers are: route simple turns to cheap models (classification/confirmation turns to a Haiku-class model can be ~12x cheaper than a Sonnet-class model with minimal quality loss), cache the system prompt (80–90% latency cut on the cached prefix, which also helps TTFT), and cache fixed TTS phrases.
Latency reality. Your p90 TTFA is what users feel, not your median. The tail is dominated by LLM TTFT variance and tool-call timeouts. Set hard timeouts on every external call and have a degradation path: if the tool times out, say "I'm having trouble pulling that up — can I take a message?" rather than dead air.
Failure modes that only appear live. (1) Echo/self-barge-in — TTS leaks into the mic, VAD self-interrupts; needs acoustic echo cancellation. (2) Transcript flicker firing the LLM on unstable partials — fixed by the confidence/silence gate. (3) Double-talk — both parties speak; you must decide who yields. (4) Context drift over long calls — summary errors compound. (5) Cascade collapse — one slow stage (a hiccupping STT provider) stalls the whole pipeline; isolate with timeouts and fallbacks. (6) Voice inconsistency — switching TTS voices mid-session breaks the user's mental model; pin one voice per session.
Observability. Per the LLMOps discipline, monitor (predefined metrics) and observe (full traces). Trace each turn end-to-end — VAD → STT → turn-detect → LLM → TTS → playback — with component-level latency so you can attribute a slow turn to a stage. Track p50/p90/p95 TTFA, barge-in rate, transcript revision count, tool-call latency, and per-turn cost. Sample ~10–20% of calls for detailed traces; log basic metrics (tokens, cost, latency) for all. See /inference for the serving-side levers (continuous batching, speculative decoding) that move TTFT.
Flashcard. Voice = overlap stages to hit ~800ms TTFA (LLM TTFT is half the budget); barge-in means cancel TTS + flush buffers within one audio frame; gate the LLM on turn-end, not partials; hide tool latency with filler + pre-fetch + timeout; carry context with window + rolling summary.
Next: /inference — the serving-side optimizations (continuous batching, speculative decoding, PagedAttention) that directly move the LLM TTFT term in your voice budget.