With Microsoft.Extensions.AI, switching from a hosted model to a local Ollama server really is close to a one-line change: register a different IChatClient and you're done. The code compiles, the first request works, and you move on.
What doesn't carry over are the assumptions. A hosted API is a big shared service with generous limits that tells you when something is wrong. A local Ollama server is one process, usually running one request at a time, with its own defaults, and it's quiet about its problems.
Here are the five assumptions that bit me while building a local-LLM chat API in C#, how each one showed up, and the shape of the fix.
Tech Skill Builder is $5/month for a very limited time only (normally $10). Grab it now before it goes back to $10. The complete, tested project behind this post (a .NET 10 local-LLM chat API with Clean Architecture and 187 passing tests, running offline against an Ollama simulator) plus a step-by-step PDF guide is inside. Join Tech Skill Builder
A note on honesty before we start: my numbers below come from an in-process simulator of Ollama's HTTP API sitting behind the real OllamaSharp client, modelled on the Ollama FAQ and public issue reports. I didn't run a real model or a GPU for them. Treat them as illustrations, and measure your own setup.
Mistake 1: "If it returned 200, the model saw my prompt"
With a hosted API, a prompt that's too long gets rejected with a clear error. Ollama's documented default context window is 4096 tokens, and public issue threads describe the server cutting an oversized prompt, logging a warning on its own side, and answering normally with a 200. The oldest content goes first, which can include your system prompt.
How it shows up: confident, wrong answers about things that were clearly in the input. In my simulator run with default settings, 15 of 29 long-document questions came back with a 200 and a wrong answer.
The fix, in shape: the response has no "truncated" flag, but it does report prompt_eval_count. Compare it to what you sent and to the window:
public TruncationSigns Inspect(int estimatedPrompt, int reportedPrompt, int outputTokens, int contextWindow) { var signs = TruncationSigns.None; if (reportedPrompt >= contextWindow * 0.97) signs |= TruncationSigns.PromptAtLimit; // ...two more checks (prompt shrank, window exhausted) // full implementation in the complete project return signs; } It's a heuristic, not proof (a prompt cache can lower the count too), so I surface it as a warning by default.
Mistake 2: "The context window is a property of the model"
It's a property of the request. If you don't send num_ctx, you get the server's default, whatever the model could handle. ChatOptions has no typed property for it, so it's easy to never set it.
The fix, in shape: the gateway sends the window and the keep-alive on every request, and a planner decides what fits before the call, instead of letting the server decide what to throw away:
public ContextPlan Plan(Message system, IReadOnlyList<Message> history, Message newest, int limit) { // 1. system + newest must fit, otherwise refuse (413), send nothing // 2. drop the oldest turns in question/answer pairs until it fits // 3. estimate pessimistically, learn from reported token counts // full implementation in the complete project } The trade-off is real: a pessimistic estimate occasionally refuses a prompt that would have fit. I'd rather get a clear 413 than a quietly wrong answer.
Mistake 3: "Concurrency is the server's problem"
OLLAMA_NUM_PARALLEL defaults to 1. Everything else waits in a server-side queue that you can't see from your app, which defaults to 512 places. Under a burst, your users just wait, and wait.
How it shows up: latency that climbs with load and never errors. In the simulator, 30 simultaneous requests gave a p95 of 3,311 ms.
The fix, in shape: put a small, visible gate in front of the model: as many slots as the server has, a short queue, a maximum wait, and a fast 429 with Retry-After beyond that.
public async Task<GateEntry> EnterAsync(CancellationToken ct) { // free slot -> run now // queue full -> QueueFull immediately (maps to 429) // waited longer than MaxWait -> WaitedTooLong (maps to 429) // full implementation in the complete project } With a queue of 4 and a 1-second wait, 5 requests were accepted at a p95 of 626 ms and 25 got a quick 429. A short line with a fast "no" beats a long line with a slow "yes".
Mistake 4: "The default resilience handler is fine"
AddStandardResilienceHandler gives each attempt 10 seconds and retries on timeouts. Both defaults hurt here. A non-streaming chat returns headers only when the whole answer is done, and a cold load of a bigger model can take longer than 10 seconds by itself. Retrying a timed-out request usually means the server is still computing the first one, so you've just doubled the load.
The fix, in shape: set the attempt timeout to the real time-to-finish you're willing to accept, size the total timeout from it, and retry only on connection failures and 503, never on timeouts. One gotcha: the standard handler won't accept zero retries.
http.AddStandardResilienceHandler(r => { r.AttemptTimeout.Timeout = options.RequestTimeout; r.Retry.ShouldHandle = args => ValueTask.FromResult(IsTransient(args.Outcome)); // total timeout sizing + IsTransient: full implementation in the complete project }); Mistake 5: "Healthy means ready"
The process being up doesn't mean the model is in memory. The first request after a start, or after the default five idle minutes, pays for the load. In the simulator, that first request took 1,693 ms cold versus 96 ms warm.
The fix, in shape: a hosted worker sends an empty chat request at startup (the Ollama FAQ documents this as a way to preload a model), keeps the model alive, and /health/ready returns 503 until the model is actually loaded. Your load balancer then never sends a real user into a cold start.
Bonus: when the line gets long, get faster instead of longer
The project also has a brownout: when the queue-wait p95 crosses a threshold, requests switch to a smaller fallback model, with separate enter and exit thresholds so it doesn't flap. In the simulator, a steady stream of 60 requests went from a p95 of 3,042 ms to 415 ms. That's a quality trade, so it's a decision you make on purpose, not a default.
Quick checklist
- [ ]
num_ctxandkeep_aliveare sent on every request. - [ ] Context is planned before the call; oversized input gets a clear 413.
- [ ]
prompt_eval_countis compared with what you sent. - [ ] A bounded gate matches
OLLAMA_NUM_PARALLELand sheds load with 429. - [ ] Timeouts fit a cold load; timeouts are never retried.
- [ ] Readiness means "model loaded", not "process running".
- [ ] Ollama stays on localhost, behind your own auth.
Get the complete version
This post gives you the map and the skeletons. The complete project fills them in: the context planner with its self-correcting token estimator, the truncation detector with an opt-in strict mode, the bounded gate, the resilience setup, the warm-up worker and readiness check, the brownout controller, a streaming endpoint with separate first-token and total budgets, metrics, and the tests that prove each piece.
The complete, working, tested project (.NET 10, Clean Architecture, 187 passing tests: 156 unit + 31 integration, runs offline against an Ollama simulator, no model or GPU needed, with a compose file for a real Ollama) plus a step-by-step PDF guide is in Tech Skill Builder. It's $5/month for a very limited time only (normally $10). Grab it now before it goes back to $10: https://elitesolutions1.gumroad.com/l/TechSkillBuilder
Have you moved anything from a hosted model to a local one? I'm curious which of these surprised you, or what broke first for you. Tell me in the comments.
