Real-time voice agents with local LLMs: the latency problem nobody fully solves

We’re running two voice agents in production for a vocational training school in Spain. Both use Retell AI for the telephony layer, Ollama for local inference on a Hetzner box with an RTX 4000 Ada (20GB VRAM), and Qdrant for RAG. The inbound agent (Aitana) runs llama3.1:8b-instruct-q4_K_M. The outbound sales agent (Gabriel) runs qwen2.5:14b-instruct-q4_K_M.

We implemented most of what the community suggested last time: fixed system prompt (~444 tokens), RAG injected into the user message instead of system, history limited to last 6 turns, both models pinned to VRAM with keep_alive: -1.

The conversations are better. The latency problem is not solved.


The hard constraint we can’t engineer around

Retell AI has an implicit ~3 second timeout before it reconnects the WebSocket. If the LLM doesn’t produce a first token within that window, Retell closes the connection and opens a new one. After 2 reconnections, it terminates the call entirely with Max ping pong reconnection count reached.

This is the constraint everything else flows from.


Problem 1: First token latency is inconsistent

With llama3.1:8b fully loaded in VRAM, first token times from our logs:

  • Turn 1–3: 0.8–1.5s :white_check_mark:
  • Turn 4–6: 1.5–2.7s :warning:
  • Turn 7+: 2.7–4.9s :skull:

The model is deterministic in terms of VRAM usage, but as the conversation grows the prompt gets longer. Even with history limited to 6 turns, by turn 7 the effective context (system prompt + 6 turns + RAG + memory) is approaching num_ctx=2048. Ollama processes the full KV cache on every call — there’s no stateful session between turns.

The math is simple and brutal: longer context = more tokens to prefill = higher first token latency. With a hard 3s ceiling, you have roughly 1500 tokens of budget before you start hitting reconnections consistently.

What we tried: Reducing num_ctx to 1024 and num_predict to 60. It helped at the cost of truncating context and producing shorter, lower-quality responses. Not a real fix.


Problem 2: Two WebSocket connections per call

Every call opens two WebSocket connections to our server. Retell documents this as a “backup connection” behavior. The second connection receives a call_details event, which triggers our preload logic (memory lookup, case continuity from PostgreSQL).

If that preload runs concurrently with an early response_required on the primary connection, Ollama is handling two requests simultaneously. First token on the primary goes up by 0.5–1.5s, which is enough to cross the timeout threshold on the first user utterance.

We solved this by checking if "_pre_contexto" in state before running preload on reconnections. But the race condition window at call start is still real.


Problem 3: RAG embedding competes with generation

nomic-embed-text is pinned in VRAM (~570MB). When a response_required arrives, we embed the user’s question and query Qdrant before calling the LLM. The embedding call itself is fast (~20ms) but it briefly occupies the GPU compute pipeline.

In practice we see ~40ms overhead per turn from the embedding. Not catastrophic, but it’s 40ms we don’t have to spare when the budget is 3000ms total.

What’s worse: if the model was slightly swapped or the GPU was mid-operation on something else (Redis, another process), the embedding can spike to 300–700ms. We’ve seen this exactly once in logs but it’s enough to cause a failure.


Problem 4: The LLM generates the ticket marker mid-stream, incorrectly

We use inline markers in the LLM output to trigger actions during the call. For ticket creation, the format is:

[CREAR_TICKET:tipo|resumen|prioridad]

The model emits this correctly about 60% of the time. The other 40%:

  • It emits [CREAR_TICKET: and then stops before the closing bracket (the stream ends before the marker is complete)
  • It emits the marker with the wrong content — using RAG context instead of what the user actually said
  • It emits the marker twice in one response (once mid-sentence, once at the end)

We handle the incomplete case by waiting for ] before parsing. We handle duplicates with a _ticket_ya_creado flag. The wrong-content case we can’t reliably detect — the ticket gets created with a hallucinated summary.

The underlying issue is that llama3.1:8b is a small model and structured output inside a conversational streaming response is genuinely hard for it. It’s trying to produce natural speech AND a machine-readable structured payload in the same generation pass.


Problem 5: Context window math doesn’t add up for long conversations

Our current budget breakdown per turn:

Component Tokens
System prompt (fixed) ~444
RAG (3 chunks avg) ~250
Episodic memory ~120
6 turns of history (avg) ~600
User question ~30
Total input ~1444
Available for response ~604
num_predict limit 80

So we’re already at 70% of num_ctx=2048 before the model generates a single output token. By turn 8–10 the history grows, RAG chunks are longer, and we’re truncating.

Ollama handles overflow by silently truncating the oldest tokens in the context. For a voice agent this means the model can lose the beginning of the conversation — including the part where the user explained their actual problem.


What we think the actual fix is

We don’t have a clean solution. These are the options we’re considering:

Option A: Speculative decoding or a draft model Use a smaller model (1B–3B) to draft tokens, verify with 8B. Faster time-to-first-token at the cost of implementation complexity. Ollama doesn’t support this natively.

Option B: KV cache reuse between turns If we could persist the KV cache for the system prompt across turns (it never changes), we’d save ~444 tokens of prefill on every call. Ollama doesn’t expose this either.

Option C: Separate the action layer from the generation layer Generate the conversational response with the LLM (fast, short), then in a second non-streaming pass extract structured data (ticket type, summary, priority). The second pass doesn’t block Retell — it runs after the response is sent. This is architecturally cleaner but adds latency to ticket creation.

Option D: Accept the 8B model can’t do this reliably and move to a hosted model gpt-4o-mini with streaming has a first token around 300ms. The latency problem goes away. The cost goes up, the privacy argument goes away, and we lose the local inference advantage.


Actual numbers from production logs

Across 47 calls logged this week:

  • Calls that completed without reconnection: 29 (61%)
  • Calls with 1 reconnection (recovered): 12 (26%)
  • Calls terminated by Retell (max reconnections): 6 (13%)

The 6 terminated calls all happened at turn 7 or later. No call terminated at turn 1–3. This confirms the latency issue is context-length-dependent, not a cold start problem.


Questions for the community

  1. Has anyone successfully implemented KV cache reuse with Ollama between WebSocket turns in a Retell-style setup?
  2. Is there a 7B–8B model that consistently hits <1s first token on an RTX 4000 Ada with ~1500 tokens of input context?
  3. For the structured marker output problem — is there a better pattern than inline markers in a streaming response? We considered a two-pass approach but haven’t implemented it.
  4. Any experience with Retell’s timeout configuration? Their docs don’t mention it as a configurable parameter but we’re not sure.

Happy to share more logs or code if useful.


Stack: Retell AI · Ollama · llama3.1:8b-instruct-q4_K_M · Qdrant · FastAPI WebSockets · Hetzner GEX44 · RTX 4000 Ada

For now, I can’t say anything definite without seeing the relevant parts of the code, but:


Direct answers first

Question My current answer
1. Can Ollama reuse the KV cache between Retell turns? Ollama does have internal prompt caching, but that is not the same thing as an application-controlled, persistent conversation KV handle. I do not see a supported session/KV identifier in the current /api/chat contract that you can safely carry between arbitrary WebSocket turns. Whether reuse occurs depends on the exact token prefix, Ollama version, runner/slot assignment, context shifting, and concurrent requests. Measure it rather than assuming either “full reuse” or “full re-prefill.”
2. Is there a 7B–8B model that will reliably stay below one second? I would not promise that from the model name and GPU alone. Your own early turns already approach that range, which suggests the more valuable question is why latency grows across the call. Queueing, superseded requests, cache eligibility, reconnect-time duplicate work, and CPU/GPU placement can all produce that slope. Benchmark the exact production prompt and measure P95/P99 time to the first speakable sentence, not only an isolated first token.
3. Is there a better pattern than inline ticket markers? Yes. Separate spoken generation from action proposal and action commit. Let the model propose typed data, validate it against the transcript and application state, then perform an idempotent durable operation. Native tool calling or JSON Schema can help with syntax, but neither guarantees that the action is semantically correct or executed exactly once.
4. Is Retell’s timeout configurable? The reconnect condition I can find in the current public protocol is not a documented three-second first-token timeout. With auto_reconnect=true, Retell documents a ping_pong exchange every two seconds, closes the current connection after five seconds without one, and reconnects up to two times. The error Max ping pong reconnection count reached therefore points first toward that heartbeat path. This does not prove that no other internal timeout exists, but I would separate the two mechanisms before treating three seconds as a hard model deadline.

Relevant primary references:

The default route I would take

Before changing models or rebuilding the architecture, I would take one failed call and do the following:

  1. Retrieve Retell’s view of the call.
    Save:

    • disconnection_reason
    • public_log_url
    • latency.llm
    • latency.llm_websocket_network_rtt
    • latency.e2e

    Retell defines its llm latency as the time until the first speakable chunk, and for a custom LLM this includes the WebSocket round trip. That is not necessarily the same number as the local Ollama first-token timestamp.

  2. Build one monotonic timeline across all layers.
    Key every event by:

    • call_id
    • a locally assigned socket_generation
    • Retell response_id
    • the local Ollama request ID
    • the logical action ID, if any
  3. Run three small controls.

    • Slow LLM, healthy heartbeat
    • Fast dummy response, blocked heartbeat
    • Superseded-response cancellation probe
  4. Make heartbeat handling independent of inference.
    The WebSocket receive loop must remain able to receive and answer ping_pong while RAG, prompt construction, Ollama prefill, streaming, or action execution are in progress.

  5. Make “latest response wins” apply to the whole application.
    A new Retell response_id should supersede not only outbound text, but also any obsolete RAG, prompt-building, LLM, TTS-preparation, or action-proposal work associated with the previous response.

  6. Only then analyze cache, context length, and model choice.
    Use Ollama’s own timing fields to distinguish prompt evaluation from queueing and application delay.

  7. Move ticket creation out of the speech stream.
    Treat the LLM output as an action proposal, not as the transaction itself.

The first code paths I would inspect

Area What I would look for
WebSocket receive loop Does it continue receiving while the LLM is generating?
ping_pong branch Can it respond immediately, or is it behind RAG/Ollama work in the same coroutine?
Ollama client Sync client inside async def, blocking iterator, or genuinely asynchronous streaming?
Response ownership What happens when a higher response_id arrives?
Cancellation Does canceling the local task stop server-side Ollama compute, or only stop reading chunks?
Disconnect cleanup Are every socket-owned task and outbound stream terminated?
call_details preload Is it once per call or once per connection? Can two connections share one in-progress preload future?
WebSocket writes Are multiple tasks writing concurrently, or is there one serialized outbound writer?
Ticket execution Can retry, reconnect, interruption, or duplicate generation perform the same mutation twice?
Why I would investigate the heartbeat path before assuming a three-second token deadline

The documented Retell behavior is fairly specific:

  • with auto_reconnect=true, Retell sends ping_pong events every two seconds;
  • your server is expected to send corresponding events back;
  • after five seconds without ping_pong, Retell closes the current connection;
  • it opens a new connection up to two times.

See the current LLM WebSocket specification.

That matches the wording of Max ping pong reconnection count reached much more directly than a first-token deadline does.

A plausible failure sequence is therefore:

larger prompt or queued request
    -> longer period before the Ollama stream yields
    -> WebSocket receive loop cannot process ping_pong
    -> no pong for five seconds
    -> Retell reconnects
    -> duplicate initialization or stale inference remains active
    -> the next response is even slower

This only applies if some relevant work is blocking or starving the receive path. It is not possible to confirm without the handler code and a timeline.

A common Python trap is that an async def endpoint does not make a synchronous library call non-blocking. A synchronous Ollama iterator, database client, embedding client, or heavy preprocessing call can still monopolize the event-loop thread.

The websockets asyncio FAQ gives the general explanation: if a coroutine does not yield control, other connection work does not get scheduled. An async Ollama client or asyncio.to_thread() may be part of the fix, but the more important contract is architectural:

receive task:
    always receive protocol events
    immediately route ping_pong

response task:
    perform RAG and LLM generation

outbound writer task:
    serialize all writes to Retell

Using a single outbound writer is also useful because it prevents heartbeat, generated text, tool events, and cleanup messages from racing through concurrent WebSocket writes.

There may be other clocks in the path:

  • Retell’s application-level ping_pong
  • WebSocket control-frame ping/pong
  • Uvicorn timeouts
  • reverse-proxy read or idle timeouts
  • load-balancer connection limits
  • application deadlines

The close code, Retell disconnection_reason, and public_log_url should identify which layer ended the connection. Retell’s disconnection guide distinguishes, for example, error_llm_websocket_lost_connection from runtime and payload errors.

I would also cheaply exclude the cases in Retell’s custom LLM troubleshooting guide:

  • accidentally sending end_call=true;
  • hosting on a platform that does not support persistent WebSockets;
  • an infrastructure timeout imposed by the hosting provider.
Three controls that should separate the main hypotheses

Control A: deliberately slow inference, responsive heartbeat

For a test call, replace the model response with an intentional delay longer than the suspected deadline:

async def delayed_response():
    await asyncio.sleep(6)
    return "This is a delayed test response."

During those six seconds, continue receiving and immediately echoing Retell ping_pong events.

Interpretation:

  • Connection survives: the “approximately three-second first-token timeout” hypothesis becomes much weaker; heartbeat starvation, production queueing, or another path becomes more likely.
  • Connection still reconnects: capture the Retell public log and exact close reason. There may be another deadline, an incorrect pong implementation, or a different infrastructure timeout.

Control B: fast response, deliberately blocked heartbeat

Return a fixed response immediately, but in an isolated test intentionally block the WebSocket event loop for more than five seconds.

Interpretation:

  • The same reconnect error appears: this provides a clean reproduction of the heartbeat path.
  • A different close occurs: compare the close reason and protocol log.

Do not run the blocking variant against production calls.

Control C: superseded-response cancellation

  1. Start a deliberately long generation A.
  2. Send or reproduce a newer Retell response_id.
  3. Cancel A locally and close its response body/stream.
  4. Immediately start a very short generation B.
  5. Observe:
    • when B actually begins;
    • GPU utilization;
    • Ollama queueing;
    • runner/debug logs;
    • whether A continues consuming compute.

This matters because stopping iteration on the client is not necessarily proof that server-side compute has stopped.

There is a recent open Ollama report, streaming handlers may retain goroutines and runner semaphore slots after client disconnect. It was reported against current development code, so it should not be treated as proof of your production cause. It is, however, a good reason to test cancellation explicitly on your exact version rather than assuming it works.

An older ollama-python issue also noted that simply breaking out of a streaming loop was not sufficient for aborting generation: ollama-python issue #210. That issue is closed and may not represent current behavior, but the underlying verification question remains useful.

A timeline that should make the failure visible

For one failed call, I would record monotonic timestamps for events like these:

Field Example meaning
call_id Retell call identity
socket_generation First connection, first reconnect, second reconnect
response_id Retell response currently requested
event ping_received, pong_sent, rag_start, ollama_first_chunk, etc.
t_monotonic_ns Local monotonic clock
task_id Local task or coroutine identity
prompt_tokens Rendered input token count
ollama_request_id Local correlation ID
superseded_by Newer response that invalidated this work
cancel_requested_at Local cancellation time
runner_stopped_at When compute actually stopped, if observable

Useful events include:

socket_open
config_sent
call_details_received
preload_started
preload_finished

ping_received
pong_queued
pong_sent

response_required_received
response_task_started
rag_started
embedding_started
embedding_finished
rag_finished
prompt_rendered

ollama_http_started
ollama_response_headers
ollama_first_chunk
first_token
first_complete_sentence
response_chunk_sent
response_complete_sent

response_superseded
cancel_requested
ollama_stream_closed
ollama_runner_stopped

socket_close_started
socket_closed

action_proposed
action_validated
action_commit_started
action_committed
action_result_sent

Then compare four latency views:

Retell e2e latency
Retell llm latency
Retell llm_websocket_network_rtt
local response_required -> first speakable chunk

Ollama’s final streaming response can provide:

  • load_duration
  • prompt_eval_count
  • prompt_eval_duration
  • eval_count
  • eval_duration

See Ollama API usage metrics.

A useful residual is:

local time to first chunk
    - model load time
    - prompt evaluation time

It is not a perfect queue metric, but if that residual is several seconds, prompt prefill alone cannot explain the observed latency.

Also note that Retell says it starts streaming at the first sentence. Its custom LLM guidance explicitly treats latency as:

time to first token
    + time to produce a speakable sentence

See Retell’s current integration guide. This means two models with similar raw TTFT may still have different voice-agent latency because one produces a complete first sentence more quickly.

Call state, socket state, and response state should probably be separate

I would avoid using “the WebSocket” as the owner of all call state.

A safer conceptual model is:

Call state, keyed by call_id
    durable memory
    authentication state
    retrieved case data
    completed business actions
    once-per-call preload future

Socket generation
    connection object
    heartbeat task
    outbound writer
    close reason
    connection-local cleanup

Response state, keyed by response_id
    prompt
    retrieval job
    Ollama request
    partial spoken output
    superseded/canceled status

A reconnect should replace the socket generation, not create a new logical call.

A new response_id should replace the current response work, not reset durable call state.

Retell’s current protocol says that when a new response is needed, it sends a new auto-incrementing response_id and discards previous responses. That is a protocol-level “latest response wins” rule. I would mirror it inside the application:

if incoming_response_id > active_response_id:
    mark previous response superseded
    cancel previous RAG/prompt/LLM work
    reject any late chunks from the previous response
    prevent previous action proposals from committing

Late-result rejection is needed even when cancellation is supported, because cancellation is cooperative and a job may finish during the race.

About the two WebSocket connections

I could not find a current Retell protocol statement describing a permanent primary/backup pair. The current documentation I found describes closing the current connection and opening a replacement after heartbeat failure.

That does not mean your observed two connections are not expected behavior. I would simply avoid assigning them “primary” and “backup” semantics until the public log confirms the timeline.

Log:

call_id
socket_generation
socket_open_time
socket_close_time
call_details_time
first_response_required_time
preload_start/end

If both sockets overlap, the important question is not their name but whether work owned by generation 1 remains active after generation 2 starts.

Preload should probably be single-flight per call

Instead of only checking whether _pre_contexto already exists, consider a once-per-call shared future:

async def get_call_preload(call_state):
    if call_state.preload_task is None:
        call_state.preload_task = asyncio.create_task(load_call_context())
    return await call_state.preload_task

That closes the window where two sockets both observe “not loaded yet” and start duplicate work.

If preload fails, store an explicit failed state or controlled retry policy rather than allowing every reconnect to launch another attempt.

What Ollama cache reuse probably does and does not mean here

The statement “Ollama processes the full KV cache on every call” is too broad for current Ollama.

Ollama has internal prompt caching, and recent release notes explicitly mention improved prompt caching and better KV reuse by decoupling it from context shift: Ollama releases.

At the same time, internal prompt caching is not equivalent to a public, portable, caller-controlled KV session.

The current /api/chat interface is message-based. I do not see a supported field that says:

continue from this exact persistent KV state

There was an older context mechanism around /api/generate, but its deprecation and lack of a direct replacement have been discussed in Ollama issue #10576. That issue is not documentation for current cache internals, but it supports the distinction between:

  • replaying messages and benefiting from internal prefix reuse;
  • owning an explicit inference session state.

keep_alive=-1 is not the same thing

keep_alive controls how long the model remains loaded. It avoids a model reload, but it does not promise that a particular conversation’s KV state remains assigned to the next request. See the Ollama FAQ.

Test the actual cache behavior

Use the same model and fixed runtime conditions, then compare:

  1. Exact repeat

    • identical messages
    • identical options
    • identical template
  2. Append-only conversation

    • exact previous messages
    • one user message appended
  3. Early-prefix mutation

    • change one token near the beginning of the system prompt
  4. RAG appended late

    • preserve the fixed system/history prefix
    • append dynamic retrieval near the end
  5. RAG inserted early

    • insert dynamic retrieval directly after the system prompt

For every condition, record:

  • fully rendered prompt;
  • token IDs if available;
  • prompt_eval_count;
  • prompt_eval_duration;
  • debug-log cache information;
  • runner/slot identity if observable.

Prefix caches generally depend on exact token identity, not semantic equivalence. A useful conceptual reference is vLLM’s prefix-caching design, although vLLM is a different runtime and its implementation should not be projected onto Ollama.

Things that may invalidate an otherwise reusable prefix include:

  • timestamps;
  • request identifiers;
  • a rewritten summary near the beginning;
  • reordered tool schemas;
  • dynamic RAG inserted before the stable history;
  • different chat templates or special tokens;
  • minor normalization differences.

Context shifting and truncation

I would log the fully rendered prompt and actual token count instead of relying only on the estimated budget.

When the prompt approaches the configured context limit, verify the exact behavior of your current Ollama version and runner:

  • which messages or tokens are removed;
  • whether context shifting occurs;
  • whether the stable prefix is retained;
  • whether cache reuse changes after the shift.

Recent Ollama changes around prompt caching and context shift are another reason to record the exact Ollama version in every benchmark.

Queueing and resource placement may explain more than model speed

The default maximum parallel request count per model is currently documented as one:

OLLAMA_NUM_PARALLEL=1

Ollama queues work when a requested model cannot be loaded or processed immediately. It also documents that increasing parallelism multiplies context-memory requirements:

required memory scales with
OLLAMA_NUM_PARALLEL * OLLAMA_CONTEXT_LENGTH

See Ollama’s concurrency documentation.

That creates several possible failure modes:

  • a superseded generation still owns the single runner slot;
  • preload or warmup overlaps the first real response;
  • embedding and generation contend for the GPU;
  • the 8B, 14B, and embedding models do not all remain resident under real pressure;
  • the model partially falls back to CPU;
  • increasing parallelism reduces queueing but creates enough KV pressure to worsen placement or cache locality.

Around each slow turn, save:

  • /api/ps output;
  • ollama ps;
  • model size_vram;
  • actual context allocation;
  • CPU/GPU split;
  • number of active and queued local requests;
  • GPU utilization and memory;
  • which model is loading or unloading.

A simple matrix for the RAG contention question

Condition Purpose
No RAG Baseline generation
Cached RAG result Removes embedding and vector-query work
CPU embedding Separates GPU contention
GPU embedding Current production path
Generation single-flight Removes overlap with stale/preload requests
Preload disabled Tests reconnect/startup interaction

The reported average 40 ms embedding overhead is probably not enough by itself to explain a four-second tail. The 300–700 ms spikes are more relevant, but they may still be a symptom of broader GPU queueing rather than an isolated embedding problem.

I would also gate retrieval by turn type. Greetings, acknowledgements, spelling confirmations, yes/no confirmations, and replies based entirely on already collected structured state often do not need fresh retrieval.

Model choice and speculative decoding

I would not interpret the current data as evidence that an 8B model categorically cannot support the application.

The same model reportedly reaches approximately 0.8–1.5 seconds during early turns. That makes these explanations at least as interesting as model size:

  • growing prompt prefill;
  • changing cache eligibility;
  • old requests accumulating;
  • response supersession;
  • reconnect-time duplicate work;
  • model/embedding contention;
  • partial CPU placement;
  • first-token versus first-sentence differences.

A useful benchmark should reproduce the production path:

same model build and quantization
same chat template
same system prompt
same RAG placement
same tool schemas
same context lengths
same concurrency
same Ollama options
same first-sentence stopping criterion

Measure at least:

  • P50/P95/P99 raw first token;
  • P50/P95/P99 first complete sentence;
  • prompt tokens;
  • prompt-evaluation duration;
  • generated tokens per second;
  • queue delay;
  • cache condition.

A model can have a fast median and still be unusable if the P99 crosses the heartbeat or conversation deadline.

Speculative decoding

The current statement “Ollama does not support this natively” is no longer universally true.

The current Ollama Modelfile reference documents draft_num_predict when a separate draft model or embedded MTP tensors are available.

However:

  • availability is model/backend dependent;
  • it may not apply to the exact Llama 3.1 model being used;
  • speculative decoding primarily improves autoregressive decoding;
  • it does not remove target-model prompt prefill;
  • it may improve time to a complete first sentence more than raw prefill latency.

So I would investigate it only after determining whether the slow component is:

queue
model load
prompt prefill
or output decoding

Hosted fallback

A hosted model may be a valid product decision, especially if a strict call-completion SLO matters more than complete locality.

It does not need to be an all-or-nothing migration. Possible boundaries include:

  • local primary with a hosted circuit-breaker for overloaded calls;
  • local generation but hosted structured extraction;
  • local handling for privacy-sensitive flows and hosted handling for low-risk intents;
  • human transfer when the local latency budget is exhausted.

Those choices depend on privacy, consent, contractual, and operational constraints rather than benchmark speed alone.

A safer action layer than inline markers

The current marker asks one generation to produce two incompatible streams:

natural language intended to be spoken immediately
machine-readable data intended to mutate external state

That creates predictable failure modes:

  • the stream ends halfway through the marker;
  • the marker is spoken aloud;
  • it is generated twice;
  • arguments are copied from RAG rather than the caller;
  • a superseded response still produces a side effect;
  • the ticket is created, but the confirmation speech is interrupted;
  • a retry creates a duplicate ticket.

I would separate the lifecycle into:

1. Spoken response
2. Action proposal
3. Validation
4. Durable commit
5. Action result
6. Spoken confirmation

Option 1: two-pass extraction

The conversational model produces only speech.

After enough evidence exists, a second short request extracts:

{
  "action": "create_ticket",
  "ticket_type": "technical_support",
  "summary": "The caller cannot access the student portal.",
  "priority": "normal",
  "source_utterance_ids": [12, 14]
}

The extractor should receive:

  • the relevant caller utterances;
  • already collected structured state;
  • a narrow schema;
  • no unrelated RAG text unless required.

The second pass can run asynchronously, but the application should not tell the caller that the ticket was successfully created until the commit succeeds.

Option 2: native tool calling

Ollama currently supports tool calling, and structured outputs can enforce a JSON Schema.

These improve the syntax boundary. They do not establish:

  • that the ticket is warranted;
  • that its summary is grounded in the caller’s words;
  • that the caller is authorized;
  • that it was not already created;
  • that the operation should execute after interruption;
  • that a retry is safe.

Treat it as a transaction

A minimal durable action record might contain:

logical_action_id
call_id
source_response_id
action_type
normalized_arguments
evidence_utterance_ids
status:
    proposed
    validated
    in_progress
    committed
    failed
external_resource_id
attempt_count
created_at
updated_at

The idempotency key should be created by the application for the logical operation, not improvised from the model’s text.

A timeout during ticket creation means “outcome unknown,” not necessarily “failed.” Retrying with the same logical action ID should return or discover the already created ticket when possible.

Retell supports tool_call_invocation and tool_call_result events, but the protocol describes them primarily as bookkeeping and transcript-weaving events. They are useful for observability; I would not treat the Retell transcript as the authoritative transaction database.

A relevant cross-framework failure example is LiveKit issue #3702: a tool completed, interruption prevented its result from being preserved in conversation history, and the next turn executed the operation again. It is not evidence about Retell, but it demonstrates why business state cannot live only in the conversational transcript.

Spoken text and committed state are different

Track separately:

  • text generated by the model;
  • text actually sent to Retell;
  • text actually spoken before interruption;
  • action proposed;
  • action committed;
  • action result acknowledged by the caller.

If a caller interrupts after hearing only the first part of a response, the next prompt should not assume they heard the entire generated answer.

Conversely, an already committed ticket must remain committed even if its confirmation sentence was interrupted or omitted from the next LLM context.

A compact decision tree
Start with one terminated call
|
|-- Does Get Call report error_llm_websocket_lost_connection?
|      |
|      |-- Yes:
|      |    inspect public_log_url and ping/pong timeline
|      |
|      |-- No:
|           follow the reported disconnection reason first
|
|-- Was ping received but pong delayed by several seconds?
|      |
|      |-- Yes:
|      |    isolate receive/heartbeat from inference
|      |
|      |-- No:
|           check proxy/runtime close, corrupt payload,
|           end_call=true, and undocumented-provider behavior
|
|-- Is Ollama prompt_eval_duration itself large?
|      |
|      |-- Yes:
|      |    inspect prompt length, exact-prefix reuse,
|      |    context shifting, placement, and model
|      |
|      |-- No:
|           inspect queueing, stale requests, HTTP delay,
|           RAG/preload, and application scheduling
|
|-- Does a superseded generation continue consuming GPU?
|      |
|      |-- Yes:
|      |    add real cancellation, late-result rejection,
|      |    and runner-health safeguards
|      |
|      |-- No:
|           move to cache and resource-contention tests
|
|-- Does exact-repeat or append-only reduce prompt evaluation?
|      |
|      |-- Yes:
|      |    preserve a stable early prefix and move dynamic
|      |    material later where the prompt format permits
|      |
|      |-- No:
|           record the exact version/model/backend and
|           investigate cache eligibility or slot assignment
|
|-- Can a ticket be committed twice after retry/interruption?
|      |
|      |-- Yes or unknown:
|           add a durable logical action ID and idempotent commit

What your 47-call result currently establishes

The sample is useful and the later-turn concentration is a real signal.

I would phrase the inference slightly more cautiously:

The failures correlate strongly with later turns, but later turn number is a proxy for several changing variables, not only context length.

By turn seven, all of these may have increased:

  • prompt length;
  • number of response supersessions;
  • number of interruptions;
  • probability of a context shift;
  • amount of dynamic RAG variation;
  • number of reconnects;
  • number of stale jobs;
  • chance of duplicate preload;
  • GPU queue pressure.

A very informative comparison would be:

  • a long prompt on turn one;
  • a short prompt after many turns;
  • a long synthetic conversation with no interruptions;
  • a short conversation with repeated response supersession.

That separates “token count” from “accumulated lifecycle state.”

Practical order of implementation

If I had to prioritize the work, I would use this order:

  1. Correlate Retell Get Call/public logs with local monotonic logs.
  2. Make ping_pong independent of generation.
  3. Implement response-scoped cancellation and late-result rejection.
  4. Make preload single-flight and once per call_id.
  5. Measure Ollama load, queue, prompt evaluation, placement, and cache behavior.
  6. Gate RAG and test CPU versus GPU embedding.
  7. Move ticket creation to a durable idempotent action layer.
  8. Only then compare models, serving runtimes, speculative decoding, or hosted fallback.

This route preserves most of the existing stack and should identify whether the real limit is model prefill, application scheduling, or accumulated stale work before you make a larger migration.

On the ping/pong hypothesis

You’re right, and this is probably the most important correction in your response. We assumed the failure mode was a 3-second first-token timeout because that’s what the timing looked like from our logs. But we were measuring local Ollama first-token, not Retell’s actual LLM latency field, and we weren’t instrumenting the ping/pong path at all.

The failure sequence you describe is plausible:

large prompt or queued request
  → longer period before Ollama stream yields
  → WebSocket receive loop cannot process ping_pong
  → no pong for five seconds
  → Retell reconnects
  → duplicate initialization
  → next response is even slower

We can’t confirm or deny this without running Control A. We’ll do it this week: replace the model response with asyncio.sleep(6) while keeping the receive loop alive and answering ping/pong. If the connection survives, the “3-second token deadline” hypothesis is wrong and we’ve been optimizing for the wrong thing.

One thing we’d add: our receive loop is currently a single async for data in websocket with RAG, Ollama, and ping/pong all handled inline in the same coroutine. If the Ollama streaming iterator doesn’t yield control frequently enough, or if httpx is doing something synchronous under the hood, the ping/pong responses could be delayed even with async/await everywhere. We haven’t verified that our Ollama client is genuinely non-blocking at every step.

We’ll instrument this before drawing conclusions.


On the three-task architecture

The separation you propose is architecturally clean:

receive task  → always receiving, immediately routes ping/pong
response task → RAG + LLM generation
outbound writer → single serialized writer to Retell

We don’t have this. Our current structure is a single async for loop that handles everything inline. That means during the Ollama streaming phase, the receive path is only unblocked between yield points in the streaming response. If Ollama batches its output (which it does for the first token especially), we could have multi-second gaps where Retell’s ping goes unanswered.

The refactor to three tasks is significant but not unreasonable. The main complexity is response ownership — when a new response_id arrives while generation is in progress, the old task needs to be cancelled cleanly and the outbound writer needs to know to discard any remaining chunks from the previous response. We currently handle this with a simple response_id check inside the streaming loop, but that only stops us from sending stale chunks — it doesn’t cancel the Ollama request itself, which means the model keeps generating and occupying the GPU slot.

This is a real problem. Cancelling the httpx stream client doesn’t guarantee Ollama stops the server-side generation. You mention the open Ollama issue about goroutines retaining runner semaphore slots after client disconnect — we’ve seen behavior consistent with this, where a superseded turn seems to slow down the next one more than context length alone would explain.


On preload and the race condition

You’re right that our current check (if "_pre_contexto" in state) doesn’t close the window where two connections observe “not loaded yet” simultaneously. The shared future pattern is the correct fix:

python

async def get_call_preload(call_state):
    if call_state.preload_task is None:
        call_state.preload_task = asyncio.create_task(load_call_context())
    return await call_state.preload_task

This requires separating call state from socket state, which brings us to the three-layer architecture you described. Right now state is a dict scoped to the WebSocket handler function. A reconnection creates a new handler with a new state dict, and we copy what we can from the previous state using the call_id. But there’s no shared object that persists across socket generations — we’re working around it rather than fixing it.

The practical consequence: during the race window at call start, both sockets can independently trigger the preload, run two concurrent Qdrant queries and two PostgreSQL lookups, and potentially issue two Ollama embedding requests. None of these fail, but they add 100-400ms of GPU contention at exactly the moment when the first response_required arrives.


On KV cache — a question back to you

You note that internal Ollama prompt caching depends on exact token prefix identity, and that dynamic RAG inserted early in the prompt invalidates the cache for everything after it.

We already moved RAG to the user message rather than the system prompt. Our current structure per turn is:

messages[0]: system (fixed, ~444 tokens, never changes)
messages[1..N-1]: conversation history (last 6 turns)
messages[N]: [episodic memory] + [RAG chunks] + "User said: [question]"

In theory, messages[0] through messages[N-1] should be identical on consecutive turns (assuming no interruption and the history window hasn’t shifted). Only messages[N] changes. This should make the prefix cache-eligible for everything except the last user message.

Open question to you: have you seen measurable prompt_eval_duration reduction with this exact pattern in practice? We can instrument it using Ollama’s final streaming response stats (prompt_eval_count, prompt_eval_duration) but we don’t have a baseline yet. If the cache isn’t actually hitting even with a stable prefix, we’d want to know what’s invalidating it — timestamps in the system prompt, chat template special tokens, or something else.


On speculative decoding — a correction

You write that “the current statement Ollama does not support this natively is no longer universally true” and reference draft_num_predict.

This is partially correct, but draft_num_predict requires either MTP (Multi-Token Prediction) tensors embedded in the model or a separate draft model. llama3.1:8b-instruct-q4_K_M from Ollama’s registry doesn’t have MTP tensors, and there’s no official llama3.1:1b draft model available for pairing. We verified this before writing the original post.

The feature exists in the Ollama Modelfile spec, but it’s not actionable for our current model. We’d be interested if you know of a working draft pairing for llama3 variants — that would change things.


On the durable action layer — where we agree but have constraints

The six-phase lifecycle you propose for ticket creation is architecturally correct:

spoken response → action proposal → validation → durable commit → action result → spoken confirmation

And the failure modes you enumerate are real:

  • stream ends halfway through the marker
  • marker spoken aloud
  • marker generated twice
  • arguments copied from RAG rather than caller’s actual words
  • superseded response still produces a side effect
  • retry creates a duplicate ticket

We’ve seen all of these except the last one (we have a _ticket_ya_creado flag that prevents duplicates within a call). The wrong-content problem — where the model summarizes the RAG chunk instead of what the caller said — is the hardest one to solve with markers.

Where we’d push back on complexity: we’re at roughly 50 calls per week. The logical_action_id with full status transitions (proposed → validated → in_progress → committed → failed) is the right pattern for a system processing thousands of actions per day with retries and distributed workers. At our scale, the operational overhead of maintaining that state machine is higher than the risk of an occasional duplicate ticket.

What we’re doing instead as a minimum viable version:

  • move ticket extraction to a second Ollama call after the conversation turn completes (not inline in the stream)
  • use the call_id + response_id combination as an idempotency key on the Supabase insert
  • validate that the ticket summary contains words that actually appeared in the caller’s utterances before committing

This doesn’t solve the interruption case (ticket committed but confirmation interrupted), but it handles the duplicate and wrong-content cases that affect us most.


On the 47-call dataset

You correctly note that turn number is a proxy for multiple changing variables, not only context length. By turn 7, prompt length, number of reconnections, GPU queue pressure, and accumulated stale jobs have all increased together.

The comparison you suggest is genuinely useful:

  • long prompt on turn 1
  • short prompt after many turns
  • long synthetic conversation with no interruptions
  • short conversation with repeated response supersession

We don’t have this data yet because we’ve been running in production rather than a controlled environment. Running synthetic call scenarios against the server would let us isolate the variables. We’ll set this up.

One thing we’d add: our production logs don’t currently include Retell’s latency.llm and latency.llm_websocket_network_rtt fields from Get Call — we’ve been logging only local timestamps. These two numbers would immediately tell us how much of the latency is network round-trip vs actual model time. We’ll add a post-call Get Call fetch to our webhook pipeline.


On the hosted fallback

You frame it as “a valid product decision rather than a failure.” We’d agree with that framing. The privacy and cost arguments for local inference are real, but they’re not absolute constraints — they’re tradeoffs.

The hybrid approach you mention is interesting:

local primary with a hosted circuit-breaker for overloaded calls

We haven’t considered this pattern. In practice it would mean: if the Ollama queue has more than N pending requests, route the next call to an external API. The complexity is in the switching logic and in maintaining prompt compatibility between the two models. Worth exploring if the latency improvements from the architectural fixes aren’t enough.


Summary of what we’re doing next

In priority order based on your recommendation:

  1. Run Control A — test ping/pong independence from inference. This will confirm or rule out the heartbeat hypothesis before we change anything else.
  2. Instrument Retell Get Call post-call — add latency.llm, latency.llm_websocket_network_rtt, disconnection_reason to our logging pipeline.
  3. Separate ping/pong handling from the inference coroutine — if Control A confirms the heartbeat is the issue, refactor the receive loop to handle ping/pong in a dedicated task.
  4. Single-flight preload per call_id — implement the shared future pattern to close the race condition at call start.
  5. Measure Ollama cache behavior — instrument prompt_eval_count and prompt_eval_duration across consecutive turns with our current prefix structure to verify whether cache reuse is actually happening.
  6. Move ticket extraction to a second pass — stop asking the conversational model to produce structured data inline. Extract it asynchronously after the turn completes.
  7. Add response cancellation that actually stops Ollama — not just stopping the stream reader but aborting the HTTP request to Ollama so the GPU slot is released.

We’ll report back with data after steps 1–2. If Control A survives the 6-second delay with active heartbeat, we’ll update the original post.

Thanks. Ah. For now, there’s one small thing I think is worth adding sooner rather than later:


For Control A, replacing the Ollama call with await asyncio.sleep(6) inside the current single async for data in websocket loop would not quite isolate the first-token timeout from the heartbeat path.

asyncio.sleep() yields control to other tasks, but it does not create another task that continues reading this WebSocket. If the same coroutine is sleeping, it is not calling websocket.receive() again, so Retell’s application-level ping_pong messages may remain unread until the sleep finishes.

That means a disconnect in that version of the experiment would still be compatible with heartbeat starvation. It would not establish that Retell has a separate approximately three-second first-token deadline.

For the control to distinguish the two paths, I think the minimum test structure needs to be something like:

receiver task
    continuously reads the WebSocket
    immediately handles ping_pong
    dispatches response_required events

delayed response task
    waits for 6 seconds
    then sends the dummy response

A serialized outbound writer would be useful too, but it is not necessary to complete the full three-task refactor before running the control. The critical part is that a receiver remains active during the artificial six-second response delay.

Python coroutines only run concurrently when they are explicitly scheduled as tasks—for example with asyncio.create_task() or TaskGroup; awaiting sleep() in the only receiver coroutine does not make that coroutine continue receiving in parallel. See the Python asyncio task documentation.

Retell’s current protocol describes ping_pong as an application message sent every two seconds when auto_reconnect is enabled. Retell expects corresponding messages back and closes/restarts the connection after five seconds without one. See the Retell LLM WebSocket protocol.

So I would interpret the control as follows:

  • The six-second delayed response survives while the independent receiver answers ping/pong: the fixed approximately three-second first-token-deadline hypothesis becomes much weaker.

  • It still reconnects, despite timely ping/pong replies: compare the Retell public log, disconnection_reason, ping receive/send timestamps, and WebSocket close reason.

  • It is tested without an independent receiver: the result cannot distinguish a response deadline from the heartbeat starvation already present in the sequential handler.

This is mostly a clarification of the test setup rather than a change to the diagnosis. Given the implementation you described, the single sequential receive loop itself is already enough to make the heartbeat hypothesis quite plausible—even if the HTTPX/Ollama iterator is technically asynchronous and yields to the event loop.

details="A smaller secondary caution about canceling the Ollama request"

Your wording earlier already recognizes this, but I would treat:

abort the HTTP request so the GPU slot is released

as something to verify, rather than as a guaranteed consequence.

Canceling the local task and closing the HTTP response are reasonable first steps. After doing so, I would check whether:

  • the old generation actually stops consuming GPU;

  • the next short request begins immediately;

  • the old request disappears from any observable queue;

  • the runner’s concurrency slot is actually released;

  • late chunks from the old response are still rejected by response_id.

There is currently an open Ollama report describing streaming handlers that may leave producer goroutines and runner semaphore slots alive after a client disconnect: Ollama issue #17131.

That report was reproduced against Ollama’s then-current main, not necessarily the exact release you are running, so I would not assume you have that bug. It is just a good reason not to use “the client connection closed” as proof that server-side generation stopped.

A useful cancellation probe would be:

start long generation A
    -> supersede A with a new response_id
    -> cancel and close A locally
    -> immediately submit short generation B
    -> measure when B actually starts

If B remains delayed while A continues using the GPU, that would support the stale-request/runner-slot hypothesis independently of context length.

/details

Other than that, the ordering you proposed looks sensible to me. The correctly isolated Control A and the Retell Get Call measurements should be much more informative than adding more hypotheses at this point.

You’re right, and this maps directly onto the exact bug pattern we’re trying to test for in the first place — a single sequential coroutine can’t prove anything about heartbeat independence if it’s the same coroutine doing the sleeping. Good catch, that would have given us a false negative (or false positive) either way.

Corrected Control A structure we’ll run:

python

async def receiver_task(websocket, state):
    while True:
        data = await websocket.receive_json()
        if data["interaction_type"] == "ping_pong":
            await websocket.send_json({
                "response_type": "ping_pong",
                "timestamp": data["timestamp"]
            })
        elif data["interaction_type"] == "response_required":
            state["response_id"] = data["response_id"]
            state["pending_response"].set()

async def delayed_response_task(websocket, state):
    await state["pending_response"].wait()
    await asyncio.sleep(6)
    await send_chunk(
        websocket, state["response_id"],
        "This is a delayed test response.", True, False
    )

async def handler(websocket):
    state = {"pending_response": asyncio.Event()}
    await asyncio.gather(
        receiver_task(websocket, state),
        delayed_response_task(websocket, state),
    )

The receiver now runs as an independently scheduled task via asyncio.gather, not as a coroutine sharing the same execution path as the delay — so ping/pong gets answered regardless of what the response task is doing. That’s the piece our original version was missing.

We’ll run this against a real test call this week and report back with the outcome.

While we’re at it, we’ll also run the cancellation probe you described — supersede a long generation A with a new response_id, cancel it, immediately fire a short generation B, and measure whether B is delayed. If A doesn’t actually release the runner slot on cancel, that would bump response cancellation above preload single-flighting in our priority order — a stuck runner slot would degrade every subsequent turn regardless of how clean the preload logic is, so it’d be the higher-leverage fix.

Will post both results once we have them.