A real-time voice AI agent with retrieval, a cloned voice and a video avatar
- LiveKit
- pipecat
- Whisper
- Qdrant
- vLLM
- VoxCPM2
- LiveAvatar
- FastAPI
- Kubernetes
The problem
The product records a person's own audio and lets specific people they've nominated ask questions about it later, and hear the answers spoken back in that person's voice. Four requirements fell out of that, and each one rules out the easy version of the system.
- It has to feel like a conversation. Not a request/response API with a spinner. If the pause after you stop speaking is long enough to notice, people start repeating themselves.
- Answers must come from the recordings, not the model. A language model will confabulate a plausible memory, and in this product that is the worst possible failure.
- Access control is per person, per item. Different people are allowed to hear different things. This can't be a UI filter: it has to hold at retrieval, or the model reads out something it shouldn't have seen.
- The voice and the face are the product. Generic TTS defeats the point, so synthesis has to clone a specific voice, and later a rendered face had to lip-sync to it in a video call.
What I built
- An async ingestion pipeline. A poller finds new audio and typed text across several source types, queues it in Redis, transcribes it with Whisper, then chunks, embeds and indexes it, with a per-item access payload written alongside every vector.
- Access-filtered hybrid retrieval. bge-m3 dense plus sparse vectors in Qdrant, combined with reciprocal rank fusion, and every query filtered by who is asking and what they're permitted to see.
- A query API. Streaming and non-streaming, with a prompt-injection guard, an intent gate, token-budgeted chat history and follow-up query contextualisation.
- A real-time voice agent. A pipecat pipeline on a LiveKit WebRTC transport: voice activity detection, turn finalisation, streaming STT, the retrieval-plus-LLM brain, and cloned-voice TTS, with barge-in.
- Video calls with a rendered avatar. A self-hosted avatar model renders the persona's face from a photo, lip-synced to the audio the voice path already produces.
- A serving and scaling layer. Pod-first LLM routing with serverless overflow, per-endpoint circuit breakers, GPU pod pools, and an autoscaler that learns each pod's real concurrency instead of trusting a configured number.
- Usage accounting. Token usage for text, and per-call minutes for voice and video billed to the persona's owner rather than the caller, since a nominated user's call should count against the owner's plan.
Architecture
Why cascaded, not speech-to-speech
The pipeline is deliberately VAD → turn → STT → retrieval + LLM → TTS, not an end-to-end speech-to-speech model. Two things force text into the middle: retrieval needs a text query to embed, and the injection guard needs text to inspect. An end-to-end model would be fewer moving parts and strictly less controllable: you can't filter what you never render as text.
Access control lives in the vector payload
This is the design decision I'd defend hardest. At ingest, every Qdrant point is stamped with the owner, the source type, the list of people it was assigned to, and whether it's visible to everyone. At query time the caller is classified: owner or nominated user, and that becomes a hard filter on the search itself.
The consequence is that unauthorised content is never retrieved, so it can never reach the prompt, so no amount of clever phrasing can talk the model into repeating it. Filtering after retrieval, or trusting the system prompt to enforce a rule, would both have been less code and a genuine hole. It does mean a schema change has a migration problem: pre-existing vectors had no payload and were invisible until a backfill stamped them, which is the right failure direction, invisible rather than over-shared.
Separate services, separately scalable
The parts have completely different load profiles, so they're separate deployments on K3s with their own node roles: the API and workers, the LiveKit SFU, the voice agent consumers, and the autoscaler as its own process. The GPU work (streaming speech-to-text, synthesis, the LLM, the embedder, the avatar renderer) runs on rented GPU pods and serverless workers I operate, not on vendor model APIs. Postgres holds the text and the job state, Qdrant holds the vectors, Redis carries the queues, and object storage holds the media behind presigned URLs.
Making it fast enough to feel real
Measured on a real call, from turn-final (the moment speech-to-text decides you've stopped) to the bot being audible is 0.83–1.17s. From when you actually stop talking it's 1.3–2.1s, and the difference is a deliberate 0.4–0.8s debounce before finalising the turn.
That debounce is the most interesting trade-off in the whole system. Shorten it and the agent cuts people off mid-sentence, which is far more irritating than waiting; lengthen it and the conversation drags. It is a product decision disguised as a timeout.
- Speculative retrieval. The embed and the Qdrant search run during the STT pause, before the turn is finalised. It hits on essentially every turn and adds roughly nothing to the critical path: retrieval effectively became free by moving it into time that was already being spent.
- Sentence-pipelined TTS. Synthesis starts on the first complete sentence rather than the complete reply, so the user hears the beginning of the answer while the end is still being generated.
- Batched TTS serving. Moving synthesis onto a batching, streaming server halved first-chunk time: from 770–960ms to 450–500ms, and, more importantly, is the part that keeps holding up as concurrent calls increase.
- An intent gate before the embed. Social filler and "rephrase that" style follow-ups skip retrieval entirely. It's a heuristic that decides before any embedding happens, so it costs nothing in time-to-first-token: an earlier LLM-based version of the same gate blocked the first token behind its own model call.
- Barge-in. Voice activity detection interrupts playback mid-sentence, which means the pipeline has to be cancellable at every stage rather than just fast.
- Language pinned per turn. The detected language code is stated explicitly in the prompt each turn, because a model given a short transcript will otherwise drift into the wrong language mid-call.
Serving the models, and knowing when to add more
Generation is routed pod-first with overflow to serverless: always-on GPU pods take the traffic, and serverless workers are warmed before the pods saturate rather than after, because provisioning is not instant. Each model can be backed by a pool of pods, selected least-utilised-first, with a per-endpoint circuit breaker so a dead pod costs one fast failover instead of every subsequent request.
The piece I'm most pleased with is that the autoscaler learns capacity rather than being told it. It starts from a configured concurrency limit, watches for the point where a pod actually saturates (queuing, or per-request decode rate falling below a floor), clamps there, probes back up while healthy, and persists what it learned. That's what lets a pool mix GPU types without someone maintaining a threshold per type.
Capacity is measured, not guessed: one pod class benchmarks at ~11.5 requests/sec and ~25 concurrent users at p95 ≤ 6s, two pods at ~23 rps and ~50 users, so scaling is linear per pod. With a closed-loop load generator the useful law is just response time = concurrent users / throughput, which makes "how many pods for N users" arithmetic instead of an argument.
The avatar was the hardest part
A video call is a voice call plus one more participant in the room. That framing came out of a constraint: the agent's transport couldn't publish video at all, so the renderer has to join the call itself rather than being a stage in the audio pipeline.
- The interactive pipeline wasn't released. The model ships two pipelines and nothing upstream imports the streaming one, and it calls an audio callback that is defined nowhere. Making it work meant importing that module directly and supplying the callback from a ring buffer.
- The HTTP API had to live on an unusual rank. Serving it from the rank where audio is consumed and frames are produced, rather than from rank zero, is the difference between working and silently hanging.
- Audio forwarding had to be non-blocking. Awaiting the HTTP call per audio frame made the caller's own audio buzz. The fix is obvious in hindsight and was not obvious at all while listening to it.
- A stalled renderer must not mute the call. The agent keeps publishing audio itself, so a wedged video pipeline degrades to a voice call instead of silence. Exact lip-sync is available by giving that safety net up: a switch, deliberately, rather than a default.
- It's expensive and it's slow to start. 12fps at 384×256 (18 with compilation), ~67s to first frame on a cold pod, and one call per pod because the pipeline saturates on a single stream. A video minute costs roughly ten times a voice minute, which is why usage tracks the two separately.
- Several failure modes hang instead of erroring. That shaped how the whole integration is instrumented: a silent stall needs a timeout and a log line, because nothing raises.
Voice cloning
Cloning is zero-shot per request: the synthesis server is handed a reference clip and reproduces the voice, with no enrollment step or profile of our own to maintain. At call setup the agent picks the persona owner's most recent uploaded sample: the owner, not the caller, since the owner is who the call represents.
The two lessons were about caching and limits. The reference cache is keyed by the upload, not by the person: key it by person and a newer upload keeps serving the old voice forever. And the model hard-caps a reference clip at 30 seconds: an over-length sample makes every turn of a call fail with no audio at all, since the same rejected reference is reused for the whole call. That's a validation gap I'd rather fix by capping the recording UI upstream than by patching around downstream.
Consent for a cloned voice is owned by the layer where the upload happens, not here. That is the correct place for it, but it's worth being explicit that "the model can clone any voice from 30 seconds" is a product-policy problem long before it's a technical one.
What I took away
Nearly all of the hard work was in the seams. Every individual model is a download with a documented interface; the system is hard because a turn crosses five of them plus a vector search, each stage can stall independently, and the user perceives the sum as one pause.
It's also the project where the AI work and the infrastructure work stopped being separable for me. Deciding to warm a serverless worker before saturation, or to key a cache by upload instead of by user, or to enforce permissions in a vector filter: none of those are model problems, and all of them decide whether the product works.
Notes
Client work under NDA, so there's no repository link and the product isn't named. Everything above is architecture and engineering reasoning, with measured numbers from my own load tests and instrumented calls: no product data, no client identifiers, no code.
Related
- Voice AI agentsTurn-taking, barge-in and interruption: what makes an agent feel live.
- RAG in productionHybrid retrieval in Qdrant, and access control enforced at the filter.
- LLM infrastructurePod-first routing, serverless overflow and an autoscaler that learns capacity.
- Voice AI engineeringThe measured latency budget of a spoken turn.