Voice & realtime agents
Agents you talk to. Learn the voice loop — mic, speech-to-text, agent, text-to-speech, speaker — plus streaming transport, turn detection, barge-in, and the sub-second latency budget that makes it feel like a real conversation.
Learning objectives
- Say what a voice/realtime agent is and how it differs from a chat agent.
- Distinguish the STT→LLM→TTS pipeline from native speech-to-speech.
- Explain streaming/websocket transport, turn detection, and barge-in.
- Budget end-to-end latency and know why ~800 ms round-trip matters.
- Design a production voice-agent architecture: VAD, echo, fallback, cost.
1 · What a voice agent is essential
A voice agent is an agent you talk to out loud and that talks back — a phone support line, a hands-free assistant, a drive-thru order taker. A realtime agent is one where the interaction is live and continuous: the user speaks, the agent responds within a fraction of a second, and either side can interrupt the other. That live, low-latency, interruptible quality is what makes it feel like a conversation instead of a walkie-talkie.
Under the hood a voice turn is a loop. Sound goes in through a microphone, becomes text or tokens, an agent decides what to say, that reply becomes sound, and it comes out a speaker. The whole loop has to close fast enough that the human doesn't feel a gap.
This is the whole voice agent in one picture. Sound travels left to right, then loops back. Read each box as one job that hands its result to the next.
- Mic → STT — the microphone captures your voice, and STT (speech-to-text) turns those sound waves into words the agent can read.
- Agent (LLM) — the brain. It reads the words, decides what to say (and may call tools), and produces a text reply.
- TTS → Speaker — TTS (text-to-speech) turns the reply back into sound, and the speaker plays it to you.
- Barge-in is the hidden arrow from Speaker back to Mic: while the agent is talking, the mic keeps listening, so if you jump in it stops and listens to you.
In short: a good voice agent runs this whole loop in well under a second, and lets you interrupt it — that's what makes it feel like talking to a person, not a menu.
The dashed idea not drawn as a box: barge-in. While the speaker is playing the agent's reply, the mic is still listening. If the human starts talking, the agent must stop its own playback and listen — the loop jumps from Speaker back to Mic. Without that, talking over the agent does nothing and it feels robotic.
2 · Two architectures: pipeline vs speech-to-speech essential
There are two ways to build the loop. The classic pipeline chains three separate models: STT (speech-to-text) transcribes audio to words, an LLM reasons over the text, and TTS (text-to-speech) speaks the answer. Each stage is swappable and debuggable, but each adds latency, and tone/emotion is lost when audio becomes plain text.
Speech-to-speech (a native realtime/voice model) takes audio in and emits audio out directly — no text bottleneck in the middle. It's lower latency and keeps prosody (laughter, hesitation, emphasis), but it's harder to inspect, log, and guardrail because you don't automatically get a transcript.
| Pipeline (STT→LLM→TTS) | Speech-to-speech | |
|---|---|---|
| Latency | sum of 3 stages (higher) | single model (lower) |
| Debuggability | text at every hop | audio in/out, opaque |
| Tone / emotion | lost at the text hop | preserved |
| Swap a component | easy (mix vendors) | all-or-nothing |
| Guardrails / logging | on the transcript | needs separate transcription |
3 · Streaming transport: why not request/response intermediate
A normal API call is request → wait → full response. That's fatal for voice: you'd record the whole sentence, upload it, wait for the whole reply, then play it — seconds of dead air. Realtime voice uses a persistent bidirectional connection (a WebSocket or WebRTC) so audio flows continuously in both directions. You send mic audio in small chunks (e.g. every 20 ms) as you capture it, and you start playing the reply as the first audio frames arrive — before the agent has finished its whole sentence. That overlap is what hides latency.
The runnable model below streams a 'reply' out in chunks and shows that the user hears the first audio long before the last chunk is produced — the core reason streaming feels instant. It is pure stdlib and prints a timeline.
streaming.pydef stream_reply(chunks, per_chunk_ms):
"""Yield (elapsed_ms, chunk) as if streaming audio out frame by frame."""
elapsed = 0
for chunk in chunks:
elapsed += per_chunk_ms
yield elapsed, chunk
reply = ["Sure,", " I can", " help", " with", " that."]
first_audio_at = None
last_audio_at = 0
for elapsed, chunk in stream_reply(reply, per_chunk_ms=120):
if first_audio_at is None:
first_audio_at = elapsed
last_audio_at = elapsed
# In a NON-streaming design the user hears nothing until the whole reply is done.
print("time to FIRST audio (streaming): ", first_audio_at, "ms")
print("time to LAST audio (whole reply):", last_audio_at, "ms")
print("non-streaming would wait: ", last_audio_at, "ms before ANY sound")
time to FIRST audio (streaming): 120 ms
time to LAST audio (whole reply): 600 ms
non-streaming would wait: 600 ms before ANY sound
This shows why voice agents stream their reply out piece by piece instead of waiting for the whole answer. The key number is when you hear the first sound.
stream_replyhands back one small chunk at a time, addingper_chunk_ms(120 ms) of time for each — like audio arriving frame by frame.- The loop records
first_audio_at(the moment the very first chunk lands) and keeps updatinglast_audio_atuntil the reply is finished. - Because the reply has 5 chunks of 120 ms, the first sound arrives at 120 ms but the whole thing finishes at 600 ms.
- The last print makes the point: a non-streaming design would play nothing at all until 600 ms — five times longer before you hear anything.
What the output means: You hear the first word at 120 ms while streaming, versus waiting 600 ms for any sound at all without streaming. That gap is why real voice systems stream STT, the LLM tokens, and TTS all at once.
Try this: Add more chunks to reply. Notice first_audio_at stays 120 ms no matter how long the reply gets — streaming hides the length.
4 · Turn detection: whose turn is it? intermediate
In text chat, pressing Enter marks end-of-turn. In voice there is no Enter — the agent must infer when the human has finished speaking so it can respond, and not cut them off mid-sentence. This is turn detection (a.k.a. endpointing), usually built on VAD (voice activity detection): detect speech vs silence, and when silence lasts longer than a threshold, treat the turn as over.
Model this as a tiny state machine. States: LISTENING (user talking), MAYBE_DONE (a silence gap started), and AGENT_TURN (silence held long enough — respond). This runs offline over a fake frame stream.
turns.pySILENCE_HANGOVER_MS = 500 # how long silence must hold before the turn ends
def detect_turns(frames, frame_ms=100):
"""frames: list of 'speech'/'silence'. Emit an event when a turn ends."""
state = "LISTENING"
silence_ms = 0
events = []
for i, f in enumerate(frames):
t = i * frame_ms
if f == "speech":
silence_ms = 0
state = "LISTENING"
else: # silence
silence_ms += frame_ms
if state == "LISTENING":
state = "MAYBE_DONE"
if state == "MAYBE_DONE" and silence_ms >= SILENCE_HANGOVER_MS:
state = "AGENT_TURN"
events.append(("turn_end", t))
state = "LISTENING" # reset for the next turn
silence_ms = 0
return events
# user says something, pauses briefly (not enough), then a real long pause
stream = (["speech"] * 6 + ["silence"] * 3 # 300 ms pause: too short
+ ["speech"] * 4 + ["silence"] * 6) # 600 ms pause: turn ends
print(detect_turns(stream))
[('turn_end', 1700)]
In voice there is no Enter key, so the agent must guess when you've finished talking. This little state machine watches for a long-enough silence and calls that the end of your turn.
SILENCE_HANGOVER_MS = 500is the rule: silence must last at least half a second before we decide the turn is over. Short pauses (thinking) don't count.- It walks a list of 100 ms
framesthat are eitherspeechorsilence. Any speech resets the silence counter back to zero. - When silence starts, the state moves to
MAYBE_DONE; once the silence has piled up to 500 ms it flips toAGENT_TURNand records aturn_endevent. - The test stream has a short 300 ms pause (ignored) and then a real 600 ms pause, so only one turn-end fires.
What the output means: [('turn_end', 1700)] — one turn ended, at 1700 ms. The early 300 ms pause was correctly ignored because it was shorter than the 500 ms threshold.
Try this: Lower SILENCE_HANGOVER_MS to 200 and re-run — now the short pause also ends a turn, so you get two events. That's the snappy-vs-polite tradeoff.
5 · Barge-in: letting the human interrupt advanced
Barge-in is the ability to interrupt the agent while it is speaking — the single biggest thing that separates a natural voice agent from an IVR phone tree. When the user starts talking over the agent's playback, you must (1) immediately stop TTS playback, (2) discard the rest of the queued reply, and (3) switch to listening. The hard part is echo: the mic hears the agent's own voice from the speaker, so you need echo cancellation or you'll 'barge in' on yourself.
bargein.pyclass VoiceSession:
def __init__(self):
self.state = "IDLE"
self.play_queue = []
self.log = []
def agent_speaks(self, chunks):
self.state = "SPEAKING"
self.play_queue = list(chunks)
self.log.append(f"agent speaking, {len(self.play_queue)} chunks queued")
def on_user_speech(self, is_agents_own_echo):
# Echo cancellation: ignore the agent's own voice coming back in.
if is_agents_own_echo:
self.log.append("ignored echo of own voice")
return
if self.state == "SPEAKING":
dropped = len(self.play_queue)
self.play_queue.clear() # stop + flush playback
self.state = "LISTENING"
self.log.append(f"BARGE-IN: stopped playback, dropped {dropped} chunks")
s = VoiceSession()
s.agent_speaks(["chunk1", "chunk2", "chunk3", "chunk4"])
s.on_user_speech(is_agents_own_echo=True) # speaker bleed -> ignored
s.on_user_speech(is_agents_own_echo=False) # real interruption -> barge-in
print("final state:", s.state)
for line in s.log:
print("-", line)
final state: LISTENING
- agent speaking, 4 chunks queued
- ignored echo of own voice
- BARGE-IN: stopped playback, dropped 4 chunks
Barge-in means letting the human interrupt the agent mid-sentence. This class models a call session that stops talking the moment the user really speaks — but ignores its own echo.
agent_speaksqueues up the reply chunks and sets the state toSPEAKING— the agent is now playing audio.on_user_speechis called whenever the mic hears something. If it's the agent's own voice bleeding back from the speaker (is_agents_own_echo), it's ignored — otherwise the agent would interrupt itself.- If it's real user speech while the agent is
SPEAKING, that's a barge-in: it clears the play queue (stops playback), switches toLISTENING, and logs it. - The demo fires an echo first (ignored) then a real interruption (barge-in), so only the second one stops the agent.
What the output means: The final state is LISTENING and the log shows the echo was ignored but the real interruption dropped all 4 queued chunks — exactly what should happen when someone talks over the agent.
Try this: Call s.on_user_speech(is_agents_own_echo=False) a second time. Nothing extra happens — the agent is already listening, so there's no playback left to stop.
6 · The latency budget: why ~800 ms matters advanced
Humans notice conversational lag. In natural conversation, gaps between turns are roughly 200 ms; once the agent's response gap climbs past about 800 ms it starts to feel sluggish and people begin to talk over it. So end-to-end latency — from the user finishing their sentence to hearing the first word back — is a hard budget you must divide across every stage: capture + network up, STT finalization, LLM time-to-first-token, TTS time-to-first-audio, network down + playback.
The tool below adds up a per-stage budget and tells you whether you fit under a target, and how much headroom is left. Change the numbers to see where the time goes.
latency.pydef latency_budget(stages, target_ms=800):
"""stages: dict of stage -> milliseconds. Report total vs target."""
total = sum(stages.values())
headroom = target_ms - total
worst = max(stages, key=stages.get)
return {
"total_ms": total,
"target_ms": target_ms,
"within_budget": total <= target_ms,
"headroom_ms": headroom,
"biggest_stage": worst,
}
stages = {
"capture+upload": 60,
"stt_finalize": 150,
"llm_first_token": 350,
"tts_first_audio": 120,
"network_down+play": 80,
}
result = latency_budget(stages, target_ms=800)
for k, v in result.items():
print(f"{k}: {v}")
total_ms: 760
target_ms: 800
within_budget: True
headroom_ms: 40
biggest_stage: llm_first_token
Voice feels laggy once the reply gap passes about 800 ms, so every stage of the loop gets a slice of a fixed time budget. This adds up the slices and tells you if you fit.
stagesis a dictionary of each step (capture, STT, the LLM's first token, TTS, network+play) and how many milliseconds it takes.latency_budgetsums them intototal_ms, compares against thetarget_ms(800), and computes the leftoverheadroom_ms.max(stages, key=stages.get)finds the single biggest stage — the one to attack first if you're over budget.- Here the total is 760 ms, so
within_budgetisTruewith 40 ms to spare, and the biggest offender is the LLM's first token.
What the output means: You're under the 800 ms target by 40 ms, and the slowest stage is llm_first_token at 350 ms — that's where you'd optimise first (a faster model, prompt caching, streaming).
Try this: Bump llm_first_token to 500 and re-run — the total exceeds 800, within_budget flips to False, and headroom goes negative.
7 · Production concerns: VAD, echo, fallback, cost professional
A demo that works at your desk breaks on a real phone line. The production checklist:
Production voice hardening
- VAD / endpointing tuned per language and noise floor — noisy environments false-trigger on background sound.
- Echo cancellation (AEC) so the mic doesn't hear the speaker; without it, barge-in fires on the agent's own voice.
- Fallback path: if speech-to-speech or a stage times out, degrade gracefully (retry, switch to the text pipeline, or 'Sorry, I didn't catch that').
- Interruptibility everywhere: any long agent action (a tool call, a lookup) must remain interruptible, or barge-in dies mid-task.
- Cost & concurrency: realtime audio is billed by the second and holds an open connection per call — capacity planning is about concurrent sessions, not requests.
Cost for realtime voice is dominated by connection-time, not per-request tokens. Model the monthly bill from concurrent-call assumptions:
cost.pydef voice_cost(calls_per_day, avg_call_min, cost_per_min, days=30):
"""Realtime voice is billed by audio minute (in + out)."""
minutes = calls_per_day * avg_call_min * days
monthly = minutes * cost_per_min
return {
"billed_minutes_per_month": minutes,
"monthly_cost_usd": round(monthly, 2),
"cost_per_call_usd": round(avg_call_min * cost_per_min, 4),
}
print(voice_cost(calls_per_day=2000, avg_call_min=3.0, cost_per_min=0.08))
{'billed_minutes_per_month': 180000.0, 'monthly_cost_usd': 14400.0, 'cost_per_call_usd': 0.24}
Realtime voice isn't billed like a chat message — it's billed by the minute of audio, for as long as the call is connected. This estimates the monthly bill from call volume.
voice_costmultiplies calls per day × average call length × 30 days to get totalminutesof audio per month.- Multiplying minutes by
cost_per_mingives the monthly dollar cost; a single call's cost is just its length × the per-minute rate. - With 2000 calls/day at 3 minutes each, that's 180,000 minutes a month.
- This is why voice cost scales with talk time and concurrency, not with the number of requests — a long call is expensive even if the agent says little.
What the output means: 180,000 billed minutes cost about $14,400/month, or $0.24 per 3-minute call. Longer calls or more callers push this up linearly.
Try this: Halve avg_call_min to 1.5 — the bill halves too. Getting callers to their answer faster is a direct cost saving in voice.
8 · Tech-lead — owning a voice-agent system tech-lead
A lead owns the end-to-end contract: a latency SLO (e.g. p95 first-audio < 800 ms) measured in production, an architecture decision (pipeline vs speech-to-speech) justified by that SLO and by observability needs, a transcription path for logging/guardrails even in speech-to-speech, and a fallback + concurrency plan so a bad network or a traffic spike degrades gracefully instead of dropping calls.
The real SDK usage looks like the sketch below. It is illustrative only — it needs the vendor SDK, a network connection, and credentials, so it is NOT part of the runnable labs. Every concept in it (streaming, turn events, barge-in) you already modeled offline above.
sdk_sketch.py# ILLUSTRATIVE — requires a realtime voice SDK, credentials, and a live socket.
# Do NOT expect this to run with `python file.py`; the labs above model the ideas offline.
import realtime_voice_sdk as rv # placeholder for the actual vendor SDK
async def run_call(mic_stream, speaker):
session = await rv.connect(model="voice-realtime", voice="warm")
async for event in session.stream(mic_stream): # bidirectional websocket
if event.type == "partial_transcript":
pass # show live captions
elif event.type == "turn_end": # VAD says user finished
await session.respond()
elif event.type == "audio_out":
speaker.play(event.audio) # stream out as it arrives
elif event.type == "user_started_speaking":
await session.cancel_response() # BARGE-IN: stop + flush
streaming.py, turns.py, and bargein.py. A real SDK gives you these as events over a socket — the reasoning is the same.Exercise FA1.1 — Fit the latency budget
Context: You have a concrete p95 target and a pipeline that is currently over it — the everyday job of a voice-agent engineer is finding the fat stage and trimming it without wrecking quality.
Your task: Starting from latency.py with the given stage numbers (total 760 ms, over a 700 ms p95 first-audio target), identify the biggest stage, cut it, and re-run until within_budget is True.
Requirements:
- Locate the single largest contributor to the total
- Apply a realistic cut (e.g. faster LLM first-token or streaming TTS)
- Re-run until
within_budgetflips toTrue - Write down which stage you cut and the tradeoff you accepted
💡 Hint: There's usually one dominant stage; halving it is more effective than shaving milliseconds off three small ones.
Exercise FA1.2 — Design the turn + barge-in policy
Context: Turn-taking and barge-in policy is where a support line either feels responsive or constantly talks over people. This ties the VAD, barge-in, and fallback labs into one design decision.
Your task: For a customer-support line, pick and justify a silence hangover for turns.py, then use bargein.py to show what happens when a caller interrupts mid-answer.
Requirements:
- Choose a hangover length and justify it for support-call speech patterns
- Demonstrate a mid-answer interruption stopping the agent
- State how you'd stop the agent barging in on its own audio echo
- State the fallback that fires if STT times out
💡 Hint: Echo-cancellation or gating the mic while the agent speaks is what keeps the agent from interrupting itself.
🪜 Practice ladder beginner → industry
Six graded exercises, easy to real-world. Try each before opening its solution.
Context: A voice agent that answers even a beat too slowly feels broken; conversational turn-taking lives inside roughly an 800 ms budget. The first skill is simply adding up where that budget goes.
Your task: Given per-stage latencies for an ASR → LLM → TTS pipeline, compute the total response time and check it against the ~800 ms conversational budget.
Requirements:
- Sum the ASR, LLM, and TTS stage latencies
- Compare the total to the ~800 ms target and report over/under
- Return both the total and a within-budget boolean
- Make the stage numbers easy to change and re-check
💡 Hint: This rung is pure arithmetic over a dict of stage times — the value is seeing which single stage dominates the budget.
Show solution
Pure Python — the arithmetic behind the latency budget:
stages = {"asr": 150, "llm_first_token": 400, "tts_first_audio": 120} # ms
total = sum(stages.values())
BUDGET = 800
print("total:", total, "ms") # 670 ms
print("within budget" if total <= BUDGET else "TOO SLOW",
f"({BUDGET - total} ms headroom)") # within budget (130 ms headroom)
Humans perceive pauses over ~800 ms as awkward. The dominant term is time-to-first-token from the LLM, which is why streaming matters.
Context: The core architectural choice for a voice agent is pipeline (ASR+LLM+TTS) versus a single speech-to-speech model — and it's a genuine tradeoff, not a clear win.
Your task: Contrast the two architectures on a scorecard across latency, control/observability, emotion/prosody, and cost, then print a recommendation for a regulated support line that must log every transcript.
Requirements:
- Score both architectures on all four dimensions
- Note that a pipeline exposes an inspectable transcript at each stage
- Note that speech-to-speech typically wins on latency and prosody
- Recommend the pipeline for the regulated line and justify it by the transcript-logging requirement
💡 Hint: The regulated constraint (a loggable transcript per turn) is what tips the decision, even where speech-to-speech would otherwise feel more natural.
Show solution
archs = {
"pipeline (ASR+LLM+TTS)": {
"latency": "higher (3 hops)", "transcripts": "native (text between hops)",
"prosody": "lost/limited", "control": "high -- swap any stage"},
"speech-to-speech": {
"latency": "lower (1 model)", "transcripts": "must be derived",
"prosody": "preserved", "control": "low -- opaque"},
}
for name, a in archs.items():
print(name); [print(f" {k}: {v}") for k, v in a.items()]
# regulated line needs auditable transcripts + stage control:
print("\nRecommendation: PIPELINE -- transcripts are first-class and each "
"stage is swappable/loggable, which a compliance audit requires.")
Speech-to-speech wins on latency and natural prosody; the pipeline wins when you need transcripts, guardrails between stages, and per-stage control.
Context: Before an agent can take turns it has to know when someone is speaking. A simple energy-based voice-activity detector is the first building block of turn detection.
Your task: Implement an energy-based VAD that marks a frame as speech when its RMS energy exceeds a threshold, and only ends the turn after N consecutive silent frames (a hangover).
Requirements:
- Compute per-frame RMS energy and compare against a threshold
- A frame above threshold counts as speech
- End the turn only after N consecutive silent frames (hangover), not on the first silent frame
- Expose the threshold and hangover length as parameters
💡 Hint: The hangover counter is what stops a brief pause mid-sentence from being mistaken for end-of-turn.
Show solution
Offline model on synthetic frames (no mic needed):
def rms(frame):
return (sum(x * x for x in frame) / len(frame)) ** 0.5
def detect_turns(frames, threshold=0.2, hangover=3):
speaking = False; silence = 0; turns = []; start = None
for i, f in enumerate(frames):
loud = rms(f) > threshold
if loud:
silence = 0
if not speaking: speaking = True; start = i
elif speaking:
silence += 1
if silence >= hangover: # end turn after enough silence
turns.append((start, i - hangover)); speaking = False
if speaking: turns.append((start, len(frames) - 1))
return turns
loud, quiet = [0.5, 0.5], [0.0, 0.0]
frames = [quiet, loud, loud, quiet, quiet, quiet, loud]
print(detect_turns(frames)) # [(1, 2), (6, 6)]
The hangover prevents a brief pause mid-sentence from prematurely ending the user's turn — the core tuning knob of turn detection.
Context: Barge-in — letting the caller interrupt the agent mid-sentence — is what makes a voice agent feel human, and it's cleanest to model as a small state machine.
Your task: Model barge-in as a state machine: while the agent is speaking, if the user starts talking the agent stops its TTS and starts listening. Drive it with an event sequence.
Requirements:
- Define states such as listening, speaking, and interrupted
- A user-speech event while speaking transitions to stop-TTS-and-listen
- Transitions are driven by an event stream, not hard-coded
- Reaching the interrupted path halts synthesis immediately
💡 Hint: Keep the machine tiny — a state variable plus a transition function over events is enough; the interesting transition is user-speech arriving during speaking.
Show solution
class VoiceAgent:
def __init__(self): self.state = "idle"
def on_event(self, event):
if event == "user_speaks":
if self.state == "speaking":
self.state = "listening"; return "BARGE-IN: stop TTS, listen"
self.state = "listening"; return "listening"
if event == "user_stops":
self.state = "thinking"; return "run LLM"
if event == "llm_done":
self.state = "speaking"; return "start TTS"
if event == "tts_done":
self.state = "idle"; return "idle"
return self.state
a = VoiceAgent()
for e in ["user_speaks", "user_stops", "llm_done", "user_speaks", "tts_done"]:
print(e, "->", a.on_event(e))
# the 2nd user_speaks arrives while speaking -> BARGE-IN
Barge-in is why full-duplex streaming is required: the agent must keep listening while it speaks so it can abort playback the instant the user interrupts.
Context: Dead air is the worst failure mode for a voice agent. Production systems degrade gracefully by falling back to a holding phrase rather than silence when a stage stalls.
Your task: Wrap the LLM call so a timeout or error falls back to a canned holding phrase instead of dead air, and count fallbacks so the failure rate is monitorable.
Requirements:
- Catch timeouts and errors from the LLM stage
- On failure, return a fixed holding/acknowledgement phrase instead of nothing
- Increment a fallback counter for monitoring
- Normal responses pass through unchanged
💡 Hint: Treat the fallback as a wrapper around the real call; the counter is what lets ops alert when fallbacks spike.
Show solution
class VoiceService:
def __init__(self): self.fallbacks = 0
def respond(self, transcript, llm):
try:
reply = llm(transcript, timeout=1.5) # must beat the latency budget
if not reply:
raise ValueError("empty reply")
return reply
except Exception as e:
self.fallbacks += 1
return "Sorry, could you repeat that?" # holding phrase, never silence
def flaky_llm(text, timeout):
if "trigger" in text:
raise TimeoutError("model slow")
return f"You said: {text}"
svc = VoiceService()
print(svc.respond("hello", flaky_llm)) # You said: hello
print(svc.respond("trigger", flaky_llm)) # holding phrase
print("fallbacks:", svc.fallbacks) # 1
Dead air is the worst failure in voice; a fast holding phrase buys time and the counter feeds an alert if fallbacks spike.
Context: The "think" stage is where perceived latency is won or lost. Streaming tokens from the model straight into TTS keeps time-to-first-audio low instead of waiting for the whole reply. This uses the Anthropic Python SDK.
Your task: Sketch a real streaming LLM turn for the think stage with the Anthropic SDK so time-to-first-token stays low, and explain why you stream tokens into TTS rather than waiting for the full reply.
Requirements:
- Use the Anthropic SDK's streaming interface for the model call
- Begin handing text to TTS as tokens arrive, not after completion
- Explain that streaming cuts perceived latency by overlapping think and speak
- Keep the sketch focused on the streaming boundary, not a full app
💡 Hint: The win is overlap: the first spoken words can start while the model is still generating the rest of the sentence.
Show solution
Needs the SDK + network (pip install anthropic, ANTHROPIC_API_KEY set). Streaming shape is the documented messages.stream:
from anthropic import Anthropic
client = Anthropic()
def stream_reply(user_text, on_token):
with client.messages.stream(
model="claude-sonnet-4-5",
max_tokens=200,
messages=[{"role": "user", "content": user_text}],
) as stream:
for text in stream.text_stream: # tokens as they arrive
on_token(text) # feed straight into TTS
return stream.get_final_message()
# on_token would push each chunk to the TTS engine so audio starts
# after the FIRST sentence, not the whole reply.
Streaming collapses the LLM stage's contribution to time-to-first-audio: you start speaking after the first clause instead of paying for the entire generation, which is what keeps total latency under budget.
✓ Checkpoint — you can move on when you can…
- Explain a voice/realtime agent and the mic→STT→agent→TTS→speaker loop.
- Choose between the pipeline and speech-to-speech and justify it.
- Explain streaming transport, turn detection, and barge-in.
- Build a latency budget and defend the ~800 ms round-trip target.
- List the production concerns: VAD, echo, fallback, cost, concurrency.
Knowledge check check yourself
What is the difference between a pipeline (STT → LLM → TTS) voice architecture and a speech-to-speech model, and what does each trade off?
Show answer
Why do voice agents target roughly an 800 ms end-to-end latency budget, and what is barge-in?