Voice agent latency degrades after turn 7-8 despite fixed system prompt + limited history — looking for mitigation ideas beyond what we've already tried

Following up on an earlier thread (KV cache reuse, two-WebSocket race condition, RAG embedding overhead). Six weeks later, in production, the core problem hasn’t moved: first-token latency still climbs as a call gets longer, and past turn 7-8 it occasionally crosses Retell’s ~3s reconnect threshold and drops the call.

What’s already in place:

  • Fixed system prompt (~444 tokens, unchanged across turns for caching)
  • History capped at last 6 turns
  • RAG injected into the user message, not the system prompt
  • Both models pinned in VRAM (keep_alive: -1)
  • Separate async task for ping/pong heartbeat, independent of response generation

None of that fixes the underlying issue: Ollama re-processes the full KV cache every call, so total input tokens (system + RAG + memory + history) climbing with conversation length means prefill time climbing with it. No stateful session between turns as far as we can tell.

Two things we haven’t tried yet, wanted to sanity-check before sinking time into either:

1. Aggressive summarization instead of raw history truncation. Right now hitting 6 turns just drops the oldest ones. Alternative: once past turn 4-5, replace older turns with a single running summary (generated once, cheaply, off the hot path) instead of dropping them outright — keeps the model aware of earlier context without paying full prefill cost for it. Anyone done this in a live voice pipeline and hit issues with the summary going stale or losing something the model needed a few turns later?

2. Splitting inbound (fast, simple) vs outbound (complex, slower) onto genuinely different model sizes per turn-depth, not just per-agent like we do now. E.g., use the 8B model for turns 1-6, and if a call goes long, hand off remaining turns to something smaller/faster even at some quality cost — the tradeoff being a slightly worse late-call response beats a dropped call. Feels hacky. Is this a known pattern, or does the model-swap overhead mid-call eat the savings?

Also still curious, from the original thread, whether anyone has gotten real mileage out of speculative decoding or KV cache persistence with Ollama specifically — still doesn’t look natively supported, but wondering if anyone’s forced it via a custom build.

Stack: Retell AI · Ollama · llama3.1:8b-instruct-q4_K_M (inbound) · qwen2.5:14b (outbound) · Qdrant · FastAPI WebSockets · Hetzner GEX44 · RTX 4000 Ada.

Hmm… for now, I ran a few experiments in Colab:


My current read is:

Idea My answer
Running summary Yes, worth testing — but not necessarily as an LLM job on every turn. I would use server-owned structured state + a periodically refreshed lossy summary + a few recent raw turns.
Switching to a smaller model late in the call This is a known kind of routing pattern, but turn number alone is a weak routing signal. Route by task difficulty, latency budget, risk, queue state, and whether the model is already resident.
Persistent KV/session state in Ollama keep_alive is model residency, not a caller-owned conversation KV session. Ollama now has internal prompt-cache/KV reuse, but its documented chat interfaces still do not expose a portable session handle that the application can carry between turns.
Speculative decoding It is no longer accurate to say Ollama has no support at all. Current Ollama documents draft_num_predict when a compatible draft model or embedded MTP tensors are available, but I would only investigate it after confirming that decode — rather than prefill, queueing, or relay buffering — is the dominant delay.
Streaming Check this before changing models. Ollama streams by default, and Retell accepts partial responses. If the backend waits for the complete Ollama answer before forwarding it, that can add a large avoidable silence.

One additional possibility stood out to me: the six-turn cap itself may create the turn-7/8 boundary.

If “last 6 turns” means six completed user/assistant exchanges, request turn 8 is exactly where the first old exchange is removed. The retained token count can remain roughly bounded while the exact reusable prompt prefix changes sharply. In that case, turn 7–8 is not a mysterious model threshold; it is the first sliding-window/cache boundary.

So the default route I would try is:

  1. Compare the exact requests immediately before and after the first history eviction.
  2. Verify that Ollama chunks are forwarded to Retell as soon as a short speakable clause exists.
  3. Separate model load, prompt evaluation, application/queue wait, WebSocket relay, and TTS.
  4. Keep summary/RAG/helper work from entering the voice model’s capacity ahead of the voice request.
  5. Only then compare summary cadence, model routing, true parallel slots, and speculative decoding.

A small cross-layer timeline would probably answer more than another broad architecture change:

response_required_received
prompt_build_started
ollama_request_started
ollama_first_content
first_speakable_clause
first_retell_send
ollama_complete
cancel_requested
ollama_stream_closed

For each turn I would keep:

response_id / generation_id
rendered_prompt_hash and token count
common prefix with the previous rendered prompt
load_duration
prompt_eval_count / prompt_eval_duration
eval_count / eval_duration
active and queued request counts
model, residency, runner.parallel, and slot ID

Ollama exposes the load, prompt-evaluation, and generation metrics in the final response chunk; the durations are in nanoseconds (API usage metrics). On the Retell side, llm runs to the first speakable chunk and includes WebSocket round-trip time for a custom LLM, while llm_websocket_network_rtt, asr, tts, and e2e help isolate the remaining layers (Retell latency breakdown).

A compact decision tree would be:

Latency worsens around turn 7–8
|
|-- Does the first bounded-history eviction occur there?
|      |
|      |-- Yes, and retained tokens stay similar while the common prefix falls
|      |      -> sliding-window / prompt-cache-boundary branch
|      |
|      |-- No
|             -> continue below
|
|-- Is Ollama's first streamed content itself late?
|      |
|      |-- load_duration or residency changes
|      |      -> model unload/reload, swapping, or partial offload
|      |
|      |-- prompt_eval_duration jumps
|      |      -> prefix reuse, prompt layout, summary/RAG rewrite
|      |
|      |-- model-reported work is small but wall time grows
|             -> queue, helper work, app scheduling, HTTP/proxy delay
|
|-- Ollama is early, but first_retell_send / Retell llm is late
|      -> backend buffering, clause buffer, proxy, WebSocket RTT/writer
|
|-- Retell llm is early, but e2e is late
|      -> ASR endpointing, TTS, audio playout, turn coordinator
|
|-- Slowdown follows summary/RAG/helper overlap
|      -> postpone, coalesce, isolate capacity, or verify true parallel slots
|
|-- Slowdown follows interruption/reconnect
       -> stale generation, late chunks/actions, or heard-history mismatch

The most relevant Colab observations were these (all synthetic mechanism controls, not a reproduction of the RTX 4000 Ada + Retell deployment):

  • In a six-exchange sliding-window test, the first eviction happened at request turn 8. Median prompt evaluation rose from about 0.206 s before eviction to about 0.465 s after it, with the boundary turn around 0.513 s, even though the retained history remained bounded.
  • Ollama streaming produced a first speakable clause at about 1.12 s, versus about 2.63 s for full completion. Buffering the whole answer would therefore have added about 1.51 s in that fixture.
  • With one Llama 3.1 runner slot, submitting a long summary before the voice request changed voice first-clause latency from about 0.85 s to roughly 7.6–8.4 s.
  • With two confirmed same-model runner slots, that maintenance-before-voice penalty fell to about 0.63 s. However, the contended voice request was still around 70% slower than its solo baseline, memory usage rose, and the total time to finish both jobs did not materially improve.

That combination suggests that “off the hot path” needs two separate meanings:

  1. the work is launched asynchronously in application code; and
  2. the work does not queue ahead of, or compete heavily with, the latency-critical voice request.

The first does not guarantee the second.

Why the six-turn limit itself may create the cliff

Suppose the prompt layout is approximately:

stable system prompt
+ current RAG
+ last six completed exchanges
+ new user message

While the conversation is still filling the six-exchange window, each new prompt can look mostly append-only. When the seventh completed exchange arrives, the oldest exchange is removed from near the front of the dynamic history and a new one is appended at the end.

Even if the resulting prompt has nearly the same number of tokens, it may no longer share the same long exact prefix with the previous rendered prompt.

That distinction matters because prompt/KV reuse is tied to the rendered token sequence, not merely to semantic similarity or total token count. Ollama’s recent releases explicitly mention improvements to prompt caching and KV reuse, so I would avoid the blanket statement that it necessarily reprocesses everything on every call (Ollama releases). At the same time, reuse is model-, version-, template-, and prompt-shape-dependent, so it should be measured rather than assumed.

In my synthetic control:

  • the first six-exchange eviction occurred at request turn 8;
  • median prompt_eval_duration was about 0.206 s before the eviction and about 0.465 s afterward;
  • the boundary request was about 0.513 s;
  • an append-only control at the corresponding turn was about 0.152 s;
  • the computed common-prefix fraction fell to roughly 54%;
  • debug logs showed little useful checkpoint restoration at that boundary.

The wall-clock first-content latency was noisier and did not show an equally clean single-turn jump, so I would treat this as a credible mechanism candidate, not the production root cause.

A cheap production test would compare turns 6–9 under:

A. append-only history control
B. last six completed exchanges
C. last six individual speaker messages
D. same token count, but one early prompt mutation
E. same token count with a stable prefix

The important artifacts are the exact rendered prompts or hashes, common-prefix length, prompt_eval_count, prompt_eval_duration, and cache/checkpoint debug lines. This also resolves an ambiguity in “six turns”: six speaker messages and six complete exchanges reach their first eviction at different request numbers.

Running summaries: useful, but there are three separate costs

I think a running summary is reasonable, but I would evaluate three independent questions:

  1. Information loss: did it drop or mutate a correction, commitment, exact number, consent scope, or completed tool result?
  2. Prefix churn: did rewriting the summary invalidate a large reusable prompt prefix?
  3. Compute contention: did generating the summary occupy the same Ollama runner/GPU when the next voice turn arrived?

A structure like this seems safer:

Canonical operational state
├── exact identity/contact fields
├── appointment state
├── consent and cancellation state
├── completed tool results
└── versioned events

Prompt projection
├── stable policy and tool definitions
├── compact current-task state
├── periodically refreshed lossy summary
├── recent raw turns
└── relevant RAG evidence for this turn

The free-text summary can hold the caller’s goal, relevant background, unresolved questions, and conversational commitments. I would not make it the only source of truth for exact phone numbers, dates, authorization, consent, cancellations, or whether an external action succeeded.

This is also consistent with Retell’s current recommendation to keep explicit per-call state on the server rather than asking the model to infer all operational state from the transcript (custom LLM best practices).

Refresh cadence

The more promising result in my cache probe was not simply “put the summary later.” It was keep it stable for an epoch.

When the summary was rewritten every turn, prompt evaluation remained around 0.50 s in both of the tested positions. When it was refreshed only every few turns, refresh turns were relatively expensive (around 0.56 s), but the append-like turns between refreshes were around 0.15 s.

So I would try refreshing on events such as:

  • a task-phase transition;
  • a confirmed correction or tool result;
  • a token threshold;
  • an important fact about to leave the recent raw window;
  • a coarse epoch rather than every turn.

I would not claim that placement is irrelevant — my placement variation was limited — only that update frequency and stability mattered much more in that fixture.

Scheduling the summary

A background coroutine is not automatically background capacity.

With a single runner slot, a summary request submitted first made the short voice request wait several seconds. Starting maintenance only after the first speakable voice clause largely protected response onset.

A reasonable admission policy is:

Summary work is pending
|
|-- Can it wait?
|      -> start after first speakable clause or turn completion
|
|-- Can it use separate capacity?
|      -> separate resident model, Ollama instance, GPU/server, or CPU path
|
|-- Must it overlap on the same model?
       -> verify actual parallel slots and benchmark the tradeoff

I would also coalesce pending summaries (“latest version wins”), stamp the input/output with a state version, discard stale results, and avoid submitting maintenance while a voice request is already waiting.

What true Ollama parallelism did — and did not — solve

Ollama’s documented default is one parallel request per model. When capacity is unavailable, requests queue; increasing OLLAMA_NUM_PARALLEL also increases context-memory allocation roughly with parallel count × configured context (Ollama FAQ).

There are two important cautions:

  1. Setting the environment variable does not prove the model architecture actually received that many slots.
  2. Parallel inference shares GPU compute; it can improve fairness without improving total throughput.

In an earlier Qwen3.5 control, the server logged that the architecture did not support parallel requests and created only one slot. So that result was invalid as a parallel=2 test. In the later Llama 3.1 8B control, I verified runner.parallel=2, two distinct slot IDs, and actual overlap.

For the valid Llama 3.1 comparison:

Condition Voice first speakable clause
parallel=1, voice alone ~0.852 s
parallel=1, summary submitted first ~7.59–8.41 s
parallel=2, voice alone ~0.898 s
parallel=2, summary submitted first ~1.524 s

So two slots reduced the queue-induced penalty from roughly 6.7–7.6 s to about 0.63 s — around a 91% reduction in that particular penalty.

But it did not create free compute:

  • the voice request under contention was still about 70% slower than the parallel=2 solo baseline;
  • summary generation also became slower;
  • model memory rose by roughly 256 MiB at a 2K context in that T4 fixture;
  • peak power increased;
  • total pair completion time was approximately flat or slightly worse.

I would therefore describe parallel=2 as a latency/fairness guardrail when overlap is unavoidable, not as the first-line solution and not as a throughput doubling switch.

Preferred order:

  1. avoid unnecessary every-turn maintenance;
  2. coalesce it;
  3. start it after the first voice clause or after the turn;
  4. isolate it onto separate capacity where practical;
  5. only then benchmark true same-model parallelism.

If using it, verify the server’s actual runner/slot logs rather than trusting configuration alone. There are model-specific cases where requested parallelism is downgraded; for example, this has been reported for Qwen3.5 (Ollama issue #14621).

Streaming is an end-to-end property, not just an Ollama setting

Ollama’s native API streams by default; stream:false disables it (Ollama streaming documentation). Retell accepts partial response events grouped by response_id, with only the final event marked content_complete:true (LLM WebSocket protocol).

The useful pipeline is therefore:

Ollama NDJSON stream
→ FastAPI stream reader
→ short speakable-clause buffer
→ serialized Retell WebSocket writer
→ Retell TTS
→ audio playout

I would check for buffering at every boundary:

  • stream:false accidentally set;
  • SDK returning a completed response rather than an async stream;
  • chunks accumulated into a list/string before forwarding;
  • reverse-proxy buffering;
  • waiting for a long full sentence rather than a short clause;
  • WebSocket writer backlog or multiple competing writers;
  • time between ollama_first_content, first_speakable_clause, and first_retell_send.

Retell explicitly says it starts speaking when it has the first complete sentence, and recommends streaming rather than putting the whole generation inside caller silence (best practices).

In my short fixture:

first content             ~0.825 s
first speakable clause    ~1.12 s
full completion           ~2.63 s

So forwarding the first short clause could avoid about 1.51 s compared with full-response buffering. The gap from first content to a speakable clause was only about 0.29 s, which also suggests that forwarding single-token fragments may not be worth the TTS overhead or awkward prosody.

A simple flush rule could be:

flush when punctuation + minimum length is reached
OR when a bounded maximum wait expires

Streaming will not fix model load, queueing, prompt prefill, TTFT itself, GPU contention, or TTS/ASR latency. It only lets useful generated content leave the backend earlier.

Smaller-model fallback: direct routing is different from a cascade

I would distinguish:

direct routing:
  choose one model before generating this turn

cascade:
  call the small model, then possibly call the large model too

For a voice hot path, direct routing is usually the safer default because a cascade adds another synchronous model call on escalation. Retell’s own guidance notes that chained LLM calls may be unfavorable for conversational latency (custom LLM best practices).

Rather than turn >= 7, possible routing inputs are:

  • task type and ambiguity;
  • latency deadline;
  • action/tool risk;
  • current queue/GPU pressure;
  • model residency;
  • acceptable degradation for this response.

A smaller model may be suitable for a short acknowledgement, simple clarification, fixed workflow navigation, or reading already-confirmed state. I would keep the stronger model for multiple corrections, ambiguous intent, sensitive replies, high-risk tool selection, and conflicting memory/state.

The residency branch matters:

Both models fit and remain resident
    -> benchmark direct routing

Only one model fits
    -> measure unload/load and frequent swap cost

Helper shares the same GPU
    -> measure contention, not just standalone helper speed

Separate capacity exists
    -> helper/background processing becomes more credible

In my state-extraction probes, a Qwen3.5 4B residual model improved recall, but when it was called broadly the synchronous extraction path was still around 3.1 seconds. “Small model” did not automatically mean “safe for every voice turn.”

So I think your idea is a legitimate overload policy, but I would route by task/deadline and verify residency rather than tying it directly to conversation depth.

KV reuse and speculative decoding: a few terms that are easy to conflate

KV/cache

I would separate:

  1. Model residency — weights remain loaded (keep_alive).
  2. Runtime-owned prompt/KV reuse — Ollama internally reuses eligible prompt state/checkpoints.
  3. Application-owned persistent conversation state — the caller receives a session handle and explicitly resumes that exact inference state next turn.

keep_alive:-1 addresses (1), not (3).

Current Ollama does have internal prompt caching, and recent releases include KV-reuse improvements (releases). But the currently documented interfaces do not expose the portable application-owned conversation handle you appear to be looking for. The OpenAI Responses compatibility is explicitly non-stateful: previous_response_id and conversation are not supported (OpenAI compatibility).

That does not imply zero internal reuse. The practical test matrix is:

exact repeat
append-only messages
first sliding-window eviction
one early-token mutation
summary rewritten every turn
summary held stable for an epoch
RAG inserted early
RAG appended late

For each condition, save the exact rendered prompt/template, prompt token count, common prefix, Ollama version, prompt-eval metrics, and debug cache/checkpoint lines.

Speculative decoding

Ollama currently documents draft_num_predict: the maximum speculative draft tokens per step when a draft model is available, with support for a separate draft model or embedded MTP tensors (Modelfile reference).

That changes the answer from “not supported” to “conditionally available.” It does not establish that a useful compatible draft setup exists for llama3.1:8b-instruct-q4_K_M, nor that it would improve the stage currently causing your dropped calls.

I would use this branch:

load / queue / prompt prefill / relay dominates
    -> speculative decoding is not the first lever

decode-to-first-clause dominates
and a compatible draft/MTP path exists
    -> benchmark it

no practical compatible draft path
    -> only consider a runtime migration after estimating the gain
Interruption, history, and tool-side invariants from realtime agent systems

A few patterns from mature realtime/VTuber-style agents seem directly transferable here.

Scope everything to a response generation

response_id / generation_id
├── RAG
├── summary
├── Ollama generation
├── outbound text chunks
├── TTS/audio
├── tool calls
└── state-update candidates

Retell assigns an auto-incrementing response_id; when it asks for a new response, prior responses are discarded (WebSocket reference). I would mirror that lifecycle internally: cancel old work where possible, and reject late chunks/actions even when cancellation is not instantaneous.

Keep the history aligned with what the caller actually heard

Generated text, sent-to-TTS text, and actually played text are not always the same after an interruption.

Open-LLM-VTuber retains only content before interruption in chat history/memory. LiveKit similarly truncates conversation history to the portion the user heard.

That avoids two opposite errors:

  • keeping an unseen generated tail and acting as though the caller heard it;
  • dropping everything and making the agent repeat content that was already spoken.

It may also prevent “ghost history” from adding unnecessary assistant tokens over many interrupted turns.

Treat tool writes separately from generated speech

Retell now recommends server-owned call state, idempotent side effects, current-response checks, and raw-frame logging (best practices). This matters because a response may be discarded after a tool write has already committed.

For read-only work such as retrieval, cancellation and late-result rejection are usually straightforward. For appointment creation, cancellation, SMS, payment, or call termination, I would add idempotency and an explicit commit boundary. Pipecat’s APIs similarly distinguish function-call cancellation/interruption behavior (function-call frames).

One practical lifecycle is:

final/stable transcript
→ parser or LLM candidate
→ evidence / correction / negation checks
→ current-response and authorization check
→ idempotent deterministic commit
→ spoken confirmation

In my text-level probes, letting the LLM directly patch canonical state produced wrong or unauthorized updates. Letting it propose evidence-backed candidates while deterministic code validated/committed them prevented wrong commits in the tested fixtures. That is only a small mechanism probe, but it reinforces the separation between conversational memory and operational state.

What I would probably do in this stack

  1. Confirm that Ollama output is streamed through FastAPI to Retell without full-response buffering.
  2. Instrument the exact transition around the first six-turn history eviction.
  3. Compare rendered-prefix similarity and prompt_eval_duration, not retained token count alone.
  4. Keep exact operational state on the server; refresh the lossy summary by threshold/epoch rather than every turn.
  5. Do not let summary/RAG/helper work enter a single-slot voice runner first.
  6. Prefer postponement or separate capacity; use confirmed same-model parallelism only when overlap is unavoidable and its memory/per-request slowdown is acceptable.
  7. Route directly by task/deadline/risk only when model residency or swap cost is measured.
  8. Scope work by response_id, discard stale results, and align history with the played prefix.
  9. Benchmark speculative decoding only if decode-to-first-clause remains the dominant stage after those checks.

The short final branch is:

If Ollama first content is late:
  inspect load, prompt evaluation, and queue/application residual.

If the jump begins exactly at history eviction:
  inspect rendered-prefix reuse and summary cadence.

If Ollama is early but Retell receives late:
  inspect relay, proxy, clause buffering, and WebSocket timing.

If Retell llm is early but e2e is late:
  inspect ASR endpointing, TTS, and audio playout.

If helper work overlaps the slow turn:
  postpone, coalesce, isolate, or benchmark confirmed parallel slots.

The most useful compact artifact would probably be one early-turn and one late-turn timeline — or just turns 6–9 with rendered-prompt hashes/common-prefix values plus Ollama’s final usage metrics. That should make the next branch fairly mechanical rather than requiring another large rewrite.

A final limitation: these were small Colab T4 controls using Ollama 0.32.5, mostly Qwen3.5 4B and Llama 3.1 8B. They did not reproduce Retell, your FastAPI/Qdrant path, acoustic ASR/TTS, the RTX 4000 Ada, or production traffic. The timings should therefore be read as evidence that these mechanisms can create the relevant latency shapes, not as a diagnosis or performance prediction for your deployment.