Hybrid LLM routing in production: when Ollama beats Groq (and when it doesn't)
Notes from production: we route between Ollama (local) and Groq (cloud) using confidence-gated fallback at Josh AI. Here's the routing logic, the metrics that actually matter, and the failure modes that took us by surprise.
- LLM
- Ollama
- Groq
- Production
- Reliability
- Josh AI
Hybrid LLM routing in production: when Ollama beats Groq (and when it doesn't)
At Josh AI we route LLM calls between a local Ollama deployment and Groq's cloud API. This is not a "look how clever we are" story. It's a "we had to do this to keep the product reliable" story. Here's how the routing actually works and what the operational reality looks like.
Why we needed a fallback strategy
Groq is fast and cheap and we like it. It is also a single external dependency. When Groq has an incident — and like every cloud provider, it occasionally does — every screening interview that's mid-flight at that moment sees latency spike or requests fail. Recruiters notice, candidates notice, and the operational headache is real.
The naive answer is "fall back to OpenAI." But OpenAI is another network hop with its own SLO, its own pricing, and its own failure modes. You're trading one external dependency for another and hoping they don't fail in correlated ways. (They occasionally do.)
So we added a local fallback: Ollama running on our Azure VM, holding a smaller model with a much narrower job. The routing decision is per-request and considers the request type, the request's latency budget, and a runtime health signal from each provider.
The routing logic
In pseudo-code, it looks something like this:
`python
def route(request: LLMRequest) -> Provider:
# Health gate: skip any provider currently tripped
candidates = [p for p in providers if not p.circuit_breaker.tripped]
# Job-specific routing if request.kind == "rerank_candidates": # Latency-critical, narrow task — prefer local return prefer(candidates, [ollama, groq]) if request.kind == "rubric_evaluation": # Quality-critical, wider task — prefer cloud return prefer(candidates, [groq, ollama_fallback_with_warning]) if request.kind == "live_interview_followup": # Hard real-time — prefer local, never wait return prefer(candidates, [ollama], require_under_ms=400)
# Default
return prefer(candidates, [groq, ollama])
`
A few things are doing a lot of work here:
When Ollama actually beats Groq
This is the part that surprised us.
For narrow, repeatable, structured-output tasks with tight latency budgets, a well-prompted local 7B model is sometimes a better product choice than a cloud frontier model. Specifically:
When Groq still wins decisively
The metric that actually matters
When we first deployed this, I was tracking per-request latency and per-provider success rate. Those are necessary but not sufficient. The metric that ended up mattering most was end-to-end task completion rate — what fraction of recruitment campaigns finish without a human-visible LLM error. That metric forced us to think about correlated failures, retry budgets, and the cost of fallback degradation in a way the per-request metrics did not.
The failure mode that took us by surprise
Cold start.
Ollama has to load the model into memory on first request after a process restart. On our Azure VM that's a 4–6 second cold start. The first request after a deploy or a process recycle was always slow, sometimes timing out under load. The fix was boring: a warm-up health-check that fires after process startup and keeps the model resident. Nothing clever, just operational discipline.
The interesting bit is what this implied for our routing logic: we couldn't just route based on Ollama "being up." We had to route based on Ollama being up AND warm. That added a third state to the breaker (cold) with its own routing implications.
What I'd tell a team starting today
1. Don't build routing complexity speculatively. Start with one cloud provider. Add a local fallback only when you have a concrete reliability problem you've measured. 2. Per-job routing pays off. A single global "use cheaper model first" heuristic misses too much nuance. Routing by request kind is more code but more correct. 3. Cold starts are real. Plan for them. The cleverest routing logic in the world doesn't help if your fallback takes 5 seconds to warm up. 4. Measure end-to-end task completion, not per-request success. The former forces you to think about user-visible reliability. The latter is easy to game and not what you actually care about.
We've now been running this hybrid setup in production for several months. Groq still handles most of our traffic. Ollama catches us on the bad days. The end-to-end reliability of the product is meaningfully better than it was when we depended on Groq alone, and the operational cost has been manageable. That's the win.
Related Posts
Why we chose DuckDB over pgvector for embedded semantic search at Josh AI
An architecture decision write-up: we evaluated pgvector, FAISS, and DuckDB for our candidate-search vector index at Josh AI. DuckDB won. Here's how we got there, the tradeoffs we accepted, and what we'd change in v2.
Building a 96%-accurate Hebrew OCR pipeline: lexical post-processing as the secret weapon
Notes from Introva: how we got from 78% to 96% accuracy on Hebrew historical text OCR. The model wasn't the bottleneck — the post-processing layer was. A 23,000-word lexical database did most of the heavy lifting.
Voice-first recruiting: how we built AI-led screening interviews on Vapi
An architecture deep-dive on Josh AI's voice interview system. Vapi for the call layer, MediaPipe + TF.js for proctoring, transcript-driven rubric scoring, and the hard problems that show up only in production.