.NETAspireOllamaAI Agents

Run Your Whole AI Agent Stack Locally with Aspire (Ollama + Qdrant + Traces)

Wire an LLM, a vector database and your API into one F5 with Aspire — no docker-compose, no API keys during development, and OpenTelemetry traces of every model call for free.

Getting a .NET AI agent running on a new machine usually looks like this: install Ollama, pull a model, run a Qdrant container, copy an appsettings.Development.json from a colleague, discover the ports collide, and then find out someone’s API key expired. Multiply by every new joiner.

Aspire collapses that into F5. It is not an AI framework — it is orchestration and observability for the supporting cast around your agent, and that turns out to be exactly the painful part.

What you get

An AppHost project describes your local topology in C#, and Aspire starts everything, wires the connection strings, and gives you a dashboard with logs, traces and metrics across all of it.

For an agent, that topology is usually:

  • an LLM (Ollama locally, a hosted model in production)
  • a vector store for RAG
  • your API and maybe a worker
  • optionally a cache and a relational database

Two properties make this worth setting up rather than clever-but-optional. First, the AppHost is C#, so it refactors, autocompletes and fails at compile time — none of which a docker-compose.yml does. Second, the dashboard shows model calls on the same trace timeline as your HTTP and database spans, which is how you discover that the “slow LLM” was actually three sequential retrieval round-trips.

The AppHost

Add the integrations you need. Aspire ships a CLI for this:

aspire add qdrant

Then describe the stack:

var builder = DistributedApplication.CreateBuilder(args);

// Local inference. Aspire runs the Ollama container and pulls the model on startup.
var ollama = builder.AddOllama("ollama")
    .WithDataVolume();                      // keep pulled models across restarts

var chatModel = ollama.AddModel("llama3");

// Vector store for retrieval.
var qdrant = builder.AddQdrant("qdrant")
    .WithLifetime(ContainerLifetime.Persistent);   // Qdrant is slow to start; keep it warm

builder.AddProject<Projects.AgentApi>("agent-api")
    .WithReference(chatModel)
    .WithReference(qdrant)
    .WaitFor(qdrant);

builder.Build().Run();

That is the entire local environment. No compose file, no port allocation, no README section titled “Local setup (please read carefully)”.

A few details that save time:

  • WithDataVolume() on Ollama is not optional in practice. Without it, every restart re-downloads the model. That is several gigabytes and a lot of goodwill.
  • WithLifetime(ContainerLifetime.Persistent) on Qdrant keeps the container alive between debug sessions. Qdrant’s startup is slow enough that you notice it every single time otherwise.
  • WaitFor(qdrant) stops your API racing the database on startup — the classic “works on the second run” bug.
  • GPU: .WithGPUSupport() for Nvidia, or .WithGPUSupport(OllamaGpuVendor.AMD). On a laptop without one, expect small models to be slow but usable for wiring work.

The consuming project

The model resource name is derived from the model you added: AddModel("llama3") under a server named "ollama" produces the resource ollama-llama3. That derived name is the connection name you use.

Worth knowing before it confuses you: the naming rules strip the tag suffix, take the last path segment of a slash-separated name, and replace dots with hyphens — so phi3.5 becomes ollama-phi3-5. If a connection name is not resolving, that transformation is usually why.

var builder = WebApplication.CreateBuilder(args);
builder.AddServiceDefaults();

// Registers IChatClient over the Ollama model resource.
builder.AddOllamaApiClient("ollama-llama3")
       .AddChatClient();

// Embeddings for retrieval — same resource, different abstraction.
builder.AddOllamaApiClient("ollama-llama3")
       .AddEmbeddingGenerator();

Need more than one model — a small fast one for classification, a larger one for answers? Register them keyed:

builder.AddKeyedOllamaApiClient(connectionName: "chat");
builder.AddKeyedOllamaApiClient(connectionName: "embeddings");

and resolve with [FromKeyedServices("chat")].

The important thing about all of this: what lands in DI is IChatClient and IEmbeddingGenerator<string, Embedding<float>> from Microsoft.Extensions.AI — the same interfaces Azure OpenAI, OpenAI and Anthropic connectors register. Nothing downstream knows or cares that a container is doing the inference. See Microsoft.Extensions.AI explained for why that abstraction is the load-bearing piece.

Your agent code does not change

Because the abstraction is the same, the agent is written once:

public sealed class SupportAgent(IChatClient chat)
{
    private readonly AIAgent _agent = chat.AsAIAgent(
        instructions: "You answer support questions from the provided context only.",
        tools: [AIFunctionFactory.Create(LookupOrder)]);

    public async Task<string> AskAsync(string question, AgentSession session)
    {
        AgentResponse response = await _agent.RunAsync(question, session);
        return response.Text;
    }
}

Local Ollama in development, Azure OpenAI in production, and this class is untouched. That is the payoff.

One caveat, stated plainly: a small local model is a way to exercise your plumbing — tool schemas, retrieval, streaming, error paths. It is not a proxy for production answer quality. Run your evaluation set against the model you will actually deploy before you believe any quality number.

And check tool support before you commit to a local model. Not every small model handles function calling reliably, and an agent whose tools silently never fire is a confusing afternoon.

The part that pays for itself: traces

AddServiceDefaults() wires OpenTelemetry across the whole app. Add instrumentation to the chat client and every model call becomes a span:

builder.Services.AddChatClient(sp =>
    sp.GetRequiredService<IChatClient>()
      .AsBuilder()
      .UseOpenTelemetry(configure: o => o.EnableSensitiveData = true)
      .UseLogging()
      .Build());

EnableSensitiveData records prompts and completions in the trace — development only, obviously; it puts user input into your telemetry. But during development it answers the question you actually have, which is not “was the model slow” but “what exactly did the model receive?” Nine times out of ten the answer explains the bad output: the retrieved chunk was wrong, or the tool result got truncated, or the system prompt lost a line.

In the dashboard those spans sit on the same timeline as your HTTP requests and Qdrant queries. Nesting makes the shape of a request obvious — and a RAG request that looked like one slow LLM call frequently turns out to be five embedding calls in a loop. For the production version of this, see observability for AI agents with OpenTelemetry.

Where Aspire stops

Be clear about the boundary. Aspire is local orchestration and dev-time observability. It is not a production runtime, not an agent framework, and not a replacement for your deployment story — you still ship to Container Apps, GKE or wherever you were already going.

Which is fine, because the problem it solves is real and specific: the distance between “clone the repo” and “the agent answers a question.” On an AI project that distance is unusually long — more moving parts than a typical CRUD service, and several of them are containers with awkward startup behaviour. Closing it to one keypress is worth an afternoon.

Takeaway

Aspire does not make your agent smarter. It makes the ten things around your agent start together, wire themselves up, and report what they are doing on one timeline. Describe Ollama and Qdrant in the AppHost, consume them as IChatClient and IEmbeddingGenerator so the same code runs against a hosted model in production, and turn on OpenTelemetry so you can see the prompt that actually reached the model. Remember the two boundaries: a small local model validates plumbing, not answer quality, and Aspire is a development-time tool — production deployment is still your own.


Have a correction or a topic you want covered? Email mani.bc72@gmail.com.

Frequently asked questions

Do I need Aspire to build AI agents in .NET?

No. Aspire is a local orchestration and observability layer, not a dependency of the Microsoft Agent Framework or Microsoft.Extensions.AI. It earns its place when your agent needs more than one supporting service — an LLM, a vector store, a cache — and you are tired of maintaining a docker-compose file and a page of setup instructions.

Can I develop against a local model and deploy against Azure OpenAI?

Yes, and this is the main reason to bother. Because both register as IChatClient from Microsoft.Extensions.AI, the model behind the interface is a registration detail. Point at Ollama locally and Azure OpenAI in production, and your agent code does not change. Do run your evaluation set against the production model before shipping — small local models behave differently.

Does Aspire replace Docker Compose?

For local development in a .NET codebase, largely yes — the AppHost is C#, so it gets refactoring, IntelliSense and compile-time checking that a YAML file does not, and it adds a dashboard with traces and logs. Aspire is not a production runtime, though; you still deploy to Kubernetes, Container Apps or wherever you were going.

Which Ollama model should I use for local agent development?

Pick one that fits your machine and supports tool calling if your agent uses tools — that constraint eliminates more small models than people expect. Treat the local model as a way to exercise plumbing, not as a proxy for production answer quality, and keep a switch back to the hosted model for anything quality-related.

How do I see what the model actually received?

Add OpenTelemetry instrumentation to the chat client and open the Aspire dashboard's trace view. Because the client is instrumented at the Microsoft.Extensions.AI layer, you get prompts, token counts and latency per call on the same timeline as your HTTP and database spans — which is how you find out that a slow request was three retrieval round-trips rather than a slow model.